feat(viz): what the agents actually did, as structured events

The World could draw a mission's shape but nothing about the work. The
detail existed only as prose in checkpoint.log and model output, where a
tool name is indistinguishable from an agent *talking about* a tool — so
it was never parsed, deliberately. `mission_events` is the structured
channel that replaces it.

Three taps, one table:

- Container tier: the `_ => {}` at the end of topology_exec's typed frame
  stream now matches `tool_call` and reads the tool's JSON ARGUMENTS for a
  path. Never the prose summary — a path scraped from a sentence would put
  files on the map that no agent opened, and the test proves a Grep whose
  summary says "src/main.rs" produces no file touch. The frame name itself
  is unverified, so the same commit ships an unmatched-frame-type
  histogram: a tap that matches nothing looks exactly like a mission that
  used no tools, and this is how one gw-04 run names the real frame.

- microVM tier: a `PostToolUse` hook, the seam vm_stop_gate already proved
  fires under `claude -p`. It copies stdin to /root/tap and exits 0
  unconditionally — a non-zero PostToolUse hook talks back to the model,
  which would turn the observer into a participant. Drained before collect,
  since the VM is destroyed moments later.

- Phase transitions: five identical copies of the pending→running UPDATE
  became one `mark_phase_running`, and `close_finished_phases` grew
  RETURNING. Its CASE decides each phase's status inside SQL from rows the
  statement does not change, so it cannot be re-derived afterwards without
  writing that CASE twice — without RETURNING it emits zero phase.completed
  and reports success.

The settings.json hazard the plan called out: the stop gate wrote the
WHOLE document, so a second hook writer would have silently erased it and
a coding phase would then complete having written nothing — the exact
failure the gate exists to catch. There is now one composer,
`vm_tool_tap::guest_settings`, one writer, and a source-walk test that
fails if anything else writes a settings document.

`mission_events.run_id` carries no FK on purpose: phase_runner DELETEs
topology_runs on retry, and a cascade would erase a phase's whole history
the moment it retried — silently, since a cascade is not an error.

world.rs streams it with a cursor that separates backfill from motion.
Everything already in the table when a subscriber arrives is drawn as
settled history; only what lands afterwards animates. Otherwise opening a
finished mission replays an hour of tool calls as a burst storm.

Bounded twice: 400 events per phase (enforced inside the INSERT, since
two concurrent taps would each read a count below the cap) and a 7-day
retention sweep in mission_gc.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-11 09:16:50 -07:00
co-authored by Claude Opus 5
parent c2fa8067e1
commit 9e61e3ba35
13 changed files with 1257 additions and 101 deletions
+229
View File
@@ -0,0 +1,229 @@
//! Structured mission activity — the channel that replaced parsing prose.
//!
//! The operator decision behind this module: action detail comes from
//! **structured events at the source**, never from `checkpoint.log` or model
//! output. A tool name in a log line is indistinguishable from an agent
//! *discussing* a tool, and a visualization built on that distinction reads as
//! confident fact while being partly fiction.
//!
//! Everything here is best-effort. A mission must not fail because its
//! telemetry could not be written — so every write logs and swallows. That is a
//! deliberate exception to this codebase's usual rule, and it is bounded: the
//! only thing lost is detail in a picture.
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
/// A phase entered `running`.
pub const PHASE_STARTED: &str = "phase.started";
/// A phase reached a terminal state. `detail.status` says which.
pub const PHASE_COMPLETED: &str = "phase.completed";
/// An agent called a tool. `target` is the tool name.
pub const TOOL_CALL: &str = "tool.call";
/// A tool touched a path. `target` is the path, repo-relative where known.
pub const FILE_TOUCH: &str = "file.touch";
/// Most events one phase may record.
///
/// A capped stream that says so beats an uncapped one that quietly becomes the
/// largest table in the database: a coding phase can call thousands of tools,
/// and every one of them would be replayed to every World subscriber. Past the
/// cap the picture is already complete — nobody reads the four-thousandth file
/// orb.
pub const PER_PHASE_CAP: i64 = 400;
/// One recorded event.
#[derive(Debug, Clone, Default)]
pub struct MissionEvent {
pub mission_id: Uuid,
pub phase_id: Option<Uuid>,
pub run_id: Option<Uuid>,
pub agent_id: Option<Uuid>,
pub kind: String,
pub target: Option<String>,
pub detail: Value,
}
impl MissionEvent {
pub fn new(mission_id: Uuid, kind: &str) -> Self {
MissionEvent {
mission_id,
kind: kind.to_string(),
detail: Value::Null,
..Default::default()
}
}
pub fn phase(mut self, id: Uuid) -> Self {
self.phase_id = Some(id);
self
}
pub fn run(mut self, id: Uuid) -> Self {
self.run_id = Some(id);
self
}
pub fn agent(mut self, id: Option<Uuid>) -> Self {
self.agent_id = id;
self
}
pub fn target(mut self, t: impl Into<String>) -> Self {
self.target = Some(t.into());
self
}
pub fn detail(mut self, d: Value) -> Self {
self.detail = d;
self
}
}
/// Record one event, best-effort.
///
/// The per-phase cap is enforced in the INSERT itself rather than by a read
/// followed by a write: two tool taps writing concurrently would both read a
/// count below the cap and both insert, and the cap would drift by however many
/// writers there are. `INSERT … SELECT … WHERE (subquery) < cap` makes the
/// decision inside the statement.
pub async fn record(pool: &PgPool, e: MissionEvent) {
let detail = if e.detail.is_null() {
Value::Object(Default::default())
} else {
e.detail
};
let res = sqlx::query(
"INSERT INTO mission_events
(mission_id, phase_id, run_id, agent_id, kind, target, detail)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE $2::uuid IS NULL
OR (SELECT count(*) FROM mission_events WHERE phase_id = $2) < $8",
)
.bind(e.mission_id)
.bind(e.phase_id)
.bind(e.run_id)
.bind(e.agent_id)
.bind(&e.kind)
.bind(&e.target)
.bind(&detail)
.bind(PER_PHASE_CAP)
.execute(pool)
.await;
if let Err(err) = res {
eprintln!("mission_events: record {} failed: {err}", e.kind);
}
}
/// Record several events under one round trip's worth of intent.
pub async fn record_all(pool: &PgPool, events: Vec<MissionEvent>) {
for e in events {
record(pool, e).await;
}
}
/// The path a tool's **arguments** name, if any.
///
/// Reads the arguments as JSON — never the tool's prose summary. The summary is
/// a sentence written for a human; a path pulled out of it by regex would be
/// right often enough to be trusted and wrong often enough to matter.
///
/// The key names are the ones Claude Code and the ZeroClaw tools actually use.
/// An unrecognised shape returns `None`, which renders as a tool call with no
/// file — accurate, rather than a guess at which argument was a path.
pub fn tool_path(args: &Value) -> Option<String> {
const KEYS: [&str; 6] = [
"file_path",
"filePath",
"path",
"notebook_path",
"file",
"target_file",
];
let obj = args.as_object()?;
for k in KEYS {
if let Some(s) = obj.get(k).and_then(Value::as_str) {
let s = s.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
}
None
}
/// Strip the guest/host workspace prefix so a path is repo-relative.
///
/// Tool arguments are absolute inside the sandbox (`/mission/repo/src/a.rs`).
/// Left alone, every mission's file tree would nest under a `mission` → `repo`
/// pair of directory orbs that exist in no repository and mean nothing to the
/// person reading the map.
pub fn repo_relative(path: &str, roots: &[&str]) -> String {
let p = path.trim();
for root in roots {
let root = root.trim_end_matches('/');
if let Some(rest) = p.strip_prefix(root) {
let rest = rest.trim_start_matches('/');
if !rest.is_empty() {
return rest.to_string();
}
}
}
p.trim_start_matches("./").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Paths come from arguments, and only from argument keys we know.
///
/// The alternative — scanning the values for anything that looks like a
/// path — is what makes a viz confidently wrong: a `pattern` of `*.rs` or a
/// `command` of `ls src/` would both become "the agent edited a file".
#[test]
fn a_path_comes_from_a_known_argument_or_not_at_all() {
assert_eq!(
tool_path(&json!({"file_path": "/mission/repo/src/a.rs"})).as_deref(),
Some("/mission/repo/src/a.rs")
);
assert_eq!(tool_path(&json!({"path": "docs/x.md"})).as_deref(), Some("docs/x.md"));
// A shell command mentions paths and touches none we can name.
assert_eq!(tool_path(&json!({"command": "ls src/"})), None);
// A glob is a query, not a file.
assert_eq!(tool_path(&json!({"pattern": "**/*.rs"})), None);
// Blank is absence, not a file called "".
assert_eq!(tool_path(&json!({"file_path": " "})), None);
assert_eq!(tool_path(&json!("not an object")), None);
}
/// The sandbox prefix must not become two directory orbs in every mission.
#[test]
fn paths_are_made_repo_relative() {
let roots = ["/mission/repo", "/workspace"];
assert_eq!(repo_relative("/mission/repo/src/a.rs", &roots), "src/a.rs");
assert_eq!(repo_relative("/workspace/README.md", &roots), "README.md");
assert_eq!(repo_relative("./src/a.rs", &roots), "src/a.rs");
// Outside every root, it is left alone rather than mangled.
assert_eq!(repo_relative("/etc/hosts", &roots), "/etc/hosts");
// The root ITSELF is not a file, so it must not collapse to "".
assert_eq!(repo_relative("/mission/repo", &roots), "/mission/repo");
}
/// The cap must be decided inside the INSERT.
///
/// A count-then-insert is the classic version of this and it is wrong here:
/// the container tap and the microVM drain both write for the same phase,
/// and each would see a count below the cap and insert. Nothing errors —
/// the table simply grows past the bound that exists to hold it.
#[test]
fn the_cap_is_enforced_in_one_statement() {
let src = include_str!("mission_events.rs");
let body = src
.split("pub async fn record(")
.nth(1)
.and_then(|s| s.split("pub async fn").next())
.expect("record body");
assert!(
body.contains("INSERT INTO mission_events") && body.contains("SELECT count(*)"),
"the cap must be a subquery in the INSERT, not a separate read"
);
}
}