Skip to main content

ironflow_api/routes/
run_events.rs

1//! SSE endpoint for per-run workflow event streaming.
2
3use 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
20/// Deserialize a comma-separated string into `Option<Vec<String>>`.
21fn 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/// Query parameters for the per-run SSE events endpoint.
60///
61/// # Examples
62///
63/// ```
64/// use ironflow_api::routes::run_events::RunEventsQuery;
65///
66/// let query = RunEventsQuery { types: None };
67/// ```
68#[derive(Debug, Deserialize)]
69pub struct RunEventsQuery {
70    /// Comma-separated list of workflow event types to include
71    /// (e.g. `?types=step_started,step_completed`).
72    #[serde(default, deserialize_with = "deserialize_comma_strings")]
73    pub types: Option<Vec<String>>,
74}
75
76/// `GET /api/v1/runs/{id}/events` -- per-run Server-Sent Events stream.
77///
78/// Streams [`WorkflowEvent`]s for a specific workflow run in real time.
79/// Supports optional filtering via `?types=step_started,step_completed`.
80///
81/// Each SSE message has:
82/// - `event:` set to the event type (e.g. `step_started`)
83/// - `data:` JSON-serialized event payload
84///
85/// A keep-alive comment is sent every 30 seconds.
86///
87/// # Errors
88///
89/// Returns 401 if the request is not authenticated.
90/// Returns 404 if the run does not exist.
91#[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    /// Reads and discards the HTTP status line and headers, blocking until
263    /// the blank line that terminates them.
264    ///
265    /// By the time a client can read any response bytes, `run_events` has
266    /// already called `bus.subscribe`, so this is a deterministic
267    /// synchronization point for tests that publish events right after
268    /// connecting -- unlike a fixed `sleep`, it cannot race under load.
269    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}