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
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user