Topologies UI: a "Run" tab for durable runs with live progress
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

New TopologyRun component: pick a topology + roles + task, enqueue via
POST /api/topologies/run (202), then poll GET /api/topology-runs/{id} for
live turn-by-turn progress — rendering the per-step checkpoint while running
and the final RunRecord (+ final output) when completed, with a status badge
and recent-runs list. Added as the middle "run" tab in the workbench.

Since the work runs server-side as a durable job, a long-horizon run survives
navigation/restarts; the UI just re-attaches by polling. lint + typecheck +
next build clean. (Also fixed a pre-existing set-state-in-effect lint error in
TopologyCompare surfaced by the Next 16 toolchain.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-17 21:19:35 -07:00
co-authored by Claude Opus 4.8
parent 272669e1f5
commit f0963a46b4
3 changed files with 263 additions and 4 deletions
@@ -0,0 +1,254 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
/** A journaled step — the same shape whether mid-run (checkpoint) or final. */
interface StepRecord {
node_id: string;
role: string;
phase: string;
output: string;
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;
status: string;
kind: string;
created_at: string;
}
const STATUS_STYLE: Record<string, string> = {
queued: "bg-muted text-muted-foreground",
running: "bg-coral/15 text-coral",
completed: "bg-emerald-500/15 text-emerald-500",
failed: "bg-red-500/15 text-red-500",
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. */
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 [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [runs, setRuns] = useState<RunSummary[]>([]);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
async function loadRuns() {
try {
const r = await fetch("/api/topology-runs");
if (r.ok) setRuns((await r.json()) as RunSummary[]);
} catch {
/* best-effort */
}
}
useEffect(() => {
// Initial recent-runs load; setRuns runs after a fetch await (not a
// synchronous cascading render), so the rule is a false positive here.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadRuns();
return () => {
if (pollRef.current) clearTimeout(pollRef.current);
};
}, []);
function poll(id: string) {
const tick = async () => {
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;
}
}
} catch {
/* transient; keep polling */
}
pollRef.current = setTimeout(tick, 2000);
};
pollRef.current = setTimeout(tick, 1200);
}
async function run() {
setBusy(true);
setError(null);
setDetail(null);
if (pollRef.current) clearTimeout(pollRef.current);
try {
const roleList = roles
.split(",")
.map((r) => r.trim())
.filter(Boolean);
if (roleList.length === 0) throw new Error("Add at least one role");
const b = await fetch("/api/topologies/build", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind, roles: roleList }),
});
if (!b.ok) throw new Error(`Build failed (${b.status})`);
const graph = (await b.json()) as TopologyGraph;
const res = await fetch("/api/topologies/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task, graph }),
});
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);
} 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;
return (
<div className="flex flex-col gap-4">
<label className="text-xs text-muted-foreground">
Task
<input
value={task}
onChange={(e) => setTask(e.target.value)}
className="mt-1 w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm text-foreground"
/>
</label>
<div className="flex gap-3">
<label className="flex-1 text-xs text-muted-foreground">
Topology
<select
value={kind}
onChange={(e) => setKind(e.target.value)}
className="mt-1 w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm text-foreground"
>
{catalog.map((c) => (
<option key={c.kind} value={c.kind}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex-[2] text-xs text-muted-foreground">
Roles (comma-separated)
<input
value={roles}
onChange={(e) => setRoles(e.target.value)}
className="mt-1 w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm text-foreground"
/>
</label>
</div>
<div>
<button
type="button"
onClick={run}
disabled={busy}
className="rounded-lg bg-coral px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? "Running…" : "Run topology"}
</button>
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{detail ? (
<div className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex items-center gap-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
STATUS_STYLE[status ?? ""] ?? "bg-muted text-muted-foreground"
}`}
>
{status}
</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">
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{s.role}</span>
<span>· {s.phase}</span>
<span>· {s.node_id}</span>
</div>
<p className="whitespace-pre-wrap text-sm text-foreground">{s.output}</p>
</li>
))}
</ol>
{finalOutput ? (
<div className="rounded-md bg-muted/40 p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">Final output</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{finalOutput}</p>
</div>
) : null}
</div>
) : null}
{runs.length > 0 ? (
<div className="mt-2">
<p className="mb-2 text-xs font-medium text-muted-foreground">Recent runs</p>
<ul className="flex flex-col gap-1">
{runs.slice(0, 8).map((r) => (
<li key={r.id} className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`rounded-full px-2 py-0.5 ${
STATUS_STYLE[r.status] ?? "bg-muted text-muted-foreground"
}`}
>
{r.status}
</span>
<span className="text-foreground">{r.kind}</span>
<span className="truncate">{r.task}</span>
</li>
))}
</ul>
</div>
) : null}
</div>
);
}