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
@@ -13,25 +13,6 @@ interface StepRecord {
tokens: number;
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 {
id: string;
task: string;
@@ -48,19 +29,21 @@ const STATUS_STYLE: Record<string, string> = {
cancelled: "bg-muted text-muted-foreground",
};
/** Run ONE topology as a durable job: enqueue, then poll for live turn-by-turn
* progress (the server checkpoints each step) until it completes. This is the
* surface for long-horizon runs — the work happens server-side, not in the
* request, so it survives navigation and restarts. */
/** Run ONE topology as a durable job: enqueue, then stream live turn-by-turn
* progress over SSE (the server checkpoints each step) until it completes. This
* is the surface for long-horizon runs — the work happens server-side, not in
* the request, so it survives navigation and restarts. */
export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
const [task, setTask] = useState("Draft a go-to-market launch plan in 3 bullet points.");
const [kind, setKind] = useState(catalog[0]?.kind ?? "pipeline");
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 [error, setError] = useState<string | null>(null);
const [runs, setRuns] = useState<RunSummary[]>([]);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const esRef = useRef<EventSource | null>(null);
async function loadRuns() {
try {
@@ -77,36 +60,57 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadRuns();
return () => {
if (pollRef.current) clearTimeout(pollRef.current);
esRef.current?.close();
};
}, []);
function poll(id: string) {
const tick = async () => {
/** Stream live progress over SSE (the same-origin proxy adds auth). The
* 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 {
const r = await fetch(`/api/topology-runs/${id}`);
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;
}
}
setSteps((s) => [...s, JSON.parse((e as MessageEvent).data) as StepRecord]);
} 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() {
setBusy(true);
setError(null);
setDetail(null);
if (pollRef.current) clearTimeout(pollRef.current);
setSteps([]);
setFinalOutput(null);
setStatus("queued");
esRef.current?.close();
try {
const roleList = roles
.split(",")
@@ -128,17 +132,14 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
if (res.status !== 202) throw new Error(`Enqueue failed (${res.status})`);
const { run_id } = (await res.json()) as { run_id: string };
void loadRuns();
poll(run_id);
stream(run_id);
} catch (e) {
setError(e instanceof Error ? e.message : "Run failed");
setBusy(false);
}
}
// Render the final journal when done, else the live checkpoint progress.
const steps: StepRecord[] = detail?.comparison?.steps ?? detail?.checkpoint?.records ?? [];
const finalOutput = detail?.comparison?.final_output;
const status = detail?.status;
const started = status !== null;
return (
<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}
{detail ? (
{started ? (
<div className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex items-center gap-2">
<span
@@ -199,14 +200,9 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
</span>
<span className="text-xs text-muted-foreground">
{steps.length} step{steps.length === 1 ? "" : "s"}
{detail.comparison?.totals
? ` · ${detail.comparison.totals.tokens} tokens`
: ""}
</span>
</div>
{detail.error ? <p className="text-sm text-red-500">{detail.error}</p> : null}
<ol className="flex flex-col gap-2">
{steps.map((s, i) => (
<li key={`${s.node_id}-${i}`} className="rounded-md border border-border/60 p-3">