assay_workflow/api/
events.rs1use std::convert::Infallible;
24use std::sync::Arc;
25use std::time::Duration;
26
27use assay_domain::events::{EventFilter, Subsystem};
28use axum::Router;
29use axum::extract::{Query, State};
30use axum::http::{HeaderMap, StatusCode};
31use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
32use axum::response::{IntoResponse, Response};
33use axum::routing::get;
34use futures_util::StreamExt;
35use futures_util::stream::{self, Stream};
36use serde::Deserialize;
37use tokio_stream::wrappers::BroadcastStream;
38
39use crate::ctx::WorkflowCtx;
40use crate::store::WorkflowStore;
41
42const REPLAY_PAGE_LIMIT: u32 = 500;
43
44#[derive(Deserialize, Default)]
45struct StreamQuery {
46 #[serde(default)]
47 ns: Option<String>,
48 #[serde(default)]
49 subsystem: Option<Vec<String>>,
50 #[serde(default)]
51 workflow_id: Option<String>,
52 #[serde(default)]
53 kind: Option<Vec<String>>,
54}
55
56pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<WorkflowCtx<S>>> {
57 Router::new().route("/events/stream", get(event_stream))
58}
59
60async fn event_stream<S: WorkflowStore>(
61 State(state): State<Arc<WorkflowCtx<S>>>,
62 Query(q): Query<StreamQuery>,
63 headers: HeaderMap,
64) -> Response {
65 let Some(wf_bus) = state.bus() else {
66 return (StatusCode::SERVICE_UNAVAILABLE, "event bus not configured").into_response();
67 };
68
69 let namespace = q.ns.clone().unwrap_or_else(|| "main".to_string());
70 let filter = EventFilter {
71 subsystems: q
72 .subsystem
73 .clone()
74 .unwrap_or_default()
75 .into_iter()
76 .filter_map(|s| match s.as_str() {
77 "workflow" => Some(Subsystem::Workflow),
78 "auth" => Some(Subsystem::Auth),
79 "secrets" => Some(Subsystem::Secrets),
80 "system" => Some(Subsystem::System),
81 _ => None,
82 })
83 .collect(),
84 kinds: q.kind.clone().unwrap_or_default(),
85 workflow_id: q.workflow_id.clone(),
86 };
87
88 let last_id: Option<i64> = headers
90 .get("last-event-id")
91 .and_then(|h| h.to_str().ok())
92 .and_then(|s| s.parse().ok());
93
94 let inner = wf_bus.inner();
95
96 let replay_events = match inner
97 .read_since(&namespace, last_id, &filter, REPLAY_PAGE_LIMIT)
98 .await
99 {
100 Ok(evs) => evs,
101 Err(gone) => {
102 return (
103 StatusCode::GONE,
104 format!(
105 "cursor {} older than retention (oldest {}); resync via point queries then reconnect without Last-Event-ID",
106 gone.after, gone.oldest
107 ),
108 )
109 .into_response();
110 }
111 };
112
113 let replay_stream = stream::iter(
114 replay_events
115 .into_iter()
116 .map(|e| Ok::<_, Infallible>(event_to_sse(&e))),
117 );
118
119 let rx = inner.subscribe(&namespace);
123 let ns_for_live = namespace.clone();
124 let filter_for_live = filter.clone();
125 let live_stream = BroadcastStream::new(rx).filter_map(move |result| {
126 let ns = ns_for_live.clone();
127 let f = filter_for_live.clone();
128 async move {
129 match result {
130 Ok(arc_ev) => {
131 if arc_ev.namespace != ns {
132 return None;
133 }
134 if !f.matches(&arc_ev) {
135 return None;
136 }
137 Some(Ok::<_, Infallible>(event_to_sse(&arc_ev)))
138 }
139 Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
140 tracing::warn!(lagged = n, "SSE client lagged; forcing close");
141 None
142 }
143 }
144 }
145 });
146
147 let combined: std::pin::Pin<Box<dyn Stream<Item = Result<SseEvent, Infallible>> + Send>> =
148 Box::pin(replay_stream.chain(live_stream));
149 Sse::new(combined)
150 .keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
151 .into_response()
152}
153
154fn event_to_sse(e: &assay_domain::events::Event) -> SseEvent {
155 let data = serde_json::json!({
156 "id": e.id,
157 "ts": e.ts,
158 "namespace": e.namespace,
159 "subsystem": e.subsystem,
160 "kind": e.kind,
161 "payload": e.payload,
162 })
163 .to_string();
164 SseEvent::default()
165 .id(e.id.to_string())
166 .event(&e.kind)
167 .data(data)
168}