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 priority: 0,
220 concurrency_limits: Vec::new(),
221 max_cost_usd: None,
222 worker_tags: Vec::new(),
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 = create_user_auth_header(&state, "testuser", false).await;
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 wait_for_response_headers(reader: &mut BufReader<tokio::net::TcpStream>) {
270 loop {
271 let mut line = String::new();
272 let n = reader.read_line(&mut line).await.unwrap();
273 if n == 0 || line == "\r\n" || line == "\n" {
274 break;
275 }
276 }
277 }
278
279 async fn read_until_contains(
280 reader: &mut BufReader<tokio::net::TcpStream>,
281 needle: &str,
282 dur: Duration,
283 ) -> String {
284 let mut accumulated = String::new();
285 let result = timeout(dur, async {
286 loop {
287 let mut line = String::new();
288 let n = reader.read_line(&mut line).await.unwrap();
289 if n == 0 {
290 break;
291 }
292 accumulated.push_str(&line);
293 if accumulated.contains(needle) {
294 break;
295 }
296 }
297 })
298 .await;
299 if result.is_err() {
300 panic!("timeout waiting for '{needle}' in SSE stream. Data so far:\n{accumulated}");
301 }
302 accumulated
303 }
304
305 #[tokio::test]
306 async fn sse_stream_receives_workflow_events() {
307 let (state, bus) = test_state_with_bus();
308 let run_id = create_run(&state).await;
309 let (addr, auth) = start_sse_server(state).await;
310
311 let mut reader = connect_sse(&addr, &format!("/{run_id}/events"), &auth).await;
312 wait_for_response_headers(&mut reader).await;
313
314 bus.publish(
315 run_id,
316 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
317 step_name: "build".to_string(),
318 step_index: 0,
319 timestamp: Utc::now(),
320 }),
321 );
322
323 let text = read_until_contains(&mut reader, "build", Duration::from_secs(5)).await;
324
325 assert!(text.contains("event: step_started"));
326 assert!(text.contains("build"));
327 }
328
329 #[tokio::test]
330 async fn returns_404_for_unknown_run() {
331 let (state, _bus) = test_state_with_bus();
332 let (addr, auth) = start_sse_server(state).await;
333
334 let unknown = Uuid::nil();
335 let mut reader = connect_sse(&addr, &format!("/{unknown}/events"), &auth).await;
336
337 let text = read_until_contains(&mut reader, "404", Duration::from_secs(5)).await;
338 assert!(text.contains("404"));
339 }
340
341 #[tokio::test]
342 async fn rejects_unauthenticated() {
343 let (state, _bus) = test_state_with_bus();
344 let run_id = create_run(&state).await;
345 let (addr, _auth) = start_sse_server(state).await;
346
347 let stream = tokio::net::TcpStream::connect(&addr).await.unwrap();
348 let (reader, mut writer) = stream.into_split();
349
350 use tokio::io::AsyncWriteExt;
351 writer
352 .write_all(
353 format!(
354 "GET /{run_id}/events HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\n\r\n"
355 )
356 .as_bytes(),
357 )
358 .await
359 .unwrap();
360
361 let mut buf_reader = BufReader::new(reader.reunite(writer).unwrap());
362 let text = read_until_contains(&mut buf_reader, "401", Duration::from_secs(5)).await;
363 assert!(text.contains("401"));
364 }
365
366 #[tokio::test]
367 async fn filters_by_event_type() {
368 let (state, bus) = test_state_with_bus();
369 let run_id = create_run(&state).await;
370 let (addr, auth) = start_sse_server(state).await;
371
372 let mut reader = connect_sse(
373 &addr,
374 &format!("/{run_id}/events?types=step_completed"),
375 &auth,
376 )
377 .await;
378 wait_for_response_headers(&mut reader).await;
379
380 bus.publish(
381 run_id,
382 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
383 step_name: "build".to_string(),
384 step_index: 0,
385 timestamp: Utc::now(),
386 }),
387 );
388 bus.publish(
389 run_id,
390 WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
391 step_name: "build".to_string(),
392 step_index: 0,
393 duration_ms: 1234,
394 output_summary: None,
395 }),
396 );
397
398 let text = read_until_contains(&mut reader, "step_completed", Duration::from_secs(5)).await;
399
400 assert!(text.contains("step_completed"));
401 assert!(!text.contains("event: step_started"));
402 }
403
404 #[tokio::test]
405 async fn filters_by_agent_step_resumed_event_type() {
406 let (state, bus) = test_state_with_bus();
407 let run_id = create_run(&state).await;
408 let (addr, auth) = start_sse_server(state).await;
409
410 let mut reader = connect_sse(
411 &addr,
412 &format!("/{run_id}/events?types=agent_step_resumed"),
413 &auth,
414 )
415 .await;
416 wait_for_response_headers(&mut reader).await;
417
418 bus.publish(
419 run_id,
420 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
421 step_name: "review".to_string(),
422 step_index: 0,
423 timestamp: Utc::now(),
424 }),
425 );
426 bus.publish(
427 run_id,
428 WorkflowEvent::AgentStepResumed(WorkflowAgentStepResumedEvent {
429 step_name: "review".to_string(),
430 step_index: 0,
431 session_id: "0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d".to_string(),
432 timestamp: Utc::now(),
433 }),
434 );
435
436 let text = read_until_contains(
437 &mut reader,
438 "0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d",
439 Duration::from_secs(5),
440 )
441 .await;
442
443 assert!(text.contains("event: agent_step_resumed"));
444 assert!(!text.contains("event: step_started"));
445 }
446
447 #[tokio::test]
448 async fn events_isolated_between_runs() {
449 let (state, bus) = test_state_with_bus();
450 let run_a = create_run(&state).await;
451 let run_b = create_run(&state).await;
452 let (addr, auth) = start_sse_server(state).await;
453
454 let mut reader_a = connect_sse(&addr, &format!("/{run_a}/events"), &auth).await;
455 wait_for_response_headers(&mut reader_a).await;
456
457 bus.publish(
458 run_b,
459 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
460 step_name: "only-for-b".to_string(),
461 step_index: 0,
462 timestamp: Utc::now(),
463 }),
464 );
465 bus.publish(
466 run_a,
467 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
468 step_name: "only-for-a".to_string(),
469 step_index: 0,
470 timestamp: Utc::now(),
471 }),
472 );
473
474 let text = read_until_contains(&mut reader_a, "only-for-a", Duration::from_secs(5)).await;
475
476 assert!(text.contains("only-for-a"));
477 assert!(!text.contains("only-for-b"));
478 }
479}