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
@@ -46,8 +46,9 @@ export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// setRuns runs after a fetch await, not synchronously — false positive.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void loadRuns();
|
void loadRuns();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadRun(id: string) {
|
async function loadRun(id: string) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ import type { CatalogEntry } from "@/lib/api/topology";
|
|||||||
|
|
||||||
import { TopologyCompare } from "./TopologyCompare";
|
import { TopologyCompare } from "./TopologyCompare";
|
||||||
import { TopologyExplorer } from "./TopologyExplorer";
|
import { TopologyExplorer } from "./TopologyExplorer";
|
||||||
|
import { TopologyRun } from "./TopologyRun";
|
||||||
|
|
||||||
const TABS = ["build", "compare"] as const;
|
const TABS = ["build", "run", "compare"] as const;
|
||||||
type Tab = (typeof TABS)[number];
|
type Tab = (typeof TABS)[number];
|
||||||
|
|
||||||
/** Tabbed topology workbench: Build (catalog + visualizer) and Compare
|
/** Tabbed topology workbench: Build (catalog + visualizer), Run (one topology
|
||||||
* (run a task across topologies → leaderboard + Pareto). */
|
* as a durable job with live progress), and Compare (a task across topologies
|
||||||
|
* → leaderboard + Pareto). */
|
||||||
export function TopologyWorkbench({ catalog }: { catalog: CatalogEntry[] }) {
|
export function TopologyWorkbench({ catalog }: { catalog: CatalogEntry[] }) {
|
||||||
const [tab, setTab] = useState<Tab>("build");
|
const [tab, setTab] = useState<Tab>("build");
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +38,8 @@ export function TopologyWorkbench({ catalog }: { catalog: CatalogEntry[] }) {
|
|||||||
</div>
|
</div>
|
||||||
{tab === "build" ? (
|
{tab === "build" ? (
|
||||||
<TopologyExplorer catalog={catalog} />
|
<TopologyExplorer catalog={catalog} />
|
||||||
|
) : tab === "run" ? (
|
||||||
|
<TopologyRun catalog={catalog} />
|
||||||
) : (
|
) : (
|
||||||
<TopologyCompare catalog={catalog} />
|
<TopologyCompare catalog={catalog} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user