Files
clawmates/crates/cm-api/src/workflow_registry.rs
T
Omar SobhandClaude Opus 5 ceec0423ad feat(teams): staff research phases with a research team
`research_only` is repo-less, one research phase, "produce a markdown
artifact" — and it defaulted to `rust_sdlc`. So it was staffed with a
planner, a coder, a tester, a reviewer and a committer, four of whom had
nothing to do, each carrying the code-and-commit skills its role is bound
to. Measured 2026-08-21: 9 distinct skills across 5 role prompts, ~50KB,
one applicable. That is what "most skills score not_applicable" in the
Skill-Use baseline has been measuring all along — the skills were
correctly bound to their roles; the roles were wrong for the workflow.

None of the three existing research templates fit, so this adds
`topic_research`: frame the brief into answerable questions, gather
evidence with the URL and the quoted passage, check every claim against
its source, write the report. Three roles, four skills, each checked
against its own `when_to_use` before binding — and two obvious candidates
deliberately NOT bound, because `executive-summary-writing` tells the
writer to discard any item not tied to a named project and
`signal-to-noise-ranking` scores relevance the same way. On a standalone
topic report that discards the deliverable.

`default_phase_teams` lets a recipe staff each phase PURPOSE separately,
resolved into `config.phase_teams` at create. A multi-phase recipe does
not have one job: `research_and_code`'s research phase spends a paragraph
of `task` telling its team not to change source files, because
`rust_sdlc` gave that phase a coder and a committer and they did what
coders do — mission 01a00c57 shipped both INT items during RESEARCH and
the coding phase then delivered +0/-0. Prose was the only lever
available; staffing is the actual one.

Also fixed in the three existing research templates, all verified rather
than inferred:

  - `papers_research` bound `arxiv-daily` to its DOMAIN SCOUT. That
    skill's entire content is "Do not search arXiv yourself — the harvest
    already ran", and its `when_to_use` names Continuous Research
    missions, which are the only ones the platform writes a harvest
    manifest for. The role whose job is searching was bound a skill
    forbidding it.
  - Its PAPER READER was told to "fetch the PDF, extract text". The
    runtime image has no pdftotext, no mutool and no pypdf — checked in
    the container. Every paper would have hit the `[read: abstract only]`
    fallback, which reads identically to the fallback working as designed.
  - `insight_research` cross-referenced "our repos'" history. A mission
    binds ONE repo (`missions.repo_id`).
  - `codebase_research` wrote to "the Obsidian vault"; no vault is
    mounted, and both it and `papers_research` were committing in "PRs",
    which the platform does not open.

And `research_only` itself had neither `task` nor `done_when` — the same
defect `benchmark`, `security_hardening` and `research_and_code` were each
fixed for, and it was left out. A phase with no `done_when` is never
judged. It also still asked for `pdf`, a format nothing generates.

Two new guards, both negative-controlled: every team a recipe names must
exist (a typo currently only logs, and the mission is staffed by the
fallback crew looking deliberate), and every `default_phase_teams` key
must be a purpose `purposes_for` actually emits.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 09:35:33 -07:00

179 lines
6.6 KiB
Rust

//! 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<WorkflowPhase>,
#[serde(default)]
pub default_team_template: Option<String>,
/// 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<String, String>,
}
#[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<Vec<WorkflowRecipe>> = 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<WorkflowRecipe, String> {
let text =
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
toml::from_str::<WorkflowRecipe>(&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<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
);
}
}
}
}