slice 4: 5 workflow templates as TOML recipes + mission-launch orchestrator
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m20s
ci / e2e (push) Skipped
ci / publish (push) Successful in 3m50s

Two things land together:

1. Ships the 5 workflow recipes as TOML files under
   templates/workflows/*.toml:
     - research_only          (hub_spoke research → MD + PDF)
     - research_and_code      (research → coding loop until INT-XX drained)
     - security_hardening     (scan → research patches → coding with
                               reviewer approval + full MCP bundle)
     - refactor               (single-pass coding with dep audit
                               preamble + before/after benchmarks)
     - benchmark              (author + baseline benchmarks per stack)
   Each declares phases[], per-phase config, default_team_template.
   Loaded read-only into an in-memory registry (workflow_registry)
   via OnceLock — no DB row per recipe.

2. Ships the mission-launch orchestrator that closes the loop from
   Slice 3.5d's mechanics. When a mission transitions draft→running,
   `mission_orchestrator::on_launch`:
     - Reads mission.team_template_id (skips if unset)
     - Loads the team template detail (roles + skills bindings)
     - Builds a topology graph from role slots via cm_topology::build
     - Inserts the teams row + stamps template_id/version/risk_profile/mcp_bundles
     - For each role: agent insert, model binding, runtime provision
       (opt-in via RuntimeProvisioner::from_env), brain_seed::ingest
       (Slice 3.5d), agent_template_link::upsert (Slice 3.5d),
       team_members bind, audit trail
     - UPDATE missions SET team_id = ...
   Wired into routes::missions::set_status when prior.status='draft'
   and new='running'. Failures log + are non-fatal (mission still
   flips to running so the user can inspect + retry).

With this, Slice 3.5d's brain-seed + link machinery actually gets
populated, and the MCP skills server's template-defaults-merge path
(Slice 3.5b/d) starts serving real bindings to real agents.

Follow-ups (Slice 5-8):
  - Task-card parser watches run events for TASK/COMPLETED markers
    → mission_tasks rows
  - PDF renderer worker turns MD artifacts into PDFs
  - Before/after benchmark runner honors phases[].config.benchmark
  - Security scan MCP bundle exposes cargo-audit/gitleaks/trivy/semgrep
  - Level-up endpoints diff learned-vs-seeded via agent_template_link

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 14:34:26 -07:00
co-authored by Claude Opus 4.7
parent 9d9f24a563
commit 565f6cae65
9 changed files with 484 additions and 0 deletions
+2
View File
@@ -8,6 +8,7 @@ mod extract;
pub mod fleet; pub mod fleet;
mod mcp_door; mod mcp_door;
mod mcp_skills; mod mcp_skills;
pub mod mission_orchestrator;
pub mod node_rules; pub mod node_rules;
pub mod quota; pub mod quota;
mod recursive_exec; mod recursive_exec;
@@ -20,6 +21,7 @@ pub mod team_template_loader;
pub mod tool_versions; pub mod tool_versions;
mod topology_exec; mod topology_exec;
pub mod topology_worker; pub mod topology_worker;
pub mod workflow_registry;
use axum::routing::{delete, get, patch, post}; use axum::routing::{delete, get, patch, post};
use axum::Router; use axum::Router;
+253
View File
@@ -0,0 +1,253 @@
//! Mission-launch orchestrator. Slice 4.
//!
//! When a mission's status flips from `draft` to `running`, this
//! module fires:
//! 1. Materialize a team from `team_template_id` (if set + no
//! `team_id` yet) — inserts agents, ingests brain seeds,
//! records agent_template_link lineage, wires team_members.
//! 2. Bind the resulting `team_id` back onto the mission.
//!
//! Phase execution (running research/coding/benchmark/security_scan
//! against the world) is layered on top by Slices 5–8.
//!
//! Design notes:
//! - Runtime provisioning is opt-in via `RuntimeProvisioner::from_env`.
//! Missing runtime = "insert DB rows only, no live claw" — the
//! mission still boots; live claws land the moment the runtime
//! env is configured + the mission re-launches.
//! - Every step is best-effort logged so a partial materialization
//! can be picked up by a subsequent launch rather than blocking.
use cm_db::repo::team_templates::TeamTemplateDetail;
use cm_domain::{AccessPolicy, Agent, AgentStatus, WorkspaceId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::runtime_provision::RuntimeProvisioner;
/// Called from `routes::missions::set_status` when the transition is
/// draft→running. Materializes the team + returns the new team_id
/// (or an existing one — no-op when the mission already has a team).
///
/// Non-fatal on failure: logs + returns Ok(None) so the mission still
/// becomes `running` and the user can inspect the error via server
/// logs + re-attempt. Later slices surface this on the canvas.
pub async fn on_launch(
pool: &PgPool,
workspace_id: WorkspaceId,
user_id: cm_domain::UserId,
mission_id: Uuid,
) -> Result<Option<Uuid>, String> {
let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load mission: {e}"))?
else {
return Err("mission not found".into());
};
// Skip if already bound.
if mission.team_id.is_some() {
return Ok(mission.team_id);
}
let Some(template_id) = mission.team_template_id else {
// No template + no team = phase execution will auto-provision
// via the LLM path (Slice 2's fallback), or run against the
// shared runtime. Nothing to do here.
return Ok(None);
};
let template = cm_db::repo::team_templates::get(pool, template_id)
.await
.map_err(|e| format!("load template: {e}"))?
.ok_or_else(|| format!("template {template_id} not found"))?;
let provisioner = RuntimeProvisioner::from_env();
let team_id = mint_team_from_template(
pool,
workspace_id,
user_id,
provisioner.as_ref(),
&template,
&mission.title,
"claude-sonnet-5",
)
.await?;
// Bind the team onto the mission.
sqlx::query("UPDATE missions SET team_id = $1, updated_at = now() WHERE id = $2")
.bind(team_id)
.bind(mission_id)
.execute(pool)
.await
.map_err(|e| format!("bind team on mission: {e}"))?;
Ok(Some(team_id))
}
async fn mint_team_from_template(
pool: &PgPool,
workspace_id: WorkspaceId,
user_id: cm_domain::UserId,
provisioner: Option<&RuntimeProvisioner>,
template: &TeamTemplateDetail,
team_name: &str,
default_model: &str,
) -> Result<Uuid, String> {
// Build the topology graph from role slots so the team's `graph`
// NOT NULL column is satisfied + downstream topology executors
// have a valid shape to iterate over.
let roles: Vec<&str> = template.roles.iter().map(|r| r.slot.as_str()).collect();
let topology_kind = parse_topology_kind(&template.template.default_topology);
let graph = cm_topology::build(topology_kind, &roles)
.map_err(|e| format!("build topology graph: {e}"))?;
let graph_json =
serde_json::to_value(&graph).map_err(|e| format!("serialize topology graph: {e}"))?;
let team_id = Uuid::now_v7();
cm_db::repo::teams::insert_team_with_lifecycle(
pool,
team_id,
workspace_id,
team_name,
&template.template.default_topology,
&graph_json,
"permanent",
)
.await
.map_err(|e| format!("insert team: {e}"))?;
// Stamp template lineage on the team row (Slice 3 columns).
sqlx::query(
"UPDATE teams SET template_id = $1, template_version = $2,
risk_profile = $3, mcp_bundles = $4
WHERE id = $5",
)
.bind(template.template.id)
.bind(template.template.version)
.bind(&template.template.risk_profile)
.bind(serde_json::json!(template.template.mcp_bundles))
.bind(team_id)
.execute(pool)
.await
.map_err(|e| format!("stamp template lineage: {e}"))?;
// For each role: create agent, provision runtime, ingest brain
// seed, record link, bind to topology node.
for (idx, role) in template.roles.iter().enumerate() {
// Node id in the graph follows cm_topology's `n0..` convention
// — read the actual node id from the graph so team_members
// stays consistent with the topology.
let Some(node) = graph.nodes.get(idx) else {
return Err(format!(
"topology graph produced {} nodes but template has {} roles",
graph.nodes.len(),
template.roles.len(),
));
};
let agent = Agent {
id: cm_domain::AgentId::new(),
workspace_id,
name: format!("{} · {}", team_name, role.slot),
job_title: role.slot.clone(),
system_prompt: role.system_prompt.clone(),
avatar: String::new(),
accent: default_accent_for(&role.slot).to_string(),
wallpaper: String::new(),
managed_by: user_id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.map_err(|e| format!("insert agent {}: {e}", role.slot))?;
let claw_id = agent.id.as_uuid();
cm_db::repo::agents::set_model_binding(pool, agent.id, default_model)
.await
.map_err(|e| format!("set_model_binding {claw_id}: {e}"))?;
// Runtime provisioning is opt-in — no-op if unconfigured.
if let Some(p) = provisioner {
if let Err(e) = p.provision_claw(claw_id, default_model).await {
eprintln!(
"mission_orchestrator: provision claw {claw_id} failed (continuing): {e}"
);
}
}
// Ingest brain seed (Slice 3.5d). Non-fatal on failure —
// agent still works from system_prompt alone.
if let Some(seed) = role.brain_seed.as_deref().filter(|s| !s.trim().is_empty()) {
if let Err(e) =
crate::brain_seed::ingest(claw_id, seed.to_string(), role.system_prompt.clone())
.await
{
eprintln!(
"mission_orchestrator: brain_seed ingest for {claw_id} failed (continuing): {e}"
);
}
}
// Record lineage (Slice 3.5d) so the MCP skills server can
// merge template default skills with per-agent overrides.
cm_db::repo::agent_template_link::upsert(
pool,
claw_id,
template.template.id,
template.template.version,
&role.slot,
)
.await
.map_err(|e| format!("agent_template_link upsert {claw_id}: {e}"))?;
cm_db::repo::agent_template_link::mark_seeded(pool, claw_id)
.await
.map_err(|e| format!("mark_seeded {claw_id}: {e}"))?;
// Wire team_members using the topology node id.
cm_db::repo::teams::add_member(pool, team_id, &node.id, claw_id, &role.slot)
.await
.map_err(|e| format!("team_members add {claw_id}: {e}"))?;
// Audit for parity with individual claw creation path.
let _ = cm_db::repo::audit::append(
pool,
workspace_id,
cm_db::repo::audit::Actor::User(user_id),
"agent.created",
"agent",
&agent.id.to_string(),
serde_json::json!({
"name": agent.name,
"job_title": agent.job_title,
"source": "mission_orchestrator",
"template_id": template.template.id.to_string(),
"template_version": template.template.version,
"role_slot": role.slot,
}),
)
.await;
}
Ok(team_id)
}
fn parse_topology_kind(s: &str) -> cm_topology::TopologyKind {
use cm_topology::TopologyKind;
match s {
"pipeline" => TopologyKind::Pipeline,
"hierarchical" => TopologyKind::Hierarchical,
"star_moe" => TopologyKind::StarMoe,
_ => TopologyKind::HubSpoke,
}
}
fn default_accent_for(slot: &str) -> &'static str {
match slot {
"planner" | "arch_analyst" | "designer" | "scene_designer" | "api_designer" => "#7cd6e0",
"coder" | "kernel_author" | "shader_author" => "#5fd08a",
"tester" | "bench_engineer" | "perf_engineer" => "#ffb44a",
"reviewer" | "db_engineer" => "#c98af0",
"committer" | "spec_integrator" => "#ff8a7a",
_ => "#8a8a92",
}
}
+17
View File
@@ -153,8 +153,25 @@ pub async fn set_status(
if !allowed.contains(&body.status.as_str()) { if !allowed.contains(&body.status.as_str()) {
return Err(ApiError::BadRequest); return Err(ApiError::BadRequest);
} }
// Snapshot prior state so we can detect the draft→running edge
// and fire the launch orchestrator (Slice 4).
let prior = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
cm_db::repo::missions::set_status(&state.pool, id, user.workspace_id.as_uuid(), &body.status) cm_db::repo::missions::set_status(&state.pool, id, user.workspace_id.as_uuid(), &body.status)
.await?; .await?;
if prior.status == "draft" && body.status == "running" {
if let Err(e) =
crate::mission_orchestrator::on_launch(&state.pool, user.workspace_id, user.user_id, id)
.await
{
eprintln!("mission {id}: on_launch failed: {e}");
}
}
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())
.await? .await?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
+91
View File
@@ -0,0 +1,91 @@
//! 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.
//! Slice 2's client-side `TEMPLATE_PRESETS` is a mirror of what
//! ends up here; a follow-up serves this registry over an API so
//! the client can drop its inline mirror.
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::OnceLock;
#[derive(Debug, Clone, Deserialize)]
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>,
}
#[derive(Debug, Clone, Deserialize)]
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)
}
+20
View File
@@ -0,0 +1,20 @@
key = "benchmark"
title = "Benchmark"
blurb = "Author + baseline benchmarks so subsequent refactors can be measured before/after."
requires_repo = true
[[phases]]
kind = "benchmark"
order_idx = 0
[phases.config]
# Slice 7 runs the benchmark_snapshots baseline pass here. This
# workflow's job is to AUTHOR the benchmarks + establish the
# baseline; subsequent refactor missions consume them.
mode = "author_and_baseline"
# Which harness to use; per-stack defaults if unset:
# rust → criterion / cargo bench
# ts/js → vitest --bench / mitata
# py → pytest-benchmark
harness = "auto"
default_team_template = "rust_sdlc"
+18
View File
@@ -0,0 +1,18 @@
key = "refactor"
title = "Refactor"
blurb = "Audit dependencies + versions, propose API/SDK adaptations, apply the changes."
requires_repo = true
[[phases]]
kind = "coding"
order_idx = 0
[phases.config]
loop = "single_pass"
# Preamble asks the planner to run `cargo tree`, `cargo outdated`,
# `npm outdated`, etc. and produce INT-XX items per stale dep.
task_preamble = "dependency_audit_v1"
commit_policy = "on_green_tests"
# Bench before + after the pass so we can measure impact.
benchmark = { mode = "before_after" }
default_team_template = "rust_sdlc"
@@ -0,0 +1,31 @@
key = "research_and_code"
title = "Research + Coding Loop"
blurb = "Research a topic against a repo, then loop the coding team through the produced INT-XX items until done."
requires_repo = true
[[phases]]
kind = "research"
order_idx = 0
[phases.config]
produces = ["md", "pdf"]
default_topology = "hub_spoke"
[[phases]]
kind = "coding"
order_idx = 1
[phases.config]
# Loop policy: keep iterating until the artifact yields no more
# unconsumed INT-XX items. Scheduler stops when `consumed_int_ids`
# equals the artifact's declared set.
loop = "until_no_more_int_items"
# Preamble injected at the head of each iteration's task text so
# the agents know where the repo lives + how to commit. Slice 3.5c's
# `workspace-repo-commit-protocol` skill also covers this — the
# preamble is the belt, the skill the suspenders.
task_preamble = "workspace_repo_v1"
# Only commit when tests pass. Enforced by the team's TEST_PASS
# marker before the committer runs. If a coding role wants to bypass
# (rare — pure docs commit), it emits COMMIT_POLICY_OVERRIDE: <reason>.
commit_policy = "on_green_tests"
default_team_template = "rust_sdlc"
+18
View File
@@ -0,0 +1,18 @@
key = "research_only"
title = "Research only"
blurb = "Produce a styled MD + PDF artifact in the workspace. One-shot or scheduled."
requires_repo = false
# Phases run in order. Each entry gets a `mission_phases` row on
# mission create; the orchestrator dispatches per-kind executors.
[[phases]]
kind = "research"
order_idx = 0
# Phase-scoped config, merged into mission_phases.config on insert.
[phases.config]
produces = ["md", "pdf"]
default_topology = "hub_spoke"
# Which team template is the "sensible default" for the picker when
# the user hasn't explicitly picked one. UI honors this.
default_team_template = "rust_sdlc"
@@ -0,0 +1,34 @@
key = "security_hardening"
title = "Security Hardening"
blurb = "Scan the repo for vulnerabilities, research patches, then apply + verify."
requires_repo = true
[[phases]]
kind = "security_scan"
order_idx = 0
[phases.config]
# Slice 8 wires these tools as an MCP bundle. Each finding becomes
# a mission_task with external_id = CVE/RUSTSEC/gitleaks fingerprint.
tools = ["cargo_audit", "gitleaks", "trivy_fs", "semgrep"]
[[phases]]
kind = "research"
order_idx = 1
[phases.config]
produces = ["md", "pdf"]
default_topology = "hub_spoke"
# The research phase reads the security_scan phase's findings from
# mission_tasks and produces a patch strategy per finding.
input_from_phase = "security_scan"
[[phases]]
kind = "coding"
order_idx = 2
[phases.config]
loop = "until_all_findings_closed"
task_preamble = "workspace_repo_v1"
# Security requires reviewer approval on top of green tests.
commit_policy = "on_reviewer_approval"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge", "security_scan"]
default_team_template = "rust_sdlc"