//! Read-only registry of workflow template recipes loaded from //! `templates/workflows/*.toml` at server boot. Slice 4. //! //! Recipes are immutable reference data — no DB row per recipe. They are //! served over `GET /api/workflows` so the client doesn't need its own copy //! of the phase composition table. //! //! **These recipes are the only place a phase's `config` comes from.** Mission //! creation copies `phases[].config` into `mission_phases.config`, which is //! where per-phase settings (`done_when`, `max_iterations`, `harness`, `tools`) //! are read from at run time. A mission created with an explicit `phases` list //! and no config gets an empty config — that is the caller's choice, not a //! default. //! //! TOML gotcha worth remembering: a bare top-level key written *after* a //! `[[phases]]` block is scoped into that block's table, not the document //! root. Every recipe here once had `default_team_template` below its phases, //! so it silently parsed as `phases[last].config.default_team_template` and //! the real field was always `None`. Keep top-level keys above the first //! `[[phases]]`. use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::OnceLock; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct WorkflowRecipe { pub key: String, pub title: String, pub blurb: String, #[serde(default)] pub requires_repo: bool, #[serde(default)] pub phases: Vec, #[serde(default)] pub default_team_template: Option, /// Default team **per phase purpose**, by template key: /// `{ research = "topic_research", coding = "rust_sdlc" }`. /// /// `default_team_template` names ONE team for a whole mission, and a /// multi-phase recipe does not have one job. `research_and_code` staffs a /// research phase and a coding phase from the same `rust_sdlc` crew, which /// is why its research phase has to spend a paragraph of `task` telling /// coders not to code — a workaround for staffing, written into the prompt. /// /// Resolved to `config.phase_teams` at mission-create, which the /// orchestrator and `composed_graph` already read. Purposes come from /// `phase_runner::purposes_for`. #[serde(default)] pub default_phase_teams: std::collections::BTreeMap, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct WorkflowPhase { pub kind: String, pub order_idx: i32, #[serde(default)] pub config: serde_json::Value, } static REGISTRY: OnceLock> = OnceLock::new(); fn workflows_dir() -> PathBuf { if let Ok(d) = std::env::var("CLAWMATES_WORKFLOWS_DIR") { return PathBuf::from(d); } let container = PathBuf::from("/etc/clawmates/templates/workflows"); if container.exists() { return container; } PathBuf::from("templates/workflows") } /// Load recipes from disk. Called once at boot; subsequent calls /// return the cached set. Missing/broken files log + are skipped. pub fn load() -> &'static [WorkflowRecipe] { REGISTRY.get_or_init(|| { let dir = workflows_dir(); let entries = match std::fs::read_dir(&dir) { Ok(r) => r, Err(e) => { eprintln!( "workflow_registry: dir {} not readable: {e} — no recipes", dir.display() ); return Vec::new(); } }; let mut out = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("toml") { continue; } match load_one(&path) { Ok(r) => { eprintln!("workflow_registry: loaded {}", r.key); out.push(r); } Err(e) => { eprintln!("workflow_registry: failed to load {}: {e}", path.display()); } } } out.sort_by(|a, b| a.key.cmp(&b.key)); out }) } fn load_one(path: &std::path::Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; toml::from_str::(&text).map_err(|e| format!("parse {}: {e}", path.display())) } pub fn get(key: &str) -> Option<&'static WorkflowRecipe> { load().iter().find(|r| r.key == key) } #[cfg(test)] mod tests { use super::*; fn recipes() -> Vec { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../templates/workflows") .canonicalize() .expect("templates/workflows resolves"); std::fs::read_dir(&dir) .expect("workflows dir readable") .flatten() .map(|e| e.path()) .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("toml")) .map(|p| load_one(&p).unwrap_or_else(|e| panic!("{e}"))) .collect() } /// Every shipped recipe parses and declares the fields mission creation /// depends on. #[test] fn shipped_recipes_parse() { let all = recipes(); assert!(!all.is_empty(), "no recipes found"); for r in &all { assert!(!r.key.is_empty(), "recipe missing key"); assert!(!r.phases.is_empty(), "{} has no phases", r.key); for p in &r.phases { assert!(!p.kind.is_empty(), "{} has a phase with no kind", r.key); } } } /// A bare top-level key written after a `[[phases]]` block is scoped INTO /// that block by TOML, not the document root. Every recipe shipped with /// `default_team_template` below its phases, so it parsed as /// `phases[last].config.default_team_template` and the real field was /// always `None` — invisible while the registry was unused. #[test] fn top_level_keys_are_not_swallowed_by_phase_tables() { for r in recipes() { assert!( r.default_team_template.is_some(), "{}: default_team_template is None — it is probably written below \ the first [[phases]] block and got scoped into a phase config", r.key ); for p in &r.phases { assert!( p.config.get("default_team_template").is_none(), "{}: phase {:?} config contains default_team_template — a \ top-level key leaked into the phase table", r.key, p.kind ); } } } }