Topology runs: SSE live-progress (replace polling)
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:
co-authored by
Claude Opus 4.8
parent
f0963a46b4
commit
f845dfb15f
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user