Skip to main content

vv_agent/runtime/
checkpoint_codec.rs

1//! Strict codec for the current checkpoint wire format.
2
3use std::collections::BTreeMap;
4
5use serde::de::{self, MapAccess, SeqAccess, Visitor};
6use serde::Deserialize;
7use serde_json::{Map, Number, Value};
8
9use crate::budget::BudgetUsageSnapshot;
10use crate::checkpoint::{
11    canonical_json_bytes, CheckpointError, CheckpointResult, CheckpointStatus, CHECKPOINT_SCHEMA,
12    RUN_DEFINITION_SCHEMA,
13};
14use crate::runtime::state::{
15    validate_checkpoint, validate_extension_state_size, Checkpoint, EventOutboxEntry,
16    ExtensionStateEntry, OperationJournalEntry,
17};
18use crate::types::{CycleRecord, Message, ModelCallRecord};
19
20const KNOWN_FIELDS: &[&str] = &[
21    "schema_version",
22    "run_definition_schema",
23    "run_definition",
24    "checkpoint_key",
25    "task_id",
26    "root_run_id",
27    "trace_id",
28    "run_definition_digest",
29    "resume_attempt",
30    "cycle_index",
31    "status",
32    "messages",
33    "cycles",
34    "model_calls",
35    "shared_state",
36    "budget_usage",
37    "event_cursor",
38    "event_outbox",
39    "extension_state",
40    "model_call_journal",
41    "tool_journal",
42    "revision",
43    "claim_token",
44    "claimed_cycle",
45    "lease_expires_at_ms",
46    "terminal_result",
47    "terminal_acknowledged",
48];
49
50pub fn checkpoint_to_value(
51    checkpoint: &Checkpoint,
52    max_extension_state_bytes: u64,
53) -> CheckpointResult<Value> {
54    validate_checkpoint(checkpoint)?;
55    let extension_bytes =
56        validate_extension_state_size(&checkpoint.extension_state, max_extension_state_bytes);
57    extension_bytes?;
58
59    let mut object = Map::new();
60    object.insert(
61        "schema_version".to_string(),
62        Value::String(CHECKPOINT_SCHEMA.to_string()),
63    );
64    object.insert(
65        "run_definition_schema".to_string(),
66        Value::String(RUN_DEFINITION_SCHEMA.to_string()),
67    );
68    object.insert(
69        "run_definition".to_string(),
70        checkpoint.run_definition.clone(),
71    );
72    object.insert(
73        "checkpoint_key".to_string(),
74        Value::String(checkpoint.checkpoint_key.clone()),
75    );
76    object.insert(
77        "task_id".to_string(),
78        Value::String(checkpoint.task_id.clone()),
79    );
80    object.insert(
81        "root_run_id".to_string(),
82        Value::String(checkpoint.root_run_id.clone()),
83    );
84    object.insert(
85        "trace_id".to_string(),
86        Value::String(checkpoint.trace_id.clone()),
87    );
88    object.insert(
89        "run_definition_digest".to_string(),
90        Value::String(checkpoint.run_definition_digest.clone()),
91    );
92    object.insert(
93        "resume_attempt".to_string(),
94        Value::from(checkpoint.resume_attempt),
95    );
96    object.insert(
97        "cycle_index".to_string(),
98        Value::from(checkpoint.cycle_index),
99    );
100    object.insert(
101        "status".to_string(),
102        Value::String(checkpoint.status.as_str().to_string()),
103    );
104    object.insert(
105        "messages".to_string(),
106        Value::Array(checkpoint.messages.iter().map(Message::to_dict).collect()),
107    );
108    object.insert(
109        "cycles".to_string(),
110        Value::Array(checkpoint.cycles.iter().map(CycleRecord::to_dict).collect()),
111    );
112    object.insert(
113        "model_calls".to_string(),
114        serde_json::to_value(&checkpoint.model_calls).map_err(|error| {
115            CheckpointError::new("checkpoint_model_calls_invalid", error.to_string())
116        })?,
117    );
118    object.insert(
119        "shared_state".to_string(),
120        Value::Object(checkpoint.shared_state.clone().into_iter().collect()),
121    );
122    object.insert(
123        "budget_usage".to_string(),
124        checkpoint
125            .budget_usage
126            .as_ref()
127            .map(serde_json::to_value)
128            .transpose()
129            .map_err(|error| CheckpointError::new("checkpoint_json_invalid", error.to_string()))?
130            .unwrap_or(Value::Null),
131    );
132    object.insert(
133        "event_cursor".to_string(),
134        checkpoint
135            .event_cursor
136            .as_ref()
137            .map(serde_json::to_value)
138            .transpose()
139            .map_err(|error| CheckpointError::new("checkpoint_json_invalid", error.to_string()))?
140            .unwrap_or(Value::Null),
141    );
142    object.insert(
143        "event_outbox".to_string(),
144        Value::Array(
145            checkpoint
146                .event_outbox
147                .iter()
148                .map(EventOutboxEntry::to_value)
149                .collect(),
150        ),
151    );
152    object.insert(
153        "extension_state".to_string(),
154        Value::Object(
155            checkpoint
156                .extension_state
157                .iter()
158                .map(|(namespace, entry)| (namespace.clone(), entry.to_value()))
159                .collect(),
160        ),
161    );
162    object.insert(
163        "model_call_journal".to_string(),
164        Value::Array(
165            checkpoint
166                .model_call_journal
167                .iter()
168                .map(OperationJournalEntry::to_value)
169                .collect(),
170        ),
171    );
172    object.insert(
173        "tool_journal".to_string(),
174        Value::Array(
175            checkpoint
176                .tool_journal
177                .iter()
178                .map(OperationJournalEntry::to_value)
179                .collect(),
180        ),
181    );
182    object.insert("revision".to_string(), Value::from(checkpoint.revision));
183    object.insert(
184        "claim_token".to_string(),
185        checkpoint
186            .claim_token
187            .clone()
188            .map_or(Value::Null, Value::String),
189    );
190    object.insert(
191        "claimed_cycle".to_string(),
192        checkpoint.claimed_cycle.map_or(Value::Null, Value::from),
193    );
194    object.insert(
195        "lease_expires_at_ms".to_string(),
196        checkpoint
197            .lease_expires_at_ms
198            .map_or(Value::Null, Value::from),
199    );
200    object.insert(
201        "terminal_result".to_string(),
202        checkpoint.terminal_result.clone().unwrap_or(Value::Null),
203    );
204    object.insert(
205        "terminal_acknowledged".to_string(),
206        Value::Bool(checkpoint.terminal_acknowledged),
207    );
208    Ok(Value::Object(object))
209}
210
211pub fn checkpoint_from_value(
212    payload: &Value,
213    max_extension_state_bytes: u64,
214) -> CheckpointResult<Checkpoint> {
215    let object = payload.as_object().ok_or_else(|| {
216        CheckpointError::new(
217            "checkpoint_payload_invalid",
218            "checkpoint v3 payload must be an object",
219        )
220    })?;
221    if let Some(field) = object
222        .keys()
223        .find(|field| !KNOWN_FIELDS.contains(&field.as_str()))
224    {
225        return Err(CheckpointError::new(
226            "checkpoint_unknown_field",
227            format!("checkpoint contains unknown field: {field}"),
228        ));
229    }
230    if object.get("schema_version").and_then(Value::as_str) != Some(CHECKPOINT_SCHEMA) {
231        return Err(CheckpointError::new(
232            "checkpoint_schema_unsupported",
233            "checkpoint schema_version is not vv-agent.checkpoint.v3",
234        ));
235    }
236    let run_definition_schema = required_string(
237        object,
238        "run_definition_schema",
239        "checkpoint_definition_schema_unsupported",
240    )?;
241    if run_definition_schema != RUN_DEFINITION_SCHEMA {
242        return Err(CheckpointError::new(
243            "checkpoint_definition_schema_unsupported",
244            "run_definition_schema is unsupported",
245        ));
246    }
247    let run_definition = object.get("run_definition").cloned().ok_or_else(|| {
248        CheckpointError::new(
249            "checkpoint_definition_invalid",
250            "run_definition is required",
251        )
252    })?;
253    if let Some(field) = KNOWN_FIELDS
254        .iter()
255        .find(|field| !object.contains_key(**field))
256    {
257        return Err(CheckpointError::new(
258            "checkpoint_field_invalid",
259            format!("required checkpoint field {field} is missing"),
260        ));
261    }
262    let checkpoint = Checkpoint {
263        schema_version: CHECKPOINT_SCHEMA.to_string(),
264        run_definition_schema: run_definition_schema.to_string(),
265        run_definition,
266        checkpoint_key: required_string(object, "checkpoint_key", "checkpoint_key_invalid")?
267            .to_string(),
268        task_id: required_string(object, "task_id", "checkpoint_value_invalid")?.to_string(),
269        root_run_id: required_string(object, "root_run_id", "checkpoint_value_invalid")?
270            .to_string(),
271        trace_id: required_string(object, "trace_id", "checkpoint_value_invalid")?.to_string(),
272        run_definition_digest: required_string(
273            object,
274            "run_definition_digest",
275            "checkpoint_digest_invalid",
276        )?
277        .to_string(),
278        resume_attempt: required_u64(
279            object,
280            "resume_attempt",
281            "checkpoint_resume_attempt_invalid",
282        )?,
283        cycle_index: required_u64(object, "cycle_index", "checkpoint_cycle_invalid")?,
284        status: parse_status(object, "status")?,
285        messages: parse_messages(object.get("messages"))?,
286        cycles: parse_cycles(object.get("cycles"))?,
287        model_calls: parse_model_calls(object.get("model_calls"))?,
288        shared_state: parse_object_map(object, "shared_state", "checkpoint_shared_state_invalid")?,
289        budget_usage: parse_optional_budget(object.get("budget_usage"))?,
290        event_cursor: parse_optional(object.get("event_cursor"), "event_cursor")?,
291        event_outbox: parse_array(object, "event_outbox")?
292            .iter()
293            .map(EventOutboxEntry::from_value)
294            .collect::<CheckpointResult<Vec<_>>>()?,
295        extension_state: parse_extensions(object.get("extension_state"))?,
296        model_call_journal: parse_array(object, "model_call_journal")?
297            .iter()
298            .map(OperationJournalEntry::from_value)
299            .collect::<CheckpointResult<Vec<_>>>()?,
300        tool_journal: parse_array(object, "tool_journal")?
301            .iter()
302            .map(OperationJournalEntry::from_value)
303            .collect::<CheckpointResult<Vec<_>>>()?,
304        revision: required_u64(object, "revision", "checkpoint_revision_invalid")?,
305        claim_token: parse_optional_string(object, "claim_token")?,
306        claimed_cycle: parse_optional_u64(object, "claimed_cycle")?,
307        lease_expires_at_ms: parse_optional_u64(object, "lease_expires_at_ms")?,
308        terminal_result: parse_optional_value(object, "terminal_result")?,
309        terminal_acknowledged: object
310            .get("terminal_acknowledged")
311            .and_then(Value::as_bool)
312            .ok_or_else(|| {
313                CheckpointError::new(
314                    "checkpoint_status_invalid",
315                    "terminal_acknowledged must be a boolean",
316                )
317            })?,
318    };
319    validate_checkpoint(&checkpoint)?;
320    crate::runtime::state::validate_extension_state_size(
321        &checkpoint.extension_state,
322        max_extension_state_bytes,
323    )?;
324    Ok(checkpoint)
325}
326
327pub fn checkpoint_to_json(
328    checkpoint: &Checkpoint,
329    max_extension_state_bytes: u64,
330) -> CheckpointResult<String> {
331    let value = checkpoint_to_value(checkpoint, max_extension_state_bytes)?;
332    let bytes = canonical_json_bytes(&value, "checkpoint v3")?;
333    String::from_utf8(bytes).map_err(|error| {
334        CheckpointError::new(
335            "checkpoint_canonicalization_invalid",
336            format!("checkpoint canonical JSON is not UTF-8: {error}"),
337        )
338    })
339}
340
341pub fn checkpoint_from_json(
342    payload: &str,
343    max_extension_state_bytes: u64,
344) -> CheckpointResult<Checkpoint> {
345    let value = strict_json_value(payload)?;
346    checkpoint_from_value(&value, max_extension_state_bytes)
347}
348
349fn parse_status(object: &Map<String, Value>, field: &str) -> CheckpointResult<CheckpointStatus> {
350    let value = required_string(object, field, "checkpoint_status_invalid")?;
351    serde_json::from_value(Value::String(value.to_string())).map_err(|_| {
352        CheckpointError::new(
353            "checkpoint_status_invalid",
354            format!("unknown checkpoint status {value}"),
355        )
356    })
357}
358
359fn parse_messages(value: Option<&Value>) -> CheckpointResult<Vec<Message>> {
360    let values = value.and_then(Value::as_array).ok_or_else(|| {
361        CheckpointError::new("checkpoint_messages_invalid", "messages must be an array")
362    })?;
363    values
364        .iter()
365        .map(|value| {
366            Message::from_dict(value)
367                .map_err(|error| CheckpointError::new("checkpoint_messages_invalid", error))
368        })
369        .collect()
370}
371
372fn parse_cycles(value: Option<&Value>) -> CheckpointResult<Vec<CycleRecord>> {
373    let values = value.and_then(Value::as_array).ok_or_else(|| {
374        CheckpointError::new("checkpoint_cycles_invalid", "cycles must be an array")
375    })?;
376    values
377        .iter()
378        .map(|value| {
379            CycleRecord::from_dict(value)
380                .map_err(|error| CheckpointError::new("checkpoint_cycles_invalid", error))
381        })
382        .collect()
383}
384
385fn parse_model_calls(value: Option<&Value>) -> CheckpointResult<Vec<ModelCallRecord>> {
386    let values = value.and_then(Value::as_array).ok_or_else(|| {
387        CheckpointError::new(
388            "checkpoint_model_calls_invalid",
389            "model_calls must be an array",
390        )
391    })?;
392    values
393        .iter()
394        .cloned()
395        .map(|value| {
396            serde_json::from_value(value).map_err(|error| {
397                CheckpointError::new("checkpoint_model_calls_invalid", error.to_string())
398            })
399        })
400        .collect()
401}
402
403fn parse_object_map(
404    object: &Map<String, Value>,
405    field: &str,
406    code: &str,
407) -> CheckpointResult<BTreeMap<String, Value>> {
408    object
409        .get(field)
410        .and_then(Value::as_object)
411        .cloned()
412        .map(|map| map.into_iter().collect())
413        .ok_or_else(|| CheckpointError::new(code, format!("{field} must be an object")))
414}
415
416fn parse_optional_budget(value: Option<&Value>) -> CheckpointResult<Option<BudgetUsageSnapshot>> {
417    match value {
418        None | Some(Value::Null) => Ok(None),
419        Some(value) => serde_json::from_value(value.clone())
420            .map(Some)
421            .map_err(|error| {
422                CheckpointError::new("checkpoint_budget_usage_invalid", error.to_string())
423            }),
424    }
425}
426
427fn parse_optional<T>(value: Option<&Value>, field: &str) -> CheckpointResult<Option<T>>
428where
429    T: serde::de::DeserializeOwned,
430{
431    match value {
432        None | Some(Value::Null) => Ok(None),
433        Some(value) => serde_json::from_value(value.clone())
434            .map(Some)
435            .map_err(|error| {
436                CheckpointError::new("checkpoint_field_invalid", format!("{field}: {error}"))
437            }),
438    }
439}
440
441fn parse_extensions(
442    value: Option<&Value>,
443) -> CheckpointResult<BTreeMap<String, ExtensionStateEntry>> {
444    let object = value.and_then(Value::as_object).ok_or_else(|| {
445        CheckpointError::new(
446            "checkpoint_extension_state_invalid",
447            "extension_state must be an object",
448        )
449    })?;
450    object
451        .iter()
452        .map(|(namespace, value)| Ok((namespace.clone(), ExtensionStateEntry::from_value(value)?)))
453        .collect()
454}
455
456fn parse_optional_string(
457    object: &Map<String, Value>,
458    field: &str,
459) -> CheckpointResult<Option<String>> {
460    match object.get(field) {
461        None | Some(Value::Null) => Ok(None),
462        Some(Value::String(value)) => Ok(Some(value.clone())),
463        Some(_) => Err(CheckpointError::new(
464            "checkpoint_claim_invalid",
465            format!("{field} must be a string or null"),
466        )),
467    }
468}
469
470fn parse_optional_u64(object: &Map<String, Value>, field: &str) -> CheckpointResult<Option<u64>> {
471    match object.get(field) {
472        None | Some(Value::Null) => Ok(None),
473        Some(Value::Number(number)) => number.as_u64().map(Some).ok_or_else(|| {
474            CheckpointError::new(
475                "checkpoint_integer_invalid",
476                format!("{field} must be a non-negative integer"),
477            )
478        }),
479        Some(_) => Err(CheckpointError::new(
480            "checkpoint_integer_invalid",
481            format!("{field} must be an integer or null"),
482        )),
483    }
484}
485
486fn parse_optional_value(
487    object: &Map<String, Value>,
488    field: &str,
489) -> CheckpointResult<Option<Value>> {
490    Ok(object.get(field).filter(|value| !value.is_null()).cloned())
491}
492
493fn parse_array<'a>(
494    object: &'a Map<String, Value>,
495    field: &str,
496) -> CheckpointResult<&'a Vec<Value>> {
497    object.get(field).and_then(Value::as_array).ok_or_else(|| {
498        CheckpointError::new(
499            "checkpoint_field_invalid",
500            format!("{field} must be an array"),
501        )
502    })
503}
504
505fn required_string<'a>(
506    object: &'a Map<String, Value>,
507    field: &str,
508    code: &str,
509) -> CheckpointResult<&'a str> {
510    object
511        .get(field)
512        .and_then(Value::as_str)
513        .ok_or_else(|| CheckpointError::new(code, format!("{field} must be a string")))
514}
515
516fn required_u64(object: &Map<String, Value>, field: &str, code: &str) -> CheckpointResult<u64> {
517    object.get(field).and_then(Value::as_u64).ok_or_else(|| {
518        CheckpointError::new(code, format!("{field} must be a non-negative integer"))
519    })
520}
521
522fn strict_json_value(payload: &str) -> CheckpointResult<Value> {
523    let mut deserializer = serde_json::Deserializer::from_str(payload);
524    let StrictValue(value) = StrictValue::deserialize(&mut deserializer)
525        .map_err(|error| CheckpointError::new("checkpoint_json_invalid", error.to_string()))?;
526    deserializer
527        .end()
528        .map_err(|error| CheckpointError::new("checkpoint_json_invalid", error.to_string()))?;
529    Ok(value)
530}
531
532struct StrictValue(Value);
533
534impl<'de> Deserialize<'de> for StrictValue {
535    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
536    where
537        D: serde::Deserializer<'de>,
538    {
539        deserializer.deserialize_any(StrictValueVisitor)
540    }
541}
542
543struct StrictValueVisitor;
544
545impl<'de> Visitor<'de> for StrictValueVisitor {
546    type Value = StrictValue;
547
548    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
549        formatter.write_str("a JSON value without duplicate object keys")
550    }
551
552    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
553        Ok(StrictValue(Value::Bool(value)))
554    }
555
556    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
557        Ok(StrictValue(Value::Number(Number::from(value))))
558    }
559
560    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
561        Ok(StrictValue(Value::Number(Number::from(value))))
562    }
563
564    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
565    where
566        E: de::Error,
567    {
568        Number::from_f64(value)
569            .map(Value::Number)
570            .map(StrictValue)
571            .ok_or_else(|| E::custom("non-finite JSON number"))
572    }
573
574    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
575        Ok(StrictValue(Value::String(value.to_string())))
576    }
577
578    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
579        Ok(StrictValue(Value::String(value)))
580    }
581
582    fn visit_none<E>(self) -> Result<Self::Value, E> {
583        Ok(StrictValue(Value::Null))
584    }
585
586    fn visit_unit<E>(self) -> Result<Self::Value, E> {
587        Ok(StrictValue(Value::Null))
588    }
589
590    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
591    where
592        A: SeqAccess<'de>,
593    {
594        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0));
595        while let Some(StrictValue(value)) = sequence.next_element()? {
596            values.push(value);
597        }
598        Ok(StrictValue(Value::Array(values)))
599    }
600
601    fn visit_map<A>(self, mut object: A) -> Result<Self::Value, A::Error>
602    where
603        A: MapAccess<'de>,
604    {
605        let mut values = Map::new();
606        while let Some(key) = object.next_key::<String>()? {
607            if values.contains_key(&key) {
608                return Err(de::Error::custom(format!(
609                    "duplicate JSON object key {key}"
610                )));
611            }
612            let StrictValue(value) = object.next_value()?;
613            values.insert(key, value);
614        }
615        Ok(StrictValue(Value::Object(values)))
616    }
617}