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