a3s_code_core/orchestration/
checkpoint.rs1use 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
19pub const WORKFLOW_CHECKPOINT_SCHEMA_VERSION: u32 = 1;
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct WorkflowStepRecord {
26 pub task_id: String,
29 pub outcome: StepOutcome,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub result_receipt: Option<ExecutionResultReceiptV1>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct WorkflowCheckpoint {
43 #[serde(default)]
45 pub schema_version: u32,
46 pub workflow_id: String,
48 pub steps: Vec<WorkflowStepRecord>,
51 pub checkpoint_ms: u64,
53}
54
55impl WorkflowCheckpoint {
56 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 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 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 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 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
204pub 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
224pub 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}