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::FromSeq;
21use tail::{Frame, GapSignal, Step, Tailer};
22
23/// `GET /api/agents/{id}/stream` — replay-then-tail SSE of `FleetEvent`s.
24pub async fn agent_stream(
25    State(st): State<AppState>,
26    Path(id): Path<String>,
27    Query(q): Query<FromSeq>,
28) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
29    // Subscribe BEFORE reading history so no live event is missed in the gap:
30    // InProcessBus registers its receiver here (eagerly); DistributedBus replays
31    // from seq 0 on its first doorbell. Either way the live tail covers every
32    // event after this point, and the `seq` dedup below drops the history overlap.
33    let mut sub = st.bus.subscribe(&id);
34    let history = st.store.replay(&id, q.from).await.unwrap_or_default();
35
36    let body = stream! {
37        // 1) Replay persisted history, stopping if it already contains the
38        //    terminal event. Track the last seq delivered as the dedup
39        //    high-water mark for the live tail. Seed it from the client's
40        //    `from` floor so a later self-heal replay never re-sends events
41        //    below what the client asked for (seq is monotonic per stream, so an
42        //    agent can legitimately have no events at or above `from` yet).
43        let mut last_delivered = q.from.saturating_sub(1);
44        for ev in history {
45            let terminal = is_terminal(&ev);
46            last_delivered = ev.seq;
47            yield Ok(to_event(&ev));
48            if terminal {
49                return;
50            }
51        }
52
53        // 2) Tail live events, self-healing across a slow-consumer `Lagged`.
54        //    The per-subscriber broadcast buffer is the lag tolerance
55        //    (`FleetConfig::event_buffer`, default 1024). Exceed it and the
56        //    `Tailer` emits a `gap` signal plus replays the missed events from
57        //    the durable store, rather than silently skipping them.
58        let mut tailer = Tailer::new(id, last_delivered, st.store.clone());
59        loop {
60            match tailer.on_recv(sub.next().await).await {
61                Step::Emit(frames) => {
62                    for f in frames {
63                        yield Ok(frame_to_event(&f));
64                    }
65                }
66                Step::EmitAndClose(frames) => {
67                    for f in frames {
68                        yield Ok(frame_to_event(&f));
69                    }
70                    break;
71                }
72                Step::Skip => continue,
73                Step::Close => break,
74            }
75        }
76    };
77
78    Sse::new(body).keep_alive(KeepAlive::default())
79}
80
81fn is_terminal(ev: &FleetEvent) -> bool {
82    matches!(ev.kind, EventKind::AgentFinished { .. })
83}
84
85fn to_event(ev: &FleetEvent) -> Event {
86    // json_data only fails if serialization fails, which FleetEvent never does.
87    Event::default()
88        .json_data(ev)
89        .unwrap_or_else(|_| Event::default().data("{}"))
90}
91
92fn frame_to_event(frame: &Frame) -> Event {
93    match frame {
94        Frame::Event(ev) => to_event(ev),
95        Frame::Gap { skipped, last_seq } => Event::default()
96            .event("gap")
97            .json_data(GapSignal {
98                skipped: *skipped,
99                last_seq: *last_seq,
100            })
101            .unwrap_or_else(|_| Event::default().event("gap").data("{}")),
102    }
103}