ironflow_engine/notify/event_bus.rs
1//! Per-workflow broadcast event bus for real-time monitoring.
2//!
3//! [`WorkflowEventBus`] maintains one [`tokio::sync::broadcast`] channel per
4//! workflow run. Consumers (dashboards, SSE routes) subscribe to a specific
5//! `run_id` and receive only the events for that run.
6//!
7//! # Architecture
8//!
9//! - [`WorkflowEvent`] -- granular step-level events (started, completed,
10//! failed, approval, token usage).
11//! - [`WorkflowEventBus`] -- per-run broadcast channels with subscribe /
12//! publish / remove lifecycle.
13//!
14//! # Examples
15//!
16//! ```
17//! use ironflow_engine::notify::{WorkflowEventBus, WorkflowEvent};
18//! use uuid::Uuid;
19//! use chrono::Utc;
20//!
21//! let bus = WorkflowEventBus::new();
22//! let run_id = Uuid::now_v7();
23//!
24//! let mut rx = bus.subscribe(run_id);
25//!
26//! bus.publish(run_id, WorkflowEvent::StepStarted {
27//! step_name: "build".to_string(),
28//! step_index: 0,
29//! timestamp: Utc::now(),
30//! });
31//! ```
32
33use std::collections::HashMap;
34use std::sync::RwLock;
35
36use chrono::{DateTime, Utc};
37use rust_decimal::Decimal;
38use serde::{Deserialize, Serialize};
39use tokio::sync::broadcast;
40use uuid::Uuid;
41
42/// Default broadcast channel buffer size per run.
43const DEFAULT_BUFFER_SIZE: usize = 64;
44
45/// A granular step-level event for real-time workflow monitoring.
46///
47/// Unlike [`Event`](super::Event) which covers the full system lifecycle
48/// (runs, auth, audit), `WorkflowEvent` tracks individual step transitions
49/// within a single run. Serialized with a `type` discriminant for UI
50/// consumption.
51///
52/// # Examples
53///
54/// ```
55/// use ironflow_engine::notify::WorkflowEvent;
56/// use chrono::Utc;
57///
58/// let event = WorkflowEvent::StepStarted {
59/// step_name: "deploy".to_string(),
60/// step_index: 0,
61/// timestamp: Utc::now(),
62/// };
63/// assert_eq!(event.event_type(), "step_started");
64///
65/// let json = serde_json::to_string(&event).unwrap();
66/// assert!(json.contains("\"type\":\"step_started\""));
67/// ```
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[serde(tag = "type", rename_all = "snake_case")]
71pub enum WorkflowEvent {
72 /// A step began execution.
73 StepStarted {
74 /// Human-readable step name.
75 step_name: String,
76 /// Zero-based position in the workflow.
77 step_index: u32,
78 /// When the step started.
79 timestamp: DateTime<Utc>,
80 },
81
82 /// A step completed successfully.
83 StepCompleted {
84 /// Human-readable step name.
85 step_name: String,
86 /// Zero-based position in the workflow.
87 step_index: u32,
88 /// Step duration in milliseconds.
89 duration_ms: u64,
90 /// Optional summary of the step output.
91 output_summary: Option<String>,
92 },
93
94 /// A step failed.
95 StepFailed {
96 /// Human-readable step name.
97 step_name: String,
98 /// Zero-based position in the workflow.
99 step_index: u32,
100 /// Error description.
101 error: String,
102 /// Step duration in milliseconds.
103 duration_ms: u64,
104 },
105
106 /// A step requires human approval before the run can continue.
107 ApprovalRequired {
108 /// Human-readable step name.
109 step_name: String,
110 /// Zero-based position in the workflow.
111 step_index: u32,
112 /// Identifier of the approval gate.
113 approval_id: Uuid,
114 },
115
116 /// Token usage report for an agent step.
117 AgentStepTokensUsed {
118 /// Human-readable step name.
119 step_name: String,
120 /// Total tokens consumed.
121 tokens: u64,
122 /// Estimated cost in USD.
123 cost_usd: Decimal,
124 },
125}
126
127impl WorkflowEvent {
128 /// Event type constant for [`StepStarted`](WorkflowEvent::StepStarted).
129 pub const STEP_STARTED: &'static str = "step_started";
130 /// Event type constant for [`StepCompleted`](WorkflowEvent::StepCompleted).
131 pub const STEP_COMPLETED: &'static str = "step_completed";
132 /// Event type constant for [`StepFailed`](WorkflowEvent::StepFailed).
133 pub const STEP_FAILED: &'static str = "step_failed";
134 /// Event type constant for [`ApprovalRequired`](WorkflowEvent::ApprovalRequired).
135 pub const APPROVAL_REQUIRED: &'static str = "approval_required";
136 /// Event type constant for [`AgentStepTokensUsed`](WorkflowEvent::AgentStepTokensUsed).
137 pub const AGENT_STEP_TOKENS_USED: &'static str = "agent_step_tokens_used";
138
139 /// Returns the event type as a static string (e.g. `"step_started"`).
140 ///
141 /// # Examples
142 ///
143 /// ```
144 /// use ironflow_engine::notify::WorkflowEvent;
145 /// use chrono::Utc;
146 ///
147 /// let event = WorkflowEvent::StepStarted {
148 /// step_name: "build".to_string(),
149 /// step_index: 0,
150 /// timestamp: Utc::now(),
151 /// };
152 /// assert_eq!(event.event_type(), "step_started");
153 /// ```
154 pub fn event_type(&self) -> &'static str {
155 match self {
156 WorkflowEvent::StepStarted { .. } => Self::STEP_STARTED,
157 WorkflowEvent::StepCompleted { .. } => Self::STEP_COMPLETED,
158 WorkflowEvent::StepFailed { .. } => Self::STEP_FAILED,
159 WorkflowEvent::ApprovalRequired { .. } => Self::APPROVAL_REQUIRED,
160 WorkflowEvent::AgentStepTokensUsed { .. } => Self::AGENT_STEP_TOKENS_USED,
161 }
162 }
163}
164
165/// Per-workflow broadcast event bus for real-time monitoring.
166///
167/// Maintains one [`tokio::sync::broadcast`] channel per workflow run.
168/// Consumers call [`subscribe`](Self::subscribe) to receive events for a
169/// specific run; producers call [`publish`](Self::publish) to broadcast
170/// an event to all subscribers of that run.
171///
172/// Thread-safe and cheaply cloneable (`Clone` shares the same inner state).
173///
174/// # Examples
175///
176/// ```
177/// use ironflow_engine::notify::{WorkflowEventBus, WorkflowEvent};
178/// use uuid::Uuid;
179/// use chrono::Utc;
180///
181/// let bus = WorkflowEventBus::new();
182/// let run_id = Uuid::now_v7();
183///
184/// let mut rx = bus.subscribe(run_id);
185/// bus.publish(run_id, WorkflowEvent::StepStarted {
186/// step_name: "build".to_string(),
187/// step_index: 0,
188/// timestamp: Utc::now(),
189/// });
190/// ```
191#[derive(Clone)]
192pub struct WorkflowEventBus {
193 channels: std::sync::Arc<RwLock<HashMap<Uuid, broadcast::Sender<WorkflowEvent>>>>,
194}
195
196impl WorkflowEventBus {
197 /// Create a new empty event bus.
198 ///
199 /// # Examples
200 ///
201 /// ```
202 /// use ironflow_engine::notify::WorkflowEventBus;
203 ///
204 /// let bus = WorkflowEventBus::new();
205 /// ```
206 pub fn new() -> Self {
207 Self {
208 channels: std::sync::Arc::new(RwLock::new(HashMap::new())),
209 }
210 }
211
212 /// Subscribe to events for a specific workflow run.
213 ///
214 /// If no channel exists for this `run_id`, one is created on demand.
215 /// Returns a broadcast receiver that yields [`WorkflowEvent`]s for
216 /// that run only.
217 ///
218 /// # Examples
219 ///
220 /// ```
221 /// use ironflow_engine::notify::WorkflowEventBus;
222 /// use uuid::Uuid;
223 ///
224 /// let bus = WorkflowEventBus::new();
225 /// let run_id = Uuid::now_v7();
226 /// let _rx = bus.subscribe(run_id);
227 /// ```
228 pub fn subscribe(&self, run_id: Uuid) -> broadcast::Receiver<WorkflowEvent> {
229 let mut channels = self.channels.write().expect("event bus lock poisoned");
230 let sender = channels
231 .entry(run_id)
232 .or_insert_with(|| broadcast::channel(DEFAULT_BUFFER_SIZE).0);
233 sender.subscribe()
234 }
235
236 /// Broadcast an event to all subscribers of a specific workflow run.
237 ///
238 /// If no channel exists for `run_id` (no subscriber has called
239 /// [`subscribe`](Self::subscribe)), the event is silently dropped.
240 /// If subscribers exist but none are actively listening, the send
241 /// error is ignored.
242 ///
243 /// # Examples
244 ///
245 /// ```
246 /// use ironflow_engine::notify::{WorkflowEventBus, WorkflowEvent};
247 /// use uuid::Uuid;
248 /// use chrono::Utc;
249 ///
250 /// let bus = WorkflowEventBus::new();
251 /// let run_id = Uuid::now_v7();
252 ///
253 /// // No subscriber -- silently dropped.
254 /// bus.publish(run_id, WorkflowEvent::StepStarted {
255 /// step_name: "build".to_string(),
256 /// step_index: 0,
257 /// timestamp: Utc::now(),
258 /// });
259 /// ```
260 pub fn publish(&self, run_id: Uuid, event: WorkflowEvent) {
261 let channels = self.channels.read().expect("event bus lock poisoned");
262 if let Some(sender) = channels.get(&run_id) {
263 let _ = sender.send(event);
264 }
265 }
266
267 /// Remove the channel for a workflow run.
268 ///
269 /// Call this when a run completes or is cleaned up to free resources.
270 /// If no channel exists for `run_id`, this is a no-op.
271 ///
272 /// # Examples
273 ///
274 /// ```
275 /// use ironflow_engine::notify::WorkflowEventBus;
276 /// use uuid::Uuid;
277 ///
278 /// let bus = WorkflowEventBus::new();
279 /// let run_id = Uuid::now_v7();
280 /// let _rx = bus.subscribe(run_id);
281 /// bus.remove(run_id);
282 /// ```
283 pub fn remove(&self, run_id: Uuid) {
284 let mut channels = self.channels.write().expect("event bus lock poisoned");
285 channels.remove(&run_id);
286 }
287}
288
289impl Default for WorkflowEventBus {
290 fn default() -> Self {
291 Self::new()
292 }
293}
294
295impl std::fmt::Debug for WorkflowEventBus {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 let count = self.channels.read().map(|c| c.len()).unwrap_or(0);
298 f.debug_struct("WorkflowEventBus")
299 .field("active_channels", &count)
300 .finish()
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[tokio::test]
309 async fn subscribe_receives_published_events() {
310 let bus = WorkflowEventBus::new();
311 let run_id = Uuid::now_v7();
312
313 let mut rx = bus.subscribe(run_id);
314
315 let event = WorkflowEvent::StepStarted {
316 step_name: "build".to_string(),
317 step_index: 0,
318 timestamp: Utc::now(),
319 };
320 bus.publish(run_id, event);
321
322 let received = rx.recv().await.expect("should receive event");
323 assert_eq!(received.event_type(), "step_started");
324 match received {
325 WorkflowEvent::StepStarted {
326 step_name,
327 step_index,
328 ..
329 } => {
330 assert_eq!(step_name, "build");
331 assert_eq!(step_index, 0);
332 }
333 _ => panic!("expected StepStarted"),
334 }
335 }
336
337 #[test]
338 fn subscribe_creates_channel_on_demand() {
339 let bus = WorkflowEventBus::new();
340 let run_id = Uuid::now_v7();
341
342 let count_before = bus.channels.read().unwrap().len();
343 assert_eq!(count_before, 0);
344
345 let _rx = bus.subscribe(run_id);
346
347 let count_after = bus.channels.read().unwrap().len();
348 assert_eq!(count_after, 1);
349 }
350
351 #[test]
352 fn publish_unknown_run_is_noop() {
353 let bus = WorkflowEventBus::new();
354 let unknown_run = Uuid::now_v7();
355
356 bus.publish(
357 unknown_run,
358 WorkflowEvent::StepStarted {
359 step_name: "build".to_string(),
360 step_index: 0,
361 timestamp: Utc::now(),
362 },
363 );
364 }
365
366 #[test]
367 fn remove_cleans_up_channel() {
368 let bus = WorkflowEventBus::new();
369 let run_id = Uuid::now_v7();
370
371 let _rx = bus.subscribe(run_id);
372 assert_eq!(bus.channels.read().unwrap().len(), 1);
373
374 bus.remove(run_id);
375 assert_eq!(bus.channels.read().unwrap().len(), 0);
376 }
377
378 #[test]
379 fn remove_unknown_is_noop() {
380 let bus = WorkflowEventBus::new();
381 bus.remove(Uuid::now_v7());
382 }
383
384 #[test]
385 fn workflow_event_serde_roundtrip() {
386 let cases: Vec<WorkflowEvent> = vec![
387 WorkflowEvent::StepStarted {
388 step_name: "build".to_string(),
389 step_index: 0,
390 timestamp: Utc::now(),
391 },
392 WorkflowEvent::StepCompleted {
393 step_name: "deploy".to_string(),
394 step_index: 1,
395 duration_ms: 5000,
396 output_summary: Some("deployed v1.2.3".to_string()),
397 },
398 WorkflowEvent::StepFailed {
399 step_name: "test".to_string(),
400 step_index: 2,
401 error: "exit code 1".to_string(),
402 duration_ms: 3000,
403 },
404 WorkflowEvent::ApprovalRequired {
405 step_name: "prod-gate".to_string(),
406 step_index: 3,
407 approval_id: Uuid::now_v7(),
408 },
409 WorkflowEvent::AgentStepTokensUsed {
410 step_name: "review".to_string(),
411 tokens: 15000,
412 cost_usd: Decimal::new(42, 4),
413 },
414 ];
415
416 for event in &cases {
417 let json = serde_json::to_string(event).expect("serialize");
418 let back: WorkflowEvent = serde_json::from_str(&json).expect("deserialize");
419
420 assert_eq!(back.event_type(), event.event_type());
421 assert!(json.contains(&format!("\"type\":\"{}\"", event.event_type())));
422 }
423 }
424
425 #[test]
426 fn event_type_all_variants() {
427 let cases: Vec<(WorkflowEvent, &str)> = vec![
428 (
429 WorkflowEvent::StepStarted {
430 step_name: "s".to_string(),
431 step_index: 0,
432 timestamp: Utc::now(),
433 },
434 "step_started",
435 ),
436 (
437 WorkflowEvent::StepCompleted {
438 step_name: "s".to_string(),
439 step_index: 0,
440 duration_ms: 0,
441 output_summary: None,
442 },
443 "step_completed",
444 ),
445 (
446 WorkflowEvent::StepFailed {
447 step_name: "s".to_string(),
448 step_index: 0,
449 error: "e".to_string(),
450 duration_ms: 0,
451 },
452 "step_failed",
453 ),
454 (
455 WorkflowEvent::ApprovalRequired {
456 step_name: "s".to_string(),
457 step_index: 0,
458 approval_id: Uuid::now_v7(),
459 },
460 "approval_required",
461 ),
462 (
463 WorkflowEvent::AgentStepTokensUsed {
464 step_name: "s".to_string(),
465 tokens: 0,
466 cost_usd: Decimal::ZERO,
467 },
468 "agent_step_tokens_used",
469 ),
470 ];
471
472 for (event, expected) in cases {
473 assert_eq!(event.event_type(), expected);
474 }
475 }
476
477 #[tokio::test]
478 async fn multiple_subscribers_receive_same_event() {
479 let bus = WorkflowEventBus::new();
480 let run_id = Uuid::now_v7();
481
482 let mut rx1 = bus.subscribe(run_id);
483 let mut rx2 = bus.subscribe(run_id);
484
485 bus.publish(
486 run_id,
487 WorkflowEvent::StepStarted {
488 step_name: "build".to_string(),
489 step_index: 0,
490 timestamp: Utc::now(),
491 },
492 );
493
494 let e1 = rx1.recv().await.expect("rx1 should receive");
495 let e2 = rx2.recv().await.expect("rx2 should receive");
496
497 assert_eq!(e1.event_type(), "step_started");
498 assert_eq!(e2.event_type(), "step_started");
499 }
500
501 #[tokio::test]
502 async fn events_isolated_between_runs() {
503 let bus = WorkflowEventBus::new();
504 let run_a = Uuid::now_v7();
505 let run_b = Uuid::now_v7();
506
507 let mut rx_a = bus.subscribe(run_a);
508 let mut rx_b = bus.subscribe(run_b);
509
510 bus.publish(
511 run_a,
512 WorkflowEvent::StepStarted {
513 step_name: "only-for-a".to_string(),
514 step_index: 0,
515 timestamp: Utc::now(),
516 },
517 );
518
519 let received = rx_a.recv().await.expect("rx_a should receive");
520 match received {
521 WorkflowEvent::StepStarted { step_name, .. } => {
522 assert_eq!(step_name, "only-for-a");
523 }
524 _ => panic!("expected StepStarted"),
525 }
526
527 // rx_b should have nothing -- try_recv returns Empty.
528 assert!(rx_b.try_recv().is_err());
529 }
530}