World: Phase C — replay scrubber (Gource-style playback)
- /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:
co-authored by
Claude Opus 4.8
parent
05c54de47d
commit
53279e6339
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user