Topologies UI: a "Run" tab for durable runs with live progress
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:
co-authored by
Claude Opus 4.8
parent
272669e1f5
commit
f0963a46b4
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user