Topology runs: SSE live-progress (replace polling)
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

Backend: GET /api/topology-runs/{id}/events streams Server-Sent Events by
tailing the durable per-step checkpoint the worker already writes — a `step`
event per newly-completed step (replayed on connect so reload/reconnect
re-attaches), then a terminal `done` event with the final output/error. Each
step carries its index as the SSE id, so the browser's Last-Event-ID resumes
without duplicates on reconnect. No new table, no worker change — reuses the
checkpoint; avoids run_events' agent_runs FK.

Frontend: the Run tab now opens an EventSource (through the same-origin proxy,
which adds the bearer) instead of polling — appending steps as they stream and
finalizing on `done`. One streaming connection, lower latency, auto-resume.

cm-api builds + clippy clean; frontend lint + typecheck + next build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-17 22:32:39 -07:00
co-authored by Claude Opus 4.8
parent f0963a46b4
commit f845dfb15f
3 changed files with 122 additions and 55 deletions
+4
View File
@@ -146,6 +146,10 @@ pub fn router(state: AppState) -> Router {
.route("/api/topologies/run", post(routes::topology::run_topology)) .route("/api/topologies/run", post(routes::topology::run_topology))
.route("/api/topology-runs", get(routes::topology::list_runs)) .route("/api/topology-runs", get(routes::topology::list_runs))
.route("/api/topology-runs/{id}", get(routes::topology::get_run)) .route("/api/topology-runs/{id}", get(routes::topology::get_run))
.route(
"/api/topology-runs/{id}/events",
get(routes::topology::run_events_sse),
)
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state) .with_state(state)
} }
+68 -1
View File
@@ -3,8 +3,13 @@
//! back the topology builder UI. Running/comparing topologies is a later, //! back the topology builder UI. Running/comparing topologies is a later,
//! provider-backed endpoint. //! provider-backed endpoint.
use std::convert::Infallible;
use std::time::Duration;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use axum::Json; use axum::Json;
use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor}; use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor};
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind}; use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
@@ -208,6 +213,68 @@ pub struct RunDetail {
pub checkpoint: Option<serde_json::Value>, pub checkpoint: Option<serde_json::Value>,
} }
/// `GET /api/topology-runs/{id}/events` — Server-Sent Events stream of live run
/// progress. Tails the durable per-step `checkpoint` the worker writes: emits a
/// `step` event per newly-completed step (replaying all so far on connect, so a
/// reload/reconnect re-attaches), then a terminal `done` event with the final
/// output (or error). Each `step` carries the step index as its SSE id, so the
/// browser's automatic `Last-Event-ID` on reconnect resumes without duplicates.
/// This gives the UI live long-horizon progress with no client polling.
pub async fn run_events_sse(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> impl IntoResponse {
let pool = state.pool.clone();
let ws = user.workspace_id;
// Resume after the last step the client already saw (SSE Last-Event-ID).
let mut sent: usize = headers
.get("last-event-id")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<usize>().ok())
.map(|n| n + 1)
.unwrap_or(0);
let stream = async_stream::stream! {
loop {
match cm_db::repo::topology_runs::status(&pool, id, ws).await {
Ok(st) => {
if let Some(records) = st
.checkpoint
.as_ref()
.and_then(|c| c.get("records"))
.and_then(|r| r.as_array())
{
while sent < records.len() {
yield Ok::<Event, Infallible>(Event::default()
.id(sent.to_string())
.event("step")
.data(records[sent].to_string()));
sent += 1;
}
}
if matches!(st.status.as_str(), "completed" | "failed" | "cancelled") {
let done = serde_json::json!({
"status": st.status,
"error": st.error,
"final_output": st.result.as_ref().and_then(|r| r.get("final_output")),
"totals": st.result.as_ref().and_then(|r| r.get("totals")),
});
yield Ok(Event::default().event("done").data(done.to_string()));
break;
}
}
// Unknown id / wrong workspace / gone: end the stream.
Err(_) => break,
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
/// `GET /api/topology-runs/{id}` — a single run with status + result. /// `GET /api/topology-runs/{id}` — a single run with status + result.
pub async fn get_run( pub async fn get_run(
State(state): State<AppState>, State(state): State<AppState>,
@@ -13,25 +13,6 @@ interface StepRecord {
tokens: number; tokens: number;
gated: unknown[]; gated: unknown[];
} }
interface RunRecord {
kind: string;
steps: StepRecord[];
final_output: string;
totals: { tokens: number; turns: number };
}
interface RunProgress {
completed: number;
records: StepRecord[];
}
interface RunDetail {
id: string;
task: string;
kind: string;
status: string;
error: string | null;
comparison: RunRecord | null;
checkpoint: RunProgress | null;
}
interface RunSummary { interface RunSummary {
id: string; id: string;
task: string; task: string;
@@ -48,19 +29,21 @@ const STATUS_STYLE: Record<string, string> = {
cancelled: "bg-muted text-muted-foreground", cancelled: "bg-muted text-muted-foreground",
}; };
/** Run ONE topology as a durable job: enqueue, then poll for live turn-by-turn /** Run ONE topology as a durable job: enqueue, then stream live turn-by-turn
* progress (the server checkpoints each step) until it completes. This is the * progress over SSE (the server checkpoints each step) until it completes. This
* surface for long-horizon runs — the work happens server-side, not in the * is the surface for long-horizon runs — the work happens server-side, not in
* request, so it survives navigation and restarts. */ * the request, so it survives navigation and restarts. */
export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) { export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
const [task, setTask] = useState("Draft a go-to-market launch plan in 3 bullet points."); const [task, setTask] = useState("Draft a go-to-market launch plan in 3 bullet points.");
const [kind, setKind] = useState(catalog[0]?.kind ?? "pipeline"); const [kind, setKind] = useState(catalog[0]?.kind ?? "pipeline");
const [roles, setRoles] = useState("researcher, analyst, writer"); const [roles, setRoles] = useState("researcher, analyst, writer");
const [detail, setDetail] = useState<RunDetail | null>(null); const [steps, setSteps] = useState<StepRecord[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [finalOutput, setFinalOutput] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [runs, setRuns] = useState<RunSummary[]>([]); const [runs, setRuns] = useState<RunSummary[]>([]);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null); const esRef = useRef<EventSource | null>(null);
async function loadRuns() { async function loadRuns() {
try { try {
@@ -77,36 +60,57 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
void loadRuns(); void loadRuns();
return () => { return () => {
if (pollRef.current) clearTimeout(pollRef.current); esRef.current?.close();
}; };
}, []); }, []);
function poll(id: string) { /** Stream live progress over SSE (the same-origin proxy adds auth). The
const tick = async () => { * browser auto-sends Last-Event-ID on reconnect so the server resumes. */
function stream(id: string) {
esRef.current?.close();
const es = new EventSource(`/api/topology-runs/${id}/events`);
esRef.current = es;
setStatus("running");
es.addEventListener("step", (e) => {
try { try {
const r = await fetch(`/api/topology-runs/${id}`); setSteps((s) => [...s, JSON.parse((e as MessageEvent).data) as StepRecord]);
if (r.ok) {
const d = (await r.json()) as RunDetail;
setDetail(d);
if (d.status === "completed" || d.status === "failed" || d.status === "cancelled") {
setBusy(false);
void loadRuns();
return;
}
}
} catch { } catch {
/* transient; keep polling */ /* ignore malformed frame */
} }
pollRef.current = setTimeout(tick, 2000); });
es.addEventListener("done", (e) => {
try {
const d = JSON.parse((e as MessageEvent).data) as {
status: string;
error: string | null;
final_output: string | null;
};
setStatus(d.status);
if (d.final_output) setFinalOutput(d.final_output);
if (d.error) setError(d.error);
} catch {
/* ignore */
}
es.close();
esRef.current = null;
setBusy(false);
void loadRuns();
});
es.onerror = () => {
// Transient or terminal close; the run continues server-side regardless.
es.close();
esRef.current = null;
setBusy(false);
}; };
pollRef.current = setTimeout(tick, 1200);
} }
async function run() { async function run() {
setBusy(true); setBusy(true);
setError(null); setError(null);
setDetail(null); setSteps([]);
if (pollRef.current) clearTimeout(pollRef.current); setFinalOutput(null);
setStatus("queued");
esRef.current?.close();
try { try {
const roleList = roles const roleList = roles
.split(",") .split(",")
@@ -128,17 +132,14 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
if (res.status !== 202) throw new Error(`Enqueue failed (${res.status})`); if (res.status !== 202) throw new Error(`Enqueue failed (${res.status})`);
const { run_id } = (await res.json()) as { run_id: string }; const { run_id } = (await res.json()) as { run_id: string };
void loadRuns(); void loadRuns();
poll(run_id); stream(run_id);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Run failed"); setError(e instanceof Error ? e.message : "Run failed");
setBusy(false); setBusy(false);
} }
} }
// Render the final journal when done, else the live checkpoint progress. const started = status !== null;
const steps: StepRecord[] = detail?.comparison?.steps ?? detail?.checkpoint?.records ?? [];
const finalOutput = detail?.comparison?.final_output;
const status = detail?.status;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -187,7 +188,7 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
{error ? <p className="text-sm text-red-500">{error}</p> : null} {error ? <p className="text-sm text-red-500">{error}</p> : null}
{detail ? ( {started ? (
<div className="flex flex-col gap-3 rounded-lg border border-border p-4"> <div className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
@@ -199,14 +200,9 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{steps.length} step{steps.length === 1 ? "" : "s"} {steps.length} step{steps.length === 1 ? "" : "s"}
{detail.comparison?.totals
? ` · ${detail.comparison.totals.tokens} tokens`
: ""}
</span> </span>
</div> </div>
{detail.error ? <p className="text-sm text-red-500">{detail.error}</p> : null}
<ol className="flex flex-col gap-2"> <ol className="flex flex-col gap-2">
{steps.map((s, i) => ( {steps.map((s, i) => (
<li key={`${s.node_id}-${i}`} className="rounded-md border border-border/60 p-3"> <li key={`${s.node_id}-${i}`} className="rounded-md border border-border/60 p-3">