//! Capture for missions that have no repository. //! //! `mission_delivery` captures a phase's work by diffing a git checkout. A //! mission with `repo_id IS NULL` — every `research_only` mission, because that //! recipe sets `requires_repo = false` — has no checkout, so //! `capture_finished_coding_phases` filters it out at the SQL level //! (`AND m.repo_id IS NOT NULL`) and never reads the container at all. //! //! The agents still write files. The research directive tells them to save //! findings under `/mission/repo/research/`, and it says so whether or not a //! repo exists. So the work lands in the container's own filesystem, is never //! collected, and is destroyed when the sweeper reaps the container. //! //! # What this cost, measured //! //! Mission `019fdc35` ("ClawHDF5 Research"): four agents, 9.5 minutes, **eight //! research documents** — an HDF5 parser design, a Rust ecosystem survey, a //! seven-crate dependency map, tracing and fuzzing strategy. `mission_artifacts` //! held zero rows and the mission reported `completed`. One agent's own summary //! recorded the situation exactly: *"No git repo — file is written."* It noticed, //! wrote anyway, and the platform threw the result away without a word. //! //! Nothing survived but the summarizer's account of it — which is the agents' //! description of the work, not the work. //! //! # Why a separate path rather than widening the diff capture //! //! There is no base commit to diff against and no branch to push, so every //! concept `capture_phase_diff` is built on is absent. What a repo-less mission //! produces is simply *files*, and the honest capture is to copy them out and //! register each as an artifact. `_outputs/` is deliberately a SIBLING of the //! mission directory and survives `teardown_container`, so artifacts registered //! here outlive the reap that destroyed the originals. use std::path::{Path, PathBuf}; use sqlx::{PgPool, Row}; use time::{Duration, OffsetDateTime}; use uuid::Uuid; /// Directories never worth capturing, whatever an agent leaves behind. /// /// Same intent as `mission_fs`'s exclusion list: a captured `.git` or /// `node_modules` is noise that would bury the four documents that matter. const SKIP_DIRS: &[&str] = &[ ".git", "node_modules", "target", ".venv", "venv", "__pycache__", ".cache", "dist", "build", ]; /// How many phases to capture per tick, matching `CAPTURE_BATCH`. const BATCH: i64 = 5; /// How long a phase's outputs may stay uncollectable before the sweep stops /// retrying and calls it empty. /// /// Generous on purpose: the container is torn down asynchronously after a /// phase, so an early tick can legitimately fail. What must NOT happen is /// retrying forever — that is the state this constant exists to end. const COLLECT_GRACE: Duration = Duration::minutes(10); /// The artifact kind this path registers. Also the idempotency key: a phase with /// one of these has already been captured. pub const OUTPUT_KIND: &str = "document"; /// Filename of the marker written when a phase produced nothing. const EMPTY_MARKER: &str = "NO-OUTPUT.md"; /// Capture the outputs of finished phases on missions that have no repo. pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> { let rows = sqlx::query( "SELECT mp.id, mp.mission_id, mp.kind, mp.config, mp.completed_at, m.runtime_kind FROM mission_phases mp JOIN missions m ON m.id = mp.mission_id WHERE mp.status IN ('completed', 'failed') AND m.repo_id IS NULL -- microVM used to be excluded here because `run_phase_in_vm` -- refused to boot without a checkout. It no longer does: a -- repo-less mission gets an empty workspace at the same guest path, -- and the collect unpacks it back onto the host — so those files are -- already on disk and `collect_into` reads them instead of asking a -- container that never existed. AND NOT EXISTS ( SELECT 1 FROM mission_artifacts a WHERE a.mission_id = mp.mission_id AND a.phase_id = mp.id AND a.kind = $2 ) ORDER BY mp.completed_at DESC NULLS LAST LIMIT $1", ) .bind(BATCH) .bind(OUTPUT_KIND) .fetch_all(pool) .await .map_err(|e| format!("select repo-less phases to capture: {e}"))?; for row in rows { let phase_id: Uuid = row.get("id"); let mission_id: Uuid = row.get("mission_id"); let kind: String = row.get("kind"); let config: serde_json::Value = row.get("config"); let completed_at: Option = row.get("completed_at"); let runtime_kind: String = row.get("runtime_kind"); let dest = outputs_dir(mission_id, phase_id); let captured = match collect_into(mission_id, &dest, &runtime_kind).await { Ok(files) => files, Err(e) => { // Retryable, but BOUNDED. A bare `continue` here is how a phase // whose collect can never succeed stayed `completed` with zero // artifacts forever: the fail-empty rule and the NO-OUTPUT // marker both live below this point, so neither was ever // reached, and the phase was re-attempted on every tick for the // life of the deployment. // // The grace window exists because the container may legitimately // not be ready on the first tick after a phase finishes. Past // that, "cannot collect" and "collected nothing" are the same // fact for the operator, so we fall through and let the rules // below fail the phase and leave a marker explaining why. let settled = completed_at .map(|t| OffsetDateTime::now_utc() - t > COLLECT_GRACE) .unwrap_or(true); if !settled { eprintln!( "mission_outputs: could NOT collect outputs for phase {phase_id} \ of mission {mission_id} (will retry): {e}" ); continue; } eprintln!( "mission_outputs: giving up collecting phase {phase_id} of mission \ {mission_id} after {}s: {e} — treating it as having produced nothing", COLLECT_GRACE.whole_seconds() ); Vec::new() } }; for file in &captured { let rel = match file.strip_prefix(missions_root()) { Ok(r) => r.to_string_lossy().to_string(), Err(_) => file.to_string_lossy().to_string(), }; let title = file .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| rel.clone()); if let Err(e) = cm_db::repo::missions::register_artifact( pool, cm_db::repo::missions::RegisterArtifact { mission_id, phase_id: Some(phase_id), path: &rel, kind: OUTPUT_KIND, mime: Some(mime_for(file)), title: Some(&title), generated_by_run: None, // No PDF. The renderer converted Markdown to HTML by // calling an LLM — a paid API call, per document, on the // critical path of "save my research", which promptly // failed on depleted credits. Markdown IS the deliverable; // it is served by `artifact_content` and styled at render // time, which is free, offline, and cannot 429. render_pdf: false, metadata: Some(serde_json::json!({ "bytes": std::fs::metadata(file).map(|m| m.len()).unwrap_or(0), "captured_from": "/mission/repo", })), }, ) .await { eprintln!("mission_outputs: registering {rel}: {e}"); } } if captured.is_empty() { // Register a marker even when there is nothing to capture, or this // phase matches the `NOT EXISTS` selection on every tick forever: // re-running a docker copy_out each time and, because the batch is // bounded, permanently occupying a slot so no other repo-less // mission is ever captured again. // // `phase_runner::record_uncapturable` exists for exactly this // failure on the diff path — five dead phases starved the batch // while live work went untouched — and this code hit it again on // its first live negative control (4 log lines, then 8, 45 seconds // apart). Same shape, same fix: a real file behind a real row, // because an artifact pointing at nothing turns every reader into // an unexplained 404. if let Err(e) = register_empty_marker(pool, mission_id, phase_id, &dest).await { eprintln!("mission_outputs: marking phase {phase_id} as empty: {e}"); } } if captured.is_empty() && !allow_empty(&config) { // The same rule `empty_delivery_is_a_failure` applies to a coding // phase, for the only channel a repo-less phase has. Without it a // research mission that produced nothing is indistinguishable from // one that produced eight documents — both `completed`. eprintln!( "mission_outputs: phase {phase_id} ({kind}) of mission {mission_id} produced \ NO output files — failing it. Set config.allow_empty = true if this phase is \ meant to think rather than produce." ); if let Err(e) = sqlx::query("UPDATE mission_phases SET status = 'failed' WHERE id = $1") .bind(phase_id) .execute(pool) .await { eprintln!("mission_outputs: failing empty phase {phase_id}: {e}"); } } else { eprintln!( "mission_outputs: captured {} file(s) from phase {phase_id} ({kind}) of \ mission {mission_id}", captured.len() ); } } Ok(()) } /// Record that a phase produced nothing, so it is not reconsidered forever. /// /// Deliberately the same `OUTPUT_KIND` the real captures use: the selection /// query asks "has this phase been captured?", and "captured, and there was /// nothing" is an answer to that question. `metadata.empty` is what tells the /// two apart — the same convention `mission_delivery` uses for its "No code /// changes" artifact. async fn register_empty_marker( pool: &PgPool, mission_id: Uuid, phase_id: Uuid, dest: &Path, ) -> Result<(), String> { std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?; let file = dest.join(EMPTY_MARKER); std::fs::write( &file, "This phase finished without leaving any files in its workspace, so there\n was nothing to publish. If the phase is meant to reason rather than\n produce, set `config.allow_empty = true` on it.\n", ) .map_err(|e| format!("write {}: {e}", file.display()))?; let rel = file .strip_prefix(missions_root()) .map(|r| r.to_string_lossy().to_string()) .unwrap_or_else(|_| file.to_string_lossy().to_string()); cm_db::repo::missions::register_artifact( pool, cm_db::repo::missions::RegisterArtifact { mission_id, phase_id: Some(phase_id), path: &rel, kind: OUTPUT_KIND, mime: Some("text/markdown"), title: Some("No output produced"), generated_by_run: None, render_pdf: false, metadata: Some(serde_json::json!({ "empty": true })), }, ) .await .map(|_| ()) .map_err(|e| format!("register empty marker: {e}")) } /// Gather the mission's produced files and return the ones worth keeping. /// /// Where they come from depends on the runtime, and the difference is not /// cosmetic: a container mission's files are still INSIDE a running container, /// while a microVM's have already been unpacked onto the host by the collect at /// the end of the turn (`microvm_executor` writes them over /// `mission_workspace::checkout_path`). Asking docker for a VM mission's files /// would query a container that never existed. async fn collect_into( mission_id: Uuid, dest: &Path, runtime_kind: &str, ) -> Result, String> { // A stale copy from an earlier attempt would be registered as this pass's // output — the same "captured a tree nobody wrote" shape capture avoids. let _ = std::fs::remove_dir_all(dest); std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?; if runtime_kind == "microvm" { let src = crate::mission_workspace::checkout_path(mission_id); if !src.is_dir() { return Err(format!( "{} is absent — the VM's collect did not land", src.display() )); } copy_tree(&src, &dest.join("repo"))?; return Ok(keep_files(&dest.join("repo"))); } let container = crate::mission_runtime::container_name(mission_id); let docker = crate::container_exec::connect()?; crate::mission_fs::copy_out(&docker, &container, "/mission/repo", dest).await?; Ok(keep_files(&dest.join("repo"))) } /// Recursive file copy. Small on purpose — the alternative is a dependency or a /// shell-out, and this runs as the server's own uid against its own directory. fn copy_tree(src: &Path, dest: &Path) -> Result<(), String> { std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?; let entries = std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?; for entry in entries.flatten() { let from = entry.path(); let to = dest.join(entry.file_name()); match entry.file_type() { Ok(t) if t.is_dir() => copy_tree(&from, &to)?, Ok(t) if t.is_file() => { std::fs::copy(&from, &to).map_err(|e| format!("copy {}: {e}", from.display()))?; } // Symlinks and specials are skipped rather than followed: a link out // of the tree would publish whatever it points at. _ => {} } } Ok(()) } /// Every regular file worth keeping, recursively. fn keep_files(root: &Path) -> Vec { let mut out = Vec::new(); let mut stack = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { continue; }; for entry in entries.flatten() { let path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); if path.is_dir() { if !SKIP_DIRS.contains(&name.as_str()) { stack.push(path); } } else if path.is_file() && !name.starts_with('.') // The agent runtime seeds its own identity files into the // workspace root, which is pinned to the repo root. In a // repo-backed mission `.git/info/exclude` hides them; a // repo-less mission has no `.git`, so without this the user's // artifact list is 7 files of agent scaffolding and 2 of their // research. Measured exactly that way on the first live run. && !crate::mission_workspace::AGENT_SCAFFOLDING.contains(&name.as_str()) { out.push(path); } } } out.sort(); out } /// `/_outputs//` — a sibling of the mission /// directory, so `teardown_container` reaping the mission does not take the /// captured artifacts with it. fn outputs_dir(mission_id: Uuid, phase_id: Uuid) -> PathBuf { missions_root() .join("_outputs") .join(mission_id.to_string()) .join(phase_id.to_string()) } /// The missions root, for callers that resolve artifact paths against it. pub fn missions_root_dir() -> PathBuf { missions_root() } /// The only directory an artifact may be read from. pub fn outputs_root_dir() -> PathBuf { missions_root().join("_outputs") } fn missions_root() -> PathBuf { crate::mission_workspace::missions_root() } fn mime_for(p: &Path) -> &'static str { match p.extension().and_then(|e| e.to_str()) { Some("md") | Some("markdown") => "text/markdown", Some("json") => "application/json", Some("csv") => "text/csv", Some("html") => "text/html", _ => "text/plain", } } fn allow_empty(config: &serde_json::Value) -> bool { config.get("allow_empty").and_then(|v| v.as_bool()) == Some(true) } #[cfg(test)] mod tests { use super::*; fn touch(p: &Path) { std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, "x").unwrap(); } /// The documents a research phase writes are what must come back — and the /// machinery around them must not. #[test] fn research_documents_are_kept_and_scaffolding_is_not() { let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path().join("repo"); touch(&repo.join("research/01_repo_archaeology.md")); touch(&repo.join("research/02_ecosystem.md")); touch(&repo.join("notes.txt")); // The seven the agent runtime seeds into the workspace root. for f in crate::mission_workspace::AGENT_SCAFFOLDING { touch(&repo.join(f)); } touch(&repo.join(".git/HEAD")); touch(&repo.join("node_modules/left-pad/index.js")); touch(&repo.join("target/debug/thing")); touch(&repo.join(".hidden")); let kept: Vec = keep_files(&repo) .iter() .map(|p| p.strip_prefix(&repo).unwrap().to_string_lossy().to_string()) .collect(); assert_eq!( kept, vec![ "notes.txt".to_string(), "research/01_repo_archaeology.md".to_string(), "research/02_ecosystem.md".to_string(), ], "kept: {kept:?}" ); } /// Markdown is the deliverable, so it must be labelled as markdown — the /// viewer decides how to render from the mime type. #[test] fn markdown_is_labelled_so_the_viewer_can_style_it() { assert_eq!(mime_for(Path::new("/x/01_notes.md")), "text/markdown"); assert_eq!(mime_for(Path::new("/x/data.json")), "application/json"); } /// The containment rule the content endpoint enforces: everything readable /// lives under `_outputs`, and nothing else does. /// /// Artifact paths are written by this server, but they are DATA in a table, /// and a row saying `../../../etc/passwd` must be a 404 rather than a file /// read. The endpoint canonicalises before comparing — checking the string /// first would pass `_outputs/../../etc/passwd` straight through. #[test] fn everything_readable_lives_under_the_outputs_root() { let root = outputs_root_dir(); assert!(root.ends_with("_outputs"), "{root:?}"); assert!(root.starts_with(missions_root_dir()), "{root:?}"); // A real capture is inside it... let inside = outputs_dir(Uuid::now_v7(), Uuid::now_v7()); assert!(inside.starts_with(&root), "{inside:?}"); // ...and the traversal shape this guards against is not, once resolved. let escaped = root.join("..").join("..").join("etc/passwd"); let normalised: PathBuf = escaped.components().fold(PathBuf::new(), |mut acc, c| { match c { std::path::Component::ParentDir => { acc.pop(); } other => acc.push(other), } acc }); assert!( !normalised.starts_with(&root), "a traversal must not resolve back inside the outputs root: {normalised:?}" ); } /// A phase that produced nothing must still leave a marker, or the /// selection query matches it on every tick forever. /// /// Measured on the first live negative control: the guard logged "produced /// NO output files" 4 times, then 8 times 45 seconds later — a docker /// copy_out per tick, and with a bounded batch, five such phases would /// starve every other repo-less mission out of capture permanently. /// `phase_runner::record_uncapturable` was written for the identical /// failure on the diff path. #[test] fn an_empty_phase_leaves_a_marker_so_it_is_not_reconsidered_forever() { let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("out"); // The file-writing half of `register_empty_marker`, which is the part // that must exist for the artifact row to point at something real. std::fs::create_dir_all(&dest).unwrap(); let file = dest.join(EMPTY_MARKER); std::fs::write(&file, "x").unwrap(); assert!(file.exists(), "an artifact row must not point at nothing"); assert_eq!( file.file_name().unwrap().to_string_lossy(), "NO-OUTPUT.md", "the marker name is part of the contract with readers" ); // And the marker must not itself be mistaken for captured output on a // later pass: it is filtered like any other scaffolding would be. assert!(keep_files(&dest).iter().any(|p| p == &file)); } /// Artifacts must land OUTSIDE the mission directory. `teardown_container` /// removes `/` wholesale, so a capture written /// inside it would be destroyed by the very reap it exists to survive. #[test] fn captures_survive_the_mission_directory_being_reaped() { let mission = Uuid::now_v7(); let phase = Uuid::now_v7(); let out = outputs_dir(mission, phase); let mission_dir = missions_root().join(mission.to_string()); assert!( !out.starts_with(&mission_dir), "{} must not be inside {}", out.display(), mission_dir.display() ); assert!(out.starts_with(missions_root().join("_outputs")), "{out:?}"); } }