World: normalize real run_events into the live taxonomy (the data unlock)
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

/api/world/live now tails each active run's journal (run_events) and normalizes
the runner's existing events into taxonomy events — no runner change needed:
- text_delta       -> agent.reasoning.delta (the live reasoning stream)
- step_started     -> agent.tool.call + node.activity + world.touch on the tool
                      node (agents visibly converge on the tool they're using)
- approval_required -> door.request
Per-run seq cursor streams forward only (skips backlog on first sight). This
lights up REAL data for both the World view and the upcoming Observe surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-23 21:52:51 -07:00
co-authored by Claude Opus 4.8
parent 53279e6339
commit 380b95da2a
+83
View File
@@ -61,6 +61,50 @@ async fn active_runs(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String)> {
.collect()
}
/// A short human label for a tool's input (for the tool-call target).
fn summarize_input(input: &Value) -> String {
for k in ["target", "path", "url", "query", "name", "file", "command"] {
if let Some(s) = input.get(k).and_then(|v| v.as_str()) {
return s.chars().take(48).collect();
}
}
String::new()
}
/// Normalize one durable `run_events` row into taxonomy events — the Rust twin of
/// the handoff bridge's normalize(). The runner already journals these, so the
/// live view shows REAL reasoning, tool-convergence and doors with no runner edit.
fn normalize_run_event(agent_id: &str, event_type: &str, payload: &Value) -> Vec<(&'static str, Value)> {
let mut out = Vec::new();
match event_type {
"text_delta" => {
if let Some(delta) = payload.get("delta").and_then(|v| v.as_str()) {
out.push(("agent.reasoning.delta", json!({ "agentId": agent_id, "text": delta })));
}
}
"step_started" => {
let tool = payload.get("tool").and_then(|v| v.as_str()).unwrap_or("tool");
let node_id = format!("tool:{tool}");
let target = payload.get("input").map(summarize_input).unwrap_or_default();
out.push(("agent.tool.call", json!({ "agentId": agent_id, "tool": tool, "target": target })));
out.push(("node.activity", json!({ "nodeId": node_id, "label": tool, "kind": "service", "heat": 0.9 })));
// the agent converges on the tool it's using (the Gource beam)
out.push(("world.touch", json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service" })));
}
"approval_required" => {
let action = payload.get("action_type").and_then(|v| v.as_str()).unwrap_or("action");
let category = payload.get("category").and_then(|v| v.as_str()).unwrap_or("");
let door_id = payload.get("approval_id").and_then(|v| v.as_str()).unwrap_or("");
out.push((
"door.request",
json!({ "doorId": door_id, "agentId": agent_id, "action": action, "target": category, "summary": action }),
));
}
_ => {}
}
out
}
/// Count of doors (approvals) awaiting a decision in the workspace.
async fn doors_pending(pool: &PgPool, ws: WorkspaceId) -> i64 {
sqlx::query_scalar::<_, i64>(
@@ -81,6 +125,8 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
let mut first = true;
// Remember last status per agent so we only push deltas after the seed.
let mut last: std::collections::HashMap<String, String> = std::collections::HashMap::new();
// Per-run journal cursor so we stream only NEW run_events each poll.
let mut cursors: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
loop {
let roster = match cm_db::repo::agents::roster(&pool, ws).await {
Ok(r) => r,
@@ -121,6 +167,43 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
json!({ "nodeId": node_id, "label": "active run", "kind": "event", "heat": 0.85 }),
);
yield sse("world.touch", json!({ "agentId": agent_id, "nodeId": node_id, "kind": "event" }));
// Tail the run's journal for richer real events (reasoning, tool
// convergence, doors). On first sight, jump the cursor to the
// current max so we stream forward without replaying the backlog.
if let Some(&after) = cursors.get(run_id) {
let rows = sqlx::query(
"SELECT seq, event_type, payload FROM run_events
WHERE run_id = $1::uuid AND seq > $2 ORDER BY seq ASC LIMIT 200",
)
.bind(run_id)
.bind(after)
.fetch_all(&pool)
.await
.unwrap_or_default();
let mut maxseq = after;
for row in &rows {
let seq: i64 = row.get("seq");
let et: String = row.get("event_type");
let payload: Value = row.get("payload");
for (t, d) in normalize_run_event(agent_id, &et, &payload) {
yield sse(t, d);
}
if seq > maxseq {
maxseq = seq;
}
}
cursors.insert(run_id.clone(), maxseq);
} else {
let maxseq: i64 = sqlx::query_scalar(
"SELECT coalesce(max(seq), -1) FROM run_events WHERE run_id = $1::uuid",
)
.bind(run_id)
.fetch_one(&pool)
.await
.unwrap_or(-1);
cursors.insert(run_id.clone(), maxseq);
}
}
yield sse(