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