1use std::convert::Infallible;
4use std::pin::Pin;
5use std::time::Duration;
6
7use axum::extract::{Path, Query, State};
8use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
9use futures_util::stream::{Stream, StreamExt};
10use serde::Deserialize;
11use serde::de::{self, Deserializer};
12use tokio_stream::wrappers::BroadcastStream;
13use uuid::Uuid;
14
15use crate::error::ApiError;
16use crate::state::AppState;
17use ironflow_auth::extractor::Authenticated;
18use ironflow_engine::notify::WorkflowEvent;
19
20fn deserialize_comma_strings<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
22where
23 D: Deserializer<'de>,
24{
25 let opt: Option<String> = Option::deserialize(deserializer)?;
26 match opt {
27 None => Ok(None),
28 Some(raw) => {
29 let all_types = [
30 WorkflowEvent::STEP_STARTED,
31 WorkflowEvent::STEP_COMPLETED,
32 WorkflowEvent::STEP_FAILED,
33 WorkflowEvent::APPROVAL_REQUIRED,
34 WorkflowEvent::AGENT_STEP_TOKENS_USED,
35 ];
36
37 let kinds: Vec<String> = raw
38 .split(',')
39 .map(|s| s.trim())
40 .filter(|s| !s.is_empty())
41 .map(|s| {
42 if all_types.contains(&s) {
43 Ok(s.to_string())
44 } else {
45 Err(de::Error::custom(format!(
46 "unknown workflow event type: {s}"
47 )))
48 }
49 })
50 .collect::<Result<Vec<_>, _>>()?;
51
52 Ok(Some(kinds))
53 }
54 }
55}
56
57#[derive(Debug, Deserialize)]
67pub struct RunEventsQuery {
68 #[serde(default, deserialize_with = "deserialize_comma_strings")]
71 pub types: Option<Vec<String>>,
72}
73
74#[cfg_attr(
90 feature = "openapi",
91 utoipa::path(
92 get,
93 path = "/api/v1/runs/{id}/events",
94 tags = ["runs"],
95 params(
96 ("id" = Uuid, Path, description = "Run ID"),
97 ("types" = Option<String>, Query, description = "Comma-separated workflow event types to filter (e.g. step_started,step_completed)")
98 ),
99 responses(
100 (status = 200, description = "SSE stream of workflow events"),
101 (status = 401, description = "Unauthorized"),
102 (status = 404, description = "Run not found")
103 ),
104 security(("Bearer" = []))
105 )
106)]
107pub async fn run_events(
108 _auth: Authenticated,
109 State(state): State<AppState>,
110 Path(id): Path<Uuid>,
111 Query(query): Query<RunEventsQuery>,
112) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
113 state.get_run_or_404(id).await?;
114
115 let type_filter = query.types;
116
117 let stream: Pin<Box<dyn Stream<Item = Result<SseEvent, Infallible>> + Send>> = match state
118 .event_bus
119 {
120 Some(ref bus) => {
121 let receiver = bus.subscribe(id);
122
123 Box::pin(BroadcastStream::new(receiver).filter_map(
124 move |result: Result<WorkflowEvent, _>| {
125 let type_filter = type_filter.clone();
126 async move {
127 let event = result.ok()?;
128
129 if let Some(ref kinds) = type_filter {
130 let event_type = event.event_type();
131 if !kinds.iter().any(|k| k == event_type) {
132 return None;
133 }
134 }
135
136 let data = serde_json::to_string(&event).ok()?;
137 let sse_event = SseEvent::default().event(event.event_type()).data(data);
138
139 Some(Ok::<_, Infallible>(sse_event))
140 }
141 },
142 ))
143 }
144 None => Box::pin(futures_util::stream::empty()),
145 };
146
147 Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(30))))
148}
149
150#[cfg(test)]
151mod tests {
152 use std::collections::HashMap;
153 use std::sync::Arc;
154 use std::time::Duration;
155
156 use axum::Router;
157 use axum::routing::get;
158 use chrono::Utc;
159 use ironflow_auth::jwt::AccessToken;
160 use ironflow_core::providers::claude::ClaudeCodeProvider;
161 use ironflow_engine::engine::Engine;
162 use ironflow_engine::notify::{
163 Event, WorkflowEvent, WorkflowEventBus, WorkflowStepCompletedEvent,
164 WorkflowStepStartedEvent,
165 };
166 use ironflow_store::memory::InMemoryStore;
167 use ironflow_store::models::{NewRun, TriggerKind};
168 use serde_json::json;
169 use tokio::io::AsyncBufReadExt;
170 use tokio::io::BufReader;
171 use tokio::net::TcpListener;
172 use tokio::sync::broadcast;
173 use tokio::time::{sleep, timeout};
174 use uuid::Uuid;
175
176 use super::run_events;
177 use crate::state::AppState;
178
179 fn test_state_with_bus() -> (AppState, WorkflowEventBus) {
180 let store = Arc::new(InMemoryStore::new());
181 let provider = Arc::new(ClaudeCodeProvider::new());
182 let engine = Arc::new(Engine::new(store.clone(), provider));
183 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
184 secret: "test-secret".to_string(),
185 access_token_ttl_secs: 900,
186 refresh_token_ttl_secs: 604800,
187 cookie_domain: None,
188 cookie_secure: false,
189 });
190 let (event_sender, _) = broadcast::channel::<Event>(16);
191 let bus = WorkflowEventBus::new();
192 let state = AppState::new(
193 store,
194 engine,
195 jwt_config,
196 "test-worker-token".to_string(),
197 event_sender,
198 )
199 .with_event_bus(bus.clone());
200 (state, bus)
201 }
202
203 fn make_auth_token(state: &AppState) -> String {
204 let user_id = Uuid::now_v7();
205 let token = AccessToken::for_user(user_id, "testuser", false, &state.jwt_config).unwrap();
206 format!("Bearer {}", token.0)
207 }
208
209 async fn create_run(state: &AppState) -> Uuid {
210 state
211 .store
212 .create_run(NewRun {
213 created_by: None,
214 workflow_name: "test".to_string(),
215 trigger: TriggerKind::Manual,
216 payload: json!({}),
217 max_retries: 0,
218 handler_version: None,
219 labels: HashMap::new(),
220 scheduled_at: None,
221 idempotency_key: None,
222 max_cost_usd: None,
223 })
224 .await
225 .unwrap()
226 .into_run()
227 .id
228 }
229
230 async fn start_sse_server(state: AppState) -> (String, String) {
231 let auth = make_auth_token(&state);
232 let app = Router::new()
233 .route("/{id}/events", get(run_events))
234 .with_state(state);
235
236 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
237 let addr = listener.local_addr().unwrap().to_string();
238 tokio::spawn(async move {
239 axum::serve(listener, app).await.unwrap();
240 });
241 (addr, auth)
242 }
243
244 async fn connect_sse(addr: &str, path: &str, auth: &str) -> BufReader<tokio::net::TcpStream> {
245 let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
246 let (reader, mut writer) = stream.into_split();
247
248 use tokio::io::AsyncWriteExt;
249 writer
250 .write_all(
251 format!(
252 "GET {path} HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\nAuthorization: {auth}\r\n\r\n"
253 )
254 .as_bytes(),
255 )
256 .await
257 .unwrap();
258
259 BufReader::new(reader.reunite(writer).unwrap())
260 }
261
262 async fn read_until_contains(
263 reader: &mut BufReader<tokio::net::TcpStream>,
264 needle: &str,
265 dur: Duration,
266 ) -> String {
267 let mut accumulated = String::new();
268 let result = timeout(dur, async {
269 loop {
270 let mut line = String::new();
271 let n = reader.read_line(&mut line).await.unwrap();
272 if n == 0 {
273 break;
274 }
275 accumulated.push_str(&line);
276 if accumulated.contains(needle) {
277 break;
278 }
279 }
280 })
281 .await;
282 if result.is_err() {
283 panic!("timeout waiting for '{needle}' in SSE stream. Data so far:\n{accumulated}");
284 }
285 accumulated
286 }
287
288 #[tokio::test]
289 async fn sse_stream_receives_workflow_events() {
290 let (state, bus) = test_state_with_bus();
291 let run_id = create_run(&state).await;
292 let (addr, auth) = start_sse_server(state).await;
293
294 let mut reader = connect_sse(&addr, &format!("/{run_id}/events"), &auth).await;
295 sleep(Duration::from_millis(50)).await;
296
297 bus.publish(
298 run_id,
299 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
300 step_name: "build".to_string(),
301 step_index: 0,
302 timestamp: Utc::now(),
303 }),
304 );
305
306 let text = read_until_contains(&mut reader, "build", Duration::from_secs(5)).await;
307
308 assert!(text.contains("event: step_started"));
309 assert!(text.contains("build"));
310 }
311
312 #[tokio::test]
313 async fn returns_404_for_unknown_run() {
314 let (state, _bus) = test_state_with_bus();
315 let (addr, auth) = start_sse_server(state).await;
316
317 let unknown = Uuid::nil();
318 let mut reader = connect_sse(&addr, &format!("/{unknown}/events"), &auth).await;
319
320 let text = read_until_contains(&mut reader, "404", Duration::from_secs(5)).await;
321 assert!(text.contains("404"));
322 }
323
324 #[tokio::test]
325 async fn rejects_unauthenticated() {
326 let (state, _bus) = test_state_with_bus();
327 let run_id = create_run(&state).await;
328 let (addr, _auth) = start_sse_server(state).await;
329
330 let stream = tokio::net::TcpStream::connect(&addr).await.unwrap();
331 let (reader, mut writer) = stream.into_split();
332
333 use tokio::io::AsyncWriteExt;
334 writer
335 .write_all(
336 format!(
337 "GET /{run_id}/events HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\n\r\n"
338 )
339 .as_bytes(),
340 )
341 .await
342 .unwrap();
343
344 let mut buf_reader = BufReader::new(reader.reunite(writer).unwrap());
345 let text = read_until_contains(&mut buf_reader, "401", Duration::from_secs(5)).await;
346 assert!(text.contains("401"));
347 }
348
349 #[tokio::test]
350 async fn filters_by_event_type() {
351 let (state, bus) = test_state_with_bus();
352 let run_id = create_run(&state).await;
353 let (addr, auth) = start_sse_server(state).await;
354
355 let mut reader = connect_sse(
356 &addr,
357 &format!("/{run_id}/events?types=step_completed"),
358 &auth,
359 )
360 .await;
361 sleep(Duration::from_millis(50)).await;
362
363 bus.publish(
364 run_id,
365 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
366 step_name: "build".to_string(),
367 step_index: 0,
368 timestamp: Utc::now(),
369 }),
370 );
371 bus.publish(
372 run_id,
373 WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
374 step_name: "build".to_string(),
375 step_index: 0,
376 duration_ms: 1234,
377 output_summary: None,
378 }),
379 );
380
381 let text = read_until_contains(&mut reader, "step_completed", Duration::from_secs(5)).await;
382
383 assert!(text.contains("step_completed"));
384 assert!(!text.contains("event: step_started"));
385 }
386
387 #[tokio::test]
388 async fn events_isolated_between_runs() {
389 let (state, bus) = test_state_with_bus();
390 let run_a = create_run(&state).await;
391 let run_b = create_run(&state).await;
392 let (addr, auth) = start_sse_server(state).await;
393
394 let mut reader_a = connect_sse(&addr, &format!("/{run_a}/events"), &auth).await;
395 sleep(Duration::from_millis(50)).await;
396
397 bus.publish(
398 run_b,
399 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
400 step_name: "only-for-b".to_string(),
401 step_index: 0,
402 timestamp: Utc::now(),
403 }),
404 );
405 bus.publish(
406 run_a,
407 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
408 step_name: "only-for-a".to_string(),
409 step_index: 0,
410 timestamp: Utc::now(),
411 }),
412 );
413
414 let text = read_until_contains(&mut reader_a, "only-for-a", Duration::from_secs(5)).await;
415
416 assert!(text.contains("only-for-a"));
417 assert!(!text.contains("only-for-b"));
418 }
419}