Skip to main content

async_nodes/
events.rs

1//! The live event stream: telemetry observers for runs, and a broadcast bridge.
2//!
3//! [`BspInterceptor`] is the engine's observability surface — what a GUI subscribes to for
4//! its live execution view (highlight the active edge, badge the loop counter, show the
5//! "waiting for input" card). Events carry **no `S` payloads**, so the trait stays
6//! `dyn`-safe with no type parameter; snapshots of consumer state remain the consumer's
7//! recorder concern.
8//!
9//! Every method has a default no-op body: interceptors written against the original
10//! three-method dataflow trait (`on_node_start` / `on_node_complete` / `on_node_error`)
11//! keep compiling unchanged.
12//!
13//! Callbacks are synchronous by design; [`ChannelInterceptor`] bridges the stream to async
14//! consumers over a `tokio::sync::broadcast` channel.
15
16use std::time::Duration;
17
18use super::{
19    error::BspError,
20    graph::{EdgeId, NodeId},
21    run::PendingSuspension,
22};
23
24/// Telemetry observer to audit performance and trace the state transitions of a run.
25///
26/// All methods are no-ops by default — implement only the events you need.
27pub trait BspInterceptor: Send + Sync {
28    /// A superstep is about to execute the given frontier.
29    fn on_superstep_start(&self, _step: u64, _frontier: &[NodeId]) {}
30
31    /// A node began executing.
32    fn on_node_start(&self, _node_id: NodeId, _name: &str) {}
33
34    /// A node finished executing (including skip-bypassed executions, which report a
35    /// zero duration).
36    fn on_node_complete(&self, _node_id: NodeId, _name: &str, _duration: Duration) {}
37
38    /// A node's failure is propagating (after any retries were exhausted or classified
39    /// fatal). Also fires when the failure is routed to an error edge.
40    fn on_node_error(&self, _node_id: NodeId, _name: &str, _err: &BspError) {}
41
42    /// A node failure was classified transient; the engine will re-execute after `wait`.
43    /// `attempt` is the 1-based retry number.
44    fn on_node_retry(&self, _node_id: NodeId, _attempt: u32, _wait: Duration, _err: &BspError) {}
45
46    /// An edge fired: its condition matched and a token was emitted toward `target`.
47    /// This is what makes a GUI *alive* — highlight the active edge.
48    fn on_edge_taken(&self, _source: NodeId, _target: NodeId, _condition_label: &str) {}
49
50    /// A budgeted edge fired against its iteration cap — badge the loop counter.
51    fn on_loop_iteration(&self, _edge: EdgeId, _iteration: u64, _max: Option<u64>) {}
52
53    /// One or more nodes requested suspension; the run is pausing.
54    fn on_suspended(&self, _suspensions: &[PendingSuspension]) {}
55
56    /// The run completed naturally after `steps` supersteps.
57    fn on_complete(&self, _steps: u64) {}
58}
59
60/// One owned event from the execution stream, as broadcast by [`ChannelInterceptor`].
61#[derive(Debug, Clone)]
62pub enum BspEvent {
63    /// See [`BspInterceptor::on_superstep_start`].
64    SuperstepStart { step: u64, frontier: Vec<NodeId> },
65    /// See [`BspInterceptor::on_node_start`].
66    NodeStart { node_id: NodeId, name: String },
67    /// See [`BspInterceptor::on_node_complete`].
68    NodeComplete {
69        node_id: NodeId,
70        name: String,
71        duration: Duration,
72    },
73    /// See [`BspInterceptor::on_node_error`].
74    NodeError {
75        node_id: NodeId,
76        name: String,
77        error: BspError,
78    },
79    /// See [`BspInterceptor::on_node_retry`].
80    NodeRetry {
81        node_id: NodeId,
82        attempt: u32,
83        wait: Duration,
84        error: BspError,
85    },
86    /// See [`BspInterceptor::on_edge_taken`].
87    EdgeTaken {
88        source: NodeId,
89        target: NodeId,
90        condition_label: String,
91    },
92    /// See [`BspInterceptor::on_loop_iteration`].
93    LoopIteration {
94        edge: EdgeId,
95        iteration: u64,
96        max: Option<u64>,
97    },
98    /// See [`BspInterceptor::on_suspended`].
99    Suspended { suspensions: Vec<PendingSuspension> },
100    /// See [`BspInterceptor::on_complete`].
101    Complete { steps: u64 },
102}
103
104/// A [`BspInterceptor`] that forwards every event onto a `tokio::sync::broadcast`
105/// channel — every GUI / CLI / TUI then just subscribes.
106///
107/// Send failures (no active receivers, or a lagging receiver) are ignored by design:
108/// telemetry must never backpressure the scheduler.
109#[cfg(feature = "tokio")]
110pub struct ChannelInterceptor {
111    tx: tokio::sync::broadcast::Sender<BspEvent>,
112}
113
114#[cfg(feature = "tokio")]
115impl ChannelInterceptor {
116    /// Creates an interceptor and its broadcast channel with the given capacity.
117    #[must_use]
118    pub fn new(capacity: usize) -> Self {
119        let (tx, _) = tokio::sync::broadcast::channel(capacity);
120        Self { tx }
121    }
122
123    /// Subscribes to the event stream.
124    #[must_use]
125    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<BspEvent> {
126        self.tx.subscribe()
127    }
128
129    fn send(&self, event: BspEvent) {
130        let _ = self.tx.send(event);
131    }
132}
133
134#[cfg(feature = "tokio")]
135impl BspInterceptor for ChannelInterceptor {
136    fn on_superstep_start(&self, step: u64, frontier: &[NodeId]) {
137        self.send(BspEvent::SuperstepStart {
138            step,
139            frontier: frontier.to_vec(),
140        });
141    }
142
143    fn on_node_start(&self, node_id: NodeId, name: &str) {
144        self.send(BspEvent::NodeStart {
145            node_id,
146            name: name.to_string(),
147        });
148    }
149
150    fn on_node_complete(&self, node_id: NodeId, name: &str, duration: Duration) {
151        self.send(BspEvent::NodeComplete {
152            node_id,
153            name: name.to_string(),
154            duration,
155        });
156    }
157
158    fn on_node_error(&self, node_id: NodeId, name: &str, err: &BspError) {
159        self.send(BspEvent::NodeError {
160            node_id,
161            name: name.to_string(),
162            error: err.clone(),
163        });
164    }
165
166    fn on_node_retry(&self, node_id: NodeId, attempt: u32, wait: Duration, err: &BspError) {
167        self.send(BspEvent::NodeRetry {
168            node_id,
169            attempt,
170            wait,
171            error: err.clone(),
172        });
173    }
174
175    fn on_edge_taken(&self, source: NodeId, target: NodeId, condition_label: &str) {
176        self.send(BspEvent::EdgeTaken {
177            source,
178            target,
179            condition_label: condition_label.to_string(),
180        });
181    }
182
183    fn on_loop_iteration(&self, edge: EdgeId, iteration: u64, max: Option<u64>) {
184        self.send(BspEvent::LoopIteration {
185            edge,
186            iteration,
187            max,
188        });
189    }
190
191    fn on_suspended(&self, suspensions: &[PendingSuspension]) {
192        self.send(BspEvent::Suspended {
193            suspensions: suspensions.to_vec(),
194        });
195    }
196
197    fn on_complete(&self, steps: u64) {
198        self.send(BspEvent::Complete { steps });
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// The original three-method dataflow interceptor shape must keep compiling.
207    struct LegacyInterceptor;
208
209    impl BspInterceptor for LegacyInterceptor {
210        fn on_node_start(&self, _node_id: NodeId, _name: &str) {}
211        fn on_node_complete(&self, _node_id: NodeId, _name: &str, _duration: Duration) {}
212        fn on_node_error(&self, _node_id: NodeId, _name: &str, _err: &BspError) {}
213    }
214
215    #[test]
216    fn test_default_methods_are_noops() {
217        let interceptor = LegacyInterceptor;
218        // Every extended event is callable and a no-op by default.
219        interceptor.on_superstep_start(0, &[1, 2]);
220        interceptor.on_node_retry(1, 1, Duration::ZERO, &BspError::Deadlock);
221        interceptor.on_edge_taken(1, 2, "always");
222        interceptor.on_loop_iteration(0, 1, Some(30));
223        interceptor.on_suspended(&[]);
224        interceptor.on_complete(3);
225    }
226
227    #[cfg(feature = "tokio")]
228    #[test]
229    fn test_channel_interceptor_broadcasts() {
230        let interceptor = ChannelInterceptor::new(16);
231        let mut rx = interceptor.subscribe();
232        interceptor.on_edge_taken(1, 2, "always");
233        interceptor.on_complete(7);
234        let first = rx.try_recv().unwrap();
235        let second = rx.try_recv().unwrap();
236        assert!(matches!(
237            first,
238            BspEvent::EdgeTaken {
239                source: 1,
240                target: 2,
241                ..
242            }
243        ));
244        assert!(matches!(second, BspEvent::Complete { steps: 7 }));
245    }
246}