Skip to main content

assay_workflow/
events.rs

1//! Typed workflow event layer on top of `assay_domain::events`.
2//!
3//! Every state-mutating method in `assay-workflow` that previously
4//! called `ctx.broadcast(...)` or relied on a PG trigger for
5//! `pg_notify` now emits a `WorkflowBusEvent` via `WorkflowEventBus`.
6
7use std::sync::Arc;
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11
12use assay_domain::events::{
13    CursorGoneError, EngineEventBus, Event, EventFilter, NewEvent, Subsystem,
14};
15
16/// Every event kind the workflow subsystem emits. Each variant
17/// serialises to a `workflow_*` / `activity_*` kind string + a JSON
18/// payload. Fields are chosen to be small and self-contained; we never
19/// dump whole rows.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "snake_case")]
22pub enum WorkflowBusEvent {
23    WorkflowCreated {
24        workflow_id: String,
25        workflow_type: String,
26        task_queue: String,
27        status: String,
28    },
29    WorkflowStatusChanged {
30        workflow_id: String,
31        old_status: String,
32        new_status: String,
33        task_queue: String,
34    },
35    WorkflowNeedsDispatch {
36        workflow_id: String,
37        task_queue: String,
38    },
39    WorkflowStarted {
40        workflow_id: String,
41    },
42    WorkflowRunning {
43        workflow_id: String,
44    },
45    WorkflowCompleted {
46        workflow_id: String,
47    },
48    WorkflowFailed {
49        workflow_id: String,
50    },
51    WorkflowCancelled {
52        workflow_id: String,
53    },
54    WorkflowTerminated {
55        workflow_id: String,
56    },
57    ActivityInserted {
58        activity_id: i64,
59        workflow_id: String,
60        task_queue: String,
61        name: String,
62    },
63    ActivityStatusChanged {
64        activity_id: i64,
65        workflow_id: String,
66        old_status: String,
67        new_status: String,
68    },
69    SignalReceived {
70        workflow_id: String,
71        signal_name: String,
72    },
73    TimerFired {
74        workflow_id: String,
75        seq: i32,
76    },
77}
78
79impl WorkflowBusEvent {
80    /// The kind string written to `engine_events.kind`. Matches the
81    /// serde `rename_all = "snake_case"` tag for each variant.
82    pub fn kind(&self) -> &'static str {
83        match self {
84            WorkflowBusEvent::WorkflowCreated { .. } => "workflow_created",
85            WorkflowBusEvent::WorkflowStatusChanged { .. } => "workflow_status_changed",
86            WorkflowBusEvent::WorkflowNeedsDispatch { .. } => "workflow_needs_dispatch",
87            WorkflowBusEvent::WorkflowStarted { .. } => "workflow_started",
88            WorkflowBusEvent::WorkflowRunning { .. } => "workflow_running",
89            WorkflowBusEvent::WorkflowCompleted { .. } => "workflow_completed",
90            WorkflowBusEvent::WorkflowFailed { .. } => "workflow_failed",
91            WorkflowBusEvent::WorkflowCancelled { .. } => "workflow_cancelled",
92            WorkflowBusEvent::WorkflowTerminated { .. } => "workflow_terminated",
93            WorkflowBusEvent::ActivityInserted { .. } => "activity_inserted",
94            WorkflowBusEvent::ActivityStatusChanged { .. } => "activity_status_changed",
95            WorkflowBusEvent::SignalReceived { .. } => "signal_received",
96            WorkflowBusEvent::TimerFired { .. } => "timer_fired",
97        }
98    }
99
100    /// Serialise the variant's fields to JSON for `engine_events.payload`.
101    /// Strips the `kind` serde tag from the payload since the kind
102    /// lives in its own column.
103    pub fn payload(&self) -> serde_json::Value {
104        let v = serde_json::to_value(self).expect("WorkflowBusEvent serialisable");
105        match v {
106            serde_json::Value::Object(mut m) => {
107                m.remove("kind");
108                serde_json::Value::Object(m)
109            }
110            other => other,
111        }
112    }
113}
114
115/// Typed wrapper around an `EngineEventBus`. Per-subsystem wrappers
116/// (workflow, auth, secrets) share the underlying bus instance at the
117/// engine level but produce/consume their own typed events.
118#[derive(Clone)]
119pub struct WorkflowEventBus {
120    inner: Arc<dyn EngineEventBus>,
121}
122
123impl WorkflowEventBus {
124    pub fn new(inner: Arc<dyn EngineEventBus>) -> Self {
125        Self { inner }
126    }
127
128    /// Publish a typed workflow event. `namespace` is the owning
129    /// workflow's namespace.
130    pub async fn publish(&self, namespace: &str, ev: WorkflowBusEvent) -> Result<i64> {
131        let kind = ev.kind();
132        let payload = ev.payload();
133        self.inner
134            .publish_committed(NewEvent {
135                namespace,
136                subsystem: Subsystem::Workflow,
137                kind,
138                payload,
139            })
140            .await
141    }
142
143    pub(crate) async fn publish_retry_requested(
144        &self,
145        namespace: &str,
146        workflow_id: &str,
147        activity_id: i64,
148        activity_seq: i32,
149    ) -> Result<i64> {
150        self.inner
151            .publish_committed(NewEvent {
152                namespace,
153                subsystem: Subsystem::Workflow,
154                kind: "workflow_retry_requested",
155                payload: serde_json::json!({
156                    "workflow_id": workflow_id,
157                    "activity_id": activity_id,
158                    "activity_seq": activity_seq,
159                }),
160            })
161            .await
162    }
163
164    /// Read a cursor's worth of events for this namespace (any
165    /// subsystem — SSE uses this for the replay phase).
166    pub async fn read_since(
167        &self,
168        namespace: &str,
169        after: Option<i64>,
170        filter: &EventFilter,
171        limit: u32,
172    ) -> std::result::Result<Vec<Event>, CursorGoneError> {
173        self.inner.read_since(namespace, after, filter, limit).await
174    }
175
176    /// Expose the underlying bus so the scheduler + SSE can subscribe
177    /// at the generic level and filter for any subsystem.
178    pub fn inner(&self) -> Arc<dyn EngineEventBus> {
179        Arc::clone(&self.inner)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn kind_tag_stripped_from_payload() {
189        let e = WorkflowBusEvent::WorkflowCreated {
190            workflow_id: "wf-1".into(),
191            workflow_type: "greet".into(),
192            task_queue: "main".into(),
193            status: "PENDING".into(),
194        };
195        assert_eq!(e.kind(), "workflow_created");
196        let payload = e.payload();
197        assert_eq!(payload["workflow_id"], "wf-1");
198        assert_eq!(payload["workflow_type"], "greet");
199        assert!(payload.get("kind").is_none(), "kind tag must be stripped");
200    }
201
202    #[test]
203    fn activity_inserted_payload() {
204        let e = WorkflowBusEvent::ActivityInserted {
205            activity_id: 42,
206            workflow_id: "wf-1".into(),
207            task_queue: "main".into(),
208            name: "send_email".into(),
209        };
210        let p = e.payload();
211        assert_eq!(p["activity_id"], 42);
212        assert_eq!(p["workflow_id"], "wf-1");
213    }
214}