//! `/api/missions/*` — the unified workflow surface (Slice 1). //! //! This is a skeleton: create/list/get/status only. Slices 4–8 layer //! richer behavior on top (template dispatch, phase execution, task //! parsing, artifact rendering). The old `/api/research/*` + //! `/api/loops/*` surfaces stay live in parallel until Slice 9. use axum::{ extract::{Path, Query, State}, Json, }; use cm_db::repo::missions::{ BenchmarkSnapshot, Mission, MissionArtifact, MissionPhase, MissionTask, NewMission, NewMissionPhase, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use crate::{ApiError, AppState, Authed}; // ── Requests ───────────────────────────────────────────────────── #[derive(Debug, Deserialize)] pub struct CreateMissionRequest { pub title: String, pub template_kind: String, pub team_id: Option, pub team_template_id: Option, pub repo_id: Option, #[serde(default = "default_schedule")] pub schedule: Value, pub description: Option, #[serde(default)] pub config: Value, #[serde(default)] pub phases: Vec, /// Defaults to "zeroclaw". "local_herdr" requires target_node_id. pub runtime_kind: Option, pub target_node_id: Option, /// Which per-CLI rootfs a `microvm` mission boots (`missions.backend`), e.g. /// "claude". NULL boots the node's default image. pub backend: Option, /// Model that independently validates this mission's phase verdicts, e.g. /// `glm:glm-4.7`. Omit to use the deployment default; send `""` to opt out of /// independent validation and judge with the house model. pub validator_model: Option, /// Team engine: `"claude_code"` asks the mission's agent to form a team. /// Omit for solo, which is the default and much cheaper. pub team_engine: Option, } fn default_schedule() -> Value { serde_json::json!({ "kind": "one_shot" }) } #[derive(Debug, Deserialize)] pub struct PhaseSpec { pub kind: String, pub order_idx: i32, #[serde(default)] pub config: Value, } #[derive(Debug, Deserialize)] pub struct ListQuery { #[serde(default = "default_limit")] pub limit: i64, } fn default_limit() -> i64 { 50 } #[derive(Debug, Deserialize)] pub struct SetStatusRequest { pub status: String, } // ── Responses ──────────────────────────────────────────────────── #[derive(Debug, Serialize)] pub struct MissionDetail { #[serde(flatten)] pub mission: Mission, pub phases: Vec, pub tasks: Vec, pub artifacts: Vec, pub benchmarks: Vec, } #[derive(Debug, Deserialize)] pub struct BenchmarkTriggerRequest { pub phase_id: Uuid, /// Slot: "baseline" (records iteration 0) or "after" /// (records iteration N + delta vs baseline). pub slot: String, #[serde(default)] pub iteration: Option, } #[derive(Debug, Deserialize)] pub struct SecurityScanRequest { pub phase_id: Uuid, } #[derive(Debug, Serialize)] pub struct SecurityScanResponse { pub findings: usize, pub tasks: Vec, } // ── Handlers ───────────────────────────────────────────────────── /// A mission plus the phase progress the list card needs. `mission` is /// flattened, so the JSON is a strict SUPERSET of `Mission` — existing /// consumers keep working and simply gain fields. #[derive(Debug, Serialize)] pub struct MissionListItem { #[serde(flatten)] pub mission: Mission, pub phases_total: i64, pub phases_done: i64, /// Kind of the phase currently running, if any. pub current_phase: Option, } pub async fn list( State(state): State, Authed(user): Authed, Query(q): Query, ) -> Result>, ApiError> { let rows = cm_db::repo::missions::list_by_workspace( &state.pool, user.workspace_id.as_uuid(), q.limit.clamp(1, 500), ) .await?; // One extra grouped query for the whole page, not one per mission. let ids: Vec = rows.iter().map(|m| m.id).collect(); let progress = cm_db::repo::missions::phase_progress(&state.pool, &ids).await?; let by_id: std::collections::HashMap)> = progress .into_iter() .map(|(id, total, done, running)| (id, (total, done, running))) .collect(); Ok(Json( rows.into_iter() .map(|m| { let (phases_total, phases_done, current_phase) = by_id.get(&m.id).cloned().unwrap_or((0, 0, None)); MissionListItem { mission: m, phases_total, phases_done, current_phase, } }) .collect(), )) } /// Resolve the phase list for a new mission, merging each phase's `config` over /// the workflow recipe's. /// /// `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. /// /// The recipe is the **base** and the caller's keys override individually — /// not wholesale. A caller that sends `{done_when: "..."}` is adding a /// completion condition, not declaring that the phase has no other settings. /// Replacing here meant a conditioned `security_hardening` phase lost its /// `tools` list, which `security_scan.rs` reads, so the scan would silently /// run with no tools configured. fn phases_for_create( recipe: Option<&crate::workflow_registry::WorkflowRecipe>, requested: Vec, ) -> Vec { // 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 the shape, and merge the caller's config over // the matching recipe phase's (matched by kind + order_idx, then kind). requested .into_iter() .map(|p| { let base = 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); let config = merge_config(base, p.config); // Say what this phase asked for that will not happen. A config key // nothing reads is silent by construction — `task` sat unread // through every mission until two phases with different tasks // produced identical output. crate::phase_config::report(&p.kind, p.order_idx, &config); NewMissionPhase { kind: p.kind, order_idx: p.order_idx, config, } }) .collect() } /// Shallow-merge `over` onto `base`, key by key. /// /// Shallow is deliberate: phase config is a flat settings bag, and a caller /// that sends `tools: [...]` means to replace the list, not union it. fn merge_config(base: Value, over: Value) -> Value { match (base, over) { (Value::Object(mut b), Value::Object(o)) => { for (k, v) in o { b.insert(k, v); } Value::Object(b) } // Nothing to merge onto, or nothing to merge in. (base, Value::Null) => base, (Value::Null, over) => over, // A non-object override replaces outright — there is no sane merge of // e.g. an array onto an object, and silently picking one would hide // the caller's mistake. (_, over) => over, } } /// `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( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result, ApiError> { if body.title.trim().is_empty() { return Err(ApiError::BadRequest); } // Validate runtime_kind + require target_node when local_herdr. let runtime_kind = body.runtime_kind.as_deref().unwrap_or("zeroclaw"); match runtime_kind { "zeroclaw" => {} "local_herdr" => { if body.target_node_id.is_none() { return Err(ApiError::BadRequest); } } // microvm needs no target here: placement resolves a KVM-capable node at // launch and fails the launch when there is none, so an explicit target is // a request rather than a requirement. Rejecting the value outright — as // this did until B4.5 — made `runtime_kind='microvm'` unreachable through // the only interface that creates missions. "microvm" => {} _ => return Err(ApiError::BadRequest), } let new = NewMission { workspace_id: user.workspace_id.as_uuid(), title: body.title.trim(), template_kind: body.template_kind.trim(), team_id: body.team_id, team_template_id: body.team_template_id, repo_id: body.repo_id, schedule: body.schedule, description: body.description.as_deref(), config: body.config, runtime_kind: Some(runtime_kind), target_node_id: body.target_node_id, backend: body.backend.as_deref(), validator_model: body.validator_model.as_deref(), team_engine: body.team_engine.as_deref(), phases: phases_for_create( crate::workflow_registry::get(body.template_kind.trim()), body.phases, ), }; 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()) .await? .ok_or(ApiError::Internal)?; Ok(Json(mission)) } pub async fn get( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let phases = cm_db::repo::missions::phases_for(&state.pool, id).await?; let tasks = cm_db::repo::missions::tasks_for(&state.pool, id).await?; let artifacts = cm_db::repo::missions::artifacts_for(&state.pool, id).await?; let benchmarks = cm_db::repo::missions::benchmark_snapshots_for(&state.pool, id).await?; Ok(Json(MissionDetail { mission, phases, tasks, artifacts, benchmarks, })) } /// GET /api/missions/{id}/artifacts/{artifact_id}/content — the artifact's text. /// /// The frontend had no way to READ an artifact: it listed paths and offered a /// PDF preview, and the PDF never rendered. Markdown is the deliverable now, so /// something has to serve it. /// /// Two containment rules, both enforced rather than assumed: /// /// - the artifact row must belong to a mission in the caller's workspace, so /// an artifact id from another tenant is a 404, not a file read; /// - the resolved path must stay inside `/_outputs`. Artifact /// paths are written by this server, but a stored `../../etc/passwd` would /// otherwise be read and returned. Canonicalise, then check the prefix — /// checking the string before resolving `..` is the classic hole. /// /// Text only, and capped: these are markdown documents, and streaming an /// arbitrary captured file into a JSON body is not what this is for. /// Turn a stored artifact path into an absolute one, refusing anything outside /// `_outputs`. /// /// Shared by the read and download routes deliberately: two copies of a /// containment check is two chances for one of them to be the lenient one, and /// the lenient one is a path-traversal read of the gateway's filesystem. fn resolve_artifact_path(stored: &str) -> Result { let root = crate::mission_outputs::outputs_root_dir(); let abs = crate::mission_outputs::missions_root_dir().join(stored); // `canonicalize` on BOTH sides, so a symlink out of the tree resolves to // its target before the comparison rather than after. let resolved = std::fs::canonicalize(&abs).map_err(|_| ApiError::NotFound)?; let root = std::fs::canonicalize(&root).map_err(|_| ApiError::NotFound)?; if !resolved.starts_with(&root) { eprintln!( "missions: refused artifact {} — outside {}", resolved.display(), root.display() ); return Err(ApiError::NotFound); } Ok(resolved) } /// `GET /api/missions/{id}/artifacts/{artifact_id}/download` — the file itself. /// /// Separate from `artifact_content` because that route cannot serve the two /// cases a download exists for: it caps at 2 MiB and reads as UTF-8, so a large /// or binary artifact is unreachable by any means today. This one streams the /// bytes with a filename attached and no ceiling. pub async fn artifact_download( State(state): State, Authed(user): Authed, Path((id, artifact_id)): Path<(Uuid, Uuid)>, ) -> Result { use axum::response::IntoResponse; cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let artifacts = cm_db::repo::missions::artifacts_for(&state.pool, id).await?; let artifact = artifacts .into_iter() .find(|a| a.id == artifact_id) .ok_or(ApiError::NotFound)?; let resolved = resolve_artifact_path(&artifact.path)?; let bytes = tokio::fs::read(&resolved) .await .map_err(|_| ApiError::NotFound)?; // The basename, never the stored path: `_outputs///repo/x.md` // as a filename would arrive as a browser-mangled string, and the path is // internal layout the user has no reason to see. let name = resolved .file_name() .and_then(|n| n.to_str()) .filter(|n| !n.is_empty()) .unwrap_or("artifact"); // Quoted and stripped of quotes/newlines: a filename is attacker-influenced // input (an agent chose it) and this header is parsed by every browser. let safe: String = name .chars() .filter(|c| *c != '"' && *c != '\\' && !c.is_control()) .collect(); Ok(( [ ( axum::http::header::CONTENT_TYPE, artifact.mime.unwrap_or_else(|| "application/octet-stream".into()), ), ( axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{safe}\""), ), ], bytes, ) .into_response()) } pub async fn artifact_content( State(state): State, Authed(user): Authed, Path((id, artifact_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { /// Beyond this, a document is not something a reader wants inline. const MAX_BYTES: u64 = 2 * 1024 * 1024; // Scoped to the caller's workspace by loading the mission first. cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let artifacts = cm_db::repo::missions::artifacts_for(&state.pool, id).await?; let artifact = artifacts .into_iter() .find(|a| a.id == artifact_id) .ok_or(ApiError::NotFound)?; let resolved = resolve_artifact_path(&artifact.path)?; let meta = std::fs::metadata(&resolved).map_err(|_| ApiError::NotFound)?; if meta.len() > MAX_BYTES { return Ok(Json(serde_json::json!({ "path": artifact.path, "mime": artifact.mime, "truncated": true, "content": "", "bytes": meta.len(), }))); } let content = std::fs::read_to_string(&resolved).map_err(|_| ApiError::NotFound)?; Ok(Json(serde_json::json!({ "path": artifact.path, "mime": artifact.mime, "title": artifact.title, "truncated": false, "content": content, "bytes": meta.len(), }))) } /// POST /api/missions/{id}/merge — merge this mission's branch into the base. /// /// The operator's button. `MergePolicy::Never` — the default for anything that /// touches code — means "do not merge on your own", deferring to a human; this /// endpoint is that human saying yes. So the additive-only test does not apply /// here, and deliberately so. /// /// It works in a FRESH CLONE under `_merge/`, never the mission /// checkout: that directory is reaped on a timer after a mission ends, so a /// merge that used it would succeed right after a run and fail inexplicably an /// hour later. The clone is made by the server process, so nothing here runs as /// root and the ordinary cleanup works — unlike the copies in `root_copy`. pub async fn merge_branch( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let repo_id = mission.repo_id.ok_or(ApiError::BadRequest)?; let repo = cm_db::repo::repos::get(&state.pool, repo_id, user.workspace_id) .await .map_err(|_| ApiError::NotFound)?; let clone_url = repo.clone_url.as_deref().ok_or(ApiError::BadRequest)?; let base = repo.default_branch.as_deref().unwrap_or("main"); // The branch is whatever delivery actually pushed — read from the artifact // it recorded, not reconstructed from the mission id. A phase that never // pushed has no branch, and that must be a refusal rather than a guess. let artifacts = cm_db::repo::missions::artifacts_for(&state.pool, id).await?; let delivered = artifacts.iter().rev().find_map(|a| { let m = a.metadata.as_object()?; let branch = m.get("branch")?.as_str()?.to_string(); (m.get("pushed").and_then(|v| v.as_bool()) == Some(true)).then_some(branch) }); let Some(branch) = delivered else { return Ok(Json(serde_json::json!({ "merged": false, "reason": "this mission has no pushed branch to merge", }))); }; let auth = crate::mission_workspace::with_ambient_auth(clone_url); let workdir = crate::mission_workspace::missions_root() .join("_merge") .join(id.to_string()); let _ = tokio::fs::remove_dir_all(&workdir).await; if let Some(parent) = workdir.parent() { let _ = tokio::fs::create_dir_all(parent).await; } let clone = tokio::process::Command::new("git") .args(["clone", "--quiet", &auth.url]) .arg(&workdir) .env("GIT_TERMINAL_PROMPT", "0") .output() .await .map_err(|_| ApiError::Internal)?; if !clone.status.success() { eprintln!( "missions::merge_branch: clone for {id} failed: {}", String::from_utf8_lossy(&clone.stderr) .chars() .take(300) .collect::() ); return Ok(Json(serde_json::json!({ "merged": false, "reason": "could not clone the repository to merge", }))); } let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER") .unwrap_or_else(|_| "clawmates-runtime".to_string()); let outcome = async { let merged = crate::auto_merge::merge_on_operator_approval(&workdir, &auth.url, &branch, base) .await?; if !merged.merged { return Ok(merged); } // Run the project's own tests against the MERGED tree, before it is // published. Verifying first rather than reverting after is the // difference between "main was never broken" and "main was broken until // someone noticed". // // The merge is already committed locally at this point; refusing here // simply never pushes it, and the branch is still there to retry. match crate::mission_delivery::verify_tests(&workdir, &container).await { crate::mission_delivery::TestOutcome::Passed => {} crate::mission_delivery::TestOutcome::NoSuite => { eprintln!( "missions::merge_branch: {branch} has no discoverable test suite — publishing unverified" ); } crate::mission_delivery::TestOutcome::Failed(code) => { return Ok(crate::auto_merge::MergeOutcome { merged: false, reason: format!( "the merged tree FAILS the project's tests (exit {code}) — not published. The branch is unchanged; fix it and merge again." ), }); } // Fail closed. A suite that could not run has not passed, and // publishing on "we could not check" is how a green main stops // meaning anything. crate::mission_delivery::TestOutcome::CouldNotRun(why) => { return Ok(crate::auto_merge::MergeOutcome { merged: false, reason: format!("could not run the tests on the merged tree ({why}) — not published"), }); } } crate::auto_merge::push_merged(&workdir, &auth.url, base).await?; Ok::<_, String>(crate::auto_merge::MergeOutcome { merged: true, reason: format!("tests pass on the merged tree; published to {base}"), }) } .await; // Purge through the container: `verify_tests` runs `cargo test` as ROOT, so // the workdir now holds a root-owned `target/` the server (uid 65532) cannot // delete. Same defect as the bench and judge copies. crate::root_copy::purge(&container, &workdir).await; let _ = tokio::fs::remove_dir_all(&workdir).await; match outcome { Ok(o) => { eprintln!( "missions::merge_branch: mission {id} branch {branch} -> {base}: {}", o.reason ); Ok(Json(serde_json::json!({ "merged": o.merged, "reason": o.reason, "branch": branch, "base": base, }))) } Err(e) => { eprintln!("missions::merge_branch: mission {id} failed: {e}"); Ok(Json(serde_json::json!({ "merged": false, "reason": format!("merge failed: {e}"), "branch": branch, "base": base, }))) } } } /// POST /api/missions/{id}/benchmark — run the benchmark harness /// against a phase. Slot='baseline' records iteration 0's /// before_metrics; slot='after' with iteration=N records the /// after_metrics + computes delta against baseline. pub async fn trigger_benchmark( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result>, ApiError> { // Workspace scope check on the mission — 404 if not visible. let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let result = match body.slot.as_str() { "baseline" => crate::benchmark_runner::baseline(&state.pool, id, body.phase_id).await, "after" => { let iter = body.iteration.unwrap_or(1); crate::benchmark_runner::after_iteration(&state.pool, id, body.phase_id, iter).await } _ => return Err(ApiError::BadRequest), }; if let Err(e) = result { eprintln!("benchmark trigger for mission {id}: {e}"); return Err(ApiError::Internal); } let snaps = cm_db::repo::missions::benchmark_snapshots_for(&state.pool, id).await?; Ok(Json(snaps)) } /// POST /api/missions/{id}/security-scan — run the security phase's /// tool set (cargo-audit / gitleaks / trivy fs / semgrep) inside /// the mission's team container and materialize each finding as a /// mission_task keyed on the tool's canonical id. pub async fn trigger_security_scan( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let findings = crate::security_scan::run(&state.pool, id, body.phase_id) .await .map_err(|e| { eprintln!("security_scan for mission {id}: {e}"); ApiError::Internal })?; let tasks = cm_db::repo::missions::tasks_for(&state.pool, id).await?; Ok(Json(SecurityScanResponse { findings, tasks })) } #[derive(Debug, Serialize)] pub struct RefineResponse { pub original: String, pub refined: String, } #[derive(Debug, Deserialize)] pub struct RefineDraftRequest { #[serde(default)] pub title: String, pub description: String, #[serde(default)] pub template_kind: Option, } /// `POST /api/missions/refine-draft` — polish a description with no mission /// behind it yet. /// /// The wizard's polish button fires while the user is still typing, before /// anything is created. `refine` deliberately requires a saved draft so its /// Accept can write back; this one has nothing to write back to and returns the /// text for the caller to put in the box. /// /// The phase list comes from the workflow recipe rather than the caller, for /// the same reason `phases_for_create` prefers it: the recipe is the /// authoritative composition, and a client that guessed would have the model /// write acceptance criteria for phases the mission will not run. pub async fn refine_draft( State(state): State, Authed(_user): Authed, Json(req): Json, ) -> Result, ApiError> { let phase_kinds: Vec = req .template_kind .as_deref() .and_then(crate::workflow_registry::get) .map(|r| r.phases.iter().map(|p| p.kind.clone()).collect()) .unwrap_or_default(); let result = crate::mission_refiner::refine_draft( &state.runtime, req.title.trim(), req.template_kind.as_deref().unwrap_or("custom"), &phase_kinds, &req.description, ) .await .map_err(|e| { eprintln!("refine-draft failed: {e}"); if e.contains("empty") { ApiError::BadRequest } else { crate::subscription::as_api_error(&e) } })?; Ok(Json(RefineResponse { original: result.original, refined: result.refined, })) } /// POST /api/missions/{id}/refine — generate a coherent, sectioned /// Markdown rewrite of the current description WITHOUT persisting. /// Frontend renders a before/after diff; user hits Accept (PATCH /// /description) or Cancel. Draft-only. pub async fn refine( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let result = crate::mission_refiner::refine(&state.pool, &state.runtime, user.workspace_id, id) .await .map_err(|e| { eprintln!("mission {id}: refine failed: {e}"); if e.contains("not found") { ApiError::NotFound } else if e.contains("empty") || e.contains("only allowed on draft") { ApiError::BadRequest } else { ApiError::Internal } })?; Ok(Json(RefineResponse { original: result.original, refined: result.refined, })) } #[derive(Debug, Deserialize)] pub struct SetDescriptionRequest { pub description: String, } /// PATCH /api/missions/{id}/description — commit a new description. /// Draft-only. Used by the Refine Accept flow (and any future /// direct-edit surface). pub async fn set_description( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; if mission.status != "draft" { return Err(ApiError::BadRequest); } cm_db::repo::missions::set_description( &state.pool, id, user.workspace_id.as_uuid(), &body.description, ) .await?; let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; Ok(Json(mission)) } #[derive(Debug, Deserialize)] pub struct UpdateMissionRequest { #[serde(default)] pub title: Option, #[serde(default)] pub description: Option, } /// PATCH /api/missions/{id} — edit title + description. Draft-only. pub async fn update_meta( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; if mission.status != "draft" { return Err(ApiError::BadRequest); } let title = body .title .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); let description = body.description.as_deref(); cm_db::repo::missions::update_meta( &state.pool, id, user.workspace_id.as_uuid(), title, description, ) .await?; let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; Ok(Json(mission)) } /// DELETE /api/missions/{id} — hard-delete. Allowed in any status; /// the operator is expected to Cancel first if a run is in flight /// (cascades will still fire either way). pub async fn delete( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let ws = user.workspace_id.as_uuid(); // Verify the mission exists in this workspace before we start reaping. let exists: Option = sqlx::query_scalar("SELECT id FROM missions WHERE id = $1 AND workspace_id = $2") .bind(id) .bind(ws) .fetch_optional(&state.pool) .await .map_err(|_| ApiError::Internal)?; if exists.is_none() { return Err(ApiError::NotFound); } // Reap every resource the mission provisioned BEFORE the DB delete, so // nothing is left hanging. Runtime-side steps are best-effort (Postgres // is authoritative; the daemon config is a cache the fleet sweeper can // reconcile) — a failure logs and continues rather than blocking delete. reap_mission_resources(&state, id).await; let deleted = cm_db::repo::missions::delete(&state.pool, id, ws).await?; if deleted == 0 { return Err(ApiError::NotFound); } Ok(Json(serde_json::json!({ "deleted": true }))) } /// Tear down all resources a mission created: its per-mission runtime /// container + workspace dir, every claw (ZeroClaw config, `.brain` files, /// and all DB rows via `hard_purge`), the (permanent-lifecycle) teams, and /// its topology runs. Called before the `missions` row is deleted so the /// `mission_teams` junction is still resolvable. Best-effort throughout. async fn reap_mission_resources(state: &AppState, mission_id: Uuid) { // 1. Resolve the mission's teams, then their claws. let team_ids: Vec = sqlx::query_scalar("SELECT team_id FROM mission_teams WHERE mission_id = $1") .bind(mission_id) .fetch_all(&state.pool) .await .unwrap_or_default(); let claw_ids: Vec = if team_ids.is_empty() { Vec::new() } else { sqlx::query_scalar("SELECT DISTINCT claw_id FROM team_members WHERE team_id = ANY($1)") .bind(&team_ids) .fetch_all(&state.pool) .await .unwrap_or_default() }; // 2. Reap each claw: ZeroClaw config → sandbox container → .brain files → // all DB rows. Shared with the batch-delete reaper so this path cannot // drift back into skipping the container teardown. let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env(); for cid in &claw_ids { // Only claws this mission is the LAST holder of. // // Claws are reused across missions now (see // `agent_template_link::reusable_claw`), so a mission's team can contain // staff that other missions still employ. Purging those would delete a // user's workforce as a side effect of tidying up one mission — and it // would look like the roster quietly shrinking, not like an error. let shared: i64 = sqlx::query_scalar( "SELECT count(*) FROM team_members tm JOIN mission_teams mt ON mt.team_id = tm.team_id WHERE tm.claw_id = $1 AND mt.mission_id <> $2", ) .bind(cid) .bind(mission_id) .fetch_one(&state.pool) .await .unwrap_or(0); if shared > 0 { eprintln!( "missions::delete: keeping claw {cid} — {shared} other mission(s) still employ it" ); continue; } let report = crate::routes::claws::purge_agent( &state.pool, &state.runtime, provisioner.as_ref(), cm_domain::AgentId::from(*cid), ) .await; if let Err(e) = report.counts { eprintln!("missions::delete: hard_purge claw {cid} failed (continuing): {e}"); } } // 3. Delete the (permanent-lifecycle) teams — no mission FK cascades them. // team_members cascades from teams. if !team_ids.is_empty() { if let Err(e) = sqlx::query("DELETE FROM teams WHERE id = ANY($1)") .bind(&team_ids) .execute(&state.pool) .await { eprintln!("missions::delete: delete teams for {mission_id} failed (continuing): {e}"); } } // 4. Delete this mission's topology runs (else they linger with // mission_id nulled by the cascade and accumulate forever). if let Err(e) = sqlx::query("DELETE FROM topology_runs WHERE mission_id = $1") .bind(mission_id) .execute(&state.pool) .await { eprintln!( "missions::delete: delete topology_runs for {mission_id} failed (continuing): {e}" ); } // 5. Tear down the per-mission runtime container + its workspace dir. if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() { if let Err(e) = mp.teardown_container(mission_id).await { eprintln!( "missions::delete: teardown container for {mission_id} failed (continuing): {e}" ); } } eprintln!( "missions::delete: reaped {} claw(s), {} team(s) for mission {mission_id}", claw_ids.len(), team_ids.len() ); } #[derive(Debug, Deserialize)] pub struct HerdrDispatchRequest { pub cli: String, pub prompt: String, } #[derive(Debug, Serialize)] pub struct HerdrDispatchResponse { pub pane_id: String, pub node_id: Uuid, } /// POST /api/missions/{id}/herdr-dispatch — manually spawn a Herdr /// pane on the mission's target_node running `cli` with `prompt`. /// Requires mission.runtime_kind = 'local_herdr' + target_node_id set. /// Wizard integration + auto-dispatch land in later phases; this /// exists so Phase 1b's fleet_herdr module can be exercised end-to-end /// against a real node while the rest of the arc builds out. pub async fn herdr_dispatch( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; if mission.runtime_kind != "local_herdr" { return Err(ApiError::BadRequest); } let node_id = mission.target_node_id.ok_or(ApiError::BadRequest)?; let handle = crate::fleet_herdr::dispatch( state.node_hub.clone(), cm_domain::NodeId::from(node_id), id, body.cli.trim(), body.prompt.trim(), ) .await .map_err(|e| { eprintln!("herdr_dispatch mission {id}: {e}"); ApiError::Internal })?; Ok(Json(HerdrDispatchResponse { pane_id: handle.pane_id, node_id, })) } /// GET /api/missions/{id}/teams — teams materialized for this mission, /// grouped by purpose (research / coding / etc). Returns /// [{ purpose, team_id, team_name }] so the Team tab can render /// sections. The legacy single-team view falls back to /// mission.team_id when this array is empty. pub async fn list_teams( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; use sqlx::Row; let rows = sqlx::query( "SELECT mt.team_id::text AS team_id, mt.purpose, t.name AS team_name FROM mission_teams mt JOIN teams t ON t.id = mt.team_id WHERE mt.mission_id = $1 ORDER BY mt.created_at ASC", ) .bind(id) .fetch_all(&state.pool) .await?; let teams: Vec = rows .into_iter() .map(|r| { serde_json::json!({ "team_id": r.get::("team_id"), "purpose": r.get::("purpose"), "team_name": r.get::("team_name"), }) }) .collect(); Ok(Json(serde_json::json!({ "teams": teams }))) } /// POST /api/missions/{id}/phases/{phase_id}/retry — reset a /// failed / cancelled phase back to 'pending' so the phase_runner /// picks it up on the next tick. The runner purges old failed /// topology_runs for the phase before re-enqueuing, so the phase /// card starts fresh on the retry. pub async fn retry_phase( State(state): State, Authed(user): Authed, Path((id, phase_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { // Scope check on the mission. let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; // `failed` is retryable, and has to be: a failed phase now closes its // mission (its later phases are marked unreachable so the mission can // finish at all), so refusing anything but `running` would mean the one // outcome you would actually want to retry is the one you cannot. // `completed` and `cancelled` stay refused — reopening those is a different // decision than re-running a phase that failed. if mission.status != "running" && mission.status != "failed" { return Err(ApiError::BadRequest); } let mut tx = state.pool.begin().await?; let r = sqlx::query( "UPDATE mission_phases SET status = 'pending', started_at = NULL, completed_at = NULL WHERE id = $1 AND mission_id = $2 AND status IN ('failed', 'cancelled')", ) .bind(phase_id) .bind(id) .execute(&mut *tx) .await?; if r.rows_affected() == 0 { tx.rollback().await?; return Err(ApiError::NotFound); } // Reopen the phases this one's failure had made unreachable. Without this a // retry runs the failed phase and then stops, because everything after it // is terminal-by-skip — the mission would close again the moment this phase // finished, having done only part of the work. let reopened = sqlx::query( "UPDATE mission_phases mp SET status = 'pending', started_at = NULL, completed_at = NULL WHERE mp.mission_id = $1 AND mp.status = 'skipped' AND mp.order_idx > (SELECT order_idx FROM mission_phases WHERE id = $2)", ) .bind(id) .bind(phase_id) .execute(&mut *tx) .await? .rows_affected(); // And put the mission back to running, or nothing sweeps the phase: every // launcher and closer keys off `missions.status = 'running'`. sqlx::query( "UPDATE missions SET status = 'running', completed_at = NULL, updated_at = now() WHERE id = $1 AND status = 'failed'", ) .bind(id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Json( serde_json::json!({ "reset": true, "reopened_phases": reopened }), )) } /// GET /api/missions/{id}/phases/{phase_id}/summary — the completion /// card produced by `phase_summarizer` for a terminal-state phase. /// Returns 404 while the phase is still running / hasn't been /// summarized yet. /// `GET /api/missions/{id}/phases/{phase_id}/evaluations` — every completion /// verdict for a phase, newest first. /// /// One row per pass. The `reason` is the operator-facing explanation of why a /// phase iterated (or stopped), and is the same text fed back to the agents as /// guidance for the following pass. pub async fn list_phase_evaluations( State(state): State, Authed(user): Authed, Path((id, phase_id)): Path<(Uuid, Uuid)>, ) -> Result>, ApiError> { // Scope check — same shape as get_phase_summary. let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; use sqlx::Row; let rows = sqlx::query( "SELECT iteration, met, reason, model, error, created_at, checks FROM mission_phase_evaluations WHERE mission_id = $1 AND phase_id = $2 ORDER BY iteration DESC", ) .bind(id) .bind(phase_id) .fetch_all(&state.pool) .await?; Ok(Json( rows.into_iter() .map(|r| { let created_at: time::OffsetDateTime = r.get("created_at"); serde_json::json!({ "iteration": r.get::("iteration"), "met": r.get::("met"), "reason": r.get::("reason"), "model": r.get::("model"), "error": r.get::, _>("error"), // The verification commands the judge actually ran. An // empty list means the verdict rests on agent claims // alone, which an operator should be able to see. "checks": r.get::("checks"), "created_at": created_at .format(&time::format_description::well_known::Rfc3339) .unwrap_or_default(), }) }) .collect(), )) } pub async fn get_phase_summary( State(state): State, Authed(user): Authed, Path((id, phase_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { // Scope check. let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; use sqlx::Row; let row = sqlx::query( "SELECT kind, model, narrative, metrics, sources, artifacts, tooling, next_actions, generated_at, error FROM mission_phase_summaries WHERE mission_id = $1 AND phase_id = $2", ) .bind(id) .bind(phase_id) .fetch_optional(&state.pool) .await?; let Some(r) = row else { return Err(ApiError::NotFound); }; let generated_at: time::OffsetDateTime = r.get("generated_at"); let payload = serde_json::json!({ "kind": r.get::("kind"), "model": r.get::("model"), "narrative": r.get::("narrative"), "metrics": r.get::("metrics"), "sources": r.get::("sources"), "artifacts": r.get::("artifacts"), "tooling": r.get::("tooling"), "next_actions": r.get::("next_actions"), "generated_at": generated_at .format(&time::format_description::well_known::Rfc3339) .unwrap_or_default(), "error": r.get::, _>("error"), }); Ok(Json(payload)) } /// GET /api/missions/{id}/runs — topology_runs bound to this mission, /// newest first. Used by the Live tab to subscribe to per-run SSE. pub async fn list_runs( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { // Scope check — 404 if the mission doesn't belong to this workspace. let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let runs = cm_db::repo::topology_runs::list_by_mission(&state.pool, id, 50).await?; Ok(Json(serde_json::json!({ "runs": runs }))) } pub async fn set_status( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let allowed = ["draft", "running", "completed", "failed", "cancelled"]; if !allowed.contains(&body.status.as_str()) { 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)?; // Draft→running requires a materializable team. Run the orchestrator // BEFORE flipping status so a materialization failure keeps the // mission in draft (no orphaned "running" mission with no agents). if prior.status == "draft" && body.status == "running" { // Materializable when we have any of: // - team_id (already exists) // - team_template_id (legacy single-team path) // - config.phase_teams with at least one non-empty list (new multi-team) let has_phase_teams = prior .config .get("phase_teams") .and_then(|v| v.as_object()) .map(|obj| { obj.values() .any(|v| v.as_array().map(|a| !a.is_empty()).unwrap_or(false)) }) .unwrap_or(false); // A microVM mission materialises no team — `microvm_executor` runs the // agent CLI directly in the VM — so requiring one would reject the launch // of a perfectly well-formed mission, and satisfying it would provision // claws that never run. let needs_team = prior.runtime_kind != "microvm"; if needs_team && prior.team_id.is_none() && prior.team_template_id.is_none() && !has_phase_teams { eprintln!( "mission {id}: launch rejected — no team_id, no team_template_id, no config.phase_teams" ); return Err(ApiError::BadRequest); } if let Err(e) = crate::mission_orchestrator::on_launch( &state.pool, user.workspace_id, user.user_id, id, Some(state.node_hub.clone()), ) .await { eprintln!("mission {id}: on_launch failed: {e}"); return Err(ApiError::Internal); } } cm_db::repo::missions::set_status(&state.pool, id, user.workspace_id.as_uuid(), &body.status) .await?; let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; Ok(Json(mission)) } // ── Output reader ──────────────────────────────────────────────── // // The mission Output tab is a document reader, not a log tail. The // phase-card preview endpoint (`routes::topology::get_run_output`) caps // every turn at 6,000 chars, which shows only ~11% of a typical research // brief (they run 40–55kB) with no way to read the rest. These two routes // are the reader's data source: one lists every document in the mission // for the outline rail, the other returns one document in full. /// One agent turn's output, as a readable document. #[derive(Debug, Serialize)] pub struct MissionDocument { pub run_id: Uuid, pub phase_id: Option, /// Index into the run's `checkpoint.outputs` array. pub index: usize, /// Topology node id (`n0`) — stable within the run's graph. pub node_id: String, /// The node's role (`code_archeologist`), i.e. what this agent was. pub role: String, /// Human title: the document's first markdown heading when it has /// one, else its first non-empty line. pub title: String, pub chars: usize, pub run_status: String, } #[derive(Debug, Serialize)] pub struct MissionDocumentsResponse { pub documents: Vec, } /// Derive a display title from a document's own text: prefer the first /// markdown ATX heading, else the first non-empty line. Both are trimmed /// to keep the rail readable. fn document_title(body: &str, fallback: &str) -> String { const MAX: usize = 90; let heading = body .lines() .map(str::trim) .find(|l| l.starts_with('#')) .map(|l| l.trim_start_matches('#').trim()); let line = heading.or_else(|| body.lines().map(str::trim).find(|l| !l.is_empty())); match line { Some(l) if !l.is_empty() => { if l.chars().count() > MAX { format!("{}…", l.chars().take(MAX).collect::()) } else { l.to_string() } } _ => fallback.to_string(), } } /// Map a run's graph node index → (node_id, role). The reader labels each /// document by the agent that produced it; `checkpoint.outputs[i]` /// corresponds to `graph.nodes[i]` (the worker appends one output per /// step, in node order). fn nodes_of(graph: Option<&Value>) -> Vec<(String, String)> { graph .and_then(|g| g.get("nodes")) .and_then(|n| n.as_array()) .map(|arr| { arr.iter() .map(|n| { ( n.get("id") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), n.get("role") .and_then(|v| v.as_str()) .unwrap_or("agent") .to_string(), ) }) .collect() }) .unwrap_or_default() } fn outputs_of(checkpoint: Option<&Value>) -> Vec { checkpoint .and_then(|c| c.get("outputs")) .and_then(|o| o.as_array()) .map(|arr| { arr.iter() .map(|v| match v { Value::String(s) => s.clone(), other => other.to_string(), }) .collect() }) .unwrap_or_default() } /// `GET /api/missions/{id}/documents` — every agent output in the mission, /// oldest run first, as a flat list the reader groups by phase. Bodies are /// NOT included; the rail only needs titles and sizes. pub async fn list_documents( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let source = cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?; let mut documents = Vec::new(); for (run_id, phase_id, run_status, graph, checkpoint) in source { let nodes = nodes_of(graph.as_ref()); for (index, body) in outputs_of(checkpoint.as_ref()).into_iter().enumerate() { let (node_id, role) = nodes .get(index) .cloned() .unwrap_or_else(|| (format!("n{index}"), "agent".to_string())); let fallback = format!("Turn {}", index + 1); documents.push(MissionDocument { run_id, phase_id, index, node_id, title: document_title(&body, &fallback), role, chars: body.chars().count(), run_status: run_status.clone(), }); } } Ok(Json(MissionDocumentsResponse { documents })) } #[derive(Debug, Serialize)] pub struct MissionDocumentBody { pub run_id: Uuid, pub index: usize, pub role: String, pub title: String, /// The complete output text — untruncated, which is the whole point. pub body: String, pub chars: usize, } /// `GET /api/missions/{id}/documents/{run_id}/{index}` — one document in /// full. Separate from the list so opening the Output tab doesn't pull /// every brief in the mission over the wire at once. pub async fn get_document( State(state): State, Authed(user): Authed, Path((id, run_id, index)): Path<(Uuid, Uuid, usize)>, ) -> Result, ApiError> { let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; // Scope the run to the mission as well, so a valid run id from another // mission (or workspace) can't be read through this path. let source = cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?; let (_, _, _, graph, checkpoint) = source .into_iter() .find(|(rid, _, _, _, _)| *rid == run_id) .ok_or(ApiError::NotFound)?; let body = outputs_of(checkpoint.as_ref()) .into_iter() .nth(index) .ok_or(ApiError::NotFound)?; let role = nodes_of(graph.as_ref()) .get(index) .map(|(_, r)| r.clone()) .unwrap_or_else(|| "agent".to_string()); let fallback = format!("Turn {}", index + 1); Ok(Json(MissionDocumentBody { run_id, index, title: document_title(&body, &fallback), role, chars: body.chars().count(), body, })) } #[cfg(test)] mod tests { 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 key wins over the recipe's value for that key. #[test] fn explicit_phase_config_overrides_the_recipe_key() { 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") ); } /// ...but overriding one key must NOT drop the rest of the recipe's /// config. Sending `{done_when}` means "also apply this condition", not /// "this phase has no other settings". /// /// The case that motivated this: a `security_hardening` phase with a /// completion condition lost its `tools` list, which `security_scan.rs` /// reads — so the scan ran with nothing configured and reported clean. #[test] fn adding_a_condition_preserves_the_rest_of_the_recipe_config() { let requested = vec![PhaseSpec { kind: "coding".into(), order_idx: 1, config: serde_json::json!({"done_when": "tests pass", "max_iterations": 3}), }]; let phases = phases_for_create(Some(&test_recipe()), requested); let c = &phases[0].config; assert_eq!( c.get("done_when").and_then(|v| v.as_str()), Some("tests pass"), "the caller's condition must land" ); assert_eq!( c.get("commit_policy").and_then(|v| v.as_str()), Some("on_green_tests"), "recipe keys the caller didn't mention must survive" ); assert_eq!( c.get("loop").and_then(|v| v.as_str()), Some("until_no_more_int_items") ); } #[test] fn merge_config_handles_null_on_either_side() { let base = serde_json::json!({"a": 1}); assert_eq!(merge_config(base.clone(), Value::Null), base); assert_eq!(merge_config(Value::Null, base.clone()), base); assert_eq!(merge_config(Value::Null, Value::Null), Value::Null); } /// 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] fn title_prefers_first_markdown_heading() { let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext"; assert_eq!(document_title(body, "Turn 1"), "ClawHDF5 Research Report"); } #[test] fn title_falls_back_to_first_nonempty_line() { let body = "\n\n Architecture notes for the io crate\nmore\n"; assert_eq!( document_title(body, "Turn 1"), "Architecture notes for the io crate" ); } #[test] fn title_falls_back_to_label_when_empty() { assert_eq!(document_title(" \n\n", "Turn 3"), "Turn 3"); } #[test] fn title_is_truncated() { let body = format!("# {}", "x".repeat(200)); let t = document_title(&body, "Turn 1"); assert!(t.ends_with('…')); assert_eq!(t.chars().count(), 91); } #[test] fn nodes_and_outputs_are_positionally_aligned() { let graph = serde_json::json!({ "nodes": [ {"id": "n0", "role": "code_archeologist"}, {"id": "n1", "role": "architecture_mapper"} ] }); let cp = serde_json::json!({ "outputs": ["first brief", "second brief"] }); let nodes = nodes_of(Some(&graph)); let outs = outputs_of(Some(&cp)); assert_eq!(nodes[1], ("n1".into(), "architecture_mapper".into())); assert_eq!(outs[1], "second brief"); } #[test] fn missing_graph_or_checkpoint_yields_no_documents() { assert!(nodes_of(None).is_empty()); assert!(outputs_of(None).is_empty()); assert!(outputs_of(Some(&serde_json::json!({}))).is_empty()); } } /// GET /api/workforce — the roster grouped by the mission each claw works on. /// /// The sidebar used to flatten `orgs → companies → teams → agents`, which /// rendered a claw once per TEAM it belongs to. Since claws are reused across /// missions, a crew of five that had run five missions appeared as twenty-five /// rows of the same five people — the roster looked like it was multiplying. /// /// Grouping by mission makes that repetition mean something: the same person /// legitimately appears under each mission they staffed. `agents` is deduped /// per mission, and claws belonging to no mission come back under `unassigned` /// so a hand-created claw cannot fall out of the UI entirely. pub async fn workforce( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { use sqlx::Row; let ws = user.workspace_id.as_uuid(); // One query, not one-per-mission: the sidebar renders on every navigation. let rows = sqlx::query( "SELECT m.id::text AS mission_id, m.title AS mission_title, m.status AS mission_status, m.created_at AS created_at, a.id::text AS agent_id, a.name AS agent_name, a.job_title AS job_title, a.accent AS accent, a.status AS agent_status, tm.role AS role_slot FROM missions m JOIN mission_teams mt ON mt.mission_id = m.id JOIN team_members tm ON tm.team_id = mt.team_id JOIN agents a ON a.id = tm.claw_id WHERE m.workspace_id = $1 AND a.deleted_at IS NULL ORDER BY m.created_at DESC, tm.role ASC", ) .bind(ws) .fetch_all(&state.pool) .await?; let mut missions: Vec = Vec::new(); let mut seen_mission: std::collections::HashMap = std::collections::HashMap::new(); for r in rows { let mid: String = r.get("mission_id"); let idx = match seen_mission.get(&mid) { Some(i) => *i, None => { missions.push(serde_json::json!({ "mission_id": mid, "title": r.get::("mission_title"), "status": r.get::("mission_status"), "agents": Vec::::new(), })); seen_mission.insert(r.get::("mission_id"), missions.len() - 1); missions.len() - 1 } }; let agent = serde_json::json!({ "id": r.get::("agent_id"), "name": r.get::("agent_name"), "job_title": r.get::("job_title"), "role_slot": r.get::("role_slot"), "accent": r.get::("accent"), "status": r.get::("agent_status"), }); // A claw bound to two NODES of the same mission is still one colleague. let list = missions[idx]["agents"].as_array_mut().expect("agents array"); let id = agent["id"].clone(); if !list.iter().any(|a| a["id"] == id) { list.push(agent); } } // Claws on no mission at all — hand-created, or whose missions were // deleted. Without this they would simply vanish from the sidebar. let loose = sqlx::query( "SELECT a.id::text AS agent_id, a.name, a.job_title, a.accent, a.status FROM agents a WHERE a.workspace_id = $1 AND a.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM team_members tm JOIN mission_teams mt ON mt.team_id = tm.team_id JOIN missions m ON m.id = mt.mission_id WHERE tm.claw_id = a.id AND m.workspace_id = $1) ORDER BY a.name ASC", ) .bind(ws) .fetch_all(&state.pool) .await?; let unassigned: Vec = loose .into_iter() .map(|r| { serde_json::json!({ "id": r.get::("agent_id"), "name": r.get::("name"), "job_title": r.get::("job_title"), "role_slot": Value::Null, "accent": r.get::("accent"), "status": r.get::("status"), }) }) .collect(); Ok(Json(serde_json::json!({ "missions": missions, "unassigned": unassigned, }))) } #[cfg(test)] mod reap_tests { /// A mission's teardown must ask whether anyone else still employs a claw. /// /// Claws are reused across missions now, so a mission's team can contain /// staff other missions still hold. The old code purged every claw in the /// team unconditionally, which under reuse deletes a user's workforce as a /// side effect of tidying one mission — and it presents as the roster /// quietly shrinking rather than as an error. #[test] fn mission_teardown_checks_for_other_employers_before_purging() { let src = include_str!("missions.rs"); let reaper = src .split("async fn reap_mission_resources") .nth(1) .expect("the reaper exists"); // Scoped to the reaper, so the check cannot be satisfied by some other // function elsewhere in the file that happens to mention mission_teams. assert!( reaper.contains("mt.mission_id <> $2"), "the purge must exclude claws held by another mission" ); let purge_at = reaper.find("purge_agent").expect("it still purges"); let guard_at = reaper.find("mt.mission_id <> $2").expect("guard present"); assert!( guard_at < purge_at, "the guard has to run BEFORE the purge, or it is decoration" ); } } #[cfg(test)] mod artifact_tests { /// A filename reaches `Content-Disposition` after an AGENT chose it. /// /// The value is attacker-influenced and parsed by every browser, so the /// quote and control characters that would end the header early — or inject /// a second one — are removed rather than escaped. #[test] fn a_downloaded_filename_cannot_break_out_of_its_header() { let clean = |name: &str| -> String { name.chars() .filter(|c| *c != '"' && *c != '\\' && !c.is_control()) .collect() }; assert_eq!(clean("findings.md"), "findings.md"); assert_eq!(clean("re\"port.md"), "report.md"); assert_eq!(clean("a\r\nX-Evil: 1.md"), "aX-Evil: 1.md"); assert_eq!(clean("back\\slash.md"), "backslash.md"); } /// Both artifact routes resolve through ONE containment check. /// /// Two copies is two chances for one of them to be the lenient one, and the /// lenient one is an arbitrary read of the gateway's filesystem. #[test] fn one_containment_check_serves_both_routes() { let src = include_str!("missions.rs"); assert_eq!( src.matches(concat!("fn resolve_", "artifact_path")).count(), 1, "one resolver" ); assert_eq!( src.matches(concat!("resolve_", "artifact_path(&artifact.path)")).count(), 2, "and both routes must go through it" ); } }