Skip to main content

vv_agent/runtime/
state.rs

1//! Checkpoint v5 state and store contract.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7
8use crate::budget::BudgetUsageSnapshot;
9use crate::checkpoint::{
10    canonical_json_bytes, validate_checkpoint_key, validate_extension_namespace, validate_sha256,
11    CheckpointError, CheckpointResult, CheckpointStatus, ClaimMode, EventCursor, OperationKind,
12    OperationState, ToolIdempotency, MAX_EXTENSION_ENTRY_BYTES, MAX_WIRE_INTEGER,
13};
14use crate::events::RunEvent;
15use crate::types::{CycleRecord, Message, ModelCallOperation, ModelCallRecord};
16
17mod transitions;
18mod validation;
19
20pub use transitions::*;
21pub use validation::*;
22use validation::{optional_string, required_string, required_u64, validate_json};
23
24pub const CHECKPOINT_SCHEMA: &str = crate::checkpoint::CHECKPOINT_SCHEMA;
25pub const RUN_DEFINITION_SCHEMA: &str = crate::checkpoint::RUN_DEFINITION_SCHEMA;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct OperationError {
29    pub code: String,
30    pub message: String,
31    #[serde(default)]
32    pub retryable: bool,
33}
34
35impl OperationError {
36    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
37        Self {
38            code: code.into(),
39            message: message.into(),
40            retryable,
41        }
42    }
43
44    pub fn validate(&self) -> CheckpointResult<()> {
45        if self.code.trim().is_empty() || self.message.trim().is_empty() {
46            return Err(CheckpointError::new(
47                "operation_error_invalid",
48                "operation error code and message must be non-empty",
49            ));
50        }
51        Ok(())
52    }
53
54    pub fn to_value(&self) -> Value {
55        serde_json::json!({
56            "code": self.code,
57            "message": self.message,
58            "retryable": self.retryable,
59        })
60    }
61
62    pub fn from_value(value: &Value) -> CheckpointResult<Self> {
63        let object = value.as_object().ok_or_else(|| {
64            CheckpointError::new(
65                "operation_error_invalid",
66                "operation error must be an object",
67            )
68        })?;
69        let error = Self {
70            code: required_string(object, "code", "operation_error_invalid")?.to_string(),
71            message: required_string(object, "message", "operation_error_invalid")?.to_string(),
72            retryable: object
73                .get("retryable")
74                .and_then(Value::as_bool)
75                .ok_or_else(|| {
76                    CheckpointError::new(
77                        "operation_error_invalid",
78                        "operation error retryable must be a boolean",
79                    )
80                })?,
81        };
82        error.validate()?;
83        Ok(error)
84    }
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub struct OperationJournalEntry {
89    pub kind: OperationKind,
90    pub operation_id: String,
91    pub cycle_index: u64,
92    pub attempt: u64,
93    pub state: OperationState,
94    pub request_digest: String,
95    pub idempotency_key: Option<String>,
96    pub response: Option<Value>,
97    pub error: Option<OperationError>,
98    pub tool_call_id: Option<String>,
99    pub tool_name: Option<String>,
100    pub arguments: Option<Map<String, Value>>,
101    pub idempotency_support: Option<ToolIdempotency>,
102    pub result: Option<Value>,
103    pub model_operation: Option<ModelCallOperation>,
104    pub backend: Option<String>,
105    pub model: Option<String>,
106    pub call_id: Option<String>,
107}
108
109impl OperationJournalEntry {
110    #[allow(clippy::too_many_arguments)]
111    pub fn model(
112        operation_id: impl Into<String>,
113        cycle_index: u64,
114        attempt: u64,
115        request_digest: impl Into<String>,
116        model_operation: ModelCallOperation,
117        backend: impl Into<String>,
118        model: impl Into<String>,
119        call_id: impl Into<String>,
120    ) -> Self {
121        Self {
122            kind: OperationKind::Model,
123            operation_id: operation_id.into(),
124            cycle_index,
125            attempt,
126            state: OperationState::Planned,
127            request_digest: request_digest.into(),
128            idempotency_key: None,
129            response: None,
130            error: None,
131            tool_call_id: None,
132            tool_name: None,
133            arguments: None,
134            idempotency_support: None,
135            result: None,
136            model_operation: Some(model_operation),
137            backend: Some(backend.into()),
138            model: Some(model.into()),
139            call_id: Some(call_id.into()),
140        }
141    }
142
143    #[allow(clippy::too_many_arguments)]
144    pub fn tool(
145        operation_id: impl Into<String>,
146        cycle_index: u64,
147        attempt: u64,
148        request_digest: impl Into<String>,
149        tool_call_id: impl Into<String>,
150        tool_name: impl Into<String>,
151        arguments: Map<String, Value>,
152        idempotency_key: Option<String>,
153        idempotency_support: ToolIdempotency,
154    ) -> Self {
155        Self {
156            kind: OperationKind::Tool,
157            operation_id: operation_id.into(),
158            cycle_index,
159            attempt,
160            state: OperationState::Planned,
161            request_digest: request_digest.into(),
162            idempotency_key,
163            response: None,
164            error: None,
165            tool_call_id: Some(tool_call_id.into()),
166            tool_name: Some(tool_name.into()),
167            arguments: Some(arguments),
168            idempotency_support: Some(idempotency_support),
169            result: None,
170            model_operation: None,
171            backend: None,
172            model: None,
173            call_id: None,
174        }
175    }
176
177    pub fn validate(&self) -> CheckpointResult<()> {
178        if self.operation_id.trim().is_empty() {
179            return Err(CheckpointError::new(
180                "operation_id_invalid",
181                "operation_id must be non-empty",
182            ));
183        }
184        if self.cycle_index == 0 || self.cycle_index > MAX_WIRE_INTEGER {
185            return Err(CheckpointError::new(
186                "operation_cycle_invalid",
187                "operation cycle_index must be positive and JSON-safe",
188            ));
189        }
190        if self.attempt == 0 || self.attempt > MAX_WIRE_INTEGER {
191            return Err(CheckpointError::new(
192                "operation_attempt_invalid",
193                "operation attempt must be positive and JSON-safe",
194            ));
195        }
196        validate_sha256(&self.request_digest, "operation request_digest").map_err(|_| {
197            CheckpointError::new(
198                "operation_request_digest_invalid",
199                "operation request_digest must be lowercase SHA-256",
200            )
201        })?;
202        if let Some(response) = &self.response {
203            validate_json(response, "operation response")?;
204        }
205        if let Some(result) = &self.result {
206            validate_json(result, "operation result")?;
207        }
208        if let Some(error) = &self.error {
209            error.validate()?;
210        }
211        match self.kind {
212            OperationKind::Model => {
213                if self.tool_call_id.is_some()
214                    || self.tool_name.is_some()
215                    || self.arguments.is_some()
216                    || self.idempotency_support.is_some()
217                    || self.result.is_some()
218                {
219                    return Err(CheckpointError::new(
220                        "operation_kind_fields_invalid",
221                        "model journal entries cannot contain tool fields",
222                    ));
223                }
224                if self.model_operation.is_none()
225                    || self
226                        .backend
227                        .as_deref()
228                        .is_none_or(|value| value.trim().is_empty())
229                    || self
230                        .model
231                        .as_deref()
232                        .is_none_or(|value| value.trim().is_empty())
233                    || self
234                        .call_id
235                        .as_deref()
236                        .is_none_or(|value| value.trim().is_empty())
237                {
238                    return Err(CheckpointError::new(
239                        "model_identity_invalid",
240                        "model journal entries require operation, backend, model, and call_id",
241                    ));
242                }
243            }
244            OperationKind::Tool => {
245                if self.model_operation.is_some()
246                    || self.backend.is_some()
247                    || self.model.is_some()
248                    || self.call_id.is_some()
249                {
250                    return Err(CheckpointError::new(
251                        "operation_kind_fields_invalid",
252                        "tool journal entries cannot contain model identity fields",
253                    ));
254                }
255                if self.tool_call_id.as_deref().is_none_or(str::is_empty)
256                    || self.tool_name.as_deref().is_none_or(str::is_empty)
257                    || self.arguments.is_none()
258                    || self.idempotency_support.is_none()
259                {
260                    return Err(CheckpointError::new(
261                        "tool_idempotency_key_required",
262                        "tool journal entries require call, arguments, and idempotency support",
263                    ));
264                }
265                match self.idempotency_support.expect("checked above") {
266                    ToolIdempotency::Unsupported => {
267                        if self.idempotency_key.is_some() {
268                            return Err(CheckpointError::new(
269                                "tool_idempotency_key_invalid",
270                                "unsupported tools must not carry an idempotency key",
271                            ));
272                        }
273                    }
274                    ToolIdempotency::Supported | ToolIdempotency::Unknown => {
275                        if self.idempotency_key.as_deref().is_none_or(str::is_empty) {
276                            return Err(CheckpointError::new(
277                                "tool_idempotency_key_required",
278                                "supported or unknown tools require an idempotency key",
279                            ));
280                        }
281                    }
282                }
283                if self.response.is_some() {
284                    return Err(CheckpointError::new(
285                        "operation_kind_fields_invalid",
286                        "tool journal entries cannot contain model responses",
287                    ));
288                }
289                validate_json(
290                    &Value::Object(self.arguments.clone().expect("checked above")),
291                    "tool journal arguments",
292                )?;
293            }
294        }
295        match self.state {
296            OperationState::Succeeded => {
297                let receipt = match self.kind {
298                    OperationKind::Model => self.response.is_some(),
299                    OperationKind::Tool => self.result.is_some(),
300                };
301                if !receipt || self.error.is_some() {
302                    return Err(CheckpointError::new(
303                        "operation_receipt_required",
304                        "succeeded operation requires one success receipt",
305                    ));
306                }
307            }
308            OperationState::Failed => {
309                if self.error.is_none() || self.response.is_some() || self.result.is_some() {
310                    return Err(CheckpointError::new(
311                        "operation_error_required",
312                        "failed operation requires one typed error",
313                    ));
314                }
315            }
316            OperationState::Planned | OperationState::Started | OperationState::Ambiguous => {
317                if self.response.is_some() || self.result.is_some() || self.error.is_some() {
318                    return Err(CheckpointError::new(
319                        "operation_receipt_unexpected",
320                        "non-terminal operation cannot contain a receipt",
321                    ));
322                }
323            }
324        }
325        Ok(())
326    }
327
328    pub fn to_value(&self) -> Value {
329        let mut object = Map::new();
330        object.insert("kind".to_string(), serde_json::json!(self.kind));
331        object.insert(
332            "operation_id".to_string(),
333            Value::String(self.operation_id.clone()),
334        );
335        object.insert("cycle_index".to_string(), Value::from(self.cycle_index));
336        object.insert("attempt".to_string(), Value::from(self.attempt));
337        object.insert("state".to_string(), serde_json::json!(self.state));
338        object.insert(
339            "request_digest".to_string(),
340            Value::String(self.request_digest.clone()),
341        );
342        object.insert(
343            "idempotency_key".to_string(),
344            self.idempotency_key
345                .clone()
346                .map_or(Value::Null, Value::String),
347        );
348        match self.kind {
349            OperationKind::Model => {
350                object.insert(
351                    "response".to_string(),
352                    self.response.clone().unwrap_or(Value::Null),
353                );
354                object.insert(
355                    "model_operation".to_string(),
356                    serde_json::to_value(self.model_operation.expect("validated model operation"))
357                        .expect("model operation serializes"),
358                );
359                object.insert(
360                    "backend".to_string(),
361                    self.backend.clone().map_or(Value::Null, Value::String),
362                );
363                object.insert(
364                    "model".to_string(),
365                    self.model.clone().map_or(Value::Null, Value::String),
366                );
367                object.insert(
368                    "call_id".to_string(),
369                    self.call_id.clone().map_or(Value::Null, Value::String),
370                );
371            }
372            OperationKind::Tool => {
373                object.insert(
374                    "tool_call_id".to_string(),
375                    self.tool_call_id.clone().map_or(Value::Null, Value::String),
376                );
377                object.insert(
378                    "tool_name".to_string(),
379                    self.tool_name.clone().map_or(Value::Null, Value::String),
380                );
381                object.insert(
382                    "arguments".to_string(),
383                    self.arguments.clone().map_or(Value::Null, Value::Object),
384                );
385                object.insert(
386                    "idempotency_support".to_string(),
387                    self.idempotency_support
388                        .map_or(Value::Null, |support| serde_json::json!(support)),
389                );
390                object.insert(
391                    "result".to_string(),
392                    self.result.clone().unwrap_or(Value::Null),
393                );
394            }
395        }
396        object.insert(
397            "error".to_string(),
398            self.error
399                .as_ref()
400                .map(OperationError::to_value)
401                .unwrap_or(Value::Null),
402        );
403        Value::Object(object)
404    }
405
406    pub fn from_value(value: &Value) -> CheckpointResult<Self> {
407        let object = value.as_object().ok_or_else(|| {
408            CheckpointError::new(
409                "operation_journal_invalid",
410                "operation journal entry must be an object",
411            )
412        })?;
413        let kind: OperationKind =
414            serde_json::from_value(object.get("kind").cloned().ok_or_else(|| {
415                CheckpointError::new("operation_journal_invalid", "kind missing")
416            })?)
417            .map_err(|_| {
418                CheckpointError::new("operation_journal_invalid", "unknown operation kind")
419            })?;
420        let state: OperationState =
421            serde_json::from_value(object.get("state").cloned().ok_or_else(|| {
422                CheckpointError::new("operation_journal_invalid", "state missing")
423            })?)
424            .map_err(|_| {
425                CheckpointError::new("operation_journal_invalid", "unknown operation state")
426            })?;
427        let entry = Self {
428            kind,
429            operation_id: required_string(object, "operation_id", "operation_journal_invalid")?
430                .to_string(),
431            cycle_index: required_u64(object, "cycle_index", "operation_cycle_invalid")?,
432            attempt: required_u64(object, "attempt", "operation_attempt_invalid")?,
433            state,
434            request_digest: required_string(
435                object,
436                "request_digest",
437                "operation_request_digest_invalid",
438            )?
439            .to_string(),
440            idempotency_key: optional_string(object, "idempotency_key")?,
441            response: object
442                .get("response")
443                .filter(|value| !value.is_null())
444                .cloned(),
445            error: object
446                .get("error")
447                .filter(|value| !value.is_null())
448                .map(OperationError::from_value)
449                .transpose()?,
450            tool_call_id: optional_string(object, "tool_call_id")?,
451            tool_name: optional_string(object, "tool_name")?,
452            arguments: object
453                .get("arguments")
454                .filter(|value| !value.is_null())
455                .map(|value| {
456                    value.as_object().cloned().ok_or_else(|| {
457                        CheckpointError::new(
458                            "operation_kind_fields_invalid",
459                            "tool arguments must be an object",
460                        )
461                    })
462                })
463                .transpose()?,
464            idempotency_support: object
465                .get("idempotency_support")
466                .filter(|value| !value.is_null())
467                .cloned()
468                .map(|value| {
469                    serde_json::from_value(value).map_err(|_| {
470                        CheckpointError::new(
471                            "operation_kind_fields_invalid",
472                            "invalid tool idempotency support",
473                        )
474                    })
475                })
476                .transpose()?,
477            result: object
478                .get("result")
479                .filter(|value| !value.is_null())
480                .cloned(),
481            model_operation: object
482                .get("model_operation")
483                .filter(|value| !value.is_null())
484                .cloned()
485                .map(|value| {
486                    serde_json::from_value(value).map_err(|_| {
487                        CheckpointError::new(
488                            "model_identity_invalid",
489                            "invalid model operation identity",
490                        )
491                    })
492                })
493                .transpose()?,
494            backend: optional_string(object, "backend")?,
495            model: optional_string(object, "model")?,
496            call_id: optional_string(object, "call_id")?,
497        };
498        entry.validate()?;
499        Ok(entry)
500    }
501
502    pub fn transition_to(&mut self, next: OperationState) -> CheckpointResult<()> {
503        let allowed = matches!(
504            (self.state, next),
505            (OperationState::Planned, OperationState::Failed)
506                | (OperationState::Planned, OperationState::Started)
507                | (OperationState::Started, OperationState::Succeeded)
508                | (OperationState::Started, OperationState::Failed)
509                | (OperationState::Started, OperationState::Ambiguous)
510                | (OperationState::Ambiguous, OperationState::Planned)
511                | (OperationState::Ambiguous, OperationState::Succeeded)
512                | (OperationState::Ambiguous, OperationState::Failed)
513        );
514        if !allowed {
515            return Err(CheckpointError::new(
516                "operation_transition_invalid",
517                format!("cannot transition {:?} to {:?}", self.state, next),
518            ));
519        }
520        self.state = next;
521        self.validate()
522    }
523
524    pub fn retry(&mut self) -> CheckpointResult<()> {
525        if self.state != OperationState::Ambiguous {
526            return Err(CheckpointError::new(
527                "operation_transition_invalid",
528                "only ambiguous operations can be retried",
529            ));
530        }
531        self.attempt = self
532            .attempt
533            .checked_add(1)
534            .ok_or_else(|| CheckpointError::new("operation_attempt_invalid", "attempt overflow"))?;
535        if self.kind == OperationKind::Model {
536            self.call_id = Some(format!("{}:attempt:{}", self.operation_id, self.attempt));
537        }
538        self.state = OperationState::Planned;
539        self.validate()
540    }
541
542    pub fn mark_ambiguous(&mut self) -> CheckpointResult<()> {
543        self.transition_to(OperationState::Ambiguous)
544    }
545
546    pub fn verify_request(&self, request: &Value) -> CheckpointResult<()> {
547        let digest = crate::checkpoint::operation_request_digest(self.kind, request)?;
548        if digest != self.request_digest {
549            return Err(CheckpointError::new(
550                "checkpoint_journal_integrity_mismatch",
551                "operation request does not match the durable request_digest",
552            ));
553        }
554        Ok(())
555    }
556}
557
558#[derive(Debug, Clone, PartialEq)]
559pub struct ExtensionStateEntry {
560    pub version: String,
561    pub required: bool,
562    pub state: Value,
563}
564
565impl ExtensionStateEntry {
566    pub fn validate(&self) -> CheckpointResult<()> {
567        if self.version.trim().is_empty() {
568            return Err(CheckpointError::new(
569                "checkpoint_extension_state_invalid",
570                "extension version must be non-empty",
571            ));
572        }
573        validate_json(&self.state, "extension state")
574    }
575
576    pub fn to_value(&self) -> Value {
577        serde_json::json!({
578            "version": self.version,
579            "required": self.required,
580            "state": self.state,
581        })
582    }
583
584    pub fn from_value(value: &Value) -> CheckpointResult<Self> {
585        let object = value.as_object().ok_or_else(|| {
586            CheckpointError::new(
587                "checkpoint_extension_state_invalid",
588                "extension state entry must be an object",
589            )
590        })?;
591        const FIELDS: [&str; 3] = ["version", "required", "state"];
592        if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
593            return Err(CheckpointError::new(
594                "checkpoint_extension_state_invalid",
595                "extension state entry has missing or unknown fields",
596            ));
597        }
598        let entry = Self {
599            version: required_string(object, "version", "checkpoint_extension_state_invalid")?
600                .to_string(),
601            required: object
602                .get("required")
603                .and_then(Value::as_bool)
604                .ok_or_else(|| {
605                    CheckpointError::new(
606                        "checkpoint_extension_state_invalid",
607                        "extension required must be a boolean",
608                    )
609                })?,
610            state: object.get("state").cloned().ok_or_else(|| {
611                CheckpointError::new(
612                    "checkpoint_extension_state_invalid",
613                    "extension state is required",
614                )
615            })?,
616        };
617        entry.validate()?;
618        Ok(entry)
619    }
620}
621
622#[derive(Debug, Clone, PartialEq)]
623pub struct EventOutboxEntry {
624    pub event_id: String,
625    pub payload_digest: String,
626    pub state: String,
627    pub event: Value,
628    pub cursor: Option<Value>,
629}
630
631impl EventOutboxEntry {
632    pub fn validate(&self) -> CheckpointResult<()> {
633        if self.event_id.trim().is_empty() {
634            return Err(CheckpointError::new(
635                "checkpoint_event_outbox_invalid",
636                "event_id must be non-empty",
637            ));
638        }
639        validate_sha256(&self.payload_digest, "event_outbox.payload_digest")?;
640        if self.state != "pending" && self.state != "delivered" {
641            return Err(CheckpointError::new(
642                "checkpoint_event_outbox_invalid",
643                "outbox state must be pending or delivered",
644            ));
645        }
646        if !self.event.is_object() {
647            return Err(CheckpointError::new(
648                "checkpoint_event_outbox_invalid",
649                "outbox event must be an object",
650            ));
651        }
652        let event: RunEvent = serde_json::from_value(self.event.clone()).map_err(|_| {
653            CheckpointError::new(
654                "checkpoint_event_invalid",
655                "outbox event must match the current RunEvent wire contract",
656            )
657        })?;
658        if event.event_id().as_str() != self.event_id {
659            return Err(CheckpointError::new(
660                "event_identity_conflict",
661                "outbox event_id must match the embedded RunEvent event_id",
662            ));
663        }
664        let canonical = serde_json::to_value(event).map_err(|_| {
665            CheckpointError::new(
666                "checkpoint_event_invalid",
667                "outbox event could not be encoded as the current RunEvent wire contract",
668            )
669        })?;
670        if canonical != self.event {
671            return Err(CheckpointError::new(
672                "checkpoint_event_invalid",
673                "outbox event must use the canonical current RunEvent shape",
674            ));
675        }
676        if self.state == "pending" && self.cursor.is_some()
677            || self.state == "delivered" && self.cursor.is_none()
678        {
679            return Err(CheckpointError::new(
680                "checkpoint_event_outbox_invalid",
681                "pending entries have no cursor and delivered entries have one",
682            ));
683        }
684        if let Some(cursor) = &self.cursor {
685            validate_json(cursor, "event outbox cursor")?;
686        }
687        Ok(())
688    }
689
690    pub fn verify_payload(&self) -> CheckpointResult<()> {
691        self.validate()?;
692        let digest = crate::checkpoint::event_payload_digest(&self.event)?;
693        if digest != self.payload_digest {
694            return Err(CheckpointError::new(
695                "event_identity_conflict",
696                "outbox payload digest does not match event",
697            ));
698        }
699        Ok(())
700    }
701
702    pub fn pending(event_id: impl Into<String>, event: Value) -> CheckpointResult<Self> {
703        let payload_digest = crate::checkpoint::event_payload_digest(&event)?;
704        let entry = Self {
705            event_id: event_id.into(),
706            payload_digest,
707            state: "pending".to_string(),
708            event,
709            cursor: None,
710        };
711        entry.validate()?;
712        Ok(entry)
713    }
714
715    pub fn to_value(&self) -> Value {
716        serde_json::json!({
717            "event_id": self.event_id,
718            "payload_digest": self.payload_digest,
719            "state": self.state,
720            "event": self.event,
721            "cursor": self.cursor,
722        })
723    }
724
725    pub fn from_value(value: &Value) -> CheckpointResult<Self> {
726        let object = value.as_object().ok_or_else(|| {
727            CheckpointError::new(
728                "checkpoint_event_outbox_invalid",
729                "outbox entry must be an object",
730            )
731        })?;
732        const FIELDS: [&str; 5] = ["event_id", "payload_digest", "state", "event", "cursor"];
733        if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
734            return Err(CheckpointError::new(
735                "checkpoint_event_outbox_invalid",
736                "outbox entry has missing or unknown fields",
737            ));
738        }
739        let entry = Self {
740            event_id: required_string(object, "event_id", "checkpoint_event_outbox_invalid")?
741                .to_string(),
742            payload_digest: required_string(
743                object,
744                "payload_digest",
745                "checkpoint_event_outbox_invalid",
746            )?
747            .to_string(),
748            state: required_string(object, "state", "checkpoint_event_outbox_invalid")?.to_string(),
749            event: object.get("event").cloned().ok_or_else(|| {
750                CheckpointError::new(
751                    "checkpoint_event_outbox_invalid",
752                    "outbox event is required",
753                )
754            })?,
755            cursor: object
756                .get("cursor")
757                .filter(|value| !value.is_null())
758                .cloned(),
759        };
760        entry.validate()?;
761        Ok(entry)
762    }
763}
764
765#[derive(Debug, Clone, PartialEq)]
766pub struct Checkpoint {
767    pub schema_version: String,
768    pub run_definition_schema: String,
769    pub run_definition: Value,
770    pub checkpoint_key: String,
771    pub task_id: String,
772    pub root_run_id: String,
773    pub trace_id: String,
774    pub run_definition_digest: String,
775    pub resume_attempt: u64,
776    pub cycle_index: u64,
777    pub status: CheckpointStatus,
778    pub messages: Vec<Message>,
779    pub cycles: Vec<CycleRecord>,
780    pub model_calls: Vec<ModelCallRecord>,
781    pub shared_state: BTreeMap<String, Value>,
782    pub budget_usage: Option<BudgetUsageSnapshot>,
783    pub event_cursor: Option<EventCursor>,
784    pub event_outbox: Vec<EventOutboxEntry>,
785    pub extension_state: BTreeMap<String, ExtensionStateEntry>,
786    pub model_call_journal: Vec<OperationJournalEntry>,
787    pub tool_journal: Vec<OperationJournalEntry>,
788    pub revision: u64,
789    pub claim_token: Option<String>,
790    pub claimed_cycle: Option<u64>,
791    pub lease_expires_at_ms: Option<u64>,
792    pub terminal_result: Option<Value>,
793    pub terminal_acknowledged: bool,
794}
795
796impl Default for Checkpoint {
797    fn default() -> Self {
798        Self {
799            schema_version: CHECKPOINT_SCHEMA.to_string(),
800            run_definition_schema: RUN_DEFINITION_SCHEMA.to_string(),
801            run_definition: Value::Object(Map::new()),
802            checkpoint_key: String::new(),
803            task_id: String::new(),
804            root_run_id: String::new(),
805            trace_id: String::new(),
806            run_definition_digest: String::new(),
807            resume_attempt: 1,
808            cycle_index: 0,
809            status: CheckpointStatus::Running,
810            messages: Vec::new(),
811            cycles: Vec::new(),
812            model_calls: Vec::new(),
813            shared_state: BTreeMap::new(),
814            budget_usage: None,
815            event_cursor: None,
816            event_outbox: Vec::new(),
817            extension_state: BTreeMap::new(),
818            model_call_journal: Vec::new(),
819            tool_journal: Vec::new(),
820            revision: 0,
821            claim_token: None,
822            claimed_cycle: None,
823            lease_expires_at_ms: None,
824            terminal_result: None,
825            terminal_acknowledged: false,
826        }
827    }
828}
829
830impl Checkpoint {
831    pub fn validate(&self) -> CheckpointResult<()> {
832        validate_checkpoint(self)
833    }
834
835    pub fn active_cycle(&self) -> CheckpointResult<u64> {
836        self.claimed_cycle
837            .or_else(|| self.cycle_index.checked_add(1))
838            .ok_or_else(|| {
839                CheckpointError::new("checkpoint_cycle_invalid", "active cycle overflow")
840            })
841            .and_then(|cycle| {
842                if cycle == 0 || cycle > MAX_WIRE_INTEGER {
843                    Err(CheckpointError::new(
844                        "checkpoint_cycle_invalid",
845                        "active cycle is outside the JSON-safe range",
846                    ))
847                } else {
848                    Ok(cycle)
849                }
850            })
851    }
852
853    pub fn has_ambiguous_operation(&self) -> bool {
854        self.model_call_journal
855            .iter()
856            .chain(self.tool_journal.iter())
857            .any(|entry| entry.state == OperationState::Ambiguous)
858    }
859
860    pub fn is_operator_abort_terminal(&self) -> bool {
861        let Some(result) = self.terminal_result.as_ref().and_then(Value::as_object) else {
862            return false;
863        };
864        result
865            .get("error_code")
866            .and_then(Value::as_str)
867            .is_some_and(|code| code == "operator_abort_with_unknown_outcome")
868            || result
869                .get("resume_observation")
870                .is_some_and(|value| !value.is_null())
871    }
872}
873
874pub trait CheckpointStore: Send + Sync {
875    fn create_checkpoint(&self, checkpoint: Checkpoint) -> CheckpointResult<bool>;
876    fn load_checkpoint(&self, checkpoint_key: &str) -> CheckpointResult<Option<Checkpoint>>;
877    fn claim_checkpoint(
878        &self,
879        checkpoint_key: &str,
880        cycle_index: u64,
881        claim_token: &str,
882        lease_expires_at_ms: u64,
883        now_ms: u64,
884        claim_mode: ClaimMode,
885    ) -> CheckpointResult<Option<Checkpoint>>;
886    fn progress_checkpoint(
887        &self,
888        checkpoint: Checkpoint,
889        claim_token: &str,
890        expected_revision: u64,
891    ) -> CheckpointResult<bool>;
892    fn suspend_checkpoint(
893        &self,
894        checkpoint: Checkpoint,
895        claim_token: &str,
896        expected_revision: u64,
897    ) -> CheckpointResult<bool>;
898    fn commit_checkpoint(
899        &self,
900        checkpoint: Checkpoint,
901        claim_token: &str,
902        expected_revision: u64,
903    ) -> CheckpointResult<bool>;
904    fn finalize_claimed_checkpoint(
905        &self,
906        checkpoint: Checkpoint,
907        claim_token: &str,
908        expected_revision: u64,
909    ) -> CheckpointResult<bool>;
910    fn finalize_checkpoint(
911        &self,
912        checkpoint: Checkpoint,
913        expected_revision: u64,
914    ) -> CheckpointResult<bool>;
915    fn renew_checkpoint_claim(
916        &self,
917        checkpoint_key: &str,
918        claim_token: &str,
919        lease_expires_at_ms: u64,
920        now_ms: u64,
921    ) -> CheckpointResult<bool>;
922    fn record_event_delivery(
923        &self,
924        checkpoint_key: &str,
925        claim_token: Option<&str>,
926        expected_revision: u64,
927        event_id: &str,
928        payload_digest: &str,
929        cursor: EventCursor,
930    ) -> CheckpointResult<bool>;
931    fn acknowledge_terminal(
932        &self,
933        checkpoint_key: &str,
934        expected_revision: u64,
935    ) -> CheckpointResult<bool>;
936    fn delete_checkpoint(&self, checkpoint_key: &str) -> CheckpointResult<()>;
937    fn list_checkpoints(&self) -> CheckpointResult<Vec<String>>;
938}