Skip to main content

vv_agent/runtime/
state.rs

1//! Checkpoint v3 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: impl Into<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: Some(idempotency_key.into()),
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.idempotency_key.as_deref().is_none_or(str::is_empty)
258                    || self.arguments.is_none()
259                    || self.idempotency_support.is_none()
260                {
261                    return Err(CheckpointError::new(
262                        "tool_idempotency_key_required",
263                        "tool journal entries require call, arguments, idempotency key, and support",
264                    ));
265                }
266                if self.response.is_some() {
267                    return Err(CheckpointError::new(
268                        "operation_kind_fields_invalid",
269                        "tool journal entries cannot contain model responses",
270                    ));
271                }
272                validate_json(
273                    &Value::Object(self.arguments.clone().expect("checked above")),
274                    "tool journal arguments",
275                )?;
276            }
277        }
278        match self.state {
279            OperationState::Succeeded => {
280                let receipt = match self.kind {
281                    OperationKind::Model => self.response.is_some(),
282                    OperationKind::Tool => self.result.is_some(),
283                };
284                if !receipt || self.error.is_some() {
285                    return Err(CheckpointError::new(
286                        "operation_receipt_required",
287                        "succeeded operation requires one success receipt",
288                    ));
289                }
290            }
291            OperationState::Failed => {
292                if self.error.is_none() || self.response.is_some() || self.result.is_some() {
293                    return Err(CheckpointError::new(
294                        "operation_error_required",
295                        "failed operation requires one typed error",
296                    ));
297                }
298            }
299            OperationState::Planned | OperationState::Started | OperationState::Ambiguous => {
300                if self.response.is_some() || self.result.is_some() || self.error.is_some() {
301                    return Err(CheckpointError::new(
302                        "operation_receipt_unexpected",
303                        "non-terminal operation cannot contain a receipt",
304                    ));
305                }
306            }
307        }
308        Ok(())
309    }
310
311    pub fn to_value(&self) -> Value {
312        let mut object = Map::new();
313        object.insert("kind".to_string(), serde_json::json!(self.kind));
314        object.insert(
315            "operation_id".to_string(),
316            Value::String(self.operation_id.clone()),
317        );
318        object.insert("cycle_index".to_string(), Value::from(self.cycle_index));
319        object.insert("attempt".to_string(), Value::from(self.attempt));
320        object.insert("state".to_string(), serde_json::json!(self.state));
321        object.insert(
322            "request_digest".to_string(),
323            Value::String(self.request_digest.clone()),
324        );
325        object.insert(
326            "idempotency_key".to_string(),
327            self.idempotency_key
328                .clone()
329                .map_or(Value::Null, Value::String),
330        );
331        match self.kind {
332            OperationKind::Model => {
333                object.insert(
334                    "response".to_string(),
335                    self.response.clone().unwrap_or(Value::Null),
336                );
337                object.insert(
338                    "model_operation".to_string(),
339                    serde_json::to_value(self.model_operation.expect("validated model operation"))
340                        .expect("model operation serializes"),
341                );
342                object.insert(
343                    "backend".to_string(),
344                    self.backend.clone().map_or(Value::Null, Value::String),
345                );
346                object.insert(
347                    "model".to_string(),
348                    self.model.clone().map_or(Value::Null, Value::String),
349                );
350                object.insert(
351                    "call_id".to_string(),
352                    self.call_id.clone().map_or(Value::Null, Value::String),
353                );
354            }
355            OperationKind::Tool => {
356                object.insert(
357                    "tool_call_id".to_string(),
358                    self.tool_call_id.clone().map_or(Value::Null, Value::String),
359                );
360                object.insert(
361                    "tool_name".to_string(),
362                    self.tool_name.clone().map_or(Value::Null, Value::String),
363                );
364                object.insert(
365                    "arguments".to_string(),
366                    self.arguments.clone().map_or(Value::Null, Value::Object),
367                );
368                object.insert(
369                    "idempotency_support".to_string(),
370                    self.idempotency_support
371                        .map_or(Value::Null, |support| serde_json::json!(support)),
372                );
373                object.insert(
374                    "result".to_string(),
375                    self.result.clone().unwrap_or(Value::Null),
376                );
377            }
378        }
379        object.insert(
380            "error".to_string(),
381            self.error
382                .as_ref()
383                .map(OperationError::to_value)
384                .unwrap_or(Value::Null),
385        );
386        Value::Object(object)
387    }
388
389    pub fn from_value(value: &Value) -> CheckpointResult<Self> {
390        let object = value.as_object().ok_or_else(|| {
391            CheckpointError::new(
392                "operation_journal_invalid",
393                "operation journal entry must be an object",
394            )
395        })?;
396        let kind: OperationKind =
397            serde_json::from_value(object.get("kind").cloned().ok_or_else(|| {
398                CheckpointError::new("operation_journal_invalid", "kind missing")
399            })?)
400            .map_err(|_| {
401                CheckpointError::new("operation_journal_invalid", "unknown operation kind")
402            })?;
403        let state: OperationState =
404            serde_json::from_value(object.get("state").cloned().ok_or_else(|| {
405                CheckpointError::new("operation_journal_invalid", "state missing")
406            })?)
407            .map_err(|_| {
408                CheckpointError::new("operation_journal_invalid", "unknown operation state")
409            })?;
410        let entry = Self {
411            kind,
412            operation_id: required_string(object, "operation_id", "operation_journal_invalid")?
413                .to_string(),
414            cycle_index: required_u64(object, "cycle_index", "operation_cycle_invalid")?,
415            attempt: required_u64(object, "attempt", "operation_attempt_invalid")?,
416            state,
417            request_digest: required_string(
418                object,
419                "request_digest",
420                "operation_request_digest_invalid",
421            )?
422            .to_string(),
423            idempotency_key: optional_string(object, "idempotency_key")?,
424            response: object
425                .get("response")
426                .filter(|value| !value.is_null())
427                .cloned(),
428            error: object
429                .get("error")
430                .filter(|value| !value.is_null())
431                .map(OperationError::from_value)
432                .transpose()?,
433            tool_call_id: optional_string(object, "tool_call_id")?,
434            tool_name: optional_string(object, "tool_name")?,
435            arguments: object
436                .get("arguments")
437                .filter(|value| !value.is_null())
438                .map(|value| {
439                    value.as_object().cloned().ok_or_else(|| {
440                        CheckpointError::new(
441                            "operation_kind_fields_invalid",
442                            "tool arguments must be an object",
443                        )
444                    })
445                })
446                .transpose()?,
447            idempotency_support: object
448                .get("idempotency_support")
449                .filter(|value| !value.is_null())
450                .cloned()
451                .map(|value| {
452                    serde_json::from_value(value).map_err(|_| {
453                        CheckpointError::new(
454                            "operation_kind_fields_invalid",
455                            "invalid tool idempotency support",
456                        )
457                    })
458                })
459                .transpose()?,
460            result: object
461                .get("result")
462                .filter(|value| !value.is_null())
463                .cloned(),
464            model_operation: object
465                .get("model_operation")
466                .filter(|value| !value.is_null())
467                .cloned()
468                .map(|value| {
469                    serde_json::from_value(value).map_err(|_| {
470                        CheckpointError::new(
471                            "model_identity_invalid",
472                            "invalid model operation identity",
473                        )
474                    })
475                })
476                .transpose()?,
477            backend: optional_string(object, "backend")?,
478            model: optional_string(object, "model")?,
479            call_id: optional_string(object, "call_id")?,
480        };
481        entry.validate()?;
482        Ok(entry)
483    }
484
485    pub fn transition_to(&mut self, next: OperationState) -> CheckpointResult<()> {
486        let allowed = matches!(
487            (self.state, next),
488            (OperationState::Planned, OperationState::Failed)
489                | (OperationState::Planned, OperationState::Started)
490                | (OperationState::Started, OperationState::Succeeded)
491                | (OperationState::Started, OperationState::Failed)
492                | (OperationState::Started, OperationState::Ambiguous)
493                | (OperationState::Ambiguous, OperationState::Planned)
494                | (OperationState::Ambiguous, OperationState::Succeeded)
495                | (OperationState::Ambiguous, OperationState::Failed)
496        );
497        if !allowed {
498            return Err(CheckpointError::new(
499                "operation_transition_invalid",
500                format!("cannot transition {:?} to {:?}", self.state, next),
501            ));
502        }
503        self.state = next;
504        self.validate()
505    }
506
507    pub fn retry(&mut self) -> CheckpointResult<()> {
508        if self.state != OperationState::Ambiguous {
509            return Err(CheckpointError::new(
510                "operation_transition_invalid",
511                "only ambiguous operations can be retried",
512            ));
513        }
514        self.attempt = self
515            .attempt
516            .checked_add(1)
517            .ok_or_else(|| CheckpointError::new("operation_attempt_invalid", "attempt overflow"))?;
518        if self.kind == OperationKind::Model {
519            self.call_id = Some(format!("{}:attempt:{}", self.operation_id, self.attempt));
520        }
521        self.state = OperationState::Planned;
522        self.validate()
523    }
524
525    pub fn mark_ambiguous(&mut self) -> CheckpointResult<()> {
526        self.transition_to(OperationState::Ambiguous)
527    }
528
529    pub fn verify_request(&self, request: &Value) -> CheckpointResult<()> {
530        let digest = crate::checkpoint::operation_request_digest(self.kind, request)?;
531        if digest != self.request_digest {
532            return Err(CheckpointError::new(
533                "checkpoint_journal_integrity_mismatch",
534                "operation request does not match the durable request_digest",
535            ));
536        }
537        Ok(())
538    }
539}
540
541#[derive(Debug, Clone, PartialEq)]
542pub struct ExtensionStateEntry {
543    pub version: String,
544    pub required: bool,
545    pub state: Value,
546}
547
548impl ExtensionStateEntry {
549    pub fn validate(&self) -> CheckpointResult<()> {
550        if self.version.trim().is_empty() {
551            return Err(CheckpointError::new(
552                "checkpoint_extension_state_invalid",
553                "extension version must be non-empty",
554            ));
555        }
556        validate_json(&self.state, "extension state")
557    }
558
559    pub fn to_value(&self) -> Value {
560        serde_json::json!({
561            "version": self.version,
562            "required": self.required,
563            "state": self.state,
564        })
565    }
566
567    pub fn from_value(value: &Value) -> CheckpointResult<Self> {
568        let object = value.as_object().ok_or_else(|| {
569            CheckpointError::new(
570                "checkpoint_extension_state_invalid",
571                "extension state entry must be an object",
572            )
573        })?;
574        const FIELDS: [&str; 3] = ["version", "required", "state"];
575        if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
576            return Err(CheckpointError::new(
577                "checkpoint_extension_state_invalid",
578                "extension state entry has missing or unknown fields",
579            ));
580        }
581        let entry = Self {
582            version: required_string(object, "version", "checkpoint_extension_state_invalid")?
583                .to_string(),
584            required: object
585                .get("required")
586                .and_then(Value::as_bool)
587                .ok_or_else(|| {
588                    CheckpointError::new(
589                        "checkpoint_extension_state_invalid",
590                        "extension required must be a boolean",
591                    )
592                })?,
593            state: object.get("state").cloned().ok_or_else(|| {
594                CheckpointError::new(
595                    "checkpoint_extension_state_invalid",
596                    "extension state is required",
597                )
598            })?,
599        };
600        entry.validate()?;
601        Ok(entry)
602    }
603}
604
605#[derive(Debug, Clone, PartialEq)]
606pub struct EventOutboxEntry {
607    pub event_id: String,
608    pub payload_digest: String,
609    pub state: String,
610    pub event: Value,
611    pub cursor: Option<Value>,
612}
613
614impl EventOutboxEntry {
615    pub fn validate(&self) -> CheckpointResult<()> {
616        if self.event_id.trim().is_empty() {
617            return Err(CheckpointError::new(
618                "checkpoint_event_outbox_invalid",
619                "event_id must be non-empty",
620            ));
621        }
622        validate_sha256(&self.payload_digest, "event_outbox.payload_digest")?;
623        if self.state != "pending" && self.state != "delivered" {
624            return Err(CheckpointError::new(
625                "checkpoint_event_outbox_invalid",
626                "outbox state must be pending or delivered",
627            ));
628        }
629        if !self.event.is_object() {
630            return Err(CheckpointError::new(
631                "checkpoint_event_outbox_invalid",
632                "outbox event must be an object",
633            ));
634        }
635        let event: RunEvent = serde_json::from_value(self.event.clone()).map_err(|_| {
636            CheckpointError::new(
637                "checkpoint_event_invalid",
638                "outbox event must match the current RunEvent wire contract",
639            )
640        })?;
641        if event.event_id().as_str() != self.event_id {
642            return Err(CheckpointError::new(
643                "event_identity_conflict",
644                "outbox event_id must match the embedded RunEvent event_id",
645            ));
646        }
647        let canonical = serde_json::to_value(event).map_err(|_| {
648            CheckpointError::new(
649                "checkpoint_event_invalid",
650                "outbox event could not be encoded as the current RunEvent wire contract",
651            )
652        })?;
653        if canonical != self.event {
654            return Err(CheckpointError::new(
655                "checkpoint_event_invalid",
656                "outbox event must use the canonical current RunEvent shape",
657            ));
658        }
659        if self.state == "pending" && self.cursor.is_some()
660            || self.state == "delivered" && self.cursor.is_none()
661        {
662            return Err(CheckpointError::new(
663                "checkpoint_event_outbox_invalid",
664                "pending entries have no cursor and delivered entries have one",
665            ));
666        }
667        if let Some(cursor) = &self.cursor {
668            validate_json(cursor, "event outbox cursor")?;
669        }
670        Ok(())
671    }
672
673    pub fn verify_payload(&self) -> CheckpointResult<()> {
674        self.validate()?;
675        let digest = crate::checkpoint::event_payload_digest(&self.event)?;
676        if digest != self.payload_digest {
677            return Err(CheckpointError::new(
678                "event_identity_conflict",
679                "outbox payload digest does not match event",
680            ));
681        }
682        Ok(())
683    }
684
685    pub fn pending(event_id: impl Into<String>, event: Value) -> CheckpointResult<Self> {
686        let payload_digest = crate::checkpoint::event_payload_digest(&event)?;
687        let entry = Self {
688            event_id: event_id.into(),
689            payload_digest,
690            state: "pending".to_string(),
691            event,
692            cursor: None,
693        };
694        entry.validate()?;
695        Ok(entry)
696    }
697
698    pub fn to_value(&self) -> Value {
699        serde_json::json!({
700            "event_id": self.event_id,
701            "payload_digest": self.payload_digest,
702            "state": self.state,
703            "event": self.event,
704            "cursor": self.cursor,
705        })
706    }
707
708    pub fn from_value(value: &Value) -> CheckpointResult<Self> {
709        let object = value.as_object().ok_or_else(|| {
710            CheckpointError::new(
711                "checkpoint_event_outbox_invalid",
712                "outbox entry must be an object",
713            )
714        })?;
715        const FIELDS: [&str; 5] = ["event_id", "payload_digest", "state", "event", "cursor"];
716        if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
717            return Err(CheckpointError::new(
718                "checkpoint_event_outbox_invalid",
719                "outbox entry has missing or unknown fields",
720            ));
721        }
722        let entry = Self {
723            event_id: required_string(object, "event_id", "checkpoint_event_outbox_invalid")?
724                .to_string(),
725            payload_digest: required_string(
726                object,
727                "payload_digest",
728                "checkpoint_event_outbox_invalid",
729            )?
730            .to_string(),
731            state: required_string(object, "state", "checkpoint_event_outbox_invalid")?.to_string(),
732            event: object.get("event").cloned().ok_or_else(|| {
733                CheckpointError::new(
734                    "checkpoint_event_outbox_invalid",
735                    "outbox event is required",
736                )
737            })?,
738            cursor: object
739                .get("cursor")
740                .filter(|value| !value.is_null())
741                .cloned(),
742        };
743        entry.validate()?;
744        Ok(entry)
745    }
746}
747
748#[derive(Debug, Clone, PartialEq)]
749pub struct Checkpoint {
750    pub schema_version: String,
751    pub run_definition_schema: String,
752    pub run_definition: Value,
753    pub checkpoint_key: String,
754    pub task_id: String,
755    pub root_run_id: String,
756    pub trace_id: String,
757    pub run_definition_digest: String,
758    pub resume_attempt: u64,
759    pub cycle_index: u64,
760    pub status: CheckpointStatus,
761    pub messages: Vec<Message>,
762    pub cycles: Vec<CycleRecord>,
763    pub model_calls: Vec<ModelCallRecord>,
764    pub shared_state: BTreeMap<String, Value>,
765    pub budget_usage: Option<BudgetUsageSnapshot>,
766    pub event_cursor: Option<EventCursor>,
767    pub event_outbox: Vec<EventOutboxEntry>,
768    pub extension_state: BTreeMap<String, ExtensionStateEntry>,
769    pub model_call_journal: Vec<OperationJournalEntry>,
770    pub tool_journal: Vec<OperationJournalEntry>,
771    pub revision: u64,
772    pub claim_token: Option<String>,
773    pub claimed_cycle: Option<u64>,
774    pub lease_expires_at_ms: Option<u64>,
775    pub terminal_result: Option<Value>,
776    pub terminal_acknowledged: bool,
777}
778
779impl Default for Checkpoint {
780    fn default() -> Self {
781        Self {
782            schema_version: CHECKPOINT_SCHEMA.to_string(),
783            run_definition_schema: RUN_DEFINITION_SCHEMA.to_string(),
784            run_definition: Value::Object(Map::new()),
785            checkpoint_key: String::new(),
786            task_id: String::new(),
787            root_run_id: String::new(),
788            trace_id: String::new(),
789            run_definition_digest: String::new(),
790            resume_attempt: 1,
791            cycle_index: 0,
792            status: CheckpointStatus::Running,
793            messages: Vec::new(),
794            cycles: Vec::new(),
795            model_calls: Vec::new(),
796            shared_state: BTreeMap::new(),
797            budget_usage: None,
798            event_cursor: None,
799            event_outbox: Vec::new(),
800            extension_state: BTreeMap::new(),
801            model_call_journal: Vec::new(),
802            tool_journal: Vec::new(),
803            revision: 0,
804            claim_token: None,
805            claimed_cycle: None,
806            lease_expires_at_ms: None,
807            terminal_result: None,
808            terminal_acknowledged: false,
809        }
810    }
811}
812
813impl Checkpoint {
814    pub fn validate(&self) -> CheckpointResult<()> {
815        validate_checkpoint(self)
816    }
817
818    pub fn active_cycle(&self) -> CheckpointResult<u64> {
819        self.claimed_cycle
820            .or_else(|| self.cycle_index.checked_add(1))
821            .ok_or_else(|| {
822                CheckpointError::new("checkpoint_cycle_invalid", "active cycle overflow")
823            })
824            .and_then(|cycle| {
825                if cycle == 0 || cycle > MAX_WIRE_INTEGER {
826                    Err(CheckpointError::new(
827                        "checkpoint_cycle_invalid",
828                        "active cycle is outside the JSON-safe range",
829                    ))
830                } else {
831                    Ok(cycle)
832                }
833            })
834    }
835
836    pub fn has_ambiguous_operation(&self) -> bool {
837        self.model_call_journal
838            .iter()
839            .chain(self.tool_journal.iter())
840            .any(|entry| entry.state == OperationState::Ambiguous)
841    }
842
843    pub fn is_operator_abort_terminal(&self) -> bool {
844        let Some(result) = self.terminal_result.as_ref().and_then(Value::as_object) else {
845            return false;
846        };
847        result
848            .get("error_code")
849            .and_then(Value::as_str)
850            .is_some_and(|code| code == "operator_abort_with_unknown_outcome")
851            || result
852                .get("resume_observation")
853                .is_some_and(|value| !value.is_null())
854    }
855}
856
857pub trait CheckpointStore: Send + Sync {
858    fn create_checkpoint(&self, checkpoint: Checkpoint) -> CheckpointResult<bool>;
859    fn load_checkpoint(&self, checkpoint_key: &str) -> CheckpointResult<Option<Checkpoint>>;
860    fn claim_checkpoint(
861        &self,
862        checkpoint_key: &str,
863        cycle_index: u64,
864        claim_token: &str,
865        lease_expires_at_ms: u64,
866        now_ms: u64,
867        claim_mode: ClaimMode,
868    ) -> CheckpointResult<Option<Checkpoint>>;
869    fn progress_checkpoint(
870        &self,
871        checkpoint: Checkpoint,
872        claim_token: &str,
873        expected_revision: u64,
874    ) -> CheckpointResult<bool>;
875    fn suspend_checkpoint(
876        &self,
877        checkpoint: Checkpoint,
878        claim_token: &str,
879        expected_revision: u64,
880    ) -> CheckpointResult<bool>;
881    fn commit_checkpoint(
882        &self,
883        checkpoint: Checkpoint,
884        claim_token: &str,
885        expected_revision: u64,
886    ) -> CheckpointResult<bool>;
887    fn finalize_claimed_checkpoint(
888        &self,
889        checkpoint: Checkpoint,
890        claim_token: &str,
891        expected_revision: u64,
892    ) -> CheckpointResult<bool>;
893    fn finalize_checkpoint(
894        &self,
895        checkpoint: Checkpoint,
896        expected_revision: u64,
897    ) -> CheckpointResult<bool>;
898    fn renew_checkpoint_claim(
899        &self,
900        checkpoint_key: &str,
901        claim_token: &str,
902        lease_expires_at_ms: u64,
903        now_ms: u64,
904    ) -> CheckpointResult<bool>;
905    fn record_event_delivery(
906        &self,
907        checkpoint_key: &str,
908        claim_token: Option<&str>,
909        expected_revision: u64,
910        event_id: &str,
911        payload_digest: &str,
912        cursor: EventCursor,
913    ) -> CheckpointResult<bool>;
914    fn acknowledge_terminal(
915        &self,
916        checkpoint_key: &str,
917        expected_revision: u64,
918    ) -> CheckpointResult<bool>;
919    fn delete_checkpoint(&self, checkpoint_key: &str) -> CheckpointResult<()>;
920    fn list_checkpoints(&self) -> CheckpointResult<Vec<String>>;
921}