World page: live WebGL Gource-style visualization (Phases A+B)
Replaces the static React-Flow world forest with a real-time WebGL view of the swarm working, plus the live event contract that feeds it. Phase A — the live contract + feed: - frontend/src/lib/live/taxonomy.ts — the 13-event Clawmates Event Taxonomy as typed TS (the shared World/Observe contract). - frontend/src/lib/live/useClawmatesLive.ts — native shared-singleton live client (EventSource to /api/world/live, typed fan-out, replay-of-last-state to late subscribers, refcounted, synthetic fallback so views are always alive). - crates/cm-api/src/routes/world.rs — GET /api/world/live, authed + workspace- scoped SSE emitting the taxonomy (real agent.status from live-container state, a topology.update of the workspace's agents, telemetry with real doorsPending), mirroring run_events_sse. normalize() seam documented for run_events->world.touch. Phase B — the WebGL engine: - frontend/src/components/world/engine.ts — Gource-inspired force-directed model: org/company/team tree (sibling repulsion + parent spring + friction), agent pawns that converge on the touched node and beam it, world nodes that glow with heat and fade when idle. Framework-agnostic (renderer-independent) state+math. - frontend/src/components/world/WorldCanvas.tsx — three.js scene (ortho cam, UnrealBloom), render loop syncing engine state, camera auto-fit, HTML labels, raycast click->select, Hierarchy/Flat/Live formation switch. - Dashboard.tsx: swap <WorldFlow/> -> <WorldCanvas/> at the world-tier seam (shared claw-tier pieces untouched; WorldFlow.tsx kept for now). - Adds three (+ @types/three). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
843e9f1053
commit
39bacbd1d2
@@ -23,3 +23,4 @@ pub mod team;
|
||||
pub mod teams;
|
||||
pub mod terminal;
|
||||
pub mod topology;
|
||||
pub mod world;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `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::State;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::IntoResponse;
|
||||
use cm_domain::WorkspaceId;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use crate::{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()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// `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();
|
||||
loop {
|
||||
let roster = match cm_db::repo::agents::roster(&pool, ws).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
let working = working_agents(&pool, ws).await;
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
yield sse(
|
||||
"telemetry",
|
||||
json!({ "doorsPending": doors_pending(&pool, ws).await }),
|
||||
);
|
||||
|
||||
first = false;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user