Skip to main content

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}