Skip to main content

a3s_code_core/orchestration/
checkpoint.rs

1//! Workflow-level checkpoints: journal completed steps so an interrupted
2//! orchestration resumes from the longest completed prefix — on this node or,
3//! because the checkpoint is serializable and the executor is pluggable, on
4//! another one (host-driven migration).
5//!
6//! This is the step-boundary analogue of [`LoopCheckpoint`](crate::loop_checkpoint::LoopCheckpoint),
7//! which checkpoints at tool-round boundaries one level down.
8
9use super::executor::{AgentStepSpec, StepOutcome};
10use crate::evaluation::{digest_bytes, digest_json};
11use crate::execution_identity::{
12    ExecutionIdentityV1, ExecutionResultOutcomeV1, ExecutionResultReceiptV1,
13    WORKFLOW_STEP_EVIDENCE_DOMAIN_V1, WORKFLOW_STEP_IDENTITY_DOMAIN_V1,
14    WORKFLOW_STEP_RESULT_DOMAIN_V1,
15};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19/// Schema version. Bumped on incompatible format changes; loads from a future
20/// version are rejected (see [`WorkflowCheckpoint::ensure_loadable`]).
21pub const WORKFLOW_CHECKPOINT_SCHEMA_VERSION: u32 = 1;
22
23/// One completed step within a workflow.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct WorkflowStepRecord {
26    /// Matches the [`AgentStepSpec::task_id`](super::AgentStepSpec) of the
27    /// step that produced this outcome.
28    pub task_id: String,
29    /// The completed step's result.
30    pub outcome: StepOutcome,
31    /// Digest-only result metadata bound to this step's execution identity.
32    /// Older checkpoints omit this field and remain loadable.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub result_receipt: Option<ExecutionResultReceiptV1>,
35}
36
37/// Snapshot of a workflow's completed steps at a step boundary.
38///
39/// (`StepOutcome` contains a `serde_json::Value`, which is not `Eq`, so this
40/// derives `PartialEq` only.)
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct WorkflowCheckpoint {
43    /// Schema version — see [`WORKFLOW_CHECKPOINT_SCHEMA_VERSION`].
44    #[serde(default)]
45    pub schema_version: u32,
46    /// Logical workflow identifier the checkpoint is keyed by.
47    pub workflow_id: String,
48    /// The steps completed so far. A resuming run skips these and re-dispatches
49    /// only the rest.
50    pub steps: Vec<WorkflowStepRecord>,
51    /// Wall-clock timestamp when the checkpoint was written (Unix epoch ms).
52    pub checkpoint_ms: u64,
53}
54
55impl WorkflowCheckpoint {
56    /// Build a checkpoint from a map of completed `task_id -> outcome`.
57    pub fn from_completed(
58        workflow_id: impl Into<String>,
59        completed: &HashMap<String, StepOutcome>,
60        checkpoint_ms: u64,
61    ) -> Self {
62        let steps = completed
63            .iter()
64            .map(|(task_id, outcome)| WorkflowStepRecord {
65                task_id: task_id.clone(),
66                outcome: outcome.clone(),
67                result_receipt: None,
68            })
69            .collect();
70        Self {
71            schema_version: WORKFLOW_CHECKPOINT_SCHEMA_VERSION,
72            workflow_id: workflow_id.into(),
73            steps,
74            checkpoint_ms,
75        }
76    }
77
78    /// Build a checkpoint carrying result receipts produced by the same
79    /// execution boundary as the completed outcomes.
80    pub fn from_completed_with_receipts(
81        workflow_id: impl Into<String>,
82        completed: &HashMap<String, StepOutcome>,
83        receipts: &HashMap<String, ExecutionResultReceiptV1>,
84        checkpoint_ms: u64,
85    ) -> Self {
86        let mut checkpoint = Self::from_completed(workflow_id, completed, checkpoint_ms);
87        for record in &mut checkpoint.steps {
88            record.result_receipt = receipts.get(&record.task_id).cloned();
89        }
90        checkpoint
91    }
92
93    /// The completed steps as a `task_id -> outcome` map.
94    pub fn completed(&self) -> HashMap<String, StepOutcome> {
95        self.steps
96            .iter()
97            .map(|r| (r.task_id.clone(), r.outcome.clone()))
98            .collect()
99    }
100
101    /// Reject a checkpoint written by a *newer*, incompatible schema version
102    /// than this build understands — mirrors
103    /// [`LoopCheckpoint::ensure_loadable`](crate::loop_checkpoint::LoopCheckpoint::ensure_loadable).
104    /// Field additions are absorbed by `#[serde(default)]`, so older (incl.
105    /// pre-v1 `0`) checkpoints always remain loadable.
106    pub fn ensure_loadable(&self) -> anyhow::Result<()> {
107        if self.schema_version > WORKFLOW_CHECKPOINT_SCHEMA_VERSION {
108            anyhow::bail!(
109                "workflow checkpoint {} has schema version {} but this build supports at \
110                 most {}; refusing to resume from an incompatible future checkpoint",
111                self.workflow_id,
112                self.schema_version,
113                WORKFLOW_CHECKPOINT_SCHEMA_VERSION
114            );
115        }
116        for record in &self.steps {
117            if record.task_id != record.outcome.task_id {
118                anyhow::bail!(
119                    "workflow checkpoint {} has task id mismatch for step {:?}",
120                    self.workflow_id,
121                    record.task_id
122                );
123            }
124            if let Some(receipt) = &record.result_receipt {
125                receipt.validate().map_err(|error| {
126                    anyhow::anyhow!(
127                        "workflow checkpoint {} has invalid result receipt for step {:?}: {error}",
128                        self.workflow_id,
129                        record.task_id
130                    )
131                })?;
132                if receipt.identity.domain != WORKFLOW_STEP_IDENTITY_DOMAIN_V1 {
133                    anyhow::bail!(
134                        "workflow checkpoint {} has an unsupported result identity for step {:?}",
135                        self.workflow_id,
136                        record.task_id
137                    );
138                }
139            }
140        }
141        Ok(())
142    }
143
144    /// Verify any identity-bearing cached steps against the current workflow
145    /// specs. Legacy records without receipts remain compatible; new records
146    /// fail closed when a task id is reused for a different invocation.
147    pub fn validate_for_specs(
148        &self,
149        workflow_id: &str,
150        specs: &[AgentStepSpec],
151    ) -> anyhow::Result<()> {
152        self.ensure_loadable()?;
153        if self.workflow_id != workflow_id {
154            anyhow::bail!(
155                "workflow checkpoint is keyed to `{}`, not `{workflow_id}`",
156                self.workflow_id
157            );
158        }
159        for record in &self.steps {
160            let Some(receipt) = &record.result_receipt else {
161                continue;
162            };
163            let Some(spec) = specs.iter().find(|spec| spec.task_id == record.task_id) else {
164                continue;
165            };
166            let expected =
167                workflow_step_execution_identity(workflow_id, spec).map_err(|error| {
168                    anyhow::anyhow!(
169                        "cannot derive workflow step identity for {:?}: {error}",
170                        record.task_id
171                    )
172                })?;
173            if receipt.identity != expected {
174                anyhow::bail!(
175                    "workflow checkpoint {} has a stale result identity for step {:?}",
176                    workflow_id,
177                    record.task_id
178                );
179            }
180            let expected_result =
181                workflow_step_result_receipt(workflow_id, spec, &record.outcome, None).map_err(
182                    |error| {
183                        anyhow::anyhow!(
184                            "cannot derive workflow result receipt for {:?}: {error}",
185                            record.task_id
186                        )
187                    },
188                )?;
189            if receipt.outcome != expected_result.outcome
190                || receipt.result_digest != expected_result.result_digest
191                || receipt.result_bytes != expected_result.result_bytes
192            {
193                anyhow::bail!(
194                    "workflow checkpoint {} has a stale result receipt for step {:?}",
195                    workflow_id,
196                    record.task_id
197                );
198            }
199        }
200        Ok(())
201    }
202}
203
204/// Derive the canonical identity for one workflow step invocation.
205pub fn workflow_step_execution_identity(
206    workflow_id: &str,
207    spec: &AgentStepSpec,
208) -> Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
209    ExecutionIdentityV1::derive(
210        WORKFLOW_STEP_IDENTITY_DOMAIN_V1,
211        &serde_json::json!({
212            "workflow_id": workflow_id,
213            "task_id": spec.task_id,
214            "agent": spec.agent,
215            "description": spec.description,
216            "prompt": spec.prompt,
217            "max_steps": spec.max_steps,
218            "parent_session_id": spec.parent_session_id,
219            "output_schema": spec.output_schema,
220        }),
221    )
222}
223
224/// Build a bounded, digest-only receipt for one completed workflow step.
225///
226/// A host may provide the digest of a richer immutable evidence snapshot. When
227/// it does not, the fallback digest covers the normalized source-anchor
228/// projection available in [`StepOutcome`]. The step output itself is never
229/// copied into the receipt.
230pub fn workflow_step_result_receipt(
231    workflow_id: &str,
232    spec: &AgentStepSpec,
233    outcome: &StepOutcome,
234    evidence_digest: Option<&str>,
235) -> Result<ExecutionResultReceiptV1, crate::execution_identity::ExecutionIdentityError> {
236    if spec.task_id != outcome.task_id {
237        return Err(
238            crate::execution_identity::ExecutionIdentityError::InvalidReceiptField("task_id"),
239        );
240    }
241    if spec.agent != outcome.agent {
242        return Err(
243            crate::execution_identity::ExecutionIdentityError::InvalidReceiptField("agent"),
244        );
245    }
246    let identity = workflow_step_execution_identity(workflow_id, spec)?;
247    let evidence_digest = match evidence_digest {
248        Some(digest) => digest.to_string(),
249        None => digest_json(WORKFLOW_STEP_EVIDENCE_DOMAIN_V1, &outcome.source_anchors).map_err(
250            |error| {
251                crate::execution_identity::ExecutionIdentityError::Serialization(error.to_string())
252            },
253        )?,
254    };
255    let (result, result_bytes, result_digest) = if outcome.success {
256        let encoded = serde_json::to_vec(&serde_json::json!({
257            "output": &outcome.output,
258            "structured": &outcome.structured,
259        }))
260        .map_err(|error| {
261            crate::execution_identity::ExecutionIdentityError::Serialization(error.to_string())
262        })?;
263        (
264            ExecutionResultOutcomeV1::Succeeded,
265            u64::try_from(encoded.len())
266                .map_err(|_| crate::execution_identity::ExecutionIdentityError::ReceiptSizeLimit)?,
267            Some(digest_bytes(WORKFLOW_STEP_RESULT_DOMAIN_V1, &encoded)),
268        )
269    } else {
270        (ExecutionResultOutcomeV1::Failed, 0, None)
271    };
272    ExecutionResultReceiptV1::new(
273        identity,
274        evidence_digest,
275        result,
276        result_digest,
277        result_bytes,
278    )
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn outcome(id: &str) -> StepOutcome {
286        StepOutcome {
287            task_id: id.to_string(),
288            session_id: format!("task-run-{id}"),
289            agent: "a".to_string(),
290            output: "o".to_string(),
291            success: true,
292            structured: None,
293            source_anchors: Vec::new(),
294        }
295    }
296
297    fn spec(id: &str, prompt: &str) -> AgentStepSpec {
298        AgentStepSpec::new(id, "a", "description", prompt)
299    }
300
301    #[test]
302    fn round_trips_and_exposes_completed_map() {
303        let mut completed = HashMap::new();
304        completed.insert("t1".to_string(), outcome("t1"));
305        let cp = WorkflowCheckpoint::from_completed("wf", &completed, 123);
306        let back: WorkflowCheckpoint =
307            serde_json::from_str(&serde_json::to_string(&cp).unwrap()).unwrap();
308        assert_eq!(back, cp);
309        assert_eq!(back.schema_version, WORKFLOW_CHECKPOINT_SCHEMA_VERSION);
310        assert_eq!(back.checkpoint_ms, 123);
311        assert_eq!(back.completed().get("t1").unwrap().task_id, "t1");
312        assert!(back.steps[0].result_receipt.is_none());
313    }
314
315    #[test]
316    fn ensure_loadable_rejects_only_future_versions() {
317        let mut cp = WorkflowCheckpoint::from_completed("wf", &HashMap::new(), 0);
318        cp.ensure_loadable().expect("current version loadable");
319        cp.schema_version = 0;
320        cp.ensure_loadable().expect("pre-v1 loadable");
321        cp.schema_version = WORKFLOW_CHECKPOINT_SCHEMA_VERSION + 1;
322        let err = cp.ensure_loadable().unwrap_err();
323        assert!(err.to_string().contains("schema version"), "got: {err}");
324    }
325
326    #[test]
327    fn pre_v1_payload_without_schema_version_loads() {
328        let json = r#"{"workflow_id":"wf","steps":[],"checkpoint_ms":0}"#;
329        let cp: WorkflowCheckpoint = serde_json::from_str(json).unwrap();
330        assert_eq!(cp.schema_version, 0);
331    }
332
333    #[test]
334    fn receipt_binds_step_identity_and_host_evidence_without_plaintext() {
335        let step = spec("t1", "private prompt");
336        let receipt = workflow_step_result_receipt(
337            "wf",
338            &step,
339            &outcome("t1"),
340            Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
341        )
342        .unwrap();
343        receipt.validate().unwrap();
344        assert_eq!(receipt.identity.domain, WORKFLOW_STEP_IDENTITY_DOMAIN_V1);
345        assert_eq!(
346            receipt.evidence_digest,
347            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
348        );
349        assert!(receipt.result_digest.is_some());
350        assert!(receipt.result_bytes > 0);
351        assert!(!format!("{receipt:?}").contains("private prompt"));
352    }
353
354    #[test]
355    fn result_receipt_tampering_is_rejected_for_the_current_step() {
356        let step = spec("t1", "private prompt");
357        let result = outcome("t1");
358        let receipt = workflow_step_result_receipt("wf", &step, &result, None).unwrap();
359        let mut completed = HashMap::new();
360        completed.insert("t1".to_string(), result);
361        let mut receipts = HashMap::new();
362        receipts.insert("t1".to_string(), receipt);
363        let mut checkpoint =
364            WorkflowCheckpoint::from_completed_with_receipts("wf", &completed, &receipts, 1);
365        checkpoint.steps[0]
366            .result_receipt
367            .as_mut()
368            .unwrap()
369            .result_bytes += 1;
370        assert!(checkpoint
371            .validate_for_specs("wf", &[step])
372            .unwrap_err()
373            .to_string()
374            .contains("stale result receipt"));
375    }
376
377    #[test]
378    fn legacy_step_record_without_receipt_remains_loadable() {
379        let json = r#"{
380            "workflow_id":"wf",
381            "steps":[{
382                "task_id":"t1",
383                "outcome":{
384                    "task_id":"t1",
385                    "session_id":"task-run-t1",
386                    "agent":"a",
387                    "output":"legacy",
388                    "success":true
389                }
390            }],
391            "checkpoint_ms":1
392        }"#;
393        let cp: WorkflowCheckpoint = serde_json::from_str(json).unwrap();
394        assert!(cp.steps[0].result_receipt.is_none());
395        cp.ensure_loadable().unwrap();
396    }
397}