Two endpoints the wizard redesign needs.
`POST /api/missions/refine-draft` — the polish button fires while the user is
still typing, before anything is created, so it has no id to route on.
`refine` deliberately requires a saved draft because its Accept writes back;
this one has nothing to write back to and returns the text. Same system prompt,
same model chain. The phase list comes from the workflow recipe rather than the
caller, for the same reason `phases_for_create` prefers it: a client that
guessed would have the model write acceptance criteria for phases the mission
will not run.
`GET /api/missions/{id}/artifacts/{artifact_id}/download` — the file itself.
`artifact_content` caps at 2 MiB and reads as UTF-8, so a large or binary
artifact is unreachable by any means today; this streams the bytes with a
filename attached and no ceiling.
Both artifact routes now resolve through ONE containment check. Two copies of
"is this path under _outputs" 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 — so a
test asserts there is a single resolver and that both routes call it.
The download filename was chosen by an AGENT and lands in a header every browser
parses, so quotes, backslashes and control characters are stripped rather than
escaped; the test covers a header-injection attempt.
Co-Authored-By: Claude Opus 5 <[email protected]>
1700 lines
62 KiB
Rust
1700 lines
62 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);
|
||
let config = merge_config(base, p.config);
|
||
// Say what this phase asked for that will not happen. A config key
|
||
// nothing reads is silent by construction — `task` sat unread
|
||
// through every mission until two phases with different tasks
|
||
// produced identical output.
|
||
crate::phase_config::report(&p.kind, p.order_idx, &config);
|
||
NewMissionPhase {
|
||
kind: p.kind,
|
||
order_idx: p.order_idx,
|
||
config,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Shallow-merge `over` onto `base`, key by key.
|
||
///
|
||
/// Shallow is deliberate: phase config is a flat settings bag, and a caller
|
||
/// that sends `tools: [...]` means to replace the list, not union it.
|
||
fn merge_config(base: Value, over: Value) -> Value {
|
||
match (base, over) {
|
||
(Value::Object(mut b), Value::Object(o)) => {
|
||
for (k, v) in o {
|
||
b.insert(k, v);
|
||
}
|
||
Value::Object(b)
|
||
}
|
||
// Nothing to merge onto, or nothing to merge in.
|
||
(base, Value::Null) => base,
|
||
(Value::Null, over) => over,
|
||
// A non-object override replaces outright — there is no sane merge of
|
||
// e.g. an array onto an object, and silently picking one would hide
|
||
// the caller's mistake.
|
||
(_, over) => over,
|
||
}
|
||
}
|
||
|
||
/// `GET /api/workflows` — the workflow recipe catalog.
|
||
///
|
||
/// Serves `templates/workflows/*.toml` so the client can drop its inline
|
||
/// mirror of the phase composition table.
|
||
pub async fn list_workflows(
|
||
Authed(_user): Authed,
|
||
) -> Json<&'static [crate::workflow_registry::WorkflowRecipe]> {
|
||
Json(crate::workflow_registry::load())
|
||
}
|
||
|
||
pub async fn create(
|
||
State(state): State<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),
|
||
}
|
||
|
||
let new = NewMission {
|
||
workspace_id: user.workspace_id.as_uuid(),
|
||
title: body.title.trim(),
|
||
template_kind: body.template_kind.trim(),
|
||
team_id: body.team_id,
|
||
team_template_id: body.team_template_id,
|
||
repo_id: body.repo_id,
|
||
schedule: body.schedule,
|
||
description: body.description.as_deref(),
|
||
config: body.config,
|
||
runtime_kind: Some(runtime_kind),
|
||
target_node_id: body.target_node_id,
|
||
backend: body.backend.as_deref(),
|
||
validator_model: body.validator_model.as_deref(),
|
||
team_engine: body.team_engine.as_deref(),
|
||
phases: phases_for_create(
|
||
crate::workflow_registry::get(body.template_kind.trim()),
|
||
body.phases,
|
||
),
|
||
};
|
||
let id = cm_db::repo::missions::insert(&state.pool, new).await?;
|
||
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||
.await?
|
||
.ok_or(ApiError::Internal)?;
|
||
Ok(Json(mission))
|
||
}
|
||
|
||
pub async fn get(
|
||
State(state): State<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();
|
||
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<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();
|
||
// And put the mission back to running, or nothing sweeps the phase: every
|
||
// launcher and closer keys off `missions.status = 'running'`.
|
||
sqlx::query(
|
||
"UPDATE missions SET status = 'running', completed_at = NULL, updated_at = now()
|
||
WHERE id = $1 AND status = 'failed'",
|
||
)
|
||
.bind(id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok(Json(
|
||
serde_json::json!({ "reset": true, "reopened_phases": reopened }),
|
||
))
|
||
}
|
||
|
||
/// GET /api/missions/{id}/phases/{phase_id}/summary — the completion
|
||
/// card produced by `phase_summarizer` for a terminal-state phase.
|
||
/// Returns 404 while the phase is still running / hasn't been
|
||
/// summarized yet.
|
||
/// `GET /api/missions/{id}/phases/{phase_id}/evaluations` — every completion
|
||
/// verdict for a phase, newest first.
|
||
///
|
||
/// One row per pass. The `reason` is the operator-facing explanation of why a
|
||
/// phase iterated (or stopped), and is the same text fed back to the agents as
|
||
/// guidance for the following pass.
|
||
pub async fn list_phase_evaluations(
|
||
State(state): State<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
|
||
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"),
|
||
"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()),
|
||
)
|
||
.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<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.
|
||
#[test]
|
||
fn phase_config_is_backfilled_from_the_recipe() {
|
||
let recipe = test_recipe();
|
||
let requested = vec![
|
||
PhaseSpec {
|
||
kind: "research".into(),
|
||
order_idx: 0,
|
||
config: Value::Null,
|
||
},
|
||
PhaseSpec {
|
||
kind: "coding".into(),
|
||
order_idx: 1,
|
||
config: Value::Null,
|
||
},
|
||
];
|
||
let phases = phases_for_create(Some(&recipe), requested);
|
||
assert_eq!(phases.len(), 2);
|
||
assert!(
|
||
phases.iter().all(|p| !p.config.is_null()),
|
||
"recipe config was not backfilled: {phases:?}"
|
||
);
|
||
// The coding phase's loop policy is the setting the loop work depends on.
|
||
let coding = phases.iter().find(|p| p.kind == "coding").expect("coding");
|
||
assert_eq!(
|
||
coding.config.get("loop").and_then(|v| v.as_str()),
|
||
Some("until_no_more_int_items")
|
||
);
|
||
}
|
||
|
||
/// Omitting phases entirely takes the recipe's list wholesale.
|
||
#[test]
|
||
fn phases_default_to_the_recipe() {
|
||
let phases = phases_for_create(Some(&test_recipe()), vec![]);
|
||
assert_eq!(phases.len(), 2);
|
||
assert_eq!(phases[0].kind, "research");
|
||
assert_eq!(phases[1].kind, "coding");
|
||
}
|
||
|
||
/// An explicit key wins over the recipe's value for that key.
|
||
#[test]
|
||
fn explicit_phase_config_overrides_the_recipe_key() {
|
||
let requested = vec![PhaseSpec {
|
||
kind: "coding".into(),
|
||
order_idx: 1,
|
||
config: serde_json::json!({"loop": "single_pass"}),
|
||
}];
|
||
let phases = phases_for_create(Some(&test_recipe()), requested);
|
||
assert_eq!(
|
||
phases[0].config.get("loop").and_then(|v| v.as_str()),
|
||
Some("single_pass")
|
||
);
|
||
}
|
||
|
||
/// ...but overriding one key must NOT drop the rest of the recipe's
|
||
/// config. Sending `{done_when}` means "also apply this condition", not
|
||
/// "this phase has no other settings".
|
||
///
|
||
/// The case that motivated this: a `security_hardening` phase with a
|
||
/// completion condition lost its `tools` list, which `security_scan.rs`
|
||
/// reads — so the scan ran with nothing configured and reported clean.
|
||
#[test]
|
||
fn adding_a_condition_preserves_the_rest_of_the_recipe_config() {
|
||
let requested = vec![PhaseSpec {
|
||
kind: "coding".into(),
|
||
order_idx: 1,
|
||
config: serde_json::json!({"done_when": "tests pass", "max_iterations": 3}),
|
||
}];
|
||
let phases = phases_for_create(Some(&test_recipe()), requested);
|
||
let c = &phases[0].config;
|
||
assert_eq!(
|
||
c.get("done_when").and_then(|v| v.as_str()),
|
||
Some("tests pass"),
|
||
"the caller's condition must land"
|
||
);
|
||
assert_eq!(
|
||
c.get("commit_policy").and_then(|v| v.as_str()),
|
||
Some("on_green_tests"),
|
||
"recipe keys the caller didn't mention must survive"
|
||
);
|
||
assert_eq!(
|
||
c.get("loop").and_then(|v| v.as_str()),
|
||
Some("until_no_more_int_items")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn merge_config_handles_null_on_either_side() {
|
||
let base = serde_json::json!({"a": 1});
|
||
assert_eq!(merge_config(base.clone(), Value::Null), base);
|
||
assert_eq!(merge_config(Value::Null, base.clone()), base);
|
||
assert_eq!(merge_config(Value::Null, Value::Null), Value::Null);
|
||
}
|
||
|
||
/// An unknown template must not fabricate phases or panic.
|
||
#[test]
|
||
fn unknown_template_yields_no_phases() {
|
||
assert!(phases_for_create(None, vec![]).is_empty());
|
||
}
|
||
|
||
/// Mirrors `templates/workflows/research_and_code.toml`. Built inline
|
||
/// rather than loaded from disk because the registry resolves its
|
||
/// directory relative to the process cwd, which under `cargo test` is the
|
||
/// crate root, not the repo root.
|
||
fn test_recipe() -> crate::workflow_registry::WorkflowRecipe {
|
||
crate::workflow_registry::WorkflowRecipe {
|
||
key: "research_and_code".into(),
|
||
title: "Research + Coding Loop".into(),
|
||
blurb: String::new(),
|
||
requires_repo: true,
|
||
default_team_template: Some("rust_sdlc".into()),
|
||
phases: vec![
|
||
crate::workflow_registry::WorkflowPhase {
|
||
kind: "research".into(),
|
||
order_idx: 0,
|
||
config: serde_json::json!({"produces": ["md", "pdf"]}),
|
||
},
|
||
crate::workflow_registry::WorkflowPhase {
|
||
kind: "coding".into(),
|
||
order_idx: 1,
|
||
config: serde_json::json!({
|
||
"loop": "until_no_more_int_items",
|
||
"commit_policy": "on_green_tests"
|
||
}),
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn title_prefers_first_markdown_heading() {
|
||
let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext";
|
||
assert_eq!(document_title(body, "Turn 1"), "ClawHDF5 Research Report");
|
||
}
|
||
|
||
#[test]
|
||
fn title_falls_back_to_first_nonempty_line() {
|
||
let body = "\n\n Architecture notes for the io crate\nmore\n";
|
||
assert_eq!(
|
||
document_title(body, "Turn 1"),
|
||
"Architecture notes for the io crate"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn title_falls_back_to_label_when_empty() {
|
||
assert_eq!(document_title(" \n\n", "Turn 3"), "Turn 3");
|
||
}
|
||
|
||
#[test]
|
||
fn title_is_truncated() {
|
||
let body = format!("# {}", "x".repeat(200));
|
||
let t = document_title(&body, "Turn 1");
|
||
assert!(t.ends_with('…'));
|
||
assert_eq!(t.chars().count(), 91);
|
||
}
|
||
|
||
#[test]
|
||
fn nodes_and_outputs_are_positionally_aligned() {
|
||
let graph = serde_json::json!({
|
||
"nodes": [
|
||
{"id": "n0", "role": "code_archeologist"},
|
||
{"id": "n1", "role": "architecture_mapper"}
|
||
]
|
||
});
|
||
let cp = serde_json::json!({ "outputs": ["first brief", "second brief"] });
|
||
let nodes = nodes_of(Some(&graph));
|
||
let outs = outputs_of(Some(&cp));
|
||
assert_eq!(nodes[1], ("n1".into(), "architecture_mapper".into()));
|
||
assert_eq!(outs[1], "second brief");
|
||
}
|
||
|
||
#[test]
|
||
fn missing_graph_or_checkpoint_yields_no_documents() {
|
||
assert!(nodes_of(None).is_empty());
|
||
assert!(outputs_of(None).is_empty());
|
||
assert!(outputs_of(Some(&serde_json::json!({}))).is_empty());
|
||
}
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
}
|