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
+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 58.
//!
//! 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",
}
}