1use 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}