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::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
22fn 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#[derive(Debug, Deserialize)]
71pub struct RunEventsQuery {
72 #[serde(default, deserialize_with = "deserialize_comma_strings")]
75 pub types: Option<Vec<String>>,
76}
77
78#[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 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}