A recipe's completion condition is a condition on the recipe's own task. phases_for_create merged the recipe config under the caller's, so a phase that supplied a different task and no condition inherited a condition about work it was never given: research_and_code's coding phase carries "an implementation for each INT-XX item in IMPLEMENTATION_BRIEF", and a phase asked to write CHAIN.md failed on it, honestly, every time (01a0c20d, 01a0c493). Decided from the caller's config before the merge (afterwards a recipe task and a caller task look the same): caller task + no caller condition → the recipe's done_when/done_when_check are not inherited. A caller condition is kept; a phase with neither keeps the recipe's pair. Test fixture now carries a recipe task+condition. Harness: the triage agreement line dedupes per skill and excludes skills whose Trigger is not observable (always_inject is inlined). First live datapoint, 01a0c493: for 'create CHAIN.md and commit' Jev's top picks were workspace-repo-commit-protocol 0.63 / small-focused-commits 0.57; the agent read code-review-checklist (~0) and nothing else. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2189 lines
84 KiB
Rust
2189 lines
84 KiB
Rust
//! `/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<Uuid>,
|
||
pub team_template_id: Option<Uuid>,
|
||
pub repo_id: Option<Uuid>,
|
||
#[serde(default = "default_schedule")]
|
||
pub schedule: Value,
|
||
pub description: Option<String>,
|
||
#[serde(default)]
|
||
pub config: Value,
|
||
#[serde(default)]
|
||
pub phases: Vec<PhaseSpec>,
|
||
/// Defaults to "zeroclaw". "local_herdr" requires target_node_id.
|
||
pub runtime_kind: Option<String>,
|
||
pub target_node_id: Option<Uuid>,
|
||
/// Which per-CLI rootfs a `microvm` mission boots (`missions.backend`), e.g.
|
||
/// "claude". NULL boots the node's default image.
|
||
pub backend: Option<String>,
|
||
/// 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<String>,
|
||
/// 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<String>,
|
||
}
|
||
|
||
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<MissionPhase>,
|
||
pub tasks: Vec<MissionTask>,
|
||
pub artifacts: Vec<MissionArtifact>,
|
||
pub benchmarks: Vec<BenchmarkSnapshot>,
|
||
}
|
||
|
||
#[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<i32>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct SecurityScanRequest {
|
||
pub phase_id: Uuid,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct SecurityScanResponse {
|
||
pub findings: usize,
|
||
pub tasks: Vec<MissionTask>,
|
||
}
|
||
|
||
// ── 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<String>,
|
||
}
|
||
|
||
pub async fn list(
|
||
State(state): State<AppState>,
|
||
Authed(user): Authed,
|
||
Query(q): Query<ListQuery>,
|
||
) -> Result<Json<Vec<MissionListItem>>, 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<Uuid> = 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<Uuid, (i64, i64, Option<String>)> = 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<PhaseSpec>,
|
||
) -> Vec<NewMissionPhase> {
|
||
// 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);
|
||
// Decided from the caller's config BEFORE the merge: afterwards a
|
||
// recipe task and a caller task are indistinguishable.
|
||
let caller_task = p
|
||
.config
|
||
.get("task")
|
||
.and_then(|t| t.as_str())
|
||
.is_some_and(|s| !s.trim().is_empty());
|
||
let caller_condition =
|
||
p.config.get("done_when").is_some() || p.config.get("done_when_check").is_some();
|
||
let config = merge_config(base, p.config);
|
||
let config = if caller_task && !caller_condition {
|
||
drop_orphaned_condition(config, &p.kind, p.order_idx)
|
||
} else {
|
||
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()
|
||
}
|
||
|
||
/// A recipe's completion condition is a condition on the recipe's own task.
|
||
/// When the caller supplied a different `task` and no condition of its own,
|
||
/// keeping the recipe's `done_when` judges the phase against work it was
|
||
/// never asked to do — `research_and_code`'s coding phase inherits "an
|
||
/// implementation for each INT-XX item in IMPLEMENTATION_BRIEF", and a
|
||
/// phase asked to write CHAIN.md fails on it, honestly, every time (missions
|
||
/// 01a0c20d, 01a0c493). The condition and the check travel with the task
|
||
/// they were written for; a phase with its own task gets its own, or none.
|
||
///
|
||
/// Called only when the caller supplied a task and no condition; the
|
||
/// decision is made before the merge, where the two are still telling apart.
|
||
fn drop_orphaned_condition(config: Value, kind: &str, order_idx: i32) -> Value {
|
||
let Value::Object(mut c) = config else { return config };
|
||
let dropped: Vec<&str> = ["done_when", "done_when_check"]
|
||
.into_iter()
|
||
.filter(|k| c.contains_key(*k))
|
||
.collect();
|
||
if !dropped.is_empty() {
|
||
eprintln!(
|
||
"phase {kind}[{order_idx}]: caller supplied its own task and no completion \
|
||
condition — the recipe's {} is NOT inherited (it describes the recipe's task)",
|
||
dropped.join("/")
|
||
);
|
||
for k in dropped {
|
||
c.remove(k);
|
||
}
|
||
}
|
||
Value::Object(c)
|
||
}
|
||
|
||
/// 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<AppState>,
|
||
Authed(user): Authed,
|
||
Json(body): Json<CreateMissionRequest>,
|
||
) -> Result<Json<Mission>, 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),
|
||
}
|
||
|
||
// Honour the recipe's `default_team_template`.
|
||
//
|
||
// Every recipe declares one and NOTHING read it: the field was parsed into
|
||
// `WorkflowRecipe` and then ignored, so a mission created from a card with
|
||
// no explicit team was rejected at launch with "no team_id, no
|
||
// team_template_id, no config.phase_teams" — a card that cannot be launched
|
||
// by clicking it. Only resolved when the caller named no team of any kind,
|
||
// so an explicit choice always wins.
|
||
let recipe = crate::workflow_registry::get(body.template_kind.trim());
|
||
let mut team_template_id = body.team_template_id;
|
||
let has_phase_teams = body
|
||
.config
|
||
.get("phase_teams")
|
||
.and_then(|v| v.as_object())
|
||
.is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty())));
|
||
// Per-purpose defaults first: a multi-phase recipe does not have one job,
|
||
// and staffing every phase from one team is what put a coder, a tester and
|
||
// a committer on a repo-less markdown mission. Only applied when the caller
|
||
// named no team of any kind, so an explicit choice always wins.
|
||
let mut config = body.config;
|
||
if team_template_id.is_none() && body.team_id.is_none() && !has_phase_teams {
|
||
if let Some(r) = recipe {
|
||
let mut resolved = serde_json::Map::new();
|
||
for (purpose, key) in &r.default_phase_teams {
|
||
match cm_db::repo::team_templates::get_by_key(&state.pool, key).await {
|
||
Ok(Some(t)) => {
|
||
resolved.insert(
|
||
purpose.clone(),
|
||
serde_json::json!([t.id.to_string()]),
|
||
);
|
||
}
|
||
// Loud, and it does NOT fall back silently: a recipe naming
|
||
// a template that is not loaded would otherwise stage the
|
||
// wrong crew and look deliberate.
|
||
Ok(None) => eprintln!(
|
||
"missions: recipe {} maps purpose {purpose:?} to team template \
|
||
{key:?}, which is not loaded — that phase will fall back to the \
|
||
mission-wide default",
|
||
body.template_kind.trim()
|
||
),
|
||
Err(e) => eprintln!("missions: looking up team template {key:?}: {e}"),
|
||
}
|
||
}
|
||
if !resolved.is_empty() {
|
||
eprintln!(
|
||
"missions: {} staffs {} phase purpose(s) from the recipe",
|
||
body.template_kind.trim(),
|
||
resolved.len()
|
||
);
|
||
if let Some(obj) = config.as_object_mut() {
|
||
obj.insert("phase_teams".into(), serde_json::Value::Object(resolved));
|
||
} else {
|
||
config = serde_json::json!({ "phase_teams": resolved });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let has_phase_teams = has_phase_teams
|
||
|| config
|
||
.get("phase_teams")
|
||
.and_then(|v| v.as_object())
|
||
.is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty())));
|
||
|
||
if team_template_id.is_none() && body.team_id.is_none() && !has_phase_teams {
|
||
if let Some(key) = recipe.and_then(|r| r.default_team_template.as_deref()) {
|
||
match cm_db::repo::team_templates::get_by_key(&state.pool, key).await {
|
||
Ok(Some(t)) => {
|
||
eprintln!(
|
||
"missions: {} defaults to team template {key}",
|
||
body.template_kind.trim()
|
||
);
|
||
team_template_id = Some(t.id);
|
||
}
|
||
// Loud: a recipe naming a template that is not loaded would
|
||
// otherwise fail at launch, one step removed from the cause.
|
||
Ok(None) => eprintln!(
|
||
"missions: recipe {} names default_team_template {key:?}, which is not loaded — the mission will have no team",
|
||
body.template_kind.trim()
|
||
),
|
||
Err(e) => eprintln!("missions: looking up team template {key:?}: {e}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
repo_id: body.repo_id,
|
||
schedule: body.schedule,
|
||
description: body.description.as_deref(),
|
||
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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
) -> Result<Json<MissionDetail>, 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 `<missions_root>/_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<std::path::PathBuf, ApiError> {
|
||
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<AppState>,
|
||
Authed(user): Authed,
|
||
Path((id, artifact_id)): Path<(Uuid, Uuid)>,
|
||
) -> Result<axum::response::Response, ApiError> {
|
||
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/<mission>/<phase>/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<AppState>,
|
||
Authed(user): Authed,
|
||
Path((id, artifact_id)): Path<(Uuid, Uuid)>,
|
||
) -> Result<Json<serde_json::Value>, 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/<mission>`, 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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
) -> Result<Json<serde_json::Value>, 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::<String>()
|
||
);
|
||
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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
Json(body): Json<BenchmarkTriggerRequest>,
|
||
) -> Result<Json<Vec<BenchmarkSnapshot>>, 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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
Json(body): Json<SecurityScanRequest>,
|
||
) -> Result<Json<SecurityScanResponse>, 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<String>,
|
||
}
|
||
|
||
/// `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<AppState>,
|
||
Authed(_user): Authed,
|
||
Json(req): Json<RefineDraftRequest>,
|
||
) -> Result<Json<RefineResponse>, ApiError> {
|
||
let phase_kinds: Vec<String> = 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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
) -> Result<Json<RefineResponse>, 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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
Json(body): Json<SetDescriptionRequest>,
|
||
) -> Result<Json<Mission>, 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<String>,
|
||
#[serde(default)]
|
||
pub description: Option<String>,
|
||
}
|
||
|
||
/// PATCH /api/missions/{id} — edit title + description. Draft-only.
|
||
pub async fn update_meta(
|
||
State(state): State<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
Json(body): Json<UpdateMissionRequest>,
|
||
) -> Result<Json<Mission>, 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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||
let ws = user.workspace_id.as_uuid();
|
||
// Verify the mission exists in this workspace before we start reaping.
|
||
let exists: Option<Uuid> =
|
||
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<Uuid> =
|
||
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<Uuid> = 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();
|
||
// Counted, not assumed. The summary below used to report `claw_ids.len()`,
|
||
// which is how many claws were FOUND — including every one skipped as still
|
||
// employed and every one whose purge failed. So "reaped 4 claw(s)" was
|
||
// printed by a delete that purged none, which is exactly the log you would
|
||
// read while wondering why the agents are still there.
|
||
let mut purged = 0usize;
|
||
let mut kept = 0usize;
|
||
let mut failed = 0usize;
|
||
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"
|
||
);
|
||
kept += 1;
|
||
continue;
|
||
}
|
||
|
||
let report = crate::routes::claws::purge_agent(
|
||
&state.pool,
|
||
&state.runtime,
|
||
provisioner.as_ref(),
|
||
cm_domain::AgentId::from(*cid),
|
||
)
|
||
.await;
|
||
match report.counts {
|
||
Ok(_) => purged += 1,
|
||
Err(e) => {
|
||
failed += 1;
|
||
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}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// 6. The captured outputs. `mission_gc` keeps `_outputs/<id>` for 90 days
|
||
// because they are artifacts a user can still open — but once the
|
||
// mission row is gone so are its `mission_artifacts`, and nothing can
|
||
// open them. Found on 2026-09-14 as 163 orphaned directories on prod,
|
||
// the newest belonging to a mission deleted twenty minutes earlier.
|
||
let outputs = crate::mission_workspace::missions_root()
|
||
.join("_outputs")
|
||
.join(mission_id.to_string());
|
||
if outputs.is_dir() {
|
||
if let Err(e) = tokio::fs::remove_dir_all(&outputs).await {
|
||
eprintln!(
|
||
"missions::delete: remove {} failed (continuing; mission_gc will reap it in \
|
||
90 days): {e}",
|
||
outputs.display()
|
||
);
|
||
}
|
||
}
|
||
|
||
// Say what actually happened. `failed > 0` means the mission row is about to
|
||
// be deleted while its claws survive with nothing left pointing at them —
|
||
// the orphan case, and the only way to notice it after the fact.
|
||
eprintln!(
|
||
"missions::delete: mission {mission_id}: {purged} claw(s) purged, {kept} kept (still \
|
||
employed), {failed} FAILED, {} team(s) deleted, {} claw(s) considered",
|
||
team_ids.len(),
|
||
claw_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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
Json(body): Json<HerdrDispatchRequest>,
|
||
) -> Result<Json<HerdrDispatchResponse>, 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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
) -> Result<Json<Value>, 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<Value> = rows
|
||
.into_iter()
|
||
.map(|r| {
|
||
serde_json::json!({
|
||
"team_id": r.get::<String, _>("team_id"),
|
||
"purpose": r.get::<String, _>("purpose"),
|
||
"team_name": r.get::<String, _>("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<AppState>,
|
||
Authed(user): Authed,
|
||
Path((id, phase_id)): Path<(Uuid, Uuid)>,
|
||
) -> Result<Json<Value>, 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();
|
||
// Drop the previous attempt's capture, or this retry's work is DESTROYED.
|
||
//
|
||
// `capture_finished_coding_phases` skips any phase that already has a
|
||
// `code_diff` artifact (`NOT EXISTS`, phase_runner.rs). That guard is right
|
||
// for a phase that ran once, and catastrophic for a retried one: the stale
|
||
// artifact from the failed attempt suppresses capture of the new attempt
|
||
// forever, the container is then reaped on its normal grace, and everything
|
||
// the agents committed inside it is gone. The UI meanwhile shows the OLD
|
||
// diff, so the mission reads as delivered.
|
||
//
|
||
// That is exactly what happened to mission 01a00538: it completed both
|
||
// phases on the retry, 11 agent commits and all, and delivered a patch
|
||
// dated the previous day. Deleting here is what makes the doc comment above
|
||
// ("the phase card starts fresh on the retry") true of the artifacts too.
|
||
let cleared = sqlx::query(
|
||
"DELETE FROM mission_artifacts a
|
||
USING mission_phases mp
|
||
WHERE a.phase_id = mp.id
|
||
AND a.mission_id = $1
|
||
AND mp.status = 'pending'
|
||
AND a.kind = 'code_diff'",
|
||
)
|
||
.bind(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, "cleared_captures": cleared }),
|
||
))
|
||
}
|
||
|
||
/// 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.
|
||
/// Skill-Use scores for a mission: did the skills we delivered change what the
|
||
/// agent did?
|
||
///
|
||
/// Reads only what was recorded — the prompts the agent received and the
|
||
/// narratives it returned. An empty result means the evidence is gone (events
|
||
/// are reaped after 7 days unless `retain_events_until` is set), NOT that no
|
||
/// skill was followed, and the caller has to present it that way.
|
||
pub async fn skill_use(
|
||
State(state): State<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
) -> Result<Json<Value>, ApiError> {
|
||
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||
.await?
|
||
.ok_or(ApiError::NotFound)?;
|
||
let scores = crate::skill_use::score_mission(&state.pool, id)
|
||
.await
|
||
.map_err(|e| {
|
||
eprintln!("skill_use: scoring mission {id} failed: {e}");
|
||
ApiError::Internal
|
||
})?;
|
||
Ok(Json(serde_json::json!({
|
||
"mission_id": id,
|
||
"skills": scores,
|
||
// Said in the payload rather than left for the reader to infer: an
|
||
// empty list has two very different causes and they must not look the
|
||
// same to whoever consumes this.
|
||
"evidence": if scores.is_empty() {
|
||
"no delivered skills found in the recorded prompts — either none \
|
||
were delivered, or the events have been reaped"
|
||
} else {
|
||
"scored from recorded prompt.composed and reasoning events"
|
||
},
|
||
})))
|
||
}
|
||
|
||
pub async fn list_phase_evaluations(
|
||
State(state): State<AppState>,
|
||
Authed(user): Authed,
|
||
Path((id, phase_id)): Path<(Uuid, Uuid)>,
|
||
) -> Result<Json<Vec<Value>>, 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, expectation
|
||
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::<i32, _>("iteration"),
|
||
"met": r.get::<bool, _>("met"),
|
||
"reason": r.get::<String, _>("reason"),
|
||
"model": r.get::<String, _>("model"),
|
||
"error": r.get::<Option<String>, _>("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::<serde_json::Value, _>("checks"),
|
||
// What the judge said it would check BEFORE it read the
|
||
// evidence; set beside `checks` so an operator can see
|
||
// whether it kept to its plan.
|
||
"expectation": r.get::<Option<String>, _>("expectation"),
|
||
"created_at": created_at
|
||
.format(&time::format_description::well_known::Rfc3339)
|
||
.unwrap_or_default(),
|
||
})
|
||
})
|
||
.collect(),
|
||
))
|
||
}
|
||
|
||
pub async fn get_phase_summary(
|
||
State(state): State<AppState>,
|
||
Authed(user): Authed,
|
||
Path((id, phase_id)): Path<(Uuid, Uuid)>,
|
||
) -> Result<Json<Value>, 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::<String, _>("kind"),
|
||
"model": r.get::<String, _>("model"),
|
||
"narrative": r.get::<String, _>("narrative"),
|
||
"metrics": r.get::<Value, _>("metrics"),
|
||
"sources": r.get::<Value, _>("sources"),
|
||
"artifacts": r.get::<Value, _>("artifacts"),
|
||
"tooling": r.get::<Value, _>("tooling"),
|
||
"next_actions": r.get::<Value, _>("next_actions"),
|
||
"generated_at": generated_at
|
||
.format(&time::format_description::well_known::Rfc3339)
|
||
.unwrap_or_default(),
|
||
"error": r.get::<Option<String>, _>("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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
Json(body): Json<SetStatusRequest>,
|
||
) -> Result<Json<Mission>, 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()),
|
||
state.blobs.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?;
|
||
// An operator's stop is a terminal transition too, and the runner's
|
||
// close never sees it: revoke here as well.
|
||
if matches!(body.status.as_str(), "completed" | "failed" | "cancelled") {
|
||
crate::mission_orchestrator::revoke_mission_credentials(&state.pool, id).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<Uuid>,
|
||
/// 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<MissionDocument>,
|
||
}
|
||
|
||
/// 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::<String>())
|
||
} 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<String> {
|
||
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<AppState>,
|
||
Authed(user): Authed,
|
||
Path(id): Path<Uuid>,
|
||
) -> Result<Json<MissionDocumentsResponse>, 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<AppState>,
|
||
Authed(user): Authed,
|
||
Path((id, run_id, index)): Path<(Uuid, Uuid, usize)>,
|
||
) -> Result<Json<MissionDocumentBody>, 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.
|
||
/// Every shipped recipe must name a team template that is actually
|
||
/// authored. `default_team_template` was parsed and never read, so a
|
||
/// mismatch here used to surface as "launch rejected — no team_id" on a
|
||
/// card the user simply clicked.
|
||
#[test]
|
||
fn every_recipe_names_a_team_template_that_exists() {
|
||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/teams");
|
||
let authored: std::collections::HashSet<String> = std::fs::read_dir(dir)
|
||
.expect("templates/teams is readable")
|
||
.filter_map(Result::ok)
|
||
.filter_map(|e| {
|
||
let n = e.file_name().to_string_lossy().to_string();
|
||
n.strip_suffix(".toml").map(str::to_string)
|
||
})
|
||
.collect();
|
||
for r in crate::workflow_registry::load() {
|
||
let Some(key) = r.default_team_template.as_deref() else {
|
||
continue;
|
||
};
|
||
assert!(
|
||
authored.contains(key),
|
||
"recipe {:?} defaults to team template {key:?}, which has no \
|
||
templates/teams/{key}.toml — the card would be unlaunchable",
|
||
r.key
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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")
|
||
);
|
||
}
|
||
|
||
/// A caller's own task does not inherit the recipe's condition; a
|
||
/// caller's own condition is kept; a phase with neither keeps the
|
||
/// recipe's pair as before.
|
||
#[test]
|
||
fn a_custom_task_does_not_inherit_the_recipes_done_when() {
|
||
let recipe = test_recipe();
|
||
let coding_has_condition = recipe
|
||
.phases
|
||
.iter()
|
||
.any(|p| p.kind == "coding" && p.config.get("done_when").is_some());
|
||
assert!(coding_has_condition, "the fixture must carry a recipe condition");
|
||
let phases = phases_for_create(
|
||
Some(&recipe),
|
||
vec![
|
||
PhaseSpec {
|
||
kind: "coding".into(),
|
||
order_idx: 1,
|
||
config: serde_json::json!({"task": "write CHAIN.md"}),
|
||
},
|
||
PhaseSpec {
|
||
kind: "coding".into(),
|
||
order_idx: 2,
|
||
config: serde_json::json!({"task": "write X", "done_when": "X exists"}),
|
||
},
|
||
PhaseSpec {
|
||
kind: "coding".into(),
|
||
order_idx: 3,
|
||
config: Value::Null,
|
||
},
|
||
],
|
||
);
|
||
assert!(phases[0].config.get("done_when").is_none(), "{:?}", phases[0].config);
|
||
assert_eq!(phases[1].config["done_when"], "X exists");
|
||
assert!(phases[2].config.get("done_when").is_some(), "{:?}", phases[2].config);
|
||
// The rest of the recipe's config still backfills the custom-task phase.
|
||
assert!(phases[0].config.get("loop").is_some());
|
||
}
|
||
|
||
/// 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()),
|
||
default_phase_teams: [
|
||
("research".to_string(), "topic_research".to_string()),
|
||
("coding".to_string(), "rust_sdlc".to_string()),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
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",
|
||
// The recipe's own task and the condition written for it.
|
||
"task": "implement every INT-XX item",
|
||
"done_when": "an implementation exists for each INT-XX item"
|
||
}),
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
#[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<AppState>,
|
||
Authed(user): Authed,
|
||
) -> Result<Json<Value>, 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.template_kind AS template_kind,
|
||
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<Value> = Vec::new();
|
||
let mut seen_mission: std::collections::HashMap<String, usize> =
|
||
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::<String, _>("mission_title"),
|
||
"status": r.get::<String, _>("mission_status"),
|
||
// Drives the World's palette: what the mission is FOR
|
||
// should be visible before any label is read.
|
||
"templateKind": r.get::<String, _>("template_kind"),
|
||
"agents": Vec::<Value>::new(),
|
||
}));
|
||
seen_mission.insert(r.get::<String, _>("mission_id"), missions.len() - 1);
|
||
missions.len() - 1
|
||
}
|
||
};
|
||
let agent = serde_json::json!({
|
||
"id": r.get::<String, _>("agent_id"),
|
||
"name": r.get::<String, _>("agent_name"),
|
||
"job_title": r.get::<String, _>("job_title"),
|
||
"role_slot": r.get::<String, _>("role_slot"),
|
||
"accent": r.get::<String, _>("accent"),
|
||
"status": r.get::<String, _>("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<Value> = loose
|
||
.into_iter()
|
||
.map(|r| {
|
||
serde_json::json!({
|
||
"id": r.get::<String, _>("agent_id"),
|
||
"name": r.get::<String, _>("name"),
|
||
"job_title": r.get::<String, _>("job_title"),
|
||
"role_slot": Value::Null,
|
||
"accent": r.get::<String, _>("accent"),
|
||
"status": r.get::<String, _>("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"
|
||
);
|
||
}
|
||
}
|