//! `/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, } 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(), )) } 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); } } _ => 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, phases: body .phases .into_iter() .map(|p| NewMissionPhase { kind: p.kind, order_idx: p.order_idx, config: p.config, }) .collect(), }; let id = cm_db::repo::missions::insert(&state.pool, new).await?; let 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, })) } /// 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, } /// 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, 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 { 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)?; if mission.status != "running" { return Err(ApiError::BadRequest); } 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(&state.pool) .await?; if r.rows_affected() == 0 { return Err(ApiError::NotFound); } Ok(Json(serde_json::json!({ "reset": true }))) } /// 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. 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); if 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::*; #[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()); } }