Skip to main content

assay_workflow/
lifecycle.rs

1//! Workflow lifecycle methods: start, get, list, cancel, terminate, complete, fail,
2//! finalise_cancellation, upsert_search_attributes, notify_parent_of_child_outcome.
3
4use std::str::FromStr;
5
6use anyhow::Result;
7
8use crate::ctx::{WorkflowCtx, inject_engine_version, timestamp_now};
9use crate::events::WorkflowBusEvent;
10use crate::store::WorkflowStore;
11use crate::types::*;
12
13impl<S: WorkflowStore> WorkflowCtx<S> {
14    pub async fn start_workflow(
15        &self,
16        namespace: &str,
17        workflow_type: &str,
18        workflow_id: &str,
19        input: Option<&str>,
20        task_queue: &str,
21        search_attributes: Option<&str>,
22    ) -> Result<WorkflowRecord> {
23        let now = timestamp_now();
24        let run_id = format!("run-{workflow_id}-{}", now as u64);
25
26        // Auto-stamp the engine version that started this run into its
27        // search attributes. Makes post-mortem triage concrete: "this
28        // run was v0.11.9, that's why it was stuck in main instead of
29        // deployments" is the kind of question that's otherwise guesswork
30        // once multiple engine versions have coexisted in a deployment.
31        // The operator's own attributes take precedence if they also
32        // supplied `assay_engine_version` — we don't overwrite, just
33        // backfill on the "not set" case.
34        let stamped_attrs = inject_engine_version(search_attributes);
35
36        let wf = WorkflowRecord {
37            id: workflow_id.to_string(),
38            namespace: namespace.to_string(),
39            run_id,
40            workflow_type: workflow_type.to_string(),
41            task_queue: task_queue.to_string(),
42            status: "PENDING".to_string(),
43            input: input.map(String::from),
44            result: None,
45            error: None,
46            parent_id: None,
47            claimed_by: None,
48            search_attributes: stamped_attrs,
49            archived_at: None,
50            archive_uri: None,
51            created_at: now,
52            updated_at: now,
53            completed_at: None,
54        };
55
56        self.store.create_workflow(&wf).await?;
57
58        self.store
59            .append_event(&WorkflowEvent {
60                id: None,
61                workflow_id: workflow_id.to_string(),
62                seq: 1,
63                event_type: "WorkflowStarted".to_string(),
64                payload: input.map(String::from),
65                timestamp: now,
66            })
67            .await?;
68
69        // that need a worker to replay against — make it dispatchable and
70        // emit WorkflowNeedsDispatch for the dispatch-wakeup loop.
71        self.mark_and_emit_needs_dispatch(workflow_id).await?;
72
73        // Notify SSE subscribers via the engine event bus. An emit
74        // failure is a notification-layer issue only; the row write
75        // above is already committed.
76        self.emit(
77            namespace,
78            WorkflowBusEvent::WorkflowStarted {
79                workflow_id: workflow_id.to_string(),
80            },
81        )
82        .await;
83
84        Ok(wf)
85    }
86
87    pub async fn get_workflow(&self, id: &str) -> Result<Option<WorkflowRecord>> {
88        self.store.get_workflow(id).await
89    }
90
91    pub async fn list_workflows(
92        &self,
93        namespace: &str,
94        status: Option<WorkflowStatus>,
95        workflow_type: Option<&str>,
96        search_attrs_filter: Option<&str>,
97        limit: i64,
98        offset: i64,
99    ) -> Result<Vec<WorkflowRecord>> {
100        self.store
101            .list_workflows(
102                namespace,
103                status,
104                workflow_type,
105                search_attrs_filter,
106                limit,
107                offset,
108            )
109            .await
110    }
111
112    pub async fn upsert_search_attributes(
113        &self,
114        workflow_id: &str,
115        patch_json: &str,
116    ) -> Result<()> {
117        self.store
118            .upsert_search_attributes(workflow_id, patch_json)
119            .await
120    }
121
122    pub async fn cancel_workflow(&self, id: &str, reason: Option<&str>) -> Result<bool> {
123        let wf = self.store.get_workflow(id).await?;
124        match wf {
125            None => Ok(false),
126            Some(wf) => {
127                let status =
128                    WorkflowStatus::from_str(&wf.status).map_err(|e| anyhow::anyhow!(e))?;
129                if status.is_terminal() {
130                    return Ok(false);
131                }
132
133                // Two-phase cancel:
134                //   1. Append WorkflowCancelRequested + mark dispatchable.
135                //      The next worker replay sees the request, raises a
136                //      cancellation error inside the handler, and submits
137                //      a CancelWorkflow command.
138                //   2. CancelWorkflow command flips status to CANCELLED,
139                //      cancels pending activities/timers, appends
140                //      WorkflowCancelled.
141                //
142                // We cancel pending activities + timers up-front too so a
143                // worker that's about to claim them sees CANCELLED instead.
144                self.store.cancel_pending_activities(id).await?;
145                self.store.cancel_pending_timers(id).await?;
146
147                // Operator-supplied reason rides along on the event
148                // payload so audit queries (and the dashboard's events
149                // tab) can show why a cancel happened. Symmetric with
150                // terminate, which has always taken a reason.
151                let payload = reason.map(|r| serde_json::json!({ "reason": r }).to_string());
152
153                let seq = self.store.get_event_count(id).await? as i32 + 1;
154                self.store
155                    .append_event(&WorkflowEvent {
156                        id: None,
157                        workflow_id: id.to_string(),
158                        seq,
159                        event_type: "WorkflowCancelRequested".to_string(),
160                        payload,
161                        timestamp: timestamp_now(),
162                    })
163                    .await?;
164
165                self.mark_and_emit_needs_dispatch(id).await?;
166
167                // Propagate cancellation to all child workflows. Children
168                // inherit the reason so the audit trail explains the
169                // whole cascade in one place.
170                let children = self.store.list_child_workflows(id).await?;
171                for child in children {
172                    Box::pin(self.cancel_workflow(&child.id, reason)).await?;
173                }
174
175                // For workflows that have NO worker registered (or no
176                // handler running), cancellation would never complete.
177                // Fall back: if the workflow has no events past
178                // WorkflowStarted (handler hasn't actually run yet, e.g.
179                // PENDING with no claim), finalise immediately.
180                if matches!(status, WorkflowStatus::Pending) {
181                    self.finalise_cancellation(id).await?;
182                }
183
184                Ok(true)
185            }
186        }
187    }
188
189    pub async fn terminate_workflow(&self, id: &str, reason: Option<&str>) -> Result<bool> {
190        let wf = self.store.get_workflow(id).await?;
191        match wf {
192            None => Ok(false),
193            Some(wf) => {
194                let status =
195                    WorkflowStatus::from_str(&wf.status).map_err(|e| anyhow::anyhow!(e))?;
196                if status.is_terminal() {
197                    return Ok(false);
198                }
199
200                self.store
201                    .update_workflow_status(
202                        id,
203                        WorkflowStatus::Failed,
204                        None,
205                        Some(reason.unwrap_or("terminated")),
206                    )
207                    .await?;
208
209                // Live-refresh the dashboard — no more F5 after
210                // Terminate.
211                self.emit(
212                    &wf.namespace,
213                    WorkflowBusEvent::WorkflowTerminated {
214                        workflow_id: id.to_string(),
215                    },
216                )
217                .await;
218
219                Ok(true)
220            }
221        }
222    }
223
224    /// Finalise a cancellation: flips status to CANCELLED and appends the
225    /// terminal WorkflowCancelled event. Called by the CancelWorkflow
226    /// command handler (worker acknowledged cancel) and by cancel_workflow
227    /// directly when the workflow has no worker yet.
228    pub async fn finalise_cancellation(&self, workflow_id: &str) -> Result<()> {
229        // Avoid double-finalising
230        if let Some(wf) = self.store.get_workflow(workflow_id).await?
231            && wf.status == "CANCELLED"
232        {
233            return Ok(());
234        }
235        self.store
236            .update_workflow_status(workflow_id, WorkflowStatus::Cancelled, None, None)
237            .await?;
238        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
239        self.store
240            .append_event(&WorkflowEvent {
241                id: None,
242                workflow_id: workflow_id.to_string(),
243                seq: event_seq,
244                event_type: "WorkflowCancelled".to_string(),
245                payload: None,
246                timestamp: timestamp_now(),
247            })
248            .await?;
249        let ns = self
250            .store
251            .get_workflow(workflow_id)
252            .await?
253            .map(|w| w.namespace)
254            .unwrap_or_default();
255        self.emit(
256            &ns,
257            WorkflowBusEvent::WorkflowCancelled {
258                workflow_id: workflow_id.to_string(),
259            },
260        )
261        .await;
262        Ok(())
263    }
264
265    /// Mark a workflow COMPLETED with a result + append WorkflowCompleted event.
266    /// If the workflow has a parent, also notifies the parent with a
267    /// ChildWorkflowCompleted event and marks it dispatchable so it can
268    /// replay past `ctx:start_child_workflow` and pick up the child's result.
269    pub async fn complete_workflow(&self, workflow_id: &str, result: Option<&str>) -> Result<()> {
270        self.store
271            .update_workflow_status(workflow_id, WorkflowStatus::Completed, result, None)
272            .await?;
273        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
274        self.store
275            .append_event(&WorkflowEvent {
276                id: None,
277                workflow_id: workflow_id.to_string(),
278                seq: event_seq,
279                event_type: "WorkflowCompleted".to_string(),
280                payload: result.map(String::from),
281                timestamp: timestamp_now(),
282            })
283            .await?;
284        self.notify_parent_of_child_outcome(
285            workflow_id,
286            "ChildWorkflowCompleted",
287            serde_json::json!({
288                "child_workflow_id": workflow_id,
289                "result": result.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
290            }),
291        )
292        .await?;
293        let ns = self
294            .store
295            .get_workflow(workflow_id)
296            .await?
297            .map(|w| w.namespace)
298            .unwrap_or_default();
299        self.emit(
300            &ns,
301            WorkflowBusEvent::WorkflowCompleted {
302                workflow_id: workflow_id.to_string(),
303            },
304        )
305        .await;
306        Ok(())
307    }
308
309    /// Mark a workflow FAILED with an error + append WorkflowFailed event.
310    /// Notifies the parent if any (ChildWorkflowFailed).
311    pub async fn fail_workflow(&self, workflow_id: &str, error: &str) -> Result<()> {
312        self.store
313            .update_workflow_status(workflow_id, WorkflowStatus::Failed, None, Some(error))
314            .await?;
315        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
316        self.store
317            .append_event(&WorkflowEvent {
318                id: None,
319                workflow_id: workflow_id.to_string(),
320                seq: event_seq,
321                event_type: "WorkflowFailed".to_string(),
322                payload: Some(serde_json::json!({"error": error}).to_string()),
323                timestamp: timestamp_now(),
324            })
325            .await?;
326        self.notify_parent_of_child_outcome(
327            workflow_id,
328            "ChildWorkflowFailed",
329            serde_json::json!({
330                "child_workflow_id": workflow_id,
331                "error": error,
332            }),
333        )
334        .await?;
335        let ns = self
336            .store
337            .get_workflow(workflow_id)
338            .await?
339            .map(|w| w.namespace)
340            .unwrap_or_default();
341        self.emit(
342            &ns,
343            WorkflowBusEvent::WorkflowFailed {
344                workflow_id: workflow_id.to_string(),
345            },
346        )
347        .await;
348        Ok(())
349    }
350
351    /// Append a parent-side event when a child reaches a terminal state and
352    /// re-dispatch the parent so it can replay past its `start_child_workflow`
353    /// call. No-op for top-level workflows (no parent_id).
354    async fn notify_parent_of_child_outcome(
355        &self,
356        child_workflow_id: &str,
357        event_type: &str,
358        payload: serde_json::Value,
359    ) -> Result<()> {
360        let Some(child) = self.store.get_workflow(child_workflow_id).await? else {
361            return Ok(());
362        };
363        let Some(parent_id) = child.parent_id else {
364            return Ok(());
365        };
366        let event_seq = self.store.get_event_count(&parent_id).await? as i32 + 1;
367        self.store
368            .append_event(&WorkflowEvent {
369                id: None,
370                workflow_id: parent_id.clone(),
371                seq: event_seq,
372                event_type: event_type.to_string(),
373                payload: Some(payload.to_string()),
374                timestamp: timestamp_now(),
375            })
376            .await?;
377        self.mark_and_emit_needs_dispatch(&parent_id).await?;
378        Ok(())
379    }
380}