Files
clawmates/crates/cm-api/src/routes/world.rs
T
Omar Sobh a49cf86623
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 11s
ci / frontend (push) Successful in 25s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped
world: pre-seed repo tree on clone (V3)
Repo focus mode used to open into an empty tree — the file/dir nodes
only synthesized as agents touched files via tool calls. Cold-start
users saw a lonely repo:<id> orb with nothing under it. Now the tree
pre-seeds from the actual cloned repo the moment a client subscribes.

Backend:
- active_research_topics also returns repo_workspace_path.
- preseed_repo_paths(clone_path) runs `git ls-files` (bounded to top 200)
  against the on-disk clone. Silently returns empty on any failure so a
  missing clone / git-off-PATH / empty repo just degrades to the pre-V3
  behavior (tree still builds on touch).
- SSE loop, on first sight of a topic per client, emits one
  node.activity per pre-seeded path (label = leaf name, heat 0) so the
  tree is quiet-solid at rest.

Frontend:
- engine.onNodeActivity now synthesizes the dir:<partial> chain for
  file: nodeIds the same way onTouch does — otherwise the pre-seed
  would render as flat leaves under ROOT.
- Same 5-line synthesis extracted from onTouch; both paths now agree on
  the layout.

Cap of 200 keeps the SSE payload bounded on huge repos; the tail fills
in as agents actually touch files. When we later add per-file heat map
(V4), the 200 already-known files get first-class treatment out of the
gate.
2026-07-09 15:49:43 -07:00

715 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `GET /api/world/live` — the Clawmates Event Taxonomy as a Server-Sent Events
//! feed for the World + Observe visualizations. Read-only and workspace-scoped.
//!
//! Phase 1 emits the *real* shape of the workspace: a `topology.update` of its
//! agents, an `agent.status` per agent (working when it holds a live container,
//! else idle), and a `telemetry` snapshot — polled, mirroring `run_events_sse`.
//!
//! The richer `world.touch` / `agent.tool.call` events (an agent converging on
//! the node it acts upon — the Gource centerpiece) come from normalizing the
//! durable runner's `run_events`; that is the extension seam (see `normalize`),
//! filled in as the runner emits node targets. Until then the client's synthetic
//! fallback supplies that motion.
use std::collections::HashSet;
use std::convert::Infallible;
use std::time::Duration;
use axum::extract::{Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use axum::Json;
use cm_domain::WorkspaceId;
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::{PgPool, Row};
use crate::{ApiError, AppState, Authed};
fn sse(event: &str, data: Value) -> Result<Event, Infallible> {
Ok(Event::default().event(event).data(data.to_string()))
}
/// Agents that currently hold a live container (any kind) → "working".
async fn working_agents(pool: &PgPool, ws: WorkspaceId) -> HashSet<String> {
let rows = sqlx::query(
"SELECT DISTINCT a.id::text AS id
FROM agents a JOIN agent_containers ac ON ac.agent_id = a.id
WHERE a.workspace_id = $1",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter().map(|r| r.get::<String, _>("id")).collect()
}
/// Currently-running runs in the workspace as (run_id, agent_id) — each is a
/// real "this agent is converging on its active work" signal (Gource).
async fn active_runs(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String)> {
let rows = sqlx::query(
"SELECT ar.id::text AS run_id, s.agent_id::text AS agent_id
FROM agent_runs ar JOIN sessions s ON s.id = ar.session_id
WHERE s.workspace_id = $1 AND ar.state = 'running'",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.map(|r| (r.get::<String, _>("run_id"), r.get::<String, _>("agent_id")))
.collect()
}
/// Active research topics + their assigned agents. Each returned row is a
/// `(topic_id, title, agent_id, repo_workspace_path)` — one row per
/// (topic, agent) pair. Emitted from the SSE loop as `repo:<topic_id>`
/// project orbs so the World shows a clickable, labeled landmark for
/// every in-flight R&D initiative — no need for a file touch to land
/// first. `repo_workspace_path` (when non-null) is the on-disk clone
/// location; the SSE loop uses it to pre-seed the repo tree.
async fn active_research_topics(
pool: &PgPool,
ws: WorkspaceId,
) -> Vec<(String, String, String, Option<String>)> {
let rows = sqlx::query(
"SELECT t.id::text AS topic_id,
t.title AS title,
t.repo_workspace_path AS repo_path,
ra.agent_id::text AS agent_id
FROM research_topics t
JOIN research_topic_agents ra ON ra.topic_id = t.id
WHERE t.workspace_id = $1
AND t.status IN ('processing', 'reviewing', 'publishing')",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.map(|r| {
(
r.get::<String, _>("topic_id"),
r.get::<String, _>("title"),
r.get::<String, _>("agent_id"),
r.try_get::<Option<String>, _>("repo_path").unwrap_or(None),
)
})
.collect()
}
/// Cap on pre-seeded file entries per repo. Large repos surface only the
/// top N so the SSE payload stays bounded — a subsequent tool call
/// exercising a specific path will fill in additional nodes on demand.
const REPO_PRESEED_CAP: usize = 200;
/// Read the top-level file list of a topic's cloned repo via `git ls-files`
/// so the SSE loop can pre-seed dir:/file: nodes in the client engine.
/// Bounded by `REPO_PRESEED_CAP`. Returns an empty vec on any failure
/// (missing clone, git not on PATH, empty repo) — a missing pre-seed
/// degrades gracefully to the pre-V3 behavior (tree builds as agents
/// touch files).
async fn preseed_repo_paths(clone_path: &str) -> Vec<String> {
let path = std::path::Path::new(clone_path);
if !path.join(".git").exists() {
return Vec::new();
}
let out = tokio::process::Command::new("git")
.arg("-C")
.arg(path)
.arg("ls-files")
.output()
.await;
let Ok(out) = out else { return Vec::new() };
if !out.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.take(REPO_PRESEED_CAP)
.map(|s| s.to_string())
.collect()
}
/// Enabled scheduled loops + their assigned agents. Same shape as
/// `active_research_topics` — `(loop_id, title, agent_id)` per (loop, agent).
/// Emitted as `loop:<loop_id>` landmark orbs so recurring/scheduled work is
/// visible in the World at all times, not just while a run is mid-flight.
/// Contrast with research topics (transient statuses processing/reviewing/
/// publishing) — loops are persistent landmarks the user can click.
async fn active_loops(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String, String)> {
let rows = sqlx::query(
"SELECT l.id::text AS loop_id, l.title AS title, la.agent_id::text AS agent_id
FROM loops l
JOIN loop_agents la ON la.loop_id = l.id
WHERE l.workspace_id = $1
AND l.enabled = TRUE",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.map(|r| {
(
r.get::<String, _>("loop_id"),
r.get::<String, _>("title"),
r.get::<String, _>("agent_id"),
)
})
.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();
// File/project I/O gets an explosive burst (high touch weight).
let lower = tool.to_lowercase();
let file_op = [
"file", "read", "write", "drive", "vault", "obsidian", "edit", "fs", "save",
]
.iter()
.any(|k| lower.contains(k));
let weight = if file_op { 1.0 } else { 0.4 };
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": if file_op { 1.0 } else { 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", "weight": weight })));
// File-op tools ALSO emit a `file:<path>` touch so the repo
// detail view can build the tree from real events. The path
// comes from the input's `path` / `target` / `file` / `url`
// keys — same lookup summarize_input does but we keep the
// full string so the client can build the dir hierarchy.
if file_op {
if let Some(input) = payload.get("input") {
for k in ["path", "target", "file", "filename", "url"] {
if let Some(p) = input.get(k).and_then(|v| v.as_str()) {
let cleaned = p.trim().trim_start_matches("./");
if !cleaned.is_empty() {
let file_node = format!("file:{cleaned}");
out.push((
"node.activity",
json!({ "nodeId": file_node, "label": cleaned, "kind": "service", "heat": 1.0 }),
));
out.push((
"world.touch",
json!({ "agentId": agent_id, "nodeId": file_node, "kind": "service", "weight": 1.0 }),
));
break;
}
}
}
}
}
}
"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 }),
));
}
"agent_message" => {
let s = |k: &str| {
payload
.get(k)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned()
};
out.push((
"agent.message",
json!({
"fromAgentId": agent_id,
"toAgentId": s("to_agent_id"),
"toName": s("to_name"),
"text": s("text"),
"threadId": s("thread_id"),
}),
));
}
"room_message" => {
let s = |k: &str| {
payload
.get(k)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned()
};
let participant_ids = payload
.get("participant_ids")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_owned()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
out.push((
"room.message",
json!({
"fromAgentId": agent_id,
"threadId": s("thread_id"),
"subject": s("subject"),
"text": s("text"),
"participantIds": participant_ids,
}),
));
}
"a2a_invoked" => {
// An external A2A caller started a turn on this agent (a new ingress).
out.push(("a2a.invoked", json!({ "agentId": agent_id })));
}
_ => {}
}
out
}
/// Count of doors (approvals) awaiting a decision in the workspace.
async fn doors_pending(pool: &PgPool, ws: WorkspaceId) -> i64 {
sqlx::query_scalar::<_, i64>(
"SELECT count(*) FROM approvals WHERE workspace_id = $1 AND status = 'pending'",
)
.bind(ws.as_uuid())
.fetch_one(pool)
.await
.unwrap_or(0)
}
/// Live per-agent metrics for the agent command center's metric band.
#[derive(Default)]
struct AgentTele {
tokens_per_min: i64,
cost_per_hr: f64,
loops: i64,
doors: i64,
}
/// Per-agent telemetry for every agent in the workspace, in 4 grouped queries
/// (not N×4): tokens over the last minute, credits over the last hour, active
/// routines, and pending approvals — all keyed by agent id.
async fn agent_telemetry(
pool: &PgPool,
ws: WorkspaceId,
) -> std::collections::HashMap<String, AgentTele> {
let mut m: std::collections::HashMap<String, AgentTele> = std::collections::HashMap::new();
let id_of = |r: &sqlx::postgres::PgRow| r.get::<uuid::Uuid, _>("agent_id").to_string();
// tokens in the last minute ≈ tokens/min.
for r in sqlx::query(
"SELECT agent_id, SUM(tokens_in + tokens_out)::bigint AS v FROM usage_events
WHERE workspace_id = $1 AND agent_id IS NOT NULL AND created_at > now() - interval '1 minute'
GROUP BY agent_id",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default()
{
m.entry(id_of(&r)).or_default().tokens_per_min = r.get("v");
}
// credits spent in the last hour ≈ spend/hr.
for r in sqlx::query(
"SELECT agent_id, SUM(credits)::float8 AS v FROM usage_events
WHERE workspace_id = $1 AND agent_id IS NOT NULL AND created_at > now() - interval '1 hour'
GROUP BY agent_id",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default()
{
m.entry(id_of(&r)).or_default().cost_per_hr = r.get("v");
}
// active routines = loops.
for r in sqlx::query(
"SELECT r.agent_id AS agent_id, count(*)::bigint AS v FROM routines r
JOIN agents a ON a.id = r.agent_id
WHERE a.workspace_id = $1 AND r.status = 'active' GROUP BY r.agent_id",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default()
{
m.entry(id_of(&r)).or_default().loops = r.get("v");
}
// pending approvals = doors.
for r in sqlx::query(
"SELECT requested_by_agent AS agent_id, count(*)::bigint AS v FROM approvals
WHERE workspace_id = $1 AND status = 'pending' GROUP BY requested_by_agent",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default()
{
m.entry(id_of(&r)).or_default().doors = r.get("v");
}
m
}
/// `GET /api/world/live` — the taxonomy SSE feed.
pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) -> impl IntoResponse {
let pool = state.pool.clone();
let ws = user.workspace_id;
let stream = async_stream::stream! {
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();
// Audit-log cursor for edge-initiated inter-agent events (delegation,
// A2A) that bypass the run loop. -1 until seeded on the first pass.
let mut audit_cursor: i64 = -1;
// Track the brain-file size we last announced per agent so we only
// emit `agent.memory` when the file has actually grown (or shrunk).
// On first seed we still emit — the World engine needs the initial
// scale for every pawn.
let mut last_bytes: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
loop {
let roster = match cm_db::repo::agents::roster(&pool, ws).await {
Ok(r) => r,
Err(_) => break,
};
let mut working = working_agents(&pool, ws).await;
let runs = active_runs(&pool, ws).await;
for (_, agent_id) in &runs {
working.insert(agent_id.clone());
}
if first {
// Seed the world graph with the workspace's agents as nodes.
let nodes: Vec<Value> = roster
.iter()
.map(|a| json!({ "id": a.id.to_string(), "tier": "agent", "label": a.name }))
.collect();
yield sse("topology.update", json!({ "formation": "live", "nodes": nodes }));
}
for a in &roster {
let id = a.id.to_string();
let status = if working.contains(&id) { "working" } else { "idle" };
if last.get(&id).map(|s| s != status).unwrap_or(true) {
last.insert(id.clone(), status.to_string());
yield sse(
"agent.status",
json!({ "agentId": id, "status": status, "role": a.job_title }),
);
}
// Cheap brain-file stat — just reads the inode metadata, no
// HDF5 open. Missing file (never provisioned) → treat as 0
// so the pawn stays at its base size. Only emit on change.
let brain_path = crate::routes::claws::brain_dir()
.join(format!("claw_{}.h5", id));
let bytes = std::fs::metadata(&brain_path).ok().map(|m| m.len()).unwrap_or(0);
if last_bytes.get(&id).map(|&b| b != bytes).unwrap_or(true) {
last_bytes.insert(id.clone(), bytes);
yield sse("agent.memory", json!({ "agentId": id, "bytes": bytes }));
}
}
// Active research topics → landmark project orbs. One `repo:<id>`
// per topic, labeled with the topic title so users can click it
// and drop into the repo-focus (Gource) view before any files are
// touched. Assigned agents gently converge on their topic's orb
// so the affinity is visible even in idle windows.
let research = active_research_topics(&pool, ws).await;
let mut seen_topics = std::collections::HashSet::new();
for (topic_id, title, agent_id, repo_path) in &research {
let node_id = format!("repo:{topic_id}");
if seen_topics.insert(topic_id.clone()) {
yield sse(
"node.activity",
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
);
// Pre-seed the repo tree (V3). One-shot on first sight
// of the topic per SSE client. Each file emits with
// heat=0 so the tree is quiet-solid at rest — activity
// still hot-swaps as agents touch files. Bounded to
// REPO_PRESEED_CAP so payload stays reasonable.
if let Some(clone_path) = repo_path {
for p in preseed_repo_paths(clone_path).await {
let leaf = std::path::Path::new(&p)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&p)
.to_string();
yield sse(
"node.activity",
json!({
"nodeId": format!("file:{p}"),
"label": leaf,
"kind": "service",
"heat": 0.0,
}),
);
}
}
}
yield sse(
"world.touch",
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": 0.15 }),
);
}
// Scheduled loops → landmark orbs, symmetric to research topics.
// Persistent landmarks: emitted whenever a loop is enabled, so a
// loop between fires still reads as an in-flight project. When
// a loop actually runs, the topology_worker journals events
// which the run-cursor block below picks up and heats the orb.
let loops = active_loops(&pool, ws).await;
let mut seen_loops = std::collections::HashSet::new();
for (loop_id, title, agent_id) in &loops {
let node_id = format!("loop:{loop_id}");
if seen_loops.insert(loop_id.clone()) {
yield sse(
"node.activity",
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
);
}
yield sse(
"world.touch",
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": 0.15 }),
);
}
// Real convergence: each running agent beams toward its active-run node.
for (run_id, agent_id) in &runs {
let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]);
yield sse(
"node.activity",
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);
}
}
// Per-agent telemetry → the command-center metric band (real data:
// usage_events tokens/credits, active routines, pending approvals).
let tele = agent_telemetry(&pool, ws).await;
for a in &roster {
let id = a.id.to_string();
let t = tele.get(&id);
yield sse("telemetry", json!({
"agentId": id,
"tokensPerMin": t.map(|x| x.tokens_per_min).unwrap_or(0),
"costPerHr": t.map(|x| x.cost_per_hr).unwrap_or(0.0),
"loops": t.map(|x| x.loops).unwrap_or(0),
"doorsPending": t.map(|x| x.doors).unwrap_or(0),
}));
}
// Workspace-wide telemetry (top-bar pills / Observe system strip).
yield sse(
"telemetry",
json!({ "doorsPending": doors_pending(&pool, ws).await, "loops": runs.len() }),
);
// Edge-initiated inter-agent events (gated delegation, A2A ingress)
// bypass the run loop, so surface them from the append-only audit log.
// On first sight jump the cursor to the current max so we stream
// forward instead of replaying history.
if audit_cursor < 0 {
audit_cursor = sqlx::query_scalar(
"SELECT coalesce(max(id), 0) FROM audit_log WHERE workspace_id = $1",
)
.bind(ws.as_uuid())
.fetch_one(&pool)
.await
.unwrap_or(0);
} else {
let rows = sqlx::query(
"SELECT id, actor_id, event_type, subject_id, detail FROM audit_log
WHERE workspace_id = $1 AND id > $2
AND event_type IN ('delegation.invoked', 'a2a.invoked')
ORDER BY id ASC LIMIT 100",
)
.bind(ws.as_uuid())
.bind(audit_cursor)
.fetch_all(&pool)
.await
.unwrap_or_default();
for row in &rows {
let id: i64 = row.get("id");
let et: String = row.get("event_type");
let actor: Option<uuid::Uuid> = row.get("actor_id");
let subject: String = row.get("subject_id");
let detail: Value = row.get("detail");
match et.as_str() {
"delegation.invoked" => {
yield sse("agent.delegate", json!({
"fromAgentId": actor.map(|u| u.to_string()).unwrap_or_default(),
"toAgentId": detail.get("to_id").and_then(|v| v.as_str()).unwrap_or(""),
"toName": subject,
"task": detail.get("task").and_then(|v| v.as_str()).unwrap_or(""),
}));
}
"a2a.invoked" => {
// subject_id is the claw_<id> alias → surface the target agent.
let agent_id = subject
.strip_prefix("claw_")
.and_then(|h| uuid::Uuid::parse_str(h).ok())
.map(|u| u.to_string())
.unwrap_or_else(|| subject.clone());
yield sse("a2a.invoked", json!({ "agentId": agent_id }));
}
_ => {}
}
if id > audit_cursor {
audit_cursor = id;
}
}
}
first = false;
tokio::time::sleep(Duration::from_secs(2)).await;
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
#[derive(Deserialize)]
pub struct ReplayQuery {
hours: Option<i64>,
}
/// `GET /api/world/replay?hours=24` — a Gource-style timeline reconstructed from
/// the workspace's run history: a sorted list of timestamped taxonomy events the
/// client's WorldClock plays back into the same engine (live + replay).
pub async fn world_replay(
State(state): State<AppState>,
Authed(user): Authed,
Query(q): Query<ReplayQuery>,
) -> Result<Json<Value>, ApiError> {
let hours = q.hours.unwrap_or(24).clamp(1, 720);
let rows = sqlx::query(
"SELECT ar.id::text AS run_id, s.agent_id::text AS agent_id,
extract(epoch FROM ar.created_at)::float8 AS started
FROM agent_runs ar JOIN sessions s ON s.id = ar.session_id
WHERE s.workspace_id = $1 AND ar.created_at > now() - ($2 * interval '1 hour')
ORDER BY ar.created_at ASC",
)
.bind(user.workspace_id.as_uuid())
.bind(hours)
.fetch_all(&state.pool)
.await?;
let mut events: Vec<Value> = Vec::new();
for r in &rows {
let run_id: String = r.get("run_id");
let agent_id: String = r.get("agent_id");
let started: f64 = r.get("started");
let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]);
events.push(json!({ "t": started, "type": "agent.status", "data": { "agentId": agent_id, "status": "working" }}));
events.push(json!({ "t": started, "type": "node.activity", "data": { "nodeId": node_id, "label": "run", "kind": "event", "heat": 0.85 }}));
events.push(json!({ "t": started, "type": "world.touch", "data": { "agentId": agent_id, "nodeId": node_id, "kind": "event" }}));
}
Ok(Json(
json!({ "events": events, "hours": hours, "count": rows.len() }),
))
}
// THE NORMALIZE SEAM (future) -------------------------------------------------
// Translate one durable `run_events` row into zero+ taxonomy events, the Rust
// twin of the handoff bridge's normalize(). Wire this into the poll loop above
// once the runner emits node targets:
// turn.started -> agent.status(working) [+ agent.task.update]
// turn.token -> agent.reasoning.delta
// tool.invoked -> agent.tool.call [+ world.touch if a nodeId is present]
// door.requested -> door.request ; door.resolved -> door.resolve
// agent.message -> agent.message ; runner.telemetry -> telemetry
#[allow(dead_code)]
fn normalize(_event_type: &str, _payload: &Value) -> Vec<(&'static str, Value)> {
Vec::new()
}