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
@@ -146,6 +146,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/topologies/run", post(routes::topology::run_topology))
|
.route("/api/topologies/run", post(routes::topology::run_topology))
|
||||||
.route("/api/topology-runs", get(routes::topology::list_runs))
|
.route("/api/topology-runs", get(routes::topology::list_runs))
|
||||||
.route("/api/topology-runs/{id}", get(routes::topology::get_run))
|
.route("/api/topology-runs/{id}", get(routes::topology::get_run))
|
||||||
|
.route(
|
||||||
|
"/api/topology-runs/{id}/events",
|
||||||
|
get(routes::topology::run_events_sse),
|
||||||
|
)
|
||||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
//! back the topology builder UI. Running/comparing topologies is a later,
|
//! back the topology builder UI. Running/comparing topologies is a later,
|
||||||
//! provider-backed endpoint.
|
//! provider-backed endpoint.
|
||||||
|
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor};
|
use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor};
|
||||||
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
|
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
|
||||||
@@ -208,6 +213,68 @@ pub struct RunDetail {
|
|||||||
pub checkpoint: Option<serde_json::Value>,
|
pub checkpoint: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/topology-runs/{id}/events` — Server-Sent Events stream of live run
|
||||||
|
/// progress. Tails the durable per-step `checkpoint` the worker writes: emits a
|
||||||
|
/// `step` event per newly-completed step (replaying all so far on connect, so a
|
||||||
|
/// reload/reconnect re-attaches), then a terminal `done` event with the final
|
||||||
|
/// output (or error). Each `step` carries the step index as its SSE id, so the
|
||||||
|
/// browser's automatic `Last-Event-ID` on reconnect resumes without duplicates.
|
||||||
|
/// This gives the UI live long-horizon progress with no client polling.
|
||||||
|
pub async fn run_events_sse(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let pool = state.pool.clone();
|
||||||
|
let ws = user.workspace_id;
|
||||||
|
// Resume after the last step the client already saw (SSE Last-Event-ID).
|
||||||
|
let mut sent: usize = headers
|
||||||
|
.get("last-event-id")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
|
.map(|n| n + 1)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let stream = async_stream::stream! {
|
||||||
|
loop {
|
||||||
|
match cm_db::repo::topology_runs::status(&pool, id, ws).await {
|
||||||
|
Ok(st) => {
|
||||||
|
if let Some(records) = st
|
||||||
|
.checkpoint
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.get("records"))
|
||||||
|
.and_then(|r| r.as_array())
|
||||||
|
{
|
||||||
|
while sent < records.len() {
|
||||||
|
yield Ok::<Event, Infallible>(Event::default()
|
||||||
|
.id(sent.to_string())
|
||||||
|
.event("step")
|
||||||
|
.data(records[sent].to_string()));
|
||||||
|
sent += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(st.status.as_str(), "completed" | "failed" | "cancelled") {
|
||||||
|
let done = serde_json::json!({
|
||||||
|
"status": st.status,
|
||||||
|
"error": st.error,
|
||||||
|
"final_output": st.result.as_ref().and_then(|r| r.get("final_output")),
|
||||||
|
"totals": st.result.as_ref().and_then(|r| r.get("totals")),
|
||||||
|
});
|
||||||
|
yield Ok(Event::default().event("done").data(done.to_string()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unknown id / wrong workspace / gone: end the stream.
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET /api/topology-runs/{id}` — a single run with status + result.
|
/// `GET /api/topology-runs/{id}` — a single run with status + result.
|
||||||
pub async fn get_run(
|
pub async fn get_run(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -13,25 +13,6 @@ interface StepRecord {
|
|||||||
tokens: number;
|
tokens: number;
|
||||||
gated: unknown[];
|
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 {
|
interface RunSummary {
|
||||||
id: string;
|
id: string;
|
||||||
task: string;
|
task: string;
|
||||||
@@ -48,19 +29,21 @@ const STATUS_STYLE: Record<string, string> = {
|
|||||||
cancelled: "bg-muted text-muted-foreground",
|
cancelled: "bg-muted text-muted-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Run ONE topology as a durable job: enqueue, then poll for live turn-by-turn
|
/** Run ONE topology as a durable job: enqueue, then stream live turn-by-turn
|
||||||
* progress (the server checkpoints each step) until it completes. This is the
|
* progress over SSE (the server checkpoints each step) until it completes. This
|
||||||
* surface for long-horizon runs — the work happens server-side, not in the
|
* is the surface for long-horizon runs — the work happens server-side, not in
|
||||||
* request, so it survives navigation and restarts. */
|
* the request, so it survives navigation and restarts. */
|
||||||
export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
|
export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
|
||||||
const [task, setTask] = useState("Draft a go-to-market launch plan in 3 bullet points.");
|
const [task, setTask] = useState("Draft a go-to-market launch plan in 3 bullet points.");
|
||||||
const [kind, setKind] = useState(catalog[0]?.kind ?? "pipeline");
|
const [kind, setKind] = useState(catalog[0]?.kind ?? "pipeline");
|
||||||
const [roles, setRoles] = useState("researcher, analyst, writer");
|
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 [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [runs, setRuns] = useState<RunSummary[]>([]);
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
||||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
async function loadRuns() {
|
async function loadRuns() {
|
||||||
try {
|
try {
|
||||||
@@ -77,36 +60,57 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void loadRuns();
|
void loadRuns();
|
||||||
return () => {
|
return () => {
|
||||||
if (pollRef.current) clearTimeout(pollRef.current);
|
esRef.current?.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function poll(id: string) {
|
/** Stream live progress over SSE (the same-origin proxy adds auth). The
|
||||||
const tick = async () => {
|
* 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 {
|
try {
|
||||||
const r = await fetch(`/api/topology-runs/${id}`);
|
setSteps((s) => [...s, JSON.parse((e as MessageEvent).data) as StepRecord]);
|
||||||
if (r.ok) {
|
} catch {
|
||||||
const d = (await r.json()) as RunDetail;
|
/* ignore malformed frame */
|
||||||
setDetail(d);
|
}
|
||||||
if (d.status === "completed" || d.status === "failed" || d.status === "cancelled") {
|
});
|
||||||
|
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);
|
setBusy(false);
|
||||||
void loadRuns();
|
void loadRuns();
|
||||||
return;
|
});
|
||||||
}
|
es.onerror = () => {
|
||||||
}
|
// Transient or terminal close; the run continues server-side regardless.
|
||||||
} catch {
|
es.close();
|
||||||
/* transient; keep polling */
|
esRef.current = null;
|
||||||
}
|
setBusy(false);
|
||||||
pollRef.current = setTimeout(tick, 2000);
|
|
||||||
};
|
};
|
||||||
pollRef.current = setTimeout(tick, 1200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setDetail(null);
|
setSteps([]);
|
||||||
if (pollRef.current) clearTimeout(pollRef.current);
|
setFinalOutput(null);
|
||||||
|
setStatus("queued");
|
||||||
|
esRef.current?.close();
|
||||||
try {
|
try {
|
||||||
const roleList = roles
|
const roleList = roles
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -128,17 +132,14 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
|
|||||||
if (res.status !== 202) throw new Error(`Enqueue failed (${res.status})`);
|
if (res.status !== 202) throw new Error(`Enqueue failed (${res.status})`);
|
||||||
const { run_id } = (await res.json()) as { run_id: string };
|
const { run_id } = (await res.json()) as { run_id: string };
|
||||||
void loadRuns();
|
void loadRuns();
|
||||||
poll(run_id);
|
stream(run_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Run failed");
|
setError(e instanceof Error ? e.message : "Run failed");
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the final journal when done, else the live checkpoint progress.
|
const started = status !== null;
|
||||||
const steps: StepRecord[] = detail?.comparison?.steps ?? detail?.checkpoint?.records ?? [];
|
|
||||||
const finalOutput = detail?.comparison?.final_output;
|
|
||||||
const status = detail?.status;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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}
|
{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 flex-col gap-3 rounded-lg border border-border p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -199,14 +200,9 @@ export function TopologyRun({ catalog }: { catalog: CatalogEntry[] }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{steps.length} step{steps.length === 1 ? "" : "s"}
|
{steps.length} step{steps.length === 1 ? "" : "s"}
|
||||||
{detail.comparison?.totals
|
|
||||||
? ` · ${detail.comparison.totals.tokens} tokens`
|
|
||||||
: ""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail.error ? <p className="text-sm text-red-500">{detail.error}</p> : null}
|
|
||||||
|
|
||||||
<ol className="flex flex-col gap-2">
|
<ol className="flex flex-col gap-2">
|
||||||
{steps.map((s, i) => (
|
{steps.map((s, i) => (
|
||||||
<li key={`${s.node_id}-${i}`} className="rounded-md border border-border/60 p-3">
|
<li key={`${s.node_id}-${i}`} className="rounded-md border border-border/60 p-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user