Topology runs: SSE live-progress (replace polling)
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

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:
Omar Sobh
2026-06-17 22:32:39 -07:00
co-authored by Claude Opus 4.8
parent f0963a46b4
commit f845dfb15f
3 changed files with 122 additions and 55 deletions
+68 -1
View File
@@ -3,8 +3,13 @@
//! back the topology builder UI. Running/comparing topologies is a later,
//! provider-backed endpoint.
use std::convert::Infallible;
use std::time::Duration;
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 cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor};
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
@@ -208,6 +213,68 @@ pub struct RunDetail {
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.
pub async fn get_run(
State(state): State<AppState>,