feat(missions): wire the workflow registry so phase config reaches the database

workflow_registry.rs had zero call sites -- lib.rs declared the module and
nothing ever called load() or get(). So templates/workflows/*.toml was never
read, and because the client's TEMPLATE_PRESETS carries only {kind, order_idx}
with no config, PhaseSpec.config defaulted to Value::Null and every
wizard-created mission stored a null mission_phases.config.

Every per-phase setting was therefore inert. `loop = "until_no_more_int_items"`
and `commit_policy = "on_green_tests"` described a scheduler that does not
exist AND had no path to the database. benchmark_runner and security_scan
already read phase_config(); they were reading from null.

- Mission create derives phases from the recipe when none are sent, and
  backfills config per phase (matched on kind+order_idx, then kind) when the
  caller sends shape without config. An explicit config always wins.
- phases_for_create takes Option<&WorkflowRecipe> rather than reaching for the
  global, because the registry resolves its directory relative to the process
  cwd -- which under cargo test is the crate root, not the repo root.
- GET /api/workflows serves the catalog; the wizard fetches it and falls back
  to TEMPLATE_PRESETS. Adding a TOML now adds a template with no FE change.
- load() runs at boot so a malformed recipe appears in the boot log instead of
  silently producing a mission with no phase config.

Also fixes a latent bug in all five recipes: `default_team_template` was
written below the first [[phases]] block, and TOML scopes a bare key after a
table header INTO that table -- so it parsed as
phases[last].config.default_team_template and the real field was always None.
Invisible while the registry was dead code. Moved above the phases, with a
test asserting it neither returns None nor leaks into a phase config.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-30 12:43:32 -07:00
co-authored by Claude Opus 5
parent d94487d3ba
commit 49bcf53b84
11 changed files with 327 additions and 29 deletions
+7
View File
@@ -288,6 +288,13 @@ async fn run() -> Result<(), String> {
// for INT-XX markers in event payloads and upserts mission_tasks // for INT-XX markers in event payloads and upserts mission_tasks
// rows so the canvas renders a live status timeline. // rows so the canvas renders a live status timeline.
cm_api::task_card_worker::spawn(pool.clone()); cm_api::task_card_worker::spawn(pool.clone());
// Load the workflow recipes now rather than lazily on first mission
// create, so a malformed TOML shows up in the boot log instead of
// silently yielding a mission with no phase config.
{
let recipes = cm_api::workflow_registry::load();
eprintln!("workflow_registry: {} recipe(s) available", recipes.len());
}
cm_api::phase_runner::spawn(pool.clone()); cm_api::phase_runner::spawn(pool.clone());
// Per-mission runtime container sweeper (C3): tears down mission // Per-mission runtime container sweeper (C3): tears down mission
// runtime containers 30 min after the mission reaches a terminal // runtime containers 30 min after the mission reaches a terminal
+3
View File
@@ -443,6 +443,9 @@ pub fn router(state: AppState) -> Router {
"/api/missions", "/api/missions",
get(routes::missions::list).post(routes::missions::create), get(routes::missions::list).post(routes::missions::create),
) )
// The workflow recipe catalog (templates/workflows/*.toml). Serving it
// lets the client stop mirroring the phase composition table inline.
.route("/api/workflows", get(routes::missions::list_workflows))
.route( .route(
"/api/missions/{id}", "/api/missions/{id}",
get(routes::missions::get) get(routes::missions::get)
+163 -9
View File
@@ -148,6 +148,72 @@ pub async fn list(
)) ))
} }
/// Resolve the phase list for a new mission, filling in each phase's `config`
/// from the workflow recipe when the caller didn't supply one.
///
/// `mission_phases.config` is where per-phase settings live (`done_when`,
/// `max_iterations`, `harness`, `tools`). The client's phase list historically
/// carried only `{kind, order_idx}`, so every wizard-created mission landed
/// with a null config and every recipe setting was silently inert. Callers can
/// still override by sending a non-null config per phase.
fn phases_for_create(
recipe: Option<&crate::workflow_registry::WorkflowRecipe>,
requested: Vec<PhaseSpec>,
) -> Vec<NewMissionPhase> {
// No phases requested: take the recipe's wholesale.
if requested.is_empty() {
return recipe
.map(|r| {
r.phases
.iter()
.map(|p| NewMissionPhase {
kind: p.kind.clone(),
order_idx: p.order_idx,
config: p.config.clone(),
})
.collect()
})
.unwrap_or_default();
}
// Phases requested: honour them, but backfill config from the matching
// recipe phase (by kind + order_idx, falling back to kind alone) so a
// client that only knows the shape still gets the recipe's settings.
requested
.into_iter()
.map(|p| {
let config = if p.config.is_null() {
recipe
.and_then(|r| {
r.phases
.iter()
.find(|rp| rp.kind == p.kind && rp.order_idx == p.order_idx)
.or_else(|| r.phases.iter().find(|rp| rp.kind == p.kind))
})
.map(|rp| rp.config.clone())
.unwrap_or(Value::Null)
} else {
p.config
};
NewMissionPhase {
kind: p.kind,
order_idx: p.order_idx,
config,
}
})
.collect()
}
/// `GET /api/workflows` — the workflow recipe catalog.
///
/// Serves `templates/workflows/*.toml` so the client can drop its inline
/// mirror of the phase composition table.
pub async fn list_workflows(
Authed(_user): Authed,
) -> Json<&'static [crate::workflow_registry::WorkflowRecipe]> {
Json(crate::workflow_registry::load())
}
pub async fn create( pub async fn create(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
@@ -180,15 +246,10 @@ pub async fn create(
config: body.config, config: body.config,
runtime_kind: Some(runtime_kind), runtime_kind: Some(runtime_kind),
target_node_id: body.target_node_id, target_node_id: body.target_node_id,
phases: body phases: phases_for_create(
.phases crate::workflow_registry::get(body.template_kind.trim()),
.into_iter() body.phases,
.map(|p| NewMissionPhase { ),
kind: p.kind,
order_idx: p.order_idx,
config: p.config,
})
.collect(),
}; };
let id = cm_db::repo::missions::insert(&state.pool, new).await?; let id = cm_db::repo::missions::insert(&state.pool, new).await?;
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
@@ -918,6 +979,99 @@ pub async fn get_document(
mod tests { mod tests {
use super::*; use super::*;
/// The whole point of wiring the registry: a client that sends only the
/// phase shape must still get the recipe's config, because that is where
/// per-phase settings are read from at run time. Before this, every
/// wizard-created mission stored a null config and every recipe setting
/// was inert.
#[test]
fn phase_config_is_backfilled_from_the_recipe() {
let recipe = test_recipe();
let requested = vec![
PhaseSpec {
kind: "research".into(),
order_idx: 0,
config: Value::Null,
},
PhaseSpec {
kind: "coding".into(),
order_idx: 1,
config: Value::Null,
},
];
let phases = phases_for_create(Some(&recipe), requested);
assert_eq!(phases.len(), 2);
assert!(
phases.iter().all(|p| !p.config.is_null()),
"recipe config was not backfilled: {phases:?}"
);
// The coding phase's loop policy is the setting the loop work depends on.
let coding = phases.iter().find(|p| p.kind == "coding").expect("coding");
assert_eq!(
coding.config.get("loop").and_then(|v| v.as_str()),
Some("until_no_more_int_items")
);
}
/// Omitting phases entirely takes the recipe's list wholesale.
#[test]
fn phases_default_to_the_recipe() {
let phases = phases_for_create(Some(&test_recipe()), vec![]);
assert_eq!(phases.len(), 2);
assert_eq!(phases[0].kind, "research");
assert_eq!(phases[1].kind, "coding");
}
/// An explicit config always wins over the recipe's.
#[test]
fn explicit_phase_config_is_not_overwritten() {
let requested = vec![PhaseSpec {
kind: "coding".into(),
order_idx: 1,
config: serde_json::json!({"loop": "single_pass"}),
}];
let phases = phases_for_create(Some(&test_recipe()), requested);
assert_eq!(
phases[0].config.get("loop").and_then(|v| v.as_str()),
Some("single_pass")
);
}
/// An unknown template must not fabricate phases or panic.
#[test]
fn unknown_template_yields_no_phases() {
assert!(phases_for_create(None, vec![]).is_empty());
}
/// Mirrors `templates/workflows/research_and_code.toml`. Built inline
/// rather than loaded from disk because the registry resolves its
/// directory relative to the process cwd, which under `cargo test` is the
/// crate root, not the repo root.
fn test_recipe() -> crate::workflow_registry::WorkflowRecipe {
crate::workflow_registry::WorkflowRecipe {
key: "research_and_code".into(),
title: "Research + Coding Loop".into(),
blurb: String::new(),
requires_repo: true,
default_team_template: Some("rust_sdlc".into()),
phases: vec![
crate::workflow_registry::WorkflowPhase {
kind: "research".into(),
order_idx: 0,
config: serde_json::json!({"produces": ["md", "pdf"]}),
},
crate::workflow_registry::WorkflowPhase {
kind: "coding".into(),
order_idx: 1,
config: serde_json::json!({
"loop": "until_no_more_int_items",
"commit_policy": "on_green_tests"
}),
},
],
}
}
#[test] #[test]
fn title_prefers_first_markdown_heading() { fn title_prefers_first_markdown_heading() {
let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext"; let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext";
+80 -7
View File
@@ -1,16 +1,29 @@
//! Read-only registry of workflow template recipes loaded from //! Read-only registry of workflow template recipes loaded from
//! `templates/workflows/*.toml` at server boot. Slice 4. //! `templates/workflows/*.toml` at server boot. Slice 4.
//! //!
//! Recipes are immutable reference data — no DB row per recipe. //! Recipes are immutable reference data — no DB row per recipe. They are
//! Slice 2's client-side `TEMPLATE_PRESETS` is a mirror of what //! served over `GET /api/workflows` so the client doesn't need its own copy
//! ends up here; a follow-up serves this registry over an API so //! of the phase composition table.
//! the client can drop its inline mirror. //!
//! **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; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock; use std::sync::OnceLock;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkflowRecipe { pub struct WorkflowRecipe {
pub key: String, pub key: String,
pub title: String, pub title: String,
@@ -23,7 +36,7 @@ pub struct WorkflowRecipe {
pub default_team_template: Option<String>, pub default_team_template: Option<String>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkflowPhase { pub struct WorkflowPhase {
pub kind: String, pub kind: String,
pub order_idx: i32, pub order_idx: i32,
@@ -89,3 +102,63 @@ fn load_one(path: &std::path::Path) -> Result<WorkflowRecipe, String> {
pub fn get(key: &str) -> Option<&'static WorkflowRecipe> { pub fn get(key: &str) -> Option<&'static WorkflowRecipe> {
load().iter().find(|r| r.key == key) load().iter().find(|r| r.key == key)
} }
#[cfg(test)]
mod tests {
use super::*;
fn recipes() -> Vec<WorkflowRecipe> {
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
);
}
}
}
}
@@ -17,7 +17,8 @@ import { X } from "lucide-react";
import { import {
createMission, createMission,
presetForKind, listWorkflows,
recipeToPreset,
TEMPLATE_PRESETS, TEMPLATE_PRESETS,
type Schedule, type Schedule,
type TemplateKind, type TemplateKind,
@@ -87,9 +88,29 @@ export function MissionWizard({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Workflow recipes come from the server (templates/workflows/*.toml) so a
// new TOML shows up here without a frontend change. TEMPLATE_PRESETS is the
// fallback when the request fails or hasn't landed yet.
const [recipes, setRecipes] = useState<TemplatePreset[] | null>(null);
useEffect(() => {
let live = true;
(async () => {
try {
const rs = await listWorkflows();
if (live && rs.length > 0) setRecipes(rs.map(recipeToPreset));
} catch {
// Fallback table already covers this.
}
})();
return () => {
live = false;
};
}, []);
const templates: TemplatePreset[] = recipes ?? TEMPLATE_PRESETS;
const preset: TemplatePreset = useMemo( const preset: TemplatePreset = useMemo(
() => presetForKind(templateKind) ?? TEMPLATE_PRESETS[0], () => templates.find((p) => p.kind === templateKind) ?? templates[0],
[templateKind], [templates, templateKind],
); );
// Which panels to show on step 3 (research / dev) depends on which // Which panels to show on step 3 (research / dev) depends on which
@@ -244,7 +265,7 @@ export function MissionWizard({
marginTop: 4, marginTop: 4,
}} }}
> >
{TEMPLATE_PRESETS.map((p) => { {templates.map((p) => {
const active = p.kind === templateKind; const active = p.kind === templateKind;
return ( return (
<button <button
+39
View File
@@ -433,3 +433,42 @@ export const TEMPLATE_PRESETS: TemplatePreset[] = [
export const presetForKind = (k: TemplateKind): TemplatePreset | undefined => export const presetForKind = (k: TemplateKind): TemplatePreset | undefined =>
TEMPLATE_PRESETS.find((p) => p.kind === k); TEMPLATE_PRESETS.find((p) => p.kind === k);
// ── Server-side recipes ──────────────────────────────────────────
//
// `GET /api/workflows` serves `templates/workflows/*.toml`, which is the
// authoritative source of phase composition — including each phase's `config`,
// where per-phase settings live. The table above stays as the offline
// fallback and to keep the wizard rendering if the request fails.
//
// Note the server backfills phase config from the recipe on create, so the
// wizard does NOT need to send config; posting `{kind, order_idx}` is enough.
export interface WorkflowRecipePhase {
kind: PhaseKind;
order_idx: number;
config?: Record<string, unknown>;
}
export interface WorkflowRecipe {
key: string;
title: string;
blurb: string;
requires_repo: boolean;
phases: WorkflowRecipePhase[];
default_team_template?: string | null;
}
export const listWorkflows = () => api<WorkflowRecipe[]>("/api/workflows");
/** Shape a server recipe like the local preset table so callers are uniform. */
export const recipeToPreset = (r: WorkflowRecipe): TemplatePreset => ({
kind: r.key as TemplateKind,
title: r.title,
blurb: r.blurb,
requiresRepo: r.requires_repo,
phases: r.phases
.slice()
.sort((a, b) => a.order_idx - b.order_idx)
.map((p) => ({ kind: p.kind, order_idx: p.order_idx })),
});
+2 -2
View File
@@ -3,6 +3,8 @@ title = "Benchmark"
blurb = "Author + baseline benchmarks so subsequent refactors can be measured before/after." blurb = "Author + baseline benchmarks so subsequent refactors can be measured before/after."
requires_repo = true requires_repo = true
default_team_template = "rust_sdlc"
[[phases]] [[phases]]
kind = "benchmark" kind = "benchmark"
order_idx = 0 order_idx = 0
@@ -16,5 +18,3 @@ mode = "author_and_baseline"
# ts/js → vitest --bench / mitata # ts/js → vitest --bench / mitata
# py → pytest-benchmark # py → pytest-benchmark
harness = "auto" harness = "auto"
default_team_template = "rust_sdlc"
+2 -2
View File
@@ -3,6 +3,8 @@ title = "Refactor"
blurb = "Audit dependencies + versions, propose API/SDK adaptations, apply the changes." blurb = "Audit dependencies + versions, propose API/SDK adaptations, apply the changes."
requires_repo = true requires_repo = true
default_team_template = "rust_sdlc"
[[phases]] [[phases]]
kind = "coding" kind = "coding"
order_idx = 0 order_idx = 0
@@ -13,5 +15,3 @@ loop = "single_pass"
commit_policy = "on_green_tests" commit_policy = "on_green_tests"
# Bench before + after the pass so we can measure impact. # Bench before + after the pass so we can measure impact.
benchmark = { mode = "before_after" } benchmark = { mode = "before_after" }
default_team_template = "rust_sdlc"
+2 -2
View File
@@ -3,6 +3,8 @@ title = "Research + Coding Loop"
blurb = "Research a topic against a repo, then loop the coding team through the produced INT-XX items until done." blurb = "Research a topic against a repo, then loop the coding team through the produced INT-XX items until done."
requires_repo = true requires_repo = true
default_team_template = "rust_sdlc"
[[phases]] [[phases]]
kind = "research" kind = "research"
order_idx = 0 order_idx = 0
@@ -25,5 +27,3 @@ loop = "until_no_more_int_items"
# marker before the committer runs. If a coding role wants to bypass # marker before the committer runs. If a coding role wants to bypass
# (rare — pure docs commit), it emits COMMIT_POLICY_OVERRIDE: <reason>. # (rare — pure docs commit), it emits COMMIT_POLICY_OVERRIDE: <reason>.
commit_policy = "on_green_tests" commit_policy = "on_green_tests"
default_team_template = "rust_sdlc"
+2 -1
View File
@@ -5,6 +5,8 @@ requires_repo = false
# Phases run in order. Each entry gets a `mission_phases` row on # Phases run in order. Each entry gets a `mission_phases` row on
# mission create; the orchestrator dispatches per-kind executors. # mission create; the orchestrator dispatches per-kind executors.
default_team_template = "rust_sdlc"
[[phases]] [[phases]]
kind = "research" kind = "research"
order_idx = 0 order_idx = 0
@@ -15,4 +17,3 @@ default_topology = "hub_spoke"
# Which team template is the "sensible default" for the picker when # Which team template is the "sensible default" for the picker when
# the user hasn't explicitly picked one. UI honors this. # the user hasn't explicitly picked one. UI honors this.
default_team_template = "rust_sdlc"
+2 -2
View File
@@ -3,6 +3,8 @@ title = "Security Hardening"
blurb = "Scan the repo for vulnerabilities, research patches, then apply + verify." blurb = "Scan the repo for vulnerabilities, research patches, then apply + verify."
requires_repo = true requires_repo = true
default_team_template = "rust_sdlc"
[[phases]] [[phases]]
kind = "security_scan" kind = "security_scan"
order_idx = 0 order_idx = 0
@@ -29,5 +31,3 @@ loop = "until_all_findings_closed"
# Security requires reviewer approval on top of green tests. # Security requires reviewer approval on top of green tests.
commit_policy = "on_reviewer_approval" commit_policy = "on_reviewer_approval"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge", "security_scan"] mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge", "security_scan"]
default_team_template = "rust_sdlc"