"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 RunSummary { id: string; task: string; status: string; kind: string; created_at: string; } const STATUS_STYLE: Record = { 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 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 [steps, setSteps] = useState([]); const [status, setStatus] = useState(null); const [finalOutput, setFinalOutput] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [runs, setRuns] = useState([]); const [runId, setRunId] = useState(null); const esRef = useRef(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 () => { esRef.current?.close(); }; }, []); /** 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(); setRunId(id); const es = new EventSource(`/api/topology-runs/${id}/events`); esRef.current = es; setStatus("running"); es.addEventListener("step", (e) => { try { setSteps((s) => [...s, JSON.parse((e as MessageEvent).data) as StepRecord]); } catch { /* ignore malformed frame */ } }); 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); }; } async function run() { setBusy(true); setError(null); setSteps([]); setFinalOutput(null); setStatus("queued"); esRef.current?.close(); 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(); stream(run_id); } catch (e) { setError(e instanceof Error ? e.message : "Run failed"); setBusy(false); } } /** Request cancellation; the SSE `done` event reports the cancelled status. */ async function cancel() { if (!runId) return; try { await fetch(`/api/topology-runs/${runId}/cancel`, { method: "POST" }); } catch { /* the worker also stops on the next status read */ } } /** Deep-link into a past/running run: the SSE endpoint replays its steps from * the checkpoint, then tails live if it's still running. */ function openRun(id: string) { setError(null); setSteps([]); setFinalOutput(null); setStatus("running"); setBusy(true); stream(id); } const started = status !== null; return (
{busy ? ( ) : null}
{error ?

{error}

: null} {started ? (
{status} {steps.length} step{steps.length === 1 ? "" : "s"}
    {steps.map((s, i) => (
  1. {s.role} · {s.phase} · {s.node_id}

    {s.output}

  2. ))}
{finalOutput ? (

Final output

{finalOutput}

) : null}
) : null} {runs.length > 0 ? (

Recent runs

    {runs.slice(0, 8).map((r) => (
  • ))}
) : null}
); }