World: Phase C — replay scrubber (Gource-style playback)
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/replay?hours= — reconstructs a sorted, timestamped taxonomy timeline
  from the workspace's run history (agent_runs ⋈ sessions) for client playback.
- engine.clearWorldNodes() — drop transient world nodes/targets for a loop restart.
- WorldCanvas: a WorldClock control (Live ⇄ Replay) feeding the same engine —
  play/pause, 1x/2x/4x speed, seek bar, loop. Live subscriptions detach in replay.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-23 21:39:03 -07:00
co-authored by Claude Opus 4.8
parent 05c54de47d
commit 53279e6339
4 changed files with 234 additions and 6 deletions
+1
View File
@@ -103,6 +103,7 @@ pub fn router(state: AppState) -> Router {
.route("/healthz", get(routes::health::healthz)) .route("/healthz", get(routes::health::healthz))
.route("/api/quota", get(quota::get_quota)) .route("/api/quota", get(quota::get_quota))
.route("/api/world/live", get(routes::world::world_live)) .route("/api/world/live", get(routes::world::world_live))
.route("/api/world/replay", get(routes::world::world_replay))
.route("/mcp", post(mcp_door::mcp)) .route("/mcp", post(mcp_door::mcp))
.route("/api/auth/login", post(routes::auth::login)) .route("/api/auth/login", post(routes::auth::login))
.route("/api/auth/logout", post(routes::auth::logout)) .route("/api/auth/logout", post(routes::auth::logout))
+43 -2
View File
@@ -15,14 +15,16 @@ use std::collections::HashSet;
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
use axum::extract::State; use axum::extract::{Query, State};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Json;
use cm_domain::WorkspaceId; use cm_domain::WorkspaceId;
use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use crate::{AppState, Authed}; use crate::{ApiError, AppState, Authed};
fn sse(event: &str, data: Value) -> Result<Event, Infallible> { fn sse(event: &str, data: Value) -> Result<Event, Infallible> {
Ok(Event::default().event(event).data(data.to_string())) Ok(Event::default().event(event).data(data.to_string()))
@@ -134,6 +136,45 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
Sse::new(stream).keep_alive(KeepAlive::default()) 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) ------------------------------------------------- // THE NORMALIZE SEAM (future) -------------------------------------------------
// Translate one durable `run_events` row into zero+ taxonomy events, the Rust // 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 // twin of the handoff bridge's normalize(). Wire this into the poll loop above
+181 -4
View File
@@ -6,16 +6,45 @@
// glow with heat and fade when idle. Driven by the live taxonomy feed (with a // glow with heat and fade when idle. Driven by the live taxonomy feed (with a
// synthetic fallback) so it breathes with real agent activity. Replaces WorldFlow. // synthetic fallback) so it breathes with real agent activity. Replaces WorldFlow.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js"; import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import type { TaxonomyEvents } from "@/lib/live/taxonomy";
import { useClawmatesLive } from "@/lib/live/useClawmatesLive"; import { useClawmatesLive } from "@/lib/live/useClawmatesLive";
import { WorldEngine, type Formation, type WorldSeed } from "./engine"; import { WorldEngine, type Formation, type WorldSeed } from "./engine";
type ReplayEvent = { t: number; type: string; data: Record<string, unknown> };
type ReplayState = {
events: ReplayEvent[];
idx: number;
playhead: number;
t0: number;
t1: number;
baseSpeed: number;
progress: number;
};
function applyReplayEvent(engine: WorldEngine, ev: ReplayEvent) {
switch (ev.type) {
case "world.touch":
engine.onTouch(ev.data as unknown as TaxonomyEvents["world.touch"]);
break;
case "node.activity":
engine.onNodeActivity(ev.data as unknown as TaxonomyEvents["node.activity"]);
break;
case "agent.status":
engine.onStatus(ev.data as unknown as TaxonomyEvents["agent.status"]);
break;
case "topology.update":
engine.onTopology(ev.data as unknown as TaxonomyEvents["topology.update"]);
break;
}
}
interface WorldCanvasProps { interface WorldCanvasProps {
roots: WorldSeed[]; roots: WorldSeed[];
selectedId: string | null; selectedId: string | null;
@@ -38,16 +67,28 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
const labelLayerRef = useRef<HTMLDivElement>(null); const labelLayerRef = useRef<HTMLDivElement>(null);
const live = useClawmatesLive(); const live = useClawmatesLive();
const [formation, setFormation] = useState<Formation>("live"); const [formation, setFormation] = useState<Formation>("live");
const [mode, setMode] = useState<"live" | "replay">("live");
const [playing, setPlaying] = useState(false);
const [speedMult, setSpeedMult] = useState(1);
const [progress, setProgress] = useState(0);
const replayHours = 24;
// latest-prop refs so the imperative loop sees current values without re-init // latest-prop refs so the imperative loop sees current values without re-init
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
const selectedRef = useRef(selectedId); const selectedRef = useRef(selectedId);
const engineRef = useRef<WorldEngine | null>(null); const engineRef = useRef<WorldEngine | null>(null);
const formationRef = useRef(formation); const formationRef = useRef(formation);
const modeRef = useRef(mode);
const playingRef = useRef(playing);
const speedRef = useRef(speedMult);
const replayRef = useRef<ReplayState | null>(null);
useEffect(() => { useEffect(() => {
onSelectRef.current = onSelect; onSelectRef.current = onSelect;
selectedRef.current = selectedId; selectedRef.current = selectedId;
formationRef.current = formation; formationRef.current = formation;
modeRef.current = mode;
playingRef.current = playing;
speedRef.current = speedMult;
}); });
// seed the engine once from the structure roots // seed the engine once from the structure roots
@@ -58,10 +99,10 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// wire the live feed into the engine // wire the live feed into the engine (only in live mode — replay drives it instead)
useEffect(() => { useEffect(() => {
const e = engineRef.current; const e = engineRef.current;
if (!e) return; if (!e || mode !== "live") return;
const offs = [ const offs = [
live.on("topology.update", (d) => e.onTopology(d)), live.on("topology.update", (d) => e.onTopology(d)),
live.on("agent.status", (d) => e.onStatus(d)), live.on("agent.status", (d) => e.onStatus(d)),
@@ -69,7 +110,36 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
live.on("node.activity", (d) => e.onNodeActivity(d)), live.on("node.activity", (d) => e.onNodeActivity(d)),
]; ];
return () => offs.forEach((off) => off()); return () => offs.forEach((off) => off());
}, [live]); }, [live, mode]);
// replay mode: fetch the run-history timeline and feed it back through the engine
useEffect(() => {
if (mode !== "replay") {
replayRef.current = null;
return;
}
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/world/replay?hours=${replayHours}`);
const data = (await res.json()) as { events?: ReplayEvent[] };
if (cancelled) return;
const events = (data.events ?? []).slice().sort((a, b) => a.t - b.t);
const t0 = events.length ? events[0].t : 0;
const t1 = events.length ? events[events.length - 1].t + 6 : t0 + 1;
engineRef.current?.clearWorldNodes();
replayRef.current = { events, idx: 0, playhead: t0, t0, t1, baseSpeed: Math.max(1, (t1 - t0) / 30), progress: 0 };
setPlaying(true);
} catch {
/* ignore */
}
})();
const prog = setInterval(() => setProgress(replayRef.current?.progress ?? 0), 200);
return () => {
cancelled = true;
clearInterval(prog);
};
}, [mode]);
// the three.js scene + render loop // the three.js scene + render loop
useEffect(() => { useEffect(() => {
@@ -166,6 +236,23 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
const dt = Math.min(0.05, (now - lastT) / 1000); const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now; lastT = now;
if (modeRef.current === "replay") {
const rp = replayRef.current;
if (rp && playingRef.current && rp.events.length) {
rp.playhead += dt * rp.baseSpeed * speedRef.current;
if (rp.playhead >= rp.t1) {
rp.playhead = rp.t0;
rp.idx = 0;
engine.clearWorldNodes();
}
while (rp.idx < rp.events.length && rp.events[rp.idx].t <= rp.playhead) {
applyReplayEvent(engine, rp.events[rp.idx]);
rp.idx += 1;
}
rp.progress = rp.t1 > rp.t0 ? (rp.playhead - rp.t0) / (rp.t1 - rp.t0) : 0;
}
}
engine.formation = formationRef.current; engine.formation = formationRef.current;
engine.step(dt); engine.step(dt);
@@ -333,6 +420,32 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
}; };
}, []); }, []);
const chip = (active: boolean): CSSProperties => ({
fontFamily: mono,
fontSize: 11,
padding: "4px 9px",
borderRadius: 7,
border: `1px solid ${active ? "rgba(255,111,97,.4)" : "transparent"}`,
cursor: "pointer",
color: active ? "#fff" : "#8a8a92",
background: active ? "rgba(255,111,97,.18)" : "rgba(255,255,255,.04)",
});
const switchMode = (m: "live" | "replay") => {
setMode(m);
setPlaying(false);
setProgress(0);
};
const seek = (e: ReactMouseEvent<HTMLDivElement>) => {
const rp = replayRef.current;
if (!rp) return;
const rect = e.currentTarget.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
rp.playhead = rp.t0 + frac * (rp.t1 - rp.t0);
rp.idx = 0;
engineRef.current?.clearWorldNodes();
while (rp.idx < rp.events.length && rp.events[rp.idx].t <= rp.playhead) rp.idx += 1;
};
return ( return (
<div style={{ position: "absolute", inset: 0 }}> <div style={{ position: "absolute", inset: 0 }}>
<div ref={mountRef} style={{ position: "absolute", inset: 0 }} /> <div ref={mountRef} style={{ position: "absolute", inset: 0 }} />
@@ -375,6 +488,70 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
</button> </button>
))} ))}
</div> </div>
{/* WorldClock — live ⇄ replay (Gource-style playback of run history) */}
<div
style={{
position: "absolute",
bottom: 14,
left: "50%",
transform: "translateX(-50%)",
zIndex: 30,
display: "flex",
alignItems: "center",
gap: 10,
padding: "7px 12px",
borderRadius: 12,
background: "rgba(10,10,14,.78)",
border: "1px solid rgba(255,255,255,.08)",
backdropFilter: "blur(8px)",
fontFamily: mono,
fontSize: 11,
color: "#cfcfd5",
}}
>
<button type="button" onClick={() => switchMode("live")} style={chip(mode === "live")}>
● Live
</button>
<button type="button" onClick={() => switchMode("replay")} style={chip(mode === "replay")}>
⟲ Replay
</button>
{mode === "replay" ? (
<>
<button type="button" onClick={() => setPlaying((p) => !p)} style={chip(false)}>
{playing ? "⏸" : "▶"}
</button>
<div
role="slider"
aria-label="Seek replay"
aria-valuenow={Math.round(progress * 100)}
tabIndex={0}
onClick={seek}
style={{ width: 200, height: 5, borderRadius: 3, background: "rgba(255,255,255,.12)", cursor: "pointer", position: "relative" }}
>
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${Math.round(progress * 100)}%`,
background: "linear-gradient(90deg,#ff6f61,#ff8a7a)",
borderRadius: 3,
}}
/>
</div>
<div style={{ display: "flex", gap: 3 }}>
{[1, 2, 4].map((s) => (
<button key={s} type="button" onClick={() => setSpeedMult(s)} style={chip(speedMult === s)}>
{s}x
</button>
))}
</div>
</>
) : (
<span style={{ color: "#6a6a72" }}>streaming live</span>
)}
</div>
</div> </div>
); );
} }
+9
View File
@@ -171,6 +171,15 @@ export class WorldEngine {
return p; return p;
} }
/** Drop all transient world nodes + pawn targets (for a replay loop restart). */
clearWorldNodes() {
for (const [id, n] of this.nodes) {
if (n.tier === "service" || n.tier === "event") this.nodes.delete(id);
}
for (const p of this.pawns.values()) p.targetId = null;
this.beams.length = 0;
}
// --- taxonomy event handlers --------------------------------------------- // --- taxonomy event handlers ---------------------------------------------
onTopology(e: TaxonomyEvents["topology.update"]) { onTopology(e: TaxonomyEvents["topology.update"]) {
if (e.formation) this.formation = e.formation; if (e.formation) this.formation = e.formation;