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