feat(missions): hold every producing phase to delivering, and read markdown instead of PDFs

Two changes the portal review asked for.

1. `benchmark` and `security_hardening` had no delivery guarantee.

`empty_delivery_is_a_failure` tested `kind == "coding"`, on the reasoning that
"research phases legitimately write nothing to the tree" — which the research
directive three modules over contradicts, since it tells the agent to save
findings under /mission/repo/research/. The cost: a `benchmark` mission is ONE
benchmark phase, and with that phase exempt nothing in the platform could fail
it. Same for `security_hardening`, whose first two phases are security_scan and
research.

Now keyed on PRODUCING_KINDS = coding, research, benchmark, security_scan.
`review` stays exempt — a reviewing phase that changes nothing has done its job,
the same distinction `vm_stop_gate::per_node` makes. The test that encoded the
old rule is rewritten rather than deleted, with the reasoning that replaced it.
All 8 harness fixtures are coding phases, so harness behaviour is unchanged.

2. PDFs are dropped; markdown is the deliverable.

Rendering a PDF meant asking an LLM to convert markdown to HTML — a paid API
call per document, on the critical path of "let me read my research", which
failed on depleted Gemini credits and left every artifact unreadable. Styling at
render time is free, offline, instant and cannot 429.

- `mission_outputs` no longer requests a render.
- New `GET /api/missions/{id}/artifacts/{artifact_id}/content`. The frontend had
  no way to READ an artifact at all: it listed paths and offered a PDF preview
  that never rendered (and whose `rendered_pdf_path` had no route serving it).
  Two containment rules, both enforced: the artifact must belong to a mission in
  the caller's workspace, and the CANONICALISED path must stay under `_outputs`
  — canonicalise first, because checking the string before resolving `..` is the
  classic hole.
- `MarkdownBlock` now uses react-markdown + remark-gfm + rehype-slug. It was a
  deliberate zero-dep renderer for "the subset the refiner emits", and that
  subset stopped matching reality: agent briefs are largely GFM pipe tables,
  which it showed as literal pipes. MissionOutputReader and RefineDiffModal use
  the same component and gain tables for free.
- Heading ids come from rehype-slug and `outlineOf` slugs with the same
  GithubSlugger, so the outline rail's anchors still resolve. A test pins that
  invariant, including duplicate headings.

Styles live in globals.css under `.md-view`: the markup is generated so there
are no class hooks, and this project has no styled-jsx registry — the app-router
requirement is documented in next/dist/docs/01-app/02-guides/css-in-js.md, which
frontend/AGENTS.md exists to make me read.

The artifacts tab moved to `MissionArtifacts.tsx`. MissionCanvas was 1341 lines
against a 1250 limit BEFORE this change — already failing lint; it is now 1248.

238 backend lib tests, 20 backend test binaries, 89 frontend tests, clean tsc,
clean eslint on every file touched, production build succeeds.
This commit is contained in:
Omar Sobh
2026-08-07 12:19:06 -07:00
parent da889f83ab
commit 821cbb8622
12 changed files with 2107 additions and 439 deletions
+70
View File
@@ -327,6 +327,76 @@ pub async fn get(
}))
}
/// 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.
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 root = crate::mission_outputs::outputs_root_dir();
let abs = crate::mission_outputs::missions_root_dir().join(&artifact.path);
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::artifact_content: refused {} — outside {}",
resolved.display(),
root.display()
);
return Err(ApiError::NotFound);
}
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}/benchmark — run the benchmark harness
/// against a phase. Slot='baseline' records iteration 0's
/// before_metrics; slot='after' with iteration=N records the