Skip to main content

runifold_workflow/
checkpoint.rs

1use std::{collections::BTreeMap, fmt, sync::Arc};
2
3use runifold_core::{
4    Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, CheckpointStore, RunContext,
5    Usage,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::{StepId, WorkflowError, WorkflowOutcome};
11
12const CHECKPOINT_KIND: &str = "runifold.workflow";
13const CHECKPOINT_SCHEMA_VERSION: u32 = 3;
14
15/// Recovery behavior for a workflow interrupted inside one node.
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum WorkflowResumePolicy {
19    /// Reject recovery that could duplicate model cost or external effects.
20    #[default]
21    RejectAmbiguous,
22    /// Explicitly retry only the interrupted workflow node.
23    RetryInterruptedStep,
24}
25
26/// Persisted workflow execution phase.
27#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
28#[serde(tag = "state", rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum WorkflowCheckpointPhase {
31    /// Stable output is ready for the next node.
32    Ready,
33    /// One node may have partially executed.
34    StepInFlight {
35        /// Stable interrupted node identity.
36        step: StepId,
37    },
38    /// A parallel node has one or more incomplete branches.
39    ParallelInFlight {
40        /// Stable parallel node identity.
41        step: StepId,
42        /// Durable branch progress keyed independently of completion order.
43        branches: BTreeMap<StepId, ParallelBranchCheckpoint>,
44    },
45    /// A first-success race has no durable winner yet, or awaits commit.
46    RaceInFlight {
47        /// Stable race node identity.
48        step: StepId,
49        /// Durable branch progress keyed independently of completion order.
50        branches: BTreeMap<StepId, ParallelBranchCheckpoint>,
51    },
52    /// The workflow reached a terminal output.
53    Completed {
54        /// Complete canonical workflow result.
55        outcome: WorkflowOutcome,
56    },
57}
58
59/// Persisted state of one branch inside an in-flight parallel node.
60#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
61#[serde(tag = "state", rename_all = "snake_case")]
62#[non_exhaustive]
63pub enum ParallelBranchCheckpoint {
64    /// The branch may have partially executed.
65    InFlight,
66    /// The branch returned a stable canonical output.
67    Completed {
68        /// Canonical branch output.
69        output: Value,
70    },
71    /// The branch returned a known failure.
72    Failed {
73        /// Safe persisted failure explanation.
74        message: String,
75    },
76    /// The branch was abandoned after another branch won.
77    Cancelled,
78}
79
80/// Versioned workflow state stored in a domain-neutral checkpoint envelope.
81#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
82pub struct WorkflowCheckpointState {
83    /// Stable workflow definition name.
84    pub workflow: String,
85    /// Caller-managed definition version.
86    pub workflow_version: u32,
87    /// Ordered node layout used to reject incompatible definitions.
88    pub layout: Vec<StepId>,
89    /// Index of the next node to execute.
90    pub next_index: usize,
91    /// Canonical value presented to the next node.
92    pub value: Value,
93    /// Stable outputs of all completed nodes.
94    pub outputs: BTreeMap<StepId, Value>,
95    /// Shared usage snapshot at persistence time.
96    pub usage: Usage,
97    /// Current recovery phase.
98    pub phase: WorkflowCheckpointPhase,
99}
100
101impl WorkflowCheckpointState {
102    pub(crate) fn outcome(&self) -> Option<WorkflowOutcome> {
103        match &self.phase {
104            WorkflowCheckpointPhase::Completed { outcome } => Some(outcome.clone()),
105            _ => None,
106        }
107    }
108}
109
110/// Stable handle binding one workflow checkpoint identity to a store.
111#[derive(Clone)]
112pub struct WorkflowCheckpoint {
113    id: CheckpointId,
114    store: Arc<dyn CheckpointStore>,
115}
116
117impl WorkflowCheckpoint {
118    /// Creates a new workflow checkpoint handle.
119    pub fn new(store: Arc<dyn CheckpointStore>) -> Self {
120        Self {
121            id: CheckpointId::new(),
122            store,
123        }
124    }
125
126    /// Reconnects to an existing workflow checkpoint.
127    pub fn existing(id: CheckpointId, store: Arc<dyn CheckpointStore>) -> Self {
128        Self { id, store }
129    }
130
131    /// Returns the stable checkpoint identity.
132    pub const fn id(&self) -> CheckpointId {
133        self.id
134    }
135
136    /// Loads and validates the latest typed workflow state.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`CheckpointError`] for storage or payload failures.
141    pub fn load(&self) -> Result<(Checkpoint, WorkflowCheckpointState), CheckpointError> {
142        let checkpoint = self.store.load(self.id)?;
143        if checkpoint.kind != CHECKPOINT_KIND
144            || checkpoint.schema_version != CHECKPOINT_SCHEMA_VERSION
145        {
146            return Err(CheckpointError::new(
147                CheckpointErrorKind::InvalidPayload,
148                "checkpoint kind or schema version is not supported",
149            ));
150        }
151        let state = serde_json::from_value(checkpoint.payload.clone()).map_err(|error| {
152            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
153        })?;
154        Ok((checkpoint, state))
155    }
156}
157
158impl fmt::Debug for WorkflowCheckpoint {
159    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160        formatter
161            .debug_struct("WorkflowCheckpoint")
162            .field("id", &self.id)
163            .finish_non_exhaustive()
164    }
165}
166
167pub(crate) struct WorkflowCheckpointCursor {
168    handle: WorkflowCheckpoint,
169    envelope: Checkpoint,
170}
171
172impl WorkflowCheckpointCursor {
173    pub(crate) fn create(
174        handle: &WorkflowCheckpoint,
175        run: &RunContext,
176        state: &WorkflowCheckpointState,
177    ) -> Result<Self, WorkflowError> {
178        let envelope = Checkpoint::initial(
179            handle.id,
180            run.run_id(),
181            CHECKPOINT_KIND,
182            CHECKPOINT_SCHEMA_VERSION,
183            serialize(state)?,
184        );
185        handle.store.compare_and_swap(&envelope, None)?;
186        Ok(Self {
187            handle: handle.clone(),
188            envelope,
189        })
190    }
191
192    pub(crate) fn loaded(handle: &WorkflowCheckpoint, envelope: Checkpoint) -> Self {
193        Self {
194            handle: handle.clone(),
195            envelope,
196        }
197    }
198
199    pub(crate) fn save(&mut self, state: &WorkflowCheckpointState) -> Result<(), WorkflowError> {
200        let next = self.envelope.next(serialize(state)?)?;
201        self.handle
202            .store
203            .compare_and_swap(&next, Some(self.envelope.revision))?;
204        self.envelope = next;
205        Ok(())
206    }
207}
208
209fn serialize(state: &WorkflowCheckpointState) -> Result<Value, WorkflowError> {
210    serde_json::to_value(state).map_err(|error| {
211        CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string()).into()
212    })
213}