salvor_server/sse.rs
1//! `GET /v1/runs/{id}/events`: the event stream. This is the control plane's
2//! headline feature, so its framing and its cursor are spelled out here.
3//!
4//! # Framing
5//!
6//! The response is `text/event-stream`. Every recorded event becomes one
7//! server-sent-event frame:
8//!
9//! ```text
10//! id: <seq>
11//! data: <the pinned EventEnvelope JSON, on one line>
12//!
13//! ```
14//!
15//! The `data` line is exactly the envelope wire JSON the store holds, the same
16//! bytes `salvor history --json` prints, so a client decodes stream frames and
17//! log rows with one parser. The frame's `id` is the event's sequence number.
18//! Envelope frames carry no `event:` field, so a browser `EventSource` receives
19//! them through `onmessage`. When the run reaches a resting point (completed,
20//! failed, suspended, budget-exceeded, or needs-reconciliation) the stream
21//! sends one final `event: end` frame carrying the final status, then closes.
22//!
23//! # Replay then live tail
24//!
25//! On connect the server reads the run's whole log and sends every event at or
26//! after the cursor, then polls the store for new events and sends them as they
27//! land, until the resting frame. A run's log is append-only with contiguous,
28//! ascending sequence numbers, so tracking one "next sequence to send" number
29//! makes the stream gap-free and duplicate-free by construction.
30//!
31//! # The cursor: resuming a dropped stream
32//!
33//! A dropped connection resumes without gaps or duplicates in one of two ways:
34//!
35//! - **`Last-Event-ID`.** A browser `EventSource` resends the last `id` it saw
36//! as the `Last-Event-ID` header on reconnect. The server resumes from that
37//! sequence plus one, so the first event not yet seen is the first replayed.
38//! - **`?from_seq=<n>`.** A non-browser client that tracks its own position
39//! asks for events from sequence `n` onward. Used when there is no
40//! `Last-Event-ID` to lean on.
41//!
42//! `Last-Event-ID` wins when both are present. With neither, the stream starts
43//! at sequence 0, a full replay.
44
45use std::convert::Infallible;
46use std::time::Duration;
47
48use axum::extract::{Path, Query, State};
49use axum::http::HeaderMap;
50use axum::response::IntoResponse;
51use axum::response::sse::{Event, KeepAlive, Sse};
52use salvor_core::{RunId, RunStatus, derive_state};
53use serde::Deserialize;
54use serde_json::json;
55use tokio::sync::mpsc;
56use tokio_stream::wrappers::ReceiverStream;
57use uuid::Uuid;
58
59use crate::error::ApiError;
60use crate::json;
61use crate::state::AppState;
62
63/// The `?from_seq=` cursor query.
64#[derive(Debug, Deserialize)]
65pub struct StreamParams {
66 /// Send events from this sequence number onward. Overridden by a
67 /// `Last-Event-ID` header when one is present.
68 #[serde(default)]
69 from_seq: Option<u64>,
70}
71
72/// The event-stream handler. See the module docs for the framing and cursor.
73pub async fn stream(
74 State(state): State<AppState>,
75 Path(run_id_text): Path<String>,
76 Query(params): Query<StreamParams>,
77 headers: HeaderMap,
78) -> Result<impl IntoResponse, ApiError> {
79 let run_id = Uuid::parse_str(&run_id_text)
80 .map(RunId::from_uuid)
81 .map_err(|_| {
82 ApiError::BadRequest(format!(
83 "`{run_id_text}` is not a valid run id (expected a UUID)"
84 ))
85 })?;
86
87 // A run that neither has history nor is being driven here does not exist.
88 let log = state
89 .store()
90 .read_log(run_id)
91 .await
92 .map_err(|error| ApiError::Internal(format!("store: {error}")))?;
93 if log.is_empty() && !state.is_run_active(run_id) {
94 return Err(ApiError::UnknownRun(format!(
95 "no run {} in this store",
96 run_id.as_uuid()
97 )));
98 }
99
100 let start = cursor(&headers, params.from_seq);
101 let poll = state.poll_interval();
102 let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(64);
103 tokio::spawn(produce(state, run_id, start, poll, tx));
104
105 Ok(Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()))
106}
107
108/// The starting sequence number: `Last-Event-ID` plus one when present, else
109/// the `from_seq` query, else 0 (a full replay).
110fn cursor(headers: &HeaderMap, from_seq: Option<u64>) -> u64 {
111 if let Some(last) = headers
112 .get("last-event-id")
113 .and_then(|value| value.to_str().ok())
114 .and_then(|text| text.parse::<u64>().ok())
115 {
116 return last + 1;
117 }
118 from_seq.unwrap_or(0)
119}
120
121/// Reads the log from `start`, sends each event, then polls for new ones until
122/// the run rests, and sends a final `end` frame.
123async fn produce(
124 state: AppState,
125 run_id: RunId,
126 start: u64,
127 poll: Duration,
128 tx: mpsc::Sender<Result<Event, Infallible>>,
129) {
130 let store = state.store();
131 let mut next = start;
132 loop {
133 let log = match store.read_log(run_id).await {
134 Ok(log) => log,
135 Err(error) => {
136 let frame = Event::default()
137 .event("end")
138 .data(json!({ "error": format!("store: {error}") }).to_string());
139 let _ = tx.send(Ok(frame)).await;
140 return;
141 }
142 };
143
144 let from = next;
145 for envelope in log.iter().filter(|envelope| envelope.seq.get() >= from) {
146 let data = serde_json::to_string(envelope).unwrap_or_default();
147 let frame = Event::default()
148 .id(envelope.seq.get().to_string())
149 .data(data);
150 if tx.send(Ok(frame)).await.is_err() {
151 // The client hung up; stop producing.
152 return;
153 }
154 next = envelope.seq.get() + 1;
155 }
156
157 let status = derive_state(&log).status;
158 if is_resting(&status) {
159 let frame = Event::default()
160 .event("end")
161 .data(json!({ "status": json::status(&status) }).to_string());
162 let _ = tx.send(Ok(frame)).await;
163 return;
164 }
165
166 // A run that is mid-step but no longer being driven in this process was
167 // detached (its task was aborted, or the server that drove it is gone).
168 // End the stream so the client does not wait forever; recovering the run
169 // opens a fresh stream that tails the continuation.
170 if !log.is_empty() && !state.is_run_active(run_id) {
171 let frame = Event::default()
172 .event("end")
173 .data(json!({ "status": json::status(&status), "detached": true }).to_string());
174 let _ = tx.send(Ok(frame)).await;
175 return;
176 }
177
178 tokio::time::sleep(poll).await;
179 }
180}
181
182/// Whether a status is a resting point at which driving has stopped.
183fn is_resting(status: &RunStatus) -> bool {
184 matches!(
185 status,
186 RunStatus::Completed { .. }
187 | RunStatus::Failed { .. }
188 | RunStatus::Abandoned { .. }
189 | RunStatus::Suspended { .. }
190 | RunStatus::BudgetExceeded { .. }
191 | RunStatus::NeedsReconciliation
192 )
193}