7.3 Typed settings and definition structs
// app/src/settings.rs — the `settings:` block of config/{env}.yaml
use std::collections::BTreeMap;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub definitions_dir: String,
pub llm: LlmSettings,
pub documents: DocumentSettings,
pub telemetry: TelemetrySettings,
pub agui: AguiSettings,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LlmSettings { pub providers: BTreeMap<String, LlmProviderCfg> }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LlmProviderCfg {
pub kind: String, pub base_url: String, pub api_key: String,
#[serde(default = "d120")] pub timeout_sec: u64,
#[serde(default = "d8")] pub max_concurrency: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DocumentSettings { pub parsers: BTreeMap<String, ParserCfg> }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ParserCfg { pub kind: String, pub base_url: String, #[serde(default)] pub api_key: String,
#[serde(default)] pub options: serde_json::Value }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TelemetrySettings { pub enabled: bool, pub otlp_endpoint: String, pub service_name: String,
#[serde(default)] pub export_prompt_bodies: bool }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AguiSettings { pub protocol_version: String, pub legacy_interrupt_event: String,
#[serde(default = "d15")] pub sse_keepalive_sec: u64 }
fn d120() -> u64 { 120 } fn d8() -> usize { 8 } fn d15() -> u64 { 15 }
// app/src/definitions/mod.rs — structs for config/agents/**
use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkflowDef {
pub id: String,
pub version: u32,
#[serde(default = "yes")] pub enabled: bool,
pub title: String,
#[serde(default)] pub description: String,
pub entry: String,
#[serde(default)] pub limits: Limits,
#[serde(default)] pub state_whitelist: Vec<String>,
#[serde(default)] pub ui: Value,
pub nodes: Vec<NodeDef>,
pub edges: Vec<EdgeDef>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Limits { pub task_timeout_sec: u64, pub max_steps: usize }
impl Default for Limits { fn default() -> Self { Self { task_timeout_sec: 180, max_steps: 50 } } }
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NodeDef {
pub id: String,
pub kind: String, // must exist in the task-kind registry
#[serde(default)] pub with: Value, // kind-specific parameters, validated by the kind
#[serde(default)] pub retry: Option<Retry>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Retry { pub max: u32, pub backoff_sec: u64 }
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EdgeDef {
pub from: String,
pub to: String,
#[serde(default)] pub when: Option<Condition>,
#[serde(default, rename = "else")] pub otherwise: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum Condition {
All { all: Vec<Condition> },
Any { any: Vec<Condition> },
Test { key: String, op: Op, #[serde(default)] value: Value },
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Op { Eq, Ne, Gt, Gte, Lt, Lte, Empty, NotEmpty, In }
impl Condition {
/// `key` is a dotted path whose first segment is a context key, e.g. `approval.approved`.
pub fn eval(&self, ctx: &graph_flow::Context) -> bool {
match self {
Self::All { all } => all.iter().all(|c| c.eval(ctx)),
Self::Any { any } => any.iter().any(|c| c.eval(ctx)),
Self::Test { key, op, value } => {
let mut parts = key.split('.');
let root: Option<Value> = parts.next().and_then(|k| ctx.get(k)); // source-verified: Context::get<T>
let found = parts.fold(root, |acc, p| acc.and_then(|v| v.get(p).cloned()));
let num = |v: &Value| v.as_f64();
match op {
Op::Eq => found.as_ref() == Some(value),
Op::Ne => found.as_ref() != Some(value),
Op::Empty => found.as_ref().is_none_or(|v| v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str().is_some_and(str::is_empty)),
Op::NotEmpty => !Self::Test { key: key.clone(), op: Op::Empty, value: Value::Null }.eval(ctx),
Op::In => value.as_array().is_some_and(|a| found.as_ref().is_some_and(|f| a.contains(f))),
Op::Gt | Op::Gte | Op::Lt | Op::Lte => match (found.as_ref().and_then(num), num(value)) {
(Some(a), Some(b)) => match op { Op::Gt => a > b, Op::Gte => a >= b, Op::Lt => a < b, _ => a <= b },
_ => false,
},
}
}
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ModelsFile { pub models: BTreeMap<String, ModelDef>, pub allowed_licenses: Vec<String> }
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ModelDef { pub provider: String, pub name: String, pub revision: String, pub license: String,
pub role: String, #[serde(default)] pub dimensions: Option<u32>, #[serde(default)] pub params: Value }
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PromptDef { pub id: String, pub version: u32, pub system: String, pub user: String,
#[serde(default)] pub guards: Value }
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ConnectorDef { pub id: String, pub kind: String, #[serde(default = "yes")] pub enabled: bool,
#[serde(default)] pub credentials: Value, #[serde(default)] pub options: Value,
#[serde(default)] pub actions: BTreeMap<String, ActionDef> }
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ActionDef { #[serde(default)] pub requires_approval: bool, #[serde(default)] pub idempotent: bool }
// TODO(impl): ToolDef, RbacFile, PoliciesFile, TenantFile follow the same pattern.
fn yes() -> bool { true }
/// All definition files, loaded and cross-validated.
#[derive(Debug, Clone)]
pub struct Definitions {
pub models: ModelsFile,
pub prompts: BTreeMap<String, PromptDef>,
pub schemas: BTreeMap<String, Value>,
pub connectors: BTreeMap<String, ConnectorDef>,
pub workflows: BTreeMap<String, WorkflowDef>,
}
// app/src/definitions/loader.rs
use std::{collections::BTreeMap, fs, path::Path};
use serde::de::DeserializeOwned;
use super::*;
#[derive(Debug, thiserror::Error)]
pub enum DefError {
#[error("{file}: {msg}")] File { file: String, msg: String },
#[error("workflow `{wf}`: {msg}")] Workflow { wf: String, msg: String },
#[error("model `{0}` license `{1}` is not in allowed_licenses")] License(String, String),
}
/// Render `{{ get_env(...) }}` the same way Loco does, then parse strictly.
fn read<T: DeserializeOwned>(path: &Path) -> Result<T, DefError> {
let file = path.display().to_string();
let raw = fs::read_to_string(path).map_err(|e| DefError::File { file: file.clone(), msg: e.to_string() })?;
let rendered = render_env(&raw).map_err(|msg| DefError::File { file: file.clone(), msg })?;
serde_yaml_ng::from_str(&rendered).map_err(|e| DefError::File { file, msg: e.to_string() })
}
/// TODO(impl): Tera 2 one-off render with a `get_env` function registered (Tera 2 no longer ships it).
/// Workflow files also contain runtime placeholders such as `{{ session_id }}`; wrap those values in
/// `{% raw %}` or render only files under connectors/ and models.yaml. VERIFY Tera 2 API.
fn render_env(raw: &str) -> Result<String, String> { Ok(raw.to_string()) }
fn read_dir<T: DeserializeOwned>(dir: &Path, id: impl Fn(&T) -> String) -> Result<BTreeMap<String, T>, DefError> {
let mut out = BTreeMap::new();
let Ok(entries) = fs::read_dir(dir) else { return Ok(out) };
for e in entries.flatten() {
let p = e.path();
if p.extension().is_some_and(|x| x == "yaml" || x == "yml") {
let v: T = read(&p)?;
if out.insert(id(&v), v).is_some() {
return Err(DefError::File { file: p.display().to_string(), msg: "duplicate id".into() });
}
}
}
Ok(out)
}
impl Definitions {
pub fn load(dir: &Path) -> Result<Self, DefError> {
let defs = Self {
models: read(&dir.join("models.yaml"))?,
prompts: read_dir(&dir.join("prompts"), |p: &PromptDef| p.id.clone())?,
schemas: read_dir(&dir.join("schemas"), |s: &serde_json::Value| s["id"].as_str().unwrap_or_default().to_string())?,
connectors: read_dir(&dir.join("connectors"), |c: &ConnectorDef| c.id.clone())?,
workflows: read_dir(&dir.join("workflows"), |w: &WorkflowDef| w.id.clone())?,
};
defs.validate()?;
Ok(defs)
}
fn validate(&self) -> Result<(), DefError> {
for (id, m) in &self.models.models {
if !self.models.allowed_licenses.contains(&m.license) {
return Err(DefError::License(id.clone(), m.license.clone()));
}
}
for (id, wf) in &self.workflows {
let err = |msg: String| DefError::Workflow { wf: id.clone(), msg };
let ids: std::collections::BTreeSet<_> = wf.nodes.iter().map(|n| n.id.as_str()).collect();
if ids.len() != wf.nodes.len() { return Err(err("duplicate node id".into())); }
if !ids.contains(wf.entry.as_str()) { return Err(err(format!("entry `{}` is not a node", wf.entry))); }
for e in &wf.edges {
for t in [Some(&e.from), Some(&e.to), e.otherwise.as_ref()].into_iter().flatten() {
if !ids.contains(t.as_str()) { return Err(err(format!("edge references unknown node `{t}`"))); }
}
if e.otherwise.is_some() && e.when.is_none() { return Err(err(format!("edge from `{}` has `else` without `when`", e.from))); }
}
// TODO(impl): reachability from entry; references to models/prompts/schemas/connectors/actions;
// unknown `kind` (checked in the compiler against the registry); tenant overrides.
}
Ok(())
}
}