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: 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}