Files
clawmates/crates/cm-api/src/routes/world.rs
T
Omar Sobh d8c8793c4a
ci / gates (push) Successful in 8s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m25s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m46s
ci fixes: cargo fmt, eslint entities, max-lines split
CI on 6ffbe97 failed on two auto-fixable gates. Both fixed:

  * cargo fmt --all — rustfmt applied across the surface touched
    by the last ~20 commits (world.rs, security_scan.rs,
    routes/{missions,nodes,terminal}.rs, fleet_herdr.rs,
    mission_workspace.rs, benchmark_runner.rs, mission_refiner.rs,
    lib.rs, tests/mission_orchestrator.rs, cm-db/repo/{missions,teams}.rs,
    bins/clawmates-node/src/main.rs)
  * eslint apostrophe escapes in HerdrSessions + MissionWizard
  * eslint max-lines: extracted EditMissionModal + RefineDiffModal
    (each ~200 LoC) into their own files. MissionCanvas drops from
    1424 to 1026, comfortably under both the 1250 eslint cap and the
    1500 CI budget.

New files:
  frontend/src/components/dashboard/EditMissionModal.tsx  (211 LoC)
  frontend/src/components/dashboard/RefineDiffModal.tsx   (208 LoC)

Verified locally: cargo fmt --check clean, cargo check clean,
mission_orchestrator test 3/3 pass, tsc + eslint --quiet both silent.
2026-07-20 12:03:43 -07:00

589 lines
24 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()
}
/// Active missions (status='running') with their assigned team members —
/// one row per (mission, agent) pair. The World SSE loop emits each as
/// a `mission:<id>` landmark orb + `world.touch` beams from every team
/// member. Replaces the retired research/loops landmarks (commit
/// fdb8cfe) with the missions-era equivalent.
async fn active_missions(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String, String)> {
let rows = sqlx::query(
"SELECT m.id::text AS mission_id,
m.title AS title,
tm.claw_id::text AS agent_id
FROM missions m
JOIN team_members tm ON tm.team_id = m.team_id
WHERE m.workspace_id = $1
AND m.status = 'running'",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.map(|r| {
(
r.get::<String, _>("mission_id"),
r.get::<String, _>("title"),
r.get::<String, _>("agent_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()
}
/// 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 }));
}
}
// Mission landmarks: one `mission:<id>` orb per running mission,
// with `world.touch` beams from every assigned team member. Missions
// outlive individual runs, so the orb gives the World a persistent
// pin for "this is what the team is working on right now" even when
// no run is claimed. Replaces the retired repo:{topic}/loop:{id}
// landmarks after commit fdb8cfe.
let missions = active_missions(&pool, ws).await;
let mut seen_missions: HashSet<String> = HashSet::new();
for (mission_id, title, agent_id) in &missions {
if seen_missions.insert(mission_id.clone()) {
let node_id = format!("mission:{mission_id}");
yield sse(
"node.activity",
json!({ "nodeId": node_id, "label": title, "kind": "mission", "heat": 0.75 }),
);
}
let node_id = format!("mission:{mission_id}");
yield sse(
"world.touch",
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "mission", "weight": 0.6 }),
);
}
// 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() }),
))
}