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/// Sink for A3S-style Flow events.
193#[async_trait]
194pub trait A3sFlowEventSink: Send + Sync {
195    async fn emit(&self, event: A3sFlowEvent);
196}
197
198/// Observer adapter that maps Flow envelopes to A3S-style event records.
199#[derive(Debug)]
200pub struct A3sFlowEventBridge<S> {
201    sink: Arc<S>,
202    workflows: Mutex<HashMap<String, FlowWorkflowIdentity>>,
203}
204
205impl<S> A3sFlowEventBridge<S>
206where
207    S: A3sFlowEventSink,
208{
209    pub fn new(sink: Arc<S>) -> Self {
210        Self {
211            sink,
212            workflows: Mutex::new(HashMap::new()),
213        }
214    }
215
216    pub fn sink(&self) -> Arc<S> {
217        Arc::clone(&self.sink)
218    }
219}
220
221#[async_trait]
222impl<S> FlowEventObserver for A3sFlowEventBridge<S>
223where
224    S: A3sFlowEventSink,
225{
226    async fn observe(&self, envelope: FlowEventEnvelope) {
227        let workflow = {
228            let mut workflows = self.workflows.lock().await;
229            if let FlowEvent::RunCreated { spec, .. } = &envelope.event {
230                workflows.insert(envelope.run_id.clone(), FlowWorkflowIdentity::from(spec));
231            }
232            workflows.get(&envelope.run_id).cloned()
233        };
234        self.sink
235            .emit(A3sFlowEvent::from_envelope(&envelope, workflow))
236            .await;
237    }
238}
239
240/// In-memory A3S event sink for examples and tests.
241#[derive(Debug, Default)]
242pub struct InMemoryA3sFlowEventSink {
243    events: Mutex<Vec<A3sFlowEvent>>,
244}
245
246impl InMemoryA3sFlowEventSink {
247    pub fn new() -> Self {
248        Self::default()
249    }
250
251    pub async fn events(&self) -> Vec<A3sFlowEvent> {
252        self.events.lock().await.clone()
253    }
254}
255
256#[async_trait]
257impl A3sFlowEventSink for InMemoryA3sFlowEventSink {
258    async fn emit(&self, event: A3sFlowEvent) {
259        self.events.lock().await.push(event);
260    }
261}
262
263/// JSONL-backed A3S Flow event sink for local audit logs.
264///
265/// `A3sFlowEventSink::emit` is intentionally best-effort because observers run
266/// after the event store commit. Write failures are recorded in `last_error()`
267/// and logged, while the workflow event store remains the source of truth.
268#[derive(Debug)]
269pub struct LocalFileA3sFlowEventSink {
270    path: PathBuf,
271    lock: Mutex<()>,
272    last_error: Mutex<Option<String>>,
273}
274
275impl LocalFileA3sFlowEventSink {
276    pub fn new(path: impl Into<PathBuf>) -> Self {
277        Self {
278            path: path.into(),
279            lock: Mutex::new(()),
280            last_error: Mutex::new(None),
281        }
282    }
283
284    pub fn path(&self) -> &Path {
285        &self.path
286    }
287
288    pub async fn last_error(&self) -> Option<String> {
289        self.last_error.lock().await.clone()
290    }
291
292    pub async fn events(&self) -> Result<Vec<A3sFlowEvent>> {
293        let _guard = self.lock.lock().await;
294        let file = match File::open(&self.path).await {
295            Ok(file) => file,
296            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
297            Err(err) => return Err(FlowError::Io(err)),
298        };
299        let mut lines = BufReader::new(file).lines();
300        let mut events = Vec::new();
301        while let Some(line) = lines.next_line().await? {
302            if line.trim().is_empty() {
303                continue;
304            }
305            events.push(serde_json::from_str(&line)?);
306        }
307        Ok(events)
308    }
309
310    async fn append_event(&self, event: &A3sFlowEvent) -> Result<()> {
311        if let Some(parent) = self
312            .path
313            .parent()
314            .filter(|path| !path.as_os_str().is_empty())
315        {
316            tokio::fs::create_dir_all(parent).await?;
317        }
318        let mut file = OpenOptions::new()
319            .create(true)
320            .append(true)
321            .open(&self.path)
322            .await?;
323        file.write_all(serde_json::to_string(event)?.as_bytes())
324            .await?;
325        file.write_all(b"\n").await?;
326        file.flush().await?;
327        file.sync_data().await?;
328        Ok(())
329    }
330}
331
332#[async_trait]
333impl A3sFlowEventSink for LocalFileA3sFlowEventSink {
334    async fn emit(&self, event: A3sFlowEvent) {
335        let _guard = self.lock.lock().await;
336        match self.append_event(&event).await {
337            Ok(()) => {
338                *self.last_error.lock().await = None;
339            }
340            Err(err) => {
341                let message = err.to_string();
342                tracing::warn!(
343                    error = %message,
344                    path = %self.path.display(),
345                    "failed to emit flow audit event"
346                );
347                *self.last_error.lock().await = Some(message);
348            }
349        }
350    }
351}
352
353fn event_status(event: &FlowEvent) -> Option<&'static str> {
354    match event {
355        FlowEvent::RunCreated { .. } => Some("pending"),
356        FlowEvent::RunStarted => Some("running"),
357        FlowEvent::RunCompleted { .. } => Some("completed"),
358        FlowEvent::RunFailed { .. } => Some("failed"),
359        FlowEvent::RunCancelled { .. } => Some("cancelled"),
360        FlowEvent::StepCreated { .. } => Some("pending"),
361        FlowEvent::StepStarted { .. } => Some("running"),
362        FlowEvent::StepCompleted { .. } => Some("completed"),
363        FlowEvent::StepRetrying { .. } => Some("retrying"),
364        FlowEvent::StepFailed { .. } => Some("failed"),
365        FlowEvent::WaitCreated { .. } => Some("waiting"),
366        FlowEvent::WaitCompleted { .. } => Some("completed"),
367        FlowEvent::HookCreated { .. } => Some("active"),
368        FlowEvent::HookReceived { .. } => Some("received"),
369        FlowEvent::HookDisposed { .. } => Some("disposed"),
370    }
371}
372
373fn event_subject(event: &FlowEvent) -> Option<A3sFlowEventSubject> {
374    match event {
375        FlowEvent::StepCreated { step_id, .. }
376        | FlowEvent::StepStarted { step_id, .. }
377        | FlowEvent::StepCompleted { step_id, .. }
378        | FlowEvent::StepRetrying { step_id, .. }
379        | FlowEvent::StepFailed { step_id, .. } => Some(A3sFlowEventSubject {
380            kind: "step".to_string(),
381            id: step_id.clone(),
382        }),
383        FlowEvent::WaitCreated { wait_id, .. } | FlowEvent::WaitCompleted { wait_id } => {
384            Some(A3sFlowEventSubject {
385                kind: "wait".to_string(),
386                id: wait_id.clone(),
387            })
388        }
389        FlowEvent::HookCreated { hook_id, .. }
390        | FlowEvent::HookReceived { hook_id, .. }
391        | FlowEvent::HookDisposed { hook_id } => Some(A3sFlowEventSubject {
392            kind: "hook".to_string(),
393            id: hook_id.clone(),
394        }),
395        FlowEvent::RunCreated { .. }
396        | FlowEvent::RunStarted
397        | FlowEvent::RunCompleted { .. }
398        | FlowEvent::RunFailed { .. }
399        | FlowEvent::RunCancelled { .. } => None,
400    }
401}