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