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::{inject_engine_version, timestamp_now, WorkflowCtx};
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 = WorkflowStatus::from_str(&wf.status)
128                    .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| {
152                    serde_json::json!({ "reason": r }).to_string()
153                });
154
155                let seq = self.store.get_event_count(id).await? as i32 + 1;
156                self.store
157                    .append_event(&WorkflowEvent {
158                        id: None,
159                        workflow_id: id.to_string(),
160                        seq,
161                        event_type: "WorkflowCancelRequested".to_string(),
162                        payload,
163                        timestamp: timestamp_now(),
164                    })
165                    .await?;
166
167                self.mark_and_emit_needs_dispatch(id).await?;
168
169                // Propagate cancellation to all child workflows. Children
170                // inherit the reason so the audit trail explains the
171                // whole cascade in one place.
172                let children = self.store.list_child_workflows(id).await?;
173                for child in children {
174                    Box::pin(self.cancel_workflow(&child.id, reason)).await?;
175                }
176
177                // For workflows that have NO worker registered (or no
178                // handler running), cancellation would never complete.
179                // Fall back: if the workflow has no events past
180                // WorkflowStarted (handler hasn't actually run yet, e.g.
181                // PENDING with no claim), finalise immediately.
182                if matches!(status, WorkflowStatus::Pending) {
183                    self.finalise_cancellation(id).await?;
184                }
185
186                Ok(true)
187            }
188        }
189    }
190
191    pub async fn terminate_workflow(&self, id: &str, reason: Option<&str>) -> Result<bool> {
192        let wf = self.store.get_workflow(id).await?;
193        match wf {
194            None => Ok(false),
195            Some(wf) => {
196                let status = WorkflowStatus::from_str(&wf.status)
197                    .map_err(|e| anyhow::anyhow!(e))?;
198                if status.is_terminal() {
199                    return Ok(false);
200                }
201
202                self.store
203                    .update_workflow_status(
204                        id,
205                        WorkflowStatus::Failed,
206                        None,
207                        Some(reason.unwrap_or("terminated")),
208                    )
209                    .await?;
210
211                // Live-refresh the dashboard — no more F5 after
212                // Terminate.
213                self.emit(
214                    &wf.namespace,
215                    WorkflowBusEvent::WorkflowTerminated {
216                        workflow_id: id.to_string(),
217                    },
218                )
219                .await;
220
221                Ok(true)
222            }
223        }
224    }
225
226    /// Finalise a cancellation: flips status to CANCELLED and appends the
227    /// terminal WorkflowCancelled event. Called by the CancelWorkflow
228    /// command handler (worker acknowledged cancel) and by cancel_workflow
229    /// directly when the workflow has no worker yet.
230    pub async fn finalise_cancellation(&self, workflow_id: &str) -> Result<()> {
231        // Avoid double-finalising
232        if let Some(wf) = self.store.get_workflow(workflow_id).await?
233            && wf.status == "CANCELLED"
234        {
235            return Ok(());
236        }
237        self.store
238            .update_workflow_status(workflow_id, WorkflowStatus::Cancelled, None, None)
239            .await?;
240        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
241        self.store
242            .append_event(&WorkflowEvent {
243                id: None,
244                workflow_id: workflow_id.to_string(),
245                seq: event_seq,
246                event_type: "WorkflowCancelled".to_string(),
247                payload: None,
248                timestamp: timestamp_now(),
249            })
250            .await?;
251        let ns = self
252            .store
253            .get_workflow(workflow_id)
254            .await?
255            .map(|w| w.namespace)
256            .unwrap_or_default();
257        self.emit(
258            &ns,
259            WorkflowBusEvent::WorkflowCancelled {
260                workflow_id: workflow_id.to_string(),
261            },
262        )
263        .await;
264        Ok(())
265    }
266
267    /// Mark a workflow COMPLETED with a result + append WorkflowCompleted event.
268    /// If the workflow has a parent, also notifies the parent with a
269    /// ChildWorkflowCompleted event and marks it dispatchable so it can
270    /// replay past `ctx:start_child_workflow` and pick up the child's result.
271    pub async fn complete_workflow(&self, workflow_id: &str, result: Option<&str>) -> Result<()> {
272        self.store
273            .update_workflow_status(workflow_id, WorkflowStatus::Completed, result, None)
274            .await?;
275        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
276        self.store
277            .append_event(&WorkflowEvent {
278                id: None,
279                workflow_id: workflow_id.to_string(),
280                seq: event_seq,
281                event_type: "WorkflowCompleted".to_string(),
282                payload: result.map(String::from),
283                timestamp: timestamp_now(),
284            })
285            .await?;
286        self.notify_parent_of_child_outcome(
287            workflow_id,
288            "ChildWorkflowCompleted",
289            serde_json::json!({
290                "child_workflow_id": workflow_id,
291                "result": result.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
292            }),
293        )
294        .await?;
295        let ns = self
296            .store
297            .get_workflow(workflow_id)
298            .await?
299            .map(|w| w.namespace)
300            .unwrap_or_default();
301        self.emit(
302            &ns,
303            WorkflowBusEvent::WorkflowCompleted {
304                workflow_id: workflow_id.to_string(),
305            },
306        )
307        .await;
308        Ok(())
309    }
310
311    /// Mark a workflow FAILED with an error + append WorkflowFailed event.
312    /// Notifies the parent if any (ChildWorkflowFailed).
313    pub async fn fail_workflow(&self, workflow_id: &str, error: &str) -> Result<()> {
314        self.store
315            .update_workflow_status(workflow_id, WorkflowStatus::Failed, None, Some(error))
316            .await?;
317        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
318        self.store
319            .append_event(&WorkflowEvent {
320                id: None,
321                workflow_id: workflow_id.to_string(),
322                seq: event_seq,
323                event_type: "WorkflowFailed".to_string(),
324                payload: Some(serde_json::json!({"error": error}).to_string()),
325                timestamp: timestamp_now(),
326            })
327            .await?;
328        self.notify_parent_of_child_outcome(
329            workflow_id,
330            "ChildWorkflowFailed",
331            serde_json::json!({
332                "child_workflow_id": workflow_id,
333                "error": error,
334            }),
335        )
336        .await?;
337        let ns = self
338            .store
339            .get_workflow(workflow_id)
340            .await?
341            .map(|w| w.namespace)
342            .unwrap_or_default();
343        self.emit(
344            &ns,
345            WorkflowBusEvent::WorkflowFailed {
346                workflow_id: workflow_id.to_string(),
347            },
348        )
349        .await;
350        Ok(())
351    }
352
353    /// Append a parent-side event when a child reaches a terminal state and
354    /// re-dispatch the parent so it can replay past its `start_child_workflow`
355    /// call. No-op for top-level workflows (no parent_id).
356    async fn notify_parent_of_child_outcome(
357        &self,
358        child_workflow_id: &str,
359        event_type: &str,
360        payload: serde_json::Value,
361    ) -> Result<()> {
362        let Some(child) = self.store.get_workflow(child_workflow_id).await? else {
363            return Ok(());
364        };
365        let Some(parent_id) = child.parent_id else {
366            return Ok(());
367        };
368        let event_seq = self.store.get_event_count(&parent_id).await? as i32 + 1;
369        self.store
370            .append_event(&WorkflowEvent {
371                id: None,
372                workflow_id: parent_id.clone(),
373                seq: event_seq,
374                event_type: event_type.to_string(),
375                payload: Some(payload.to_string()),
376                timestamp: timestamp_now(),
377            })
378            .await?;
379        self.mark_and_emit_needs_dispatch(&parent_id).await?;
380        Ok(())
381    }
382}