Skip to main content

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}