prospero_api/sse.rs
1//! Server-Sent Events: replay an agent's history from the store, then tail the
2//! live event bus — joined on the monotonic `seq` with no gap or dup.
3//!
4//! The stream closes right after the agent's terminal `AgentFinished` event, so
5//! `prospero follow` behaves like `tail` of a finite run and the dashboard
6//! shows the finished run then closes cleanly (rather than hanging forever).
7
8mod tail;
9
10use std::convert::Infallible;
11
12use async_stream::stream;
13use axum::extract::{Path, Query, State};
14use axum::response::sse::{Event, KeepAlive, Sse};
15use prospero_core::FleetEvent;
16use prospero_core::event::EventKind;
17use tokio_stream::{Stream, StreamExt};
18
19use crate::AppState;
20use crate::dto::{FleetFrom, FromSeq};
21use tail::{Frame, GapSignal, Step, Tailer};
22
23/// `GET /api/fleet/stream` — replay-then-tail SSE of **every** stream's events
24/// (#219).
25///
26/// `?from=<cursor>` resumes after a cursor this endpoint issued (as the SSE
27/// `id:` of each event); `?from=now` skips history and tails only what happens
28/// next — the case a notifier needs so a restart does not re-announce the past
29/// (caliban-ai/ariel#19). Omitted means from the beginning.
30///
31/// **The bus is only a doorbell here.** Every event is read from the durable
32/// store, in cursor order, and the bus payload is discarded. The emitter
33/// appends before it publishes, so a doorbell always finds its event, and
34/// reading from the store is what makes the stream exactly-once across replicas
35/// (each replica publishes its own events; all of them are in the one store)
36/// and immune to the slow-consumer lag that the per-agent stream has to
37/// self-heal from (#28). A missed doorbell costs latency, never an event: the
38/// next one drains everything after the cursor.
39pub async fn fleet_stream(
40 State(st): State<AppState>,
41 Query(q): Query<FleetFrom>,
42) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
43 // Subscribe before reading the cursor so nothing can slip between the two.
44 let mut doorbell = st.bus.subscribe_all();
45 let mut cursor = match q.from.as_deref() {
46 Some("now") => st.store.latest_fleet_cursor().await.unwrap_or(0),
47 Some(n) => n.parse().unwrap_or(0),
48 None => 0,
49 };
50
51 let body = stream! {
52 loop {
53 // Drain everything after the cursor, in bounded batches, before
54 // waiting again — a burst arrives as one doorbell, not one each.
55 loop {
56 let batch = st.store.replay_fleet(cursor, FLEET_BATCH).await.unwrap_or_default();
57 if batch.is_empty() {
58 break;
59 }
60 for c in batch {
61 cursor = c.cursor;
62 yield Ok(to_cursored_event(c.cursor, &c.event));
63 }
64 }
65 if doorbell.next().await.is_none() {
66 break; // bus gone: the daemon is shutting down.
67 }
68 }
69 };
70
71 Sse::new(body).keep_alive(KeepAlive::default())
72}
73
74/// How many events one fleet-stream store read may return. Bounds both the
75/// query and the burst written to a slow client before yielding.
76const FLEET_BATCH: usize = 256;
77
78/// One fleet event as SSE, with its cursor as the event `id:` — what a client
79/// hands back as `?from=` to resume.
80fn to_cursored_event(cursor: u64, ev: &FleetEvent) -> Event {
81 Event::default()
82 .id(cursor.to_string())
83 .json_data(ev)
84 .unwrap_or_else(|_| Event::default().data("{}"))
85}
86
87/// `GET /api/agents/{id}/stream` — replay-then-tail SSE of `FleetEvent`s.
88pub async fn agent_stream(
89 State(st): State<AppState>,
90 Path(id): Path<String>,
91 Query(q): Query<FromSeq>,
92) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
93 // Subscribe BEFORE reading history so no live event is missed in the gap:
94 // InProcessBus registers its receiver here (eagerly); DistributedBus replays
95 // from seq 0 on its first doorbell. Either way the live tail covers every
96 // event after this point, and the `seq` dedup below drops the history overlap.
97 let mut sub = st.bus.subscribe(&id);
98 let history = st.store.replay(&id, q.from).await.unwrap_or_default();
99
100 let body = stream! {
101 // 1) Replay persisted history, stopping if it already contains the
102 // terminal event. Track the last seq delivered as the dedup
103 // high-water mark for the live tail. Seed it from the client's
104 // `from` floor so a later self-heal replay never re-sends events
105 // below what the client asked for (seq is monotonic per stream, so an
106 // agent can legitimately have no events at or above `from` yet).
107 let mut last_delivered = q.from.saturating_sub(1);
108 for ev in history {
109 let terminal = is_terminal(&ev);
110 last_delivered = ev.seq;
111 yield Ok(to_event(&ev));
112 if terminal {
113 return;
114 }
115 }
116
117 // 2) Tail live events, self-healing across a slow-consumer `Lagged`.
118 // The per-subscriber broadcast buffer is the lag tolerance
119 // (`FleetConfig::event_buffer`, default 1024). Exceed it and the
120 // `Tailer` emits a `gap` signal plus replays the missed events from
121 // the durable store, rather than silently skipping them.
122 let mut tailer = Tailer::new(id, last_delivered, st.store.clone());
123 loop {
124 match tailer.on_recv(sub.next().await).await {
125 Step::Emit(frames) => {
126 for f in frames {
127 yield Ok(frame_to_event(&f));
128 }
129 }
130 Step::EmitAndClose(frames) => {
131 for f in frames {
132 yield Ok(frame_to_event(&f));
133 }
134 break;
135 }
136 Step::Skip => continue,
137 Step::Close => break,
138 }
139 }
140 };
141
142 Sse::new(body).keep_alive(KeepAlive::default())
143}
144
145fn is_terminal(ev: &FleetEvent) -> bool {
146 matches!(ev.kind, EventKind::AgentFinished { .. })
147}
148
149fn to_event(ev: &FleetEvent) -> Event {
150 // json_data only fails if serialization fails, which FleetEvent never does.
151 Event::default()
152 .json_data(ev)
153 .unwrap_or_else(|_| Event::default().data("{}"))
154}
155
156fn frame_to_event(frame: &Frame) -> Event {
157 match frame {
158 Frame::Event(ev) => to_event(ev),
159 Frame::Gap { skipped, last_seq } => Event::default()
160 .event("gap")
161 .json_data(GapSignal {
162 skipped: *skipped,
163 last_seq: *last_seq,
164 })
165 .unwrap_or_else(|_| Event::default().event("gap").data("{}")),
166 }
167}