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 ];
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#[derive(Debug, Deserialize)]
68pub struct RunEventsQuery {
69 #[serde(default, deserialize_with = "deserialize_comma_strings")]
72 pub types: Option<Vec<String>>,
73}
74
75#[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 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}