Skip to main content

sayiir_runtime/execution/
lifecycle.rs

1//! Workflow lifecycle: prepare, resume, and finalize.
2
3use bytes::Bytes;
4use sayiir_core::error::WorkflowError;
5use sayiir_core::snapshot::{
6    ExecutionPosition, SignalKind, TaskHint, WorkflowSnapshot, WorkflowSnapshotState,
7};
8use sayiir_core::workflow::{ConflictPolicy, WorkflowStatus};
9pub use sayiir_persistence::PrepareRunOutcome;
10use sayiir_persistence::{SignalStore, SnapshotStore};
11
12use super::helpers::ResumeParkedPosition;
13use crate::error::RuntimeError;
14
15/// Check for an existing instance before encoding input.
16///
17/// For **`Fail`** and **`UseExisting`** policies, this avoids unnecessary codec
18/// work by checking the backend before the caller serialises the workflow input.
19///
20/// Returns `Ok(Some((status, output)))` when the caller should return early
21/// (instance exists and the policy says to reuse it, or the policy rejects the
22/// duplicate).  Returns `Ok(None)` when the caller should proceed to encode
23/// input and call [`prepare_run`].
24///
25/// **`TerminateExisting`** always returns `Ok(None)` — the actual cleanup is
26/// deferred to [`prepare_run`].
27///
28/// # Errors
29/// Returns [`RuntimeError::InstanceAlreadyExists`] for `Fail` when the
30/// instance already exists, [`WorkflowError::DefinitionMismatch`] when the
31/// existing snapshot has a different definition hash, or propagates backend
32/// I/O errors.
33pub async fn check_existing_instance<B>(
34    instance_id: &str,
35    definition_hash: &sayiir_core::DefinitionHash,
36    backend: &B,
37    conflict_policy: ConflictPolicy,
38) -> Result<Option<(WorkflowStatus, Option<Bytes>)>, RuntimeError>
39where
40    B: SnapshotStore,
41{
42    sayiir_core::validate_instance_id(instance_id)?;
43    if matches!(conflict_policy, ConflictPolicy::TerminateExisting) {
44        return Ok(None);
45    }
46    match backend.load_snapshot(instance_id).await {
47        Ok(existing) => {
48            if existing.definition_hash != *definition_hash {
49                return Err(WorkflowError::DefinitionMismatch {
50                    expected: *definition_hash,
51                    found: existing.definition_hash,
52                }
53                .into());
54            }
55            match conflict_policy {
56                ConflictPolicy::Fail => {
57                    Err(RuntimeError::InstanceAlreadyExists(instance_id.to_string()))
58                }
59                ConflictPolicy::UseExisting => {
60                    let output = existing.state.completed_output().cloned();
61                    let status = existing.state.as_status();
62                    Ok(Some((status, output)))
63                }
64                ConflictPolicy::TerminateExisting => unreachable!(),
65            }
66        }
67        Err(sayiir_persistence::BackendError::NotFound(_)) => Ok(None),
68        Err(e) => Err(e.into()),
69    }
70}
71
72/// Prepare a fresh workflow run: create the initial snapshot and save it.
73///
74/// Requires the caller to have already called [`check_existing_instance`] —
75/// this function only handles the cleanup half of `TerminateExisting` and
76/// the snapshot write. Callers that need the full check-and-write in one
77/// shot should use [`sayiir_persistence::prepare_run`] directly (see the
78/// `test_prepare_run_creates_snapshot` test for the standalone shape).
79///
80/// # Errors
81/// Returns an error if saving the initial snapshot fails or the
82/// `TerminateExisting` cleanup fails.
83#[tracing::instrument(
84    name = "lifecycle.prepare_run",
85    skip(input_bytes, backend),
86    fields(%instance_id),
87)]
88pub async fn prepare_run<B>(
89    instance_id: &str,
90    definition_hash: sayiir_core::DefinitionHash,
91    input_bytes: Bytes,
92    first_task: TaskHint,
93    backend: &B,
94    conflict_policy: ConflictPolicy,
95) -> Result<PrepareRunOutcome, RuntimeError>
96where
97    B: SnapshotStore + SignalStore,
98{
99    sayiir_core::validate_instance_id(instance_id)?;
100    if matches!(conflict_policy, ConflictPolicy::TerminateExisting) {
101        // Best-effort cleanup — if nothing exists the deletes are no-ops.
102        match backend.load_snapshot(instance_id).await {
103            Ok(_existing) => {
104                tracing::info!("terminating existing instance before restart");
105                backend.delete_snapshot(instance_id).await?;
106                backend
107                    .clear_signal(instance_id, SignalKind::Cancel)
108                    .await?;
109                backend.clear_signal(instance_id, SignalKind::Pause).await?;
110            }
111            Err(sayiir_persistence::BackendError::NotFound(_)) => {}
112            Err(e) => return Err(e.into()),
113        }
114    }
115    let mut snapshot =
116        WorkflowSnapshot::with_initial_input(instance_id, definition_hash, input_bytes);
117    #[cfg(feature = "otel")]
118    {
119        snapshot.trace_parent = crate::trace_context::current_trace_parent();
120    }
121    snapshot.update_position(ExecutionPosition::AtTask {
122        task_id: first_task.id,
123    });
124    snapshot.set_task_hint(&first_task);
125    backend.save_snapshot(&mut snapshot).await?;
126    Ok(PrepareRunOutcome::Fresh(Box::new(snapshot)))
127}
128
129/// Prepare to resume a workflow from a saved snapshot.
130///
131/// Loads the snapshot, validates the definition hash, checks for terminal states,
132/// and determines the correct resume input.
133///
134/// Returns `Ok(Some((snapshot, input)))` if the workflow can be resumed,
135/// or `Ok(None)` with the terminal status if the workflow is already done.
136///
137/// # Errors
138/// Returns an error if the snapshot cannot be loaded or the definition hash mismatches.
139#[tracing::instrument(
140    name = "lifecycle.prepare_resume",
141    skip(backend),
142    fields(%instance_id),
143)]
144pub async fn prepare_resume<B>(
145    instance_id: &str,
146    definition_hash: &sayiir_core::DefinitionHash,
147    backend: &B,
148) -> Result<ResumeOutcome, RuntimeError>
149where
150    B: SignalStore,
151{
152    sayiir_core::validate_instance_id(instance_id)?;
153    tracing::debug!("preparing workflow resume");
154    let mut snapshot = backend.load_snapshot(instance_id).await?;
155
156    // Validate definition hash
157    if snapshot.definition_hash != *definition_hash {
158        return Err(WorkflowError::DefinitionMismatch {
159            expected: *definition_hash,
160            found: snapshot.definition_hash,
161        }
162        .into());
163    }
164
165    // Check if already in terminal state
166    if let Some(status) = snapshot.state.as_terminal_status() {
167        if snapshot.state.is_paused() {
168            return Ok(ResumeOutcome::Paused(status));
169        }
170        return Ok(ResumeOutcome::AlreadyTerminal(status));
171    }
172
173    // Resolve any parked position (delay / signal / fork) before resuming.
174    // This consumes buffered signals, checks delay expiry, etc. and updates
175    // the snapshot so get_resume_input picks up the correct value.
176    let parked = ResumeParkedPosition::extract(&snapshot);
177    if let Some(status) = parked.resolve(&mut snapshot, instance_id, backend).await? {
178        return Ok(ResumeOutcome::NotReady(status));
179    }
180
181    // Determine resume input (after resolve, so signal payloads are reflected)
182    let input_bytes = get_resume_input(&snapshot)?;
183    Ok(ResumeOutcome::Ready {
184        snapshot: Box::new(snapshot),
185        input_bytes,
186    })
187}
188
189/// Outcome of [`prepare_resume`].
190#[derive(Debug)]
191pub enum ResumeOutcome {
192    /// Workflow can be resumed with this snapshot and input.
193    Ready {
194        /// The loaded snapshot (in-progress state).
195        snapshot: Box<WorkflowSnapshot>,
196        /// The input bytes for the next task.
197        input_bytes: Bytes,
198    },
199    /// Workflow is already in a terminal state.
200    AlreadyTerminal(WorkflowStatus),
201    /// Workflow is paused (not terminal, but cannot execute until unpaused).
202    Paused(WorkflowStatus),
203    /// Parked position not yet ready (delay not expired, signal not arrived, etc.).
204    NotReady(WorkflowStatus),
205}
206
207/// Get the input for resuming execution from a snapshot.
208///
209/// Uses the last completed task's output, or the initial input if no tasks
210/// have completed yet.
211///
212/// # Errors
213/// Returns an error if no resume input can be determined.
214pub fn get_resume_input(snapshot: &WorkflowSnapshot) -> Result<Bytes, RuntimeError> {
215    match &snapshot.state {
216        WorkflowSnapshotState::InProgress {
217            completed_tasks, ..
218        } => {
219            if completed_tasks.is_empty() {
220                snapshot.initial_input_bytes().ok_or_else(|| {
221                    WorkflowError::ResumeError(
222                        "no completed tasks and initial input not stored".into(),
223                    )
224                    .into()
225                })
226            } else {
227                snapshot.get_last_task_output().ok_or_else(|| {
228                    WorkflowError::ResumeError("no task results available".into()).into()
229                })
230            }
231        }
232        _ => Err(WorkflowError::ResumeError("workflow not in progress".into()).into()),
233    }
234}
235
236/// Finalize a workflow execution, converting the result to a [`WorkflowStatus`].
237///
238/// On success, marks the workflow as completed in the snapshot and returns the
239/// output bytes alongside the status.
240/// On cancellation error, returns `Cancelled` status with details from the backend.
241/// On other errors, marks the workflow as failed.
242///
243/// This mirrors `CheckpointingRunner::handle_execution_result`.
244///
245/// # Errors
246/// Returns an error if saving the snapshot to the backend fails.
247#[tracing::instrument(
248    name = "lifecycle.finalize",
249    skip_all,
250    fields(instance_id = %snapshot.instance_id),
251)]
252pub async fn finalize_execution<B>(
253    result: Result<Bytes, RuntimeError>,
254    snapshot: &mut WorkflowSnapshot,
255    backend: &B,
256) -> Result<(WorkflowStatus, Option<Bytes>), RuntimeError>
257where
258    B: SnapshotStore,
259{
260    tracing::debug!("finalizing workflow execution");
261    match result {
262        Ok(output) => {
263            tracing::info!(instance_id = %snapshot.instance_id, "workflow completed");
264            snapshot.mark_completed(output.clone());
265            backend.save_snapshot(snapshot).await?;
266            Ok((WorkflowStatus::Completed, Some(output)))
267        }
268        Err(RuntimeError::Workflow(WorkflowError::Waiting { wake_at })) => {
269            let delay_id = match &snapshot.state {
270                WorkflowSnapshotState::InProgress {
271                    position: ExecutionPosition::AtDelay { delay_id, .. },
272                    ..
273                } => *delay_id,
274                WorkflowSnapshotState::InProgress {
275                    position: ExecutionPosition::AtFork { fork_id, .. },
276                    ..
277                } => *fork_id,
278                _ => sayiir_core::TaskId::default(),
279            };
280            tracing::info!(
281                instance_id = %snapshot.instance_id,
282                %delay_id,
283                %wake_at,
284                "workflow parked at delay"
285            );
286            Ok((WorkflowStatus::Waiting { wake_at, delay_id }, None))
287        }
288        Err(RuntimeError::Workflow(WorkflowError::AwaitingSignal {
289            signal_id,
290            signal_name,
291            wake_at,
292        })) => {
293            tracing::info!(
294                instance_id = %snapshot.instance_id,
295                %signal_id,
296                %signal_name,
297                ?wake_at,
298                "workflow parked at signal"
299            );
300            Ok((
301                WorkflowStatus::AwaitingSignal {
302                    signal_id,
303                    signal_name,
304                    wake_at,
305                },
306                None,
307            ))
308        }
309        Err(RuntimeError::Workflow(WorkflowError::Cancelled { .. })) => {
310            tracing::info!(instance_id = %snapshot.instance_id, "workflow cancelled");
311            // Reload snapshot to get cancellation details (set by check_and_cancel)
312            if let Ok(cancelled_snapshot) = backend.load_snapshot(&snapshot.instance_id).await
313                && let Some((reason, cancelled_by)) =
314                    cancelled_snapshot.state.cancellation_details()
315            {
316                return Ok((
317                    WorkflowStatus::Cancelled {
318                        reason,
319                        cancelled_by,
320                    },
321                    None,
322                ));
323            }
324            // Fallback if we couldn't get details
325            Ok((
326                WorkflowStatus::Cancelled {
327                    reason: None,
328                    cancelled_by: None,
329                },
330                None,
331            ))
332        }
333        Err(RuntimeError::Workflow(WorkflowError::Paused { .. })) => {
334            tracing::info!(instance_id = %snapshot.instance_id, "workflow paused");
335            // Reload snapshot to get pause details (set by check_and_pause)
336            if let Ok(paused_snapshot) = backend.load_snapshot(&snapshot.instance_id).await
337                && let Some((reason, paused_by)) = paused_snapshot.state.pause_details()
338            {
339                return Ok((WorkflowStatus::Paused { reason, paused_by }, None));
340            }
341            Ok((
342                WorkflowStatus::Paused {
343                    reason: None,
344                    paused_by: None,
345                },
346                None,
347            ))
348        }
349        Err(e) => {
350            tracing::error!(instance_id = %snapshot.instance_id, error = %e, "workflow failed");
351            snapshot.mark_failed(e.to_string());
352            let _ = backend.save_snapshot(snapshot).await;
353            Ok((WorkflowStatus::Failed(e.to_string()), None))
354        }
355    }
356}