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