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:
co-authored by
Claude Opus 5
parent
c2fa8067e1
commit
9e61e3ba35
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user