Skip to main content

a3s_flow/
observe.rs

1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashMap};
5use std::fmt;
6use std::sync::Arc;
7use tokio::sync::Mutex;
8use uuid::Uuid;
9
10use crate::model::{FlowEvent, FlowEventEnvelope, WorkflowSpec};
11
12mod local_file;
13
14pub use local_file::LocalFileA3sFlowEventSink;
15
16/// Observer for committed workflow events.
17///
18/// Observers run after the event has been appended to the durable store. They
19/// must not be treated as the source of truth for workflow state.
20#[async_trait]
21pub trait FlowEventObserver: Send + Sync {
22    /// Handles one event after it has committed to the durable store.
23    async fn observe(&self, envelope: FlowEventEnvelope);
24}
25
26/// Observer that intentionally drops all events.
27#[derive(Debug, Default)]
28pub struct NoopFlowEventObserver;
29
30#[async_trait]
31impl FlowEventObserver for NoopFlowEventObserver {
32    async fn observe(&self, _envelope: FlowEventEnvelope) {}
33}
34
35/// Observer that forwards every committed event to multiple observers.
36#[derive(Clone, Default)]
37pub struct FanoutFlowEventObserver {
38    observers: Vec<Arc<dyn FlowEventObserver>>,
39}
40
41impl FanoutFlowEventObserver {
42    /// Creates an observer with no downstream observers.
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Creates a fanout observer from dynamic downstream observers.
48    pub fn from_observers(observers: Vec<Arc<dyn FlowEventObserver>>) -> Self {
49        Self { observers }
50    }
51
52    /// Appends a statically typed downstream observer.
53    pub fn with_observer<O>(mut self, observer: Arc<O>) -> Self
54    where
55        O: FlowEventObserver + 'static,
56    {
57        self.observers.push(observer);
58        self
59    }
60
61    /// Appends a dynamic downstream observer.
62    pub fn with_dyn_observer(mut self, observer: Arc<dyn FlowEventObserver>) -> Self {
63        self.observers.push(observer);
64        self
65    }
66
67    /// Returns the number of downstream observers.
68    pub fn len(&self) -> usize {
69        self.observers.len()
70    }
71
72    /// Returns whether no downstream observers are configured.
73    pub fn is_empty(&self) -> bool {
74        self.observers.is_empty()
75    }
76}
77
78impl fmt::Debug for FanoutFlowEventObserver {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        formatter
81            .debug_struct("FanoutFlowEventObserver")
82            .field("observers", &self.observers.len())
83            .finish()
84    }
85}
86
87#[async_trait]
88impl FlowEventObserver for FanoutFlowEventObserver {
89    async fn observe(&self, envelope: FlowEventEnvelope) {
90        for observer in &self.observers {
91            observer.observe(envelope.clone()).await;
92        }
93    }
94}
95
96/// In-memory observer for tests, local debugging, and embedded hosts.
97#[derive(Debug, Default)]
98pub struct InMemoryFlowEventObserver {
99    events: Mutex<Vec<FlowEventEnvelope>>,
100}
101
102impl InMemoryFlowEventObserver {
103    /// Creates an empty in-memory observer.
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Returns a snapshot of all observed envelopes in commit order.
109    pub async fn events(&self) -> Vec<FlowEventEnvelope> {
110        self.events.lock().await.clone()
111    }
112
113    /// Returns routing keys for all observed events in commit order.
114    pub async fn event_keys(&self) -> Vec<&'static str> {
115        self.events
116            .lock()
117            .await
118            .iter()
119            .map(|event| event.event.event_key())
120            .collect()
121    }
122}
123
124#[async_trait]
125impl FlowEventObserver for InMemoryFlowEventObserver {
126    async fn observe(&self, envelope: FlowEventEnvelope) {
127        self.events.lock().await.push(envelope);
128    }
129}
130
131/// Low-cardinality workflow identity copied from the run-created event.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133#[non_exhaustive]
134pub struct FlowWorkflowIdentity {
135    /// Stable workflow type name.
136    pub name: String,
137    /// Application-defined workflow definition version.
138    pub version: String,
139}
140
141impl FlowWorkflowIdentity {
142    /// Create a low-cardinality workflow identity.
143    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
144        Self {
145            name: name.into(),
146            version: version.into(),
147        }
148    }
149}
150
151impl From<&WorkflowSpec> for FlowWorkflowIdentity {
152    fn from(spec: &WorkflowSpec) -> Self {
153        Self::new(spec.name.clone(), spec.version.clone())
154    }
155}
156
157/// Subject touched by a workflow event.
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[non_exhaustive]
160pub struct A3sFlowEventSubject {
161    /// Low-cardinality subject kind such as `step` or `hook`.
162    pub kind: String,
163    /// Durable subject identity within the run.
164    pub id: String,
165}
166
167/// A3S-style event record derived from a committed [`FlowEventEnvelope`].
168///
169/// The event keeps full routing/audit identity such as `run_id` and
170/// `event_id`, but [`safe_metric_labels`](Self::safe_metric_labels) intentionally
171/// returns only low-cardinality labels.
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
173#[non_exhaustive]
174pub struct A3sFlowEvent {
175    /// Dot-separated A3S event routing key.
176    pub key: String,
177    /// Run whose history owns the event.
178    pub run_id: String,
179    /// Per-run event sequence number.
180    pub sequence: u64,
181    /// Globally unique event identity.
182    pub event_id: Uuid,
183    /// UTC time at which the event committed.
184    pub timestamp: DateTime<Utc>,
185    /// Workflow identity learned from the run-created event.
186    pub workflow: Option<FlowWorkflowIdentity>,
187    /// Low-cardinality lifecycle status associated with the event.
188    pub status: Option<String>,
189    /// Step, wait, hook, signal, progress, or child touched by the event.
190    pub subject: Option<A3sFlowEventSubject>,
191}
192
193impl A3sFlowEvent {
194    /// Maps a committed Flow envelope to an A3S-style event record.
195    pub fn from_envelope(
196        envelope: &FlowEventEnvelope,
197        workflow: Option<FlowWorkflowIdentity>,
198    ) -> Self {
199        Self {
200            key: envelope.event.event_key().to_string(),
201            run_id: envelope.run_id.clone(),
202            sequence: envelope.sequence,
203            event_id: envelope.event_id,
204            timestamp: envelope.timestamp,
205            workflow,
206            status: event_status(&envelope.event).map(str::to_string),
207            subject: event_subject(&envelope.event),
208        }
209    }
210
211    /// Returns the bounded labels safe for metric dimensions.
212    pub fn safe_metric_labels(&self) -> BTreeMap<String, String> {
213        let mut labels = BTreeMap::new();
214        labels.insert("event_key".to_string(), self.key.clone());
215        if let Some(workflow) = &self.workflow {
216            labels.insert("workflow_name".to_string(), workflow.name.clone());
217            labels.insert("workflow_version".to_string(), workflow.version.clone());
218        }
219        if let Some(status) = &self.status {
220            labels.insert("status".to_string(), status.clone());
221        }
222        labels
223    }
224}
225
226#[cfg(feature = "a3s-event")]
227/// Sink that publishes bridged Flow events into an A3S Event bus.
228///
229/// The sink uses A3S Event as the transport and history layer while preserving
230/// the durable Flow event store as the source of truth. Publish failures are
231/// recorded in `last_error()` and logged; they do not roll back workflow events
232/// that have already been committed.
233pub struct A3sEventBusFlowEventSink {
234    bus: Arc<a3s_event::EventBus>,
235    category: String,
236    source: String,
237    last_error: Mutex<Option<String>>,
238}
239
240#[cfg(feature = "a3s-event")]
241impl A3sEventBusFlowEventSink {
242    /// Creates a sink using the default `flow` category and `a3s-flow` source.
243    pub fn new(bus: Arc<a3s_event::EventBus>) -> Self {
244        Self {
245            bus,
246            category: "flow".to_string(),
247            source: "a3s-flow".to_string(),
248            last_error: Mutex::new(None),
249        }
250    }
251
252    /// Replaces the A3S Event category.
253    pub fn with_category(mut self, category: impl Into<String>) -> Self {
254        self.category = category.into();
255        self
256    }
257
258    /// Replaces the A3S Event source identity.
259    pub fn with_source(mut self, source: impl Into<String>) -> Self {
260        self.source = source.into();
261        self
262    }
263
264    /// Returns the configured A3S Event bus.
265    pub fn bus(&self) -> Arc<a3s_event::EventBus> {
266        Arc::clone(&self.bus)
267    }
268
269    /// Returns the configured A3S Event category.
270    pub fn category(&self) -> &str {
271        &self.category
272    }
273
274    /// Returns the configured A3S Event source identity.
275    pub fn source(&self) -> &str {
276        &self.source
277    }
278
279    /// Returns the most recent conversion or publish error.
280    pub async fn last_error(&self) -> Option<String> {
281        self.last_error.lock().await.clone()
282    }
283
284    /// Converts a bridged record into the A3S Event transport shape.
285    pub fn to_a3s_event(
286        &self,
287        event: &A3sFlowEvent,
288    ) -> std::result::Result<a3s_event::Event, serde_json::Error> {
289        let topic = flow_event_topic(&event.key);
290        let subject = self.bus.provider_arc().build_subject(&self.category, topic);
291        let timestamp = event.timestamp.timestamp_millis();
292        let mut metadata = HashMap::new();
293        metadata.insert("flow.event_key".to_string(), event.key.clone());
294        metadata.insert("flow.run_id".to_string(), event.run_id.clone());
295        metadata.insert("flow.sequence".to_string(), event.sequence.to_string());
296        metadata.insert("flow.event_id".to_string(), event.event_id.to_string());
297        if let Some(status) = &event.status {
298            metadata.insert("flow.status".to_string(), status.clone());
299        }
300        if let Some(workflow) = &event.workflow {
301            metadata.insert("flow.workflow_name".to_string(), workflow.name.clone());
302            metadata.insert(
303                "flow.workflow_version".to_string(),
304                workflow.version.clone(),
305            );
306        }
307        if let Some(subject) = &event.subject {
308            metadata.insert("flow.subject_kind".to_string(), subject.kind.clone());
309            metadata.insert("flow.subject_id".to_string(), subject.id.clone());
310        }
311
312        Ok(a3s_event::Event {
313            id: format!("evt-{}", event.event_id),
314            subject,
315            category: self.category.clone(),
316            event_type: event.key.clone(),
317            version: 1,
318            payload: serde_json::to_value(event)?,
319            summary: format!("{} for run {}", event.key, event.run_id),
320            source: self.source.clone(),
321            timestamp: timestamp.max(0) as u64,
322            metadata,
323        })
324    }
325}
326
327#[cfg(feature = "a3s-event")]
328impl fmt::Debug for A3sEventBusFlowEventSink {
329    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
330        formatter
331            .debug_struct("A3sEventBusFlowEventSink")
332            .field("category", &self.category)
333            .field("source", &self.source)
334            .finish_non_exhaustive()
335    }
336}
337
338#[cfg(feature = "a3s-event")]
339#[async_trait]
340impl A3sFlowEventSink for A3sEventBusFlowEventSink {
341    async fn emit(&self, event: A3sFlowEvent) {
342        let a3s_event = match self.to_a3s_event(&event) {
343            Ok(event) => event,
344            Err(err) => {
345                let message = err.to_string();
346                tracing::warn!(
347                    error = %message,
348                    event_key = %event.key,
349                    run_id = %event.run_id,
350                    "failed to convert flow event for A3S Event"
351                );
352                *self.last_error.lock().await = Some(message);
353                return;
354            }
355        };
356
357        match self.bus.publish_event(&a3s_event).await {
358            Ok(_) => {
359                *self.last_error.lock().await = None;
360            }
361            Err(err) => {
362                let message = err.to_string();
363                tracing::warn!(
364                    error = %message,
365                    subject = %a3s_event.subject,
366                    event_type = %a3s_event.event_type,
367                    "failed to publish flow event to A3S Event"
368                );
369                *self.last_error.lock().await = Some(message);
370            }
371        }
372    }
373}
374
375/// Sink for A3S-style Flow events.
376#[async_trait]
377pub trait A3sFlowEventSink: Send + Sync {
378    /// Publishes one best-effort observer record.
379    async fn emit(&self, event: A3sFlowEvent);
380}
381
382/// Observer adapter that maps Flow envelopes to A3S-style event records.
383#[derive(Debug)]
384pub struct A3sFlowEventBridge<S> {
385    sink: Arc<S>,
386    workflows: Mutex<HashMap<String, FlowWorkflowIdentity>>,
387}
388
389impl<S> A3sFlowEventBridge<S>
390where
391    S: A3sFlowEventSink,
392{
393    /// Creates a bridge that forwards records to `sink`.
394    pub fn new(sink: Arc<S>) -> Self {
395        Self {
396            sink,
397            workflows: Mutex::new(HashMap::new()),
398        }
399    }
400
401    /// Returns the configured downstream sink.
402    pub fn sink(&self) -> Arc<S> {
403        Arc::clone(&self.sink)
404    }
405}
406
407#[async_trait]
408impl<S> FlowEventObserver for A3sFlowEventBridge<S>
409where
410    S: A3sFlowEventSink,
411{
412    async fn observe(&self, envelope: FlowEventEnvelope) {
413        let workflow = {
414            let mut workflows = self.workflows.lock().await;
415            if let FlowEvent::RunCreated { spec, .. } = &envelope.event {
416                workflows.insert(envelope.run_id.clone(), FlowWorkflowIdentity::from(spec));
417            }
418            workflows.get(&envelope.run_id).cloned()
419        };
420        self.sink
421            .emit(A3sFlowEvent::from_envelope(&envelope, workflow))
422            .await;
423    }
424}
425
426/// In-memory A3S event sink for examples and tests.
427#[derive(Debug, Default)]
428pub struct InMemoryA3sFlowEventSink {
429    events: Mutex<Vec<A3sFlowEvent>>,
430}
431
432impl InMemoryA3sFlowEventSink {
433    /// Creates an empty in-memory sink.
434    pub fn new() -> Self {
435        Self::default()
436    }
437
438    /// Returns a snapshot of emitted records in observation order.
439    pub async fn events(&self) -> Vec<A3sFlowEvent> {
440        self.events.lock().await.clone()
441    }
442}
443
444#[async_trait]
445impl A3sFlowEventSink for InMemoryA3sFlowEventSink {
446    async fn emit(&self, event: A3sFlowEvent) {
447        self.events.lock().await.push(event);
448    }
449}
450
451#[cfg(feature = "a3s-event")]
452fn flow_event_topic(key: &str) -> &str {
453    key.strip_prefix("flow.").unwrap_or(key)
454}
455
456fn event_status(event: &FlowEvent) -> Option<&'static str> {
457    match event {
458        FlowEvent::RunCreated { .. } => Some("pending"),
459        FlowEvent::RunStarted => Some("running"),
460        FlowEvent::RunCompleted { .. } => Some("completed"),
461        FlowEvent::RunFailed { .. } => Some("failed"),
462        FlowEvent::RunCancellationRequested { .. } => Some("cancelling"),
463        FlowEvent::RunCancelled { .. } => Some("cancelled"),
464        FlowEvent::RunTimedOut { .. } => Some("timed_out"),
465        FlowEvent::RunRetryExhausted { .. } => Some("retry_exhausted"),
466        FlowEvent::RunHostShutdown { .. } => Some("host_shutdown"),
467        FlowEvent::RunContinuedAsNew { .. } => Some("continued_as_new"),
468        FlowEvent::RunProgressRecorded { .. } => Some("recorded"),
469        FlowEvent::ChildOperationLinked { .. } => Some("linked"),
470        FlowEvent::ChildWorkflowRequested { .. } => Some("requested"),
471        FlowEvent::ChildWorkflowResolved { .. } => Some("resolved"),
472        FlowEvent::SignalReceived { .. } => Some("received"),
473        FlowEvent::SignalWaitCreated { .. } => Some("waiting"),
474        FlowEvent::SignalWaitCompleted { .. } => Some("completed"),
475        FlowEvent::StepCreated { .. } => Some("pending"),
476        FlowEvent::StepStarted { .. } => Some("running"),
477        FlowEvent::StepCompleted { .. } => Some("completed"),
478        FlowEvent::StepRetrying { .. } => Some("retrying"),
479        FlowEvent::StepFailed { .. } => Some("failed"),
480        FlowEvent::WaitCreated { .. } => Some("waiting"),
481        FlowEvent::WaitCompleted { .. } => Some("completed"),
482        FlowEvent::HookCreated { .. } => Some("active"),
483        FlowEvent::HookReceived { .. } => Some("received"),
484        FlowEvent::HookDisposed { .. } => Some("disposed"),
485    }
486}
487
488fn event_subject(event: &FlowEvent) -> Option<A3sFlowEventSubject> {
489    match event {
490        FlowEvent::StepCreated { step_id, .. }
491        | FlowEvent::StepStarted { step_id, .. }
492        | FlowEvent::StepCompleted { step_id, .. }
493        | FlowEvent::StepRetrying { step_id, .. }
494        | FlowEvent::StepFailed { step_id, .. }
495        | FlowEvent::RunRetryExhausted { step_id, .. } => Some(A3sFlowEventSubject {
496            kind: "step".to_string(),
497            id: step_id.clone(),
498        }),
499        FlowEvent::RunProgressRecorded { progress } => Some(A3sFlowEventSubject {
500            kind: "progress".to_string(),
501            id: progress.progress_id.clone(),
502        }),
503        FlowEvent::ChildOperationLinked { child } => Some(A3sFlowEventSubject {
504            kind: "child_operation".to_string(),
505            id: child.reference_id.clone(),
506        }),
507        FlowEvent::ChildWorkflowRequested { child_id, .. }
508        | FlowEvent::ChildWorkflowResolved { child_id, .. } => Some(A3sFlowEventSubject {
509            kind: "child_workflow".to_string(),
510            id: child_id.clone(),
511        }),
512        FlowEvent::SignalReceived { signal } => Some(A3sFlowEventSubject {
513            kind: "signal".to_string(),
514            id: signal.signal_id.clone(),
515        }),
516        FlowEvent::SignalWaitCreated { wait_id, .. }
517        | FlowEvent::SignalWaitCompleted { wait_id, .. } => Some(A3sFlowEventSubject {
518            kind: "signal_wait".to_string(),
519            id: wait_id.clone(),
520        }),
521        FlowEvent::WaitCreated { wait_id, .. } | FlowEvent::WaitCompleted { wait_id } => {
522            Some(A3sFlowEventSubject {
523                kind: "wait".to_string(),
524                id: wait_id.clone(),
525            })
526        }
527        FlowEvent::HookCreated { hook_id, .. }
528        | FlowEvent::HookReceived { hook_id, .. }
529        | FlowEvent::HookDisposed { hook_id } => Some(A3sFlowEventSubject {
530            kind: "hook".to_string(),
531            id: hook_id.clone(),
532        }),
533        FlowEvent::RunCreated { .. }
534        | FlowEvent::RunStarted
535        | FlowEvent::RunCompleted { .. }
536        | FlowEvent::RunFailed { .. }
537        | FlowEvent::RunCancellationRequested { .. }
538        | FlowEvent::RunCancelled { .. }
539        | FlowEvent::RunContinuedAsNew { .. } => None,
540        FlowEvent::RunTimedOut { .. } | FlowEvent::RunHostShutdown { .. } => None,
541    }
542}