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