1use std::convert::Infallible;
9use std::pin::Pin;
10use std::time::Duration;
11
12use axum::extract::{Path, Query, State};
13use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
14use futures_util::stream::{Stream, StreamExt, empty};
15use serde::Deserialize;
16use serde::de::{self, Deserializer};
17use tokio_stream::wrappers::BroadcastStream;
18use uuid::Uuid;
19
20use crate::authorization::AuthorizeExt;
21use crate::error::ApiError;
22use crate::state::AppState;
23use ironflow_auth::extractor::Authenticated;
24use ironflow_engine::notify::WorkflowEvent;
25use ironflow_store::entities::ApiKeyScope;
26
27fn deserialize_comma_strings<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
29where
30 D: Deserializer<'de>,
31{
32 let opt: Option<String> = Option::deserialize(deserializer)?;
33 match opt {
34 None => Ok(None),
35 Some(raw) => {
36 let all_types = [
37 WorkflowEvent::STEP_STARTED,
38 WorkflowEvent::STEP_COMPLETED,
39 WorkflowEvent::STEP_FAILED,
40 WorkflowEvent::APPROVAL_REQUIRED,
41 WorkflowEvent::INPUT_REQUIRED,
42 WorkflowEvent::AGENT_STEP_TOKENS_USED,
43 WorkflowEvent::AGENT_STEP_RESUMED,
44 ];
45
46 let kinds: Vec<String> = raw
47 .split(',')
48 .map(|s| s.trim())
49 .filter(|s| !s.is_empty())
50 .map(|s| {
51 if all_types.contains(&s) {
52 Ok(s.to_string())
53 } else {
54 Err(de::Error::custom(format!(
55 "unknown workflow event type: {s}"
56 )))
57 }
58 })
59 .collect::<Result<Vec<_>, _>>()?;
60
61 Ok(Some(kinds))
62 }
63 }
64}
65
66#[derive(Debug, Deserialize)]
76pub struct RunEventsQuery {
77 #[serde(default, deserialize_with = "deserialize_comma_strings")]
80 pub types: Option<Vec<String>>,
81}
82
83#[cfg_attr(
99 feature = "openapi",
100 utoipa::path(
101 get,
102 path = "/api/v1/runs/{id}/events",
103 tags = ["runs"],
104 params(
105 ("id" = Uuid, Path, description = "Run ID"),
106 ("types" = Option<String>, Query, description = "Comma-separated workflow event types to filter (e.g. step_started,step_completed)")
107 ),
108 responses(
109 (status = 200, description = "SSE stream of workflow events"),
110 (status = 401, description = "Unauthorized"),
111 (status = 404, description = "Run not found")
112 ),
113 security(("Bearer" = []))
114 )
115)]
116pub async fn run_events(
117 auth: Authenticated,
118 State(state): State<AppState>,
119 Path(id): Path<Uuid>,
120 Query(query): Query<RunEventsQuery>,
121) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
122 auth.require(ApiKeyScope::RunsRead, false)?;
123 let run = state.get_run_or_404(id).await?;
124 let connection = state.sse_limiter.try_acquire(auth.user_id)?;
125
126 let type_filter = query.types;
127
128 let stream: Pin<Box<dyn Stream<Item = Result<SseEvent, Infallible>> + Send>> = match state
129 .event_bus
130 {
131 Some(ref bus) if !run.status.state.is_terminal() => {
134 let receiver = bus.subscribe(id);
135
136 Box::pin(BroadcastStream::new(receiver).filter_map(
137 move |result: Result<WorkflowEvent, _>| {
138 let type_filter = type_filter.clone();
139 async move {
140 let event = result.ok()?;
141
142 if let Some(ref kinds) = type_filter {
143 let event_type = event.event_type();
144 if !kinds.iter().any(|k| k == event_type) {
145 return None;
146 }
147 }
148
149 let data = serde_json::to_string(&event).ok()?;
150 let sse_event = SseEvent::default().event(event.event_type()).data(data);
151
152 Some(Ok::<_, Infallible>(sse_event))
153 }
154 },
155 ))
156 }
157 _ => Box::pin(empty()),
158 };
159
160 let stream = stream.map(move |item| {
162 let _held = &connection;
163 item
164 });
165
166 Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(30))))
167}
168
169#[cfg(test)]
170mod tests {
171 use std::collections::HashMap;
172 use std::sync::Arc;
173 use std::time::Duration;
174
175 use axum::Router;
176 use axum::routing::get;
177 use chrono::Utc;
178 use ironflow_core::providers::claude::ClaudeCodeProvider;
179 use ironflow_engine::engine::Engine;
180 use ironflow_engine::notify::{
181 Event, WorkflowAgentStepResumedEvent, WorkflowEvent, WorkflowEventBus,
182 WorkflowStepCompletedEvent, WorkflowStepStartedEvent,
183 };
184 use ironflow_store::memory::InMemoryStore;
185 use ironflow_store::models::{NewRun, RunStatus, TriggerKind};
186 use serde_json::json;
187 use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
188 use tokio::net::{TcpListener, TcpStream};
189 use tokio::sync::broadcast;
190 use tokio::time::{sleep, timeout};
191 use uuid::Uuid;
192
193 use super::run_events;
194 use crate::routes::test_helpers::create_user_auth_header;
195 use crate::sse_limiter::SseLimits;
196 use crate::state::AppState;
197
198 fn test_state_with_bus() -> (AppState, WorkflowEventBus) {
199 let store = Arc::new(InMemoryStore::new());
200 let provider = Arc::new(ClaudeCodeProvider::new());
201 let engine = Arc::new(Engine::new(store.clone(), provider));
202 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
203 secret: "test-secret".to_string(),
204 access_token_ttl_secs: 900,
205 refresh_token_ttl_secs: 604800,
206 cookie_domain: None,
207 cookie_secure: false,
208 });
209 let (event_sender, _) = broadcast::channel::<Event>(16);
210 let bus = WorkflowEventBus::new();
211 let state = AppState::new(
212 store,
213 engine,
214 jwt_config,
215 "test-worker-token".to_string(),
216 event_sender,
217 )
218 .with_event_bus(bus.clone());
219 (state, bus)
220 }
221
222 async fn create_run(state: &AppState) -> Uuid {
223 state
224 .store
225 .create_run(NewRun {
226 created_by: None,
227 workflow_name: "test".to_string(),
228 trigger: TriggerKind::Manual,
229 payload: json!({}),
230 max_retries: 0,
231 handler_version: None,
232 labels: HashMap::new(),
233 scheduled_at: None,
234 idempotency_key: None,
235 concurrency_key: None,
236 priority: 0,
237 concurrency_limits: Vec::new(),
238 max_cost_usd: None,
239 worker_tags: Vec::new(),
240 })
241 .await
242 .unwrap()
243 .into_run()
244 .id
245 }
246
247 async fn start_sse_server(state: AppState) -> (String, String) {
248 let auth = create_user_auth_header(&state, "testuser", false).await;
249 let app = Router::new()
250 .route("/{id}/events", get(run_events))
251 .with_state(state);
252
253 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
254 let addr = listener.local_addr().unwrap().to_string();
255 tokio::spawn(async move {
256 axum::serve(listener, app).await.unwrap();
257 });
258 (addr, auth)
259 }
260
261 async fn connect_sse(addr: &str, path: &str, auth: &str) -> BufReader<TcpStream> {
262 let stream = TcpStream::connect(addr).await.unwrap();
263 let (reader, mut writer) = stream.into_split();
264
265 writer
266 .write_all(
267 format!(
268 "GET {path} HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\nAuthorization: {auth}\r\n\r\n"
269 )
270 .as_bytes(),
271 )
272 .await
273 .unwrap();
274
275 BufReader::new(reader.reunite(writer).unwrap())
276 }
277
278 async fn wait_for_response_headers(reader: &mut BufReader<TcpStream>) {
286 loop {
287 let mut line = String::new();
288 let n = reader.read_line(&mut line).await.unwrap();
289 if n == 0 || line == "\r\n" || line == "\n" {
290 break;
291 }
292 }
293 }
294
295 async fn read_until_contains(
296 reader: &mut BufReader<TcpStream>,
297 needle: &str,
298 dur: Duration,
299 ) -> String {
300 let mut accumulated = String::new();
301 let result = timeout(dur, async {
302 loop {
303 let mut line = String::new();
304 let n = reader.read_line(&mut line).await.unwrap();
305 if n == 0 {
306 break;
307 }
308 accumulated.push_str(&line);
309 if accumulated.contains(needle) {
310 break;
311 }
312 }
313 })
314 .await;
315 if result.is_err() {
316 panic!("timeout waiting for '{needle}' in SSE stream. Data so far:\n{accumulated}");
317 }
318 accumulated
319 }
320
321 #[tokio::test]
322 async fn sse_stream_receives_workflow_events() {
323 let (state, bus) = test_state_with_bus();
324 let run_id = create_run(&state).await;
325 let (addr, auth) = start_sse_server(state).await;
326
327 let mut reader = connect_sse(&addr, &format!("/{run_id}/events"), &auth).await;
328 wait_for_response_headers(&mut reader).await;
329
330 bus.publish(
331 run_id,
332 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
333 step_name: "build".to_string(),
334 step_index: 0,
335 timestamp: Utc::now(),
336 }),
337 );
338
339 let text = read_until_contains(&mut reader, "build", Duration::from_secs(5)).await;
340
341 assert!(text.contains("event: step_started"));
342 assert!(text.contains("build"));
343 }
344
345 #[tokio::test]
346 async fn returns_404_for_unknown_run() {
347 let (state, _bus) = test_state_with_bus();
348 let (addr, auth) = start_sse_server(state).await;
349
350 let unknown = Uuid::nil();
351 let mut reader = connect_sse(&addr, &format!("/{unknown}/events"), &auth).await;
352
353 let text = read_until_contains(&mut reader, "404", Duration::from_secs(5)).await;
354 assert!(text.contains("404"));
355 }
356
357 #[tokio::test]
358 async fn rejects_unauthenticated() {
359 let (state, _bus) = test_state_with_bus();
360 let run_id = create_run(&state).await;
361 let (addr, _auth) = start_sse_server(state).await;
362
363 let stream = TcpStream::connect(&addr).await.unwrap();
364 let (reader, mut writer) = stream.into_split();
365
366 writer
367 .write_all(
368 format!(
369 "GET /{run_id}/events HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\n\r\n"
370 )
371 .as_bytes(),
372 )
373 .await
374 .unwrap();
375
376 let mut buf_reader = BufReader::new(reader.reunite(writer).unwrap());
377 let text = read_until_contains(&mut buf_reader, "401", Duration::from_secs(5)).await;
378 assert!(text.contains("401"));
379 }
380
381 #[tokio::test]
382 async fn filters_by_event_type() {
383 let (state, bus) = test_state_with_bus();
384 let run_id = create_run(&state).await;
385 let (addr, auth) = start_sse_server(state).await;
386
387 let mut reader = connect_sse(
388 &addr,
389 &format!("/{run_id}/events?types=step_completed"),
390 &auth,
391 )
392 .await;
393 wait_for_response_headers(&mut reader).await;
394
395 bus.publish(
396 run_id,
397 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
398 step_name: "build".to_string(),
399 step_index: 0,
400 timestamp: Utc::now(),
401 }),
402 );
403 bus.publish(
404 run_id,
405 WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
406 step_name: "build".to_string(),
407 step_index: 0,
408 duration_ms: 1234,
409 output_summary: None,
410 }),
411 );
412
413 let text = read_until_contains(&mut reader, "step_completed", Duration::from_secs(5)).await;
414
415 assert!(text.contains("step_completed"));
416 assert!(!text.contains("event: step_started"));
417 }
418
419 #[tokio::test]
420 async fn filters_by_agent_step_resumed_event_type() {
421 let (state, bus) = test_state_with_bus();
422 let run_id = create_run(&state).await;
423 let (addr, auth) = start_sse_server(state).await;
424
425 let mut reader = connect_sse(
426 &addr,
427 &format!("/{run_id}/events?types=agent_step_resumed"),
428 &auth,
429 )
430 .await;
431 wait_for_response_headers(&mut reader).await;
432
433 bus.publish(
434 run_id,
435 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
436 step_name: "review".to_string(),
437 step_index: 0,
438 timestamp: Utc::now(),
439 }),
440 );
441 bus.publish(
442 run_id,
443 WorkflowEvent::AgentStepResumed(WorkflowAgentStepResumedEvent {
444 step_name: "review".to_string(),
445 step_index: 0,
446 session_id: "0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d".to_string(),
447 timestamp: Utc::now(),
448 }),
449 );
450
451 let text = read_until_contains(
452 &mut reader,
453 "0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d",
454 Duration::from_secs(5),
455 )
456 .await;
457
458 assert!(text.contains("event: agent_step_resumed"));
459 assert!(!text.contains("event: step_started"));
460 }
461
462 #[tokio::test]
463 async fn events_isolated_between_runs() {
464 let (state, bus) = test_state_with_bus();
465 let run_a = create_run(&state).await;
466 let run_b = create_run(&state).await;
467 let (addr, auth) = start_sse_server(state).await;
468
469 let mut reader_a = connect_sse(&addr, &format!("/{run_a}/events"), &auth).await;
470 wait_for_response_headers(&mut reader_a).await;
471
472 bus.publish(
473 run_b,
474 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
475 step_name: "only-for-b".to_string(),
476 step_index: 0,
477 timestamp: Utc::now(),
478 }),
479 );
480 bus.publish(
481 run_a,
482 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
483 step_name: "only-for-a".to_string(),
484 step_index: 0,
485 timestamp: Utc::now(),
486 }),
487 );
488
489 let text = read_until_contains(&mut reader_a, "only-for-a", Duration::from_secs(5)).await;
490
491 assert!(text.contains("only-for-a"));
492 assert!(!text.contains("only-for-b"));
493 }
494
495 async fn status_line(reader: &mut BufReader<TcpStream>) -> String {
497 read_until_contains(reader, "\r\n", Duration::from_secs(5)).await
498 }
499
500 async fn read_body(reader: &mut BufReader<TcpStream>) -> String {
502 timeout(Duration::from_secs(5), async {
503 let mut content_length = 0;
504 loop {
505 let mut line = String::new();
506 reader.read_line(&mut line).await.unwrap();
507 if line.is_empty() || line == "\r\n" {
508 break;
509 }
510 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
511 content_length = value.trim().parse().unwrap();
512 }
513 }
514 let mut body = vec![0; content_length];
515 reader.read_exact(&mut body).await.unwrap();
516 String::from_utf8(body).unwrap()
517 })
518 .await
519 .expect("timeout reading the response body")
520 }
521
522 fn limited_state(per_principal: usize) -> (AppState, WorkflowEventBus) {
523 let (state, bus) = test_state_with_bus();
524 let state = state.with_sse_limits(SseLimits {
525 per_principal: Some(per_principal),
526 global: None,
527 });
528 (state, bus)
529 }
530
531 #[tokio::test]
532 async fn run_events_returns_429_past_the_per_principal_limit() {
533 let (state, _bus) = limited_state(2);
534 let run_id = create_run(&state).await;
535 let other_auth = create_user_auth_header(&state, "otheruser", false).await;
536 let (addr, auth) = start_sse_server(state).await;
537 let path = format!("/{run_id}/events");
538
539 let mut first = connect_sse(&addr, &path, &auth).await;
540 assert!(status_line(&mut first).await.starts_with("HTTP/1.1 200"));
541 let mut second = connect_sse(&addr, &path, &auth).await;
542 assert!(status_line(&mut second).await.starts_with("HTTP/1.1 200"));
543
544 let mut third = connect_sse(&addr, &path, &auth).await;
545 let status = status_line(&mut third).await;
546 assert!(status.starts_with("HTTP/1.1 429"), "status line: {status}");
547 let body = read_body(&mut third).await;
548 assert!(body.contains("SSE_CONNECTION_LIMIT"), "body: {body}");
549
550 let mut other = connect_sse(&addr, &path, &other_auth).await;
551 let status = status_line(&mut other).await;
552 assert!(
553 status.starts_with("HTTP/1.1 200"),
554 "another user keeps its own quota: {status}"
555 );
556 }
557
558 #[tokio::test]
559 async fn run_events_frees_the_slot_when_the_client_disconnects() {
560 let (state, _bus) = limited_state(1);
561 let run_id = create_run(&state).await;
562 let (addr, auth) = start_sse_server(state).await;
563 let path = format!("/{run_id}/events");
564
565 let mut first = connect_sse(&addr, &path, &auth).await;
566 assert!(status_line(&mut first).await.starts_with("HTTP/1.1 200"));
567 let mut refused = connect_sse(&addr, &path, &auth).await;
568 assert!(status_line(&mut refused).await.starts_with("HTTP/1.1 429"));
569
570 drop(first);
571
572 let reopened = timeout(Duration::from_secs(5), async {
573 loop {
574 let mut retry = connect_sse(&addr, &path, &auth).await;
575 if status_line(&mut retry).await.starts_with("HTTP/1.1 200") {
576 return retry;
577 }
578 sleep(Duration::from_millis(20)).await;
579 }
580 })
581 .await;
582 assert!(reopened.is_ok(), "the slot was not freed after disconnect");
583 }
584
585 #[tokio::test]
586 async fn run_events_unknown_run_is_404_before_the_limit() {
587 let (state, _bus) = limited_state(0);
588 let (addr, auth) = start_sse_server(state).await;
589
590 let unknown = Uuid::nil();
591 let mut reader = connect_sse(&addr, &format!("/{unknown}/events"), &auth).await;
592
593 let status = status_line(&mut reader).await;
594 assert!(status.starts_with("HTTP/1.1 404"), "status line: {status}");
595 }
596
597 #[tokio::test]
598 async fn run_events_on_terminal_run_ends_without_creating_a_channel() {
599 let (state, bus) = test_state_with_bus();
600 let run_id = create_run(&state).await;
601 state
602 .store
603 .update_run_status(run_id, RunStatus::Running)
604 .await
605 .unwrap();
606 state
607 .store
608 .update_run_status(run_id, RunStatus::Completed)
609 .await
610 .unwrap();
611 let (addr, auth) = start_sse_server(state).await;
612
613 let mut stream = TcpStream::connect(&addr).await.unwrap();
614 stream
615 .write_all(
616 format!(
617 "GET /{run_id}/events HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\nAuthorization: {auth}\r\nConnection: close\r\n\r\n"
618 )
619 .as_bytes(),
620 )
621 .await
622 .unwrap();
623
624 let mut response = String::new();
625 timeout(Duration::from_secs(5), stream.read_to_string(&mut response))
626 .await
627 .expect("the stream of a terminal run must end")
628 .unwrap();
629
630 assert!(response.starts_with("HTTP/1.1 200"), "response: {response}");
631 assert!(!bus.has_channel(run_id));
632 }
633
634 #[tokio::test]
635 async fn run_events_on_live_run_subscribes_to_the_bus() {
636 let (state, bus) = test_state_with_bus();
637 let run_id = create_run(&state).await;
638 let (addr, auth) = start_sse_server(state).await;
639
640 let mut reader = connect_sse(&addr, &format!("/{run_id}/events"), &auth).await;
641 wait_for_response_headers(&mut reader).await;
642
643 assert!(bus.has_channel(run_id));
644 }
645}