Skip to main content

vv_agent/runtime/backends/distributed/
contract.rs

1use std::collections::BTreeSet;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::budget::{RunBudgetLimits, MAX_WIRE_INTEGER};
8use crate::checkpoint::{
9    validate_checkpoint_key, validate_extension_namespace, validate_sha256, AmbiguousModelPolicy,
10    AmbiguousToolPolicy, ClaimMode, ResumePolicy, DEFAULT_MAX_EXTENSION_STATE_BYTES,
11    RUN_DEFINITION_SCHEMA,
12};
13use crate::types::AgentTask;
14
15use super::super::RuntimeRecipe;
16
17pub const DISTRIBUTED_RUN_SCHEMA_VERSION_V1: &str = "vv-agent.distributed-run.v1";
18pub const DISTRIBUTED_RUN_SCHEMA_VERSION_V2: &str = "vv-agent.distributed-run.v2";
19/// Backwards-compatible alias used by existing v1 callers.
20pub const DISTRIBUTED_RUN_SCHEMA_VERSION: &str = DISTRIBUTED_RUN_SCHEMA_VERSION_V1;
21pub const DEFAULT_TOOLSET_ID: &str = "vv-agent.builtin-tools";
22pub const DEFAULT_TOOLSET_VERSION: &str = "1";
23pub const DEFAULT_TOOLSET_SCHEMA_DIGEST: &str =
24    "f85422117d41d28ffa3cdfcfd9a42892854de624808fadc2124f4ebe7a452b61";
25pub const DEFAULT_CYCLE_NAME: &str = "vv_agent.distributed.run_single_cycle";
26pub const DEFAULT_LEASE_DURATION_MS: u64 = 5 * 60 * 1000;
27
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29pub struct CapabilityRef {
30    pub id: String,
31    pub version: String,
32}
33
34impl CapabilityRef {
35    pub fn new(id: impl Into<String>, version: impl Into<String>) -> Result<Self, String> {
36        let reference = Self {
37            id: id.into(),
38            version: version.into(),
39        };
40        reference.validate("capability_ref")?;
41        Ok(reference)
42    }
43
44    pub fn validate(&self, field_name: &str) -> Result<(), String> {
45        require_non_empty(&self.id, &format!("{field_name}.id"))?;
46        require_non_empty(&self.version, &format!("{field_name}.version"))
47    }
48
49    pub fn to_dict(&self) -> Value {
50        serde_json::json!({"id": self.id, "version": self.version})
51    }
52
53    pub fn from_dict(payload: &Value, field_name: &str) -> Result<Self, String> {
54        let reference: Self = serde_json::from_value(payload.clone())
55            .map_err(|_| format!("{field_name} must be an object"))?;
56        reference.validate(field_name)?;
57        Ok(reference)
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ToolsetRef {
63    pub id: String,
64    pub version: String,
65    pub schema_digest: String,
66}
67
68impl Default for ToolsetRef {
69    fn default() -> Self {
70        Self {
71            id: DEFAULT_TOOLSET_ID.to_string(),
72            version: DEFAULT_TOOLSET_VERSION.to_string(),
73            schema_digest: DEFAULT_TOOLSET_SCHEMA_DIGEST.to_string(),
74        }
75    }
76}
77
78impl ToolsetRef {
79    pub fn validate(&self) -> Result<(), String> {
80        CapabilityRef {
81            id: self.id.clone(),
82            version: self.version.clone(),
83        }
84        .validate("toolset_ref")?;
85        if self.schema_digest.len() != 64
86            || self
87                .schema_digest
88                .bytes()
89                .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase())
90        {
91            return Err(
92                "toolset_ref.schema_digest must be a lowercase SHA-256 hex digest".to_string(),
93            );
94        }
95        Ok(())
96    }
97
98    pub fn capability_ref(&self) -> CapabilityRef {
99        CapabilityRef {
100            id: self.id.clone(),
101            version: self.version.clone(),
102        }
103    }
104
105    pub fn to_dict(&self) -> Value {
106        serde_json::json!({
107            "id": self.id,
108            "version": self.version,
109            "schema_digest": self.schema_digest,
110        })
111    }
112
113    pub fn from_dict(payload: &Value) -> Result<Self, String> {
114        let reference: Self = serde_json::from_value(payload.clone())
115            .map_err(|_| "toolset_ref must be an object".to_string())?;
116        reference.validate()?;
117        Ok(reference)
118    }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct DistributedToolPolicy {
123    pub allowed_tools: Option<Vec<String>>,
124    #[serde(default)]
125    pub disallowed_tools: Vec<String>,
126    #[serde(default = "default_approval_policy")]
127    pub approval: String,
128    #[serde(default)]
129    pub predicate_ref: Option<CapabilityRef>,
130}
131
132impl Default for DistributedToolPolicy {
133    fn default() -> Self {
134        Self {
135            allowed_tools: None,
136            disallowed_tools: Vec::new(),
137            approval: default_approval_policy(),
138            predicate_ref: None,
139        }
140    }
141}
142
143impl DistributedToolPolicy {
144    pub fn validate(&self) -> Result<(), String> {
145        if !matches!(
146            self.approval.as_str(),
147            "default" | "always" | "never" | "on_request"
148        ) {
149            return Err("tool_policy.approval is unsupported".to_string());
150        }
151        for (field_name, values) in [
152            ("tool_policy.allowed_tools", self.allowed_tools.as_deref()),
153            (
154                "tool_policy.disallowed_tools",
155                Some(self.disallowed_tools.as_slice()),
156            ),
157        ] {
158            if values.is_some_and(|values| values.iter().any(|value| value.trim().is_empty())) {
159                return Err(format!("{field_name} must contain non-empty strings"));
160            }
161        }
162        if let Some(reference) = &self.predicate_ref {
163            reference.validate("tool_policy.predicate_ref")?;
164        }
165        Ok(())
166    }
167
168    pub fn to_dict(&self) -> Value {
169        serde_json::json!({
170            "allowed_tools": self.allowed_tools,
171            "disallowed_tools": self.disallowed_tools,
172            "approval": self.approval,
173            "predicate_ref": self.predicate_ref,
174        })
175    }
176
177    pub fn from_dict(payload: &Value) -> Result<Self, String> {
178        let policy: Self = serde_json::from_value(payload.clone())
179            .map_err(|_| "tool_policy must be an object".to_string())?;
180        policy.validate()?;
181        Ok(policy)
182    }
183}
184
185fn default_approval_policy() -> String {
186    "default".to_string()
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct DistributedCheckpointExtensionRef {
191    pub namespace: String,
192    pub reference: CapabilityRef,
193    #[serde(default)]
194    pub required: bool,
195}
196
197impl DistributedCheckpointExtensionRef {
198    pub fn validate(&self) -> Result<(), String> {
199        validate_extension_namespace(&self.namespace).map_err(|error| error.to_string())?;
200        self.reference
201            .validate("checkpoint_extension_ref.reference")
202    }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct DistributedCheckpointConfig {
207    pub key: String,
208    #[serde(default)]
209    pub resume_policy: ResumePolicy,
210    #[serde(default)]
211    pub ambiguous_model_policy: AmbiguousModelPolicy,
212    #[serde(default)]
213    pub ambiguous_tool_policy: AmbiguousToolPolicy,
214    #[serde(default)]
215    pub required_extension_namespaces: Vec<String>,
216    #[serde(default = "default_max_extension_state_bytes")]
217    pub max_extension_state_bytes: u64,
218    #[serde(default)]
219    pub credential_slots: Vec<String>,
220}
221
222impl DistributedCheckpointConfig {
223    pub fn validate(&self) -> Result<(), String> {
224        validate_checkpoint_key(&self.key).map_err(|error| error.to_string())?;
225        if self.max_extension_state_bytes > MAX_WIRE_INTEGER {
226            return Err(format!(
227                "max_extension_state_bytes must be between 0 and {MAX_WIRE_INTEGER}"
228            ));
229        }
230        validate_sorted_unique(
231            &self.required_extension_namespaces,
232            "required_extension_namespaces",
233            |namespace| validate_extension_namespace(namespace).map_err(|error| error.to_string()),
234        )?;
235        for pointer in &self.credential_slots {
236            validate_json_pointer(pointer)?;
237        }
238        if self
239            .credential_slots
240            .windows(2)
241            .any(|window| utf16_cmp(&window[0], &window[1]) != std::cmp::Ordering::Less)
242        {
243            return Err("credential_slots must be sorted and unique".to_string());
244        }
245        Ok(())
246    }
247
248    pub fn to_dict(&self) -> Value {
249        serde_json::to_value(self).expect("validated checkpoint config always serializes")
250    }
251}
252
253fn default_max_extension_state_bytes() -> u64 {
254    DEFAULT_MAX_EXTENSION_STATE_BYTES
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
258pub struct DistributedCapabilities {
259    pub toolset_ref: ToolsetRef,
260    pub tool_policy: DistributedToolPolicy,
261    pub llm_client_ref: Option<CapabilityRef>,
262    pub workspace_backend_ref: Option<CapabilityRef>,
263    pub approval_provider_ref: Option<CapabilityRef>,
264    pub approval_broker_ref: Option<CapabilityRef>,
265    pub approval_timeout_seconds: Option<f64>,
266    pub cancellation_ref: Option<CapabilityRef>,
267    pub event_sink_ref: Option<CapabilityRef>,
268    pub host_cost_meter_ref: Option<CapabilityRef>,
269    pub app_state_ref: Option<CapabilityRef>,
270    pub sub_task_manager_ref: Option<CapabilityRef>,
271    #[serde(default)]
272    pub memory_provider_refs: Vec<CapabilityRef>,
273    #[serde(default)]
274    pub hook_refs: Vec<CapabilityRef>,
275    #[serde(default)]
276    pub observer_refs: Vec<CapabilityRef>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub checkpoint_store_ref: Option<CapabilityRef>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub checkpoint_event_store_ref: Option<CapabilityRef>,
281    #[serde(default, skip_serializing_if = "Vec::is_empty")]
282    pub checkpoint_extension_refs: Vec<DistributedCheckpointExtensionRef>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub reconciliation_provider_ref: Option<CapabilityRef>,
285}
286
287impl DistributedCapabilities {
288    pub fn validate(&self) -> Result<(), String> {
289        self.toolset_ref.validate()?;
290        self.tool_policy.validate()?;
291        if self.approval_provider_ref.is_some() != self.approval_broker_ref.is_some() {
292            return Err(
293                "approval_provider_ref and approval_broker_ref must be declared together"
294                    .to_string(),
295            );
296        }
297        if self
298            .approval_timeout_seconds
299            .is_some_and(|value| !value.is_finite() || value <= 0.0)
300        {
301            return Err(
302                "approval_timeout_seconds must be a finite positive number or null".to_string(),
303            );
304        }
305        for (field_name, reference) in [
306            ("llm_client_ref", self.llm_client_ref.as_ref()),
307            ("workspace_backend_ref", self.workspace_backend_ref.as_ref()),
308            ("approval_provider_ref", self.approval_provider_ref.as_ref()),
309            ("approval_broker_ref", self.approval_broker_ref.as_ref()),
310            ("cancellation_ref", self.cancellation_ref.as_ref()),
311            ("event_sink_ref", self.event_sink_ref.as_ref()),
312            ("host_cost_meter_ref", self.host_cost_meter_ref.as_ref()),
313            ("app_state_ref", self.app_state_ref.as_ref()),
314            ("sub_task_manager_ref", self.sub_task_manager_ref.as_ref()),
315            ("checkpoint_store_ref", self.checkpoint_store_ref.as_ref()),
316            (
317                "checkpoint_event_store_ref",
318                self.checkpoint_event_store_ref.as_ref(),
319            ),
320            (
321                "reconciliation_provider_ref",
322                self.reconciliation_provider_ref.as_ref(),
323            ),
324        ] {
325            if let Some(reference) = reference {
326                reference.validate(field_name)?;
327            }
328        }
329        for (field_name, references) in [
330            ("memory_provider_refs", self.memory_provider_refs.as_slice()),
331            ("hook_refs", self.hook_refs.as_slice()),
332            ("observer_refs", self.observer_refs.as_slice()),
333        ] {
334            for (index, reference) in references.iter().enumerate() {
335                reference.validate(&format!("capabilities.{field_name}[{index}]"))?;
336            }
337        }
338        let mut namespaces = BTreeSet::new();
339        for reference in &self.checkpoint_extension_refs {
340            reference.validate()?;
341            if !namespaces.insert(reference.namespace.as_str()) {
342                return Err(format!(
343                    "duplicate checkpoint extension namespace {}",
344                    reference.namespace
345                ));
346            }
347        }
348        Ok(())
349    }
350
351    pub fn to_dict(&self) -> Value {
352        let mut value = serde_json::json!({
353            "toolset_ref": self.toolset_ref,
354            "tool_policy": self.tool_policy,
355            "llm_client_ref": self.llm_client_ref,
356            "workspace_backend_ref": self.workspace_backend_ref,
357            "approval_provider_ref": self.approval_provider_ref,
358            "approval_broker_ref": self.approval_broker_ref,
359            "approval_timeout_seconds": self.approval_timeout_seconds,
360            "cancellation_ref": self.cancellation_ref,
361            "event_sink_ref": self.event_sink_ref,
362            "host_cost_meter_ref": self.host_cost_meter_ref,
363            "app_state_ref": self.app_state_ref,
364            "sub_task_manager_ref": self.sub_task_manager_ref,
365            "memory_provider_refs": self.memory_provider_refs,
366            "hook_refs": self.hook_refs,
367            "observer_refs": self.observer_refs,
368        });
369        let object = value
370            .as_object_mut()
371            .expect("distributed capabilities are always an object");
372        for (name, reference) in [
373            ("checkpoint_store_ref", self.checkpoint_store_ref.as_ref()),
374            (
375                "checkpoint_event_store_ref",
376                self.checkpoint_event_store_ref.as_ref(),
377            ),
378            (
379                "reconciliation_provider_ref",
380                self.reconciliation_provider_ref.as_ref(),
381            ),
382        ] {
383            if let Some(reference) = reference {
384                object.insert(name.to_string(), reference.to_dict());
385            }
386        }
387        if !self.checkpoint_extension_refs.is_empty() {
388            object.insert(
389                "checkpoint_extension_refs".to_string(),
390                serde_json::to_value(&self.checkpoint_extension_refs)
391                    .expect("validated checkpoint extension references always serialize"),
392            );
393        }
394        value
395    }
396
397    pub fn from_dict(payload: &Value) -> Result<Self, String> {
398        let capabilities: Self = serde_json::from_value(payload.clone())
399            .map_err(|_| "capabilities must be an object".to_string())?;
400        capabilities.validate()?;
401        Ok(capabilities)
402    }
403}
404
405#[derive(Debug, Clone, PartialEq, Deserialize)]
406pub struct DistributedRunEnvelope {
407    pub schema_version: String,
408    pub job_id: String,
409    pub run_id: String,
410    pub task: AgentTask,
411    #[serde(default)]
412    pub budget_limits: Option<RunBudgetLimits>,
413    pub recipe: RuntimeRecipe,
414    pub cycle_name: String,
415    pub cycle_index: u32,
416    pub idempotency_key: String,
417    pub deadline_unix_ms: Option<u64>,
418    pub lease_duration_ms: u64,
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub root_run_id: Option<String>,
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub trace_id: Option<String>,
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub run_definition_schema: Option<String>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub run_definition_digest: Option<String>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub claim_mode: Option<ClaimMode>,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub resume_attempt: Option<u64>,
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub checkpoint_config: Option<DistributedCheckpointConfig>,
433}
434
435impl DistributedRunEnvelope {
436    #[allow(clippy::too_many_arguments)]
437    pub fn for_cycle(
438        task: AgentTask,
439        recipe: RuntimeRecipe,
440        cycle_index: u32,
441        cycle_name: impl Into<String>,
442        run_id: Option<String>,
443        deadline_unix_ms: Option<u64>,
444        lease_duration_ms: u64,
445        budget_limits: Option<RunBudgetLimits>,
446    ) -> Result<Self, String> {
447        let run_id = run_id
448            .filter(|value| !value.trim().is_empty())
449            .or_else(|| {
450                task.metadata
451                    .get("_vv_agent_run_id")
452                    .and_then(Value::as_str)
453                    .filter(|value| !value.trim().is_empty())
454                    .map(str::to_string)
455            })
456            .unwrap_or_else(|| task.task_id.clone());
457        let idempotency_key = format!("{run_id}:cycle:{cycle_index}");
458        let envelope = Self {
459            schema_version: DISTRIBUTED_RUN_SCHEMA_VERSION.to_string(),
460            job_id: idempotency_key.clone(),
461            run_id,
462            task,
463            budget_limits,
464            recipe,
465            cycle_name: cycle_name.into(),
466            cycle_index,
467            idempotency_key,
468            deadline_unix_ms,
469            lease_duration_ms,
470            root_run_id: None,
471            trace_id: None,
472            run_definition_schema: None,
473            run_definition_digest: None,
474            claim_mode: None,
475            resume_attempt: None,
476            checkpoint_config: None,
477        };
478        envelope.validate()?;
479        Ok(envelope)
480    }
481
482    #[allow(clippy::too_many_arguments)]
483    pub fn enable_checkpoint_v2(
484        mut self,
485        root_run_id: impl Into<String>,
486        trace_id: impl Into<String>,
487        run_definition_digest: impl Into<String>,
488        claim_mode: ClaimMode,
489        resume_attempt: u64,
490        checkpoint_config: DistributedCheckpointConfig,
491    ) -> Result<Self, String> {
492        self.schema_version = DISTRIBUTED_RUN_SCHEMA_VERSION_V2.to_string();
493        self.root_run_id = Some(root_run_id.into());
494        self.trace_id = Some(trace_id.into());
495        self.run_definition_schema = Some(RUN_DEFINITION_SCHEMA.to_string());
496        self.run_definition_digest = Some(run_definition_digest.into());
497        self.claim_mode = Some(claim_mode);
498        self.resume_attempt = Some(resume_attempt);
499        self.checkpoint_config = Some(checkpoint_config);
500        self.validate()?;
501        Ok(self)
502    }
503
504    #[allow(clippy::too_many_arguments)]
505    pub fn for_checkpoint_cycle(
506        task: AgentTask,
507        recipe: RuntimeRecipe,
508        cycle_index: u32,
509        cycle_name: impl Into<String>,
510        run_id: Option<String>,
511        deadline_unix_ms: Option<u64>,
512        lease_duration_ms: u64,
513        budget_limits: Option<RunBudgetLimits>,
514        root_run_id: impl Into<String>,
515        trace_id: impl Into<String>,
516        run_definition_digest: impl Into<String>,
517        claim_mode: ClaimMode,
518        resume_attempt: u64,
519        checkpoint_config: DistributedCheckpointConfig,
520    ) -> Result<Self, String> {
521        let run_id = run_id
522            .filter(|value| !value.trim().is_empty())
523            .or_else(|| {
524                task.metadata
525                    .get("_vv_agent_run_id")
526                    .and_then(Value::as_str)
527                    .filter(|value| !value.trim().is_empty())
528                    .map(str::to_string)
529            })
530            .unwrap_or_else(|| task.task_id.clone());
531        let idempotency_key = format!("{run_id}:cycle:{cycle_index}");
532        let envelope = Self {
533            schema_version: DISTRIBUTED_RUN_SCHEMA_VERSION_V2.to_string(),
534            job_id: idempotency_key.clone(),
535            run_id,
536            task,
537            budget_limits,
538            recipe,
539            cycle_name: cycle_name.into(),
540            cycle_index,
541            idempotency_key,
542            deadline_unix_ms,
543            lease_duration_ms,
544            root_run_id: Some(root_run_id.into()),
545            trace_id: Some(trace_id.into()),
546            run_definition_schema: Some(RUN_DEFINITION_SCHEMA.to_string()),
547            run_definition_digest: Some(run_definition_digest.into()),
548            claim_mode: Some(claim_mode),
549            resume_attempt: Some(resume_attempt),
550            checkpoint_config: Some(checkpoint_config),
551        };
552        envelope.validate()?;
553        Ok(envelope)
554    }
555
556    pub fn is_checkpoint_v2(&self) -> bool {
557        self.schema_version == DISTRIBUTED_RUN_SCHEMA_VERSION_V2
558    }
559
560    pub fn validate(&self) -> Result<(), String> {
561        match self.schema_version.as_str() {
562            DISTRIBUTED_RUN_SCHEMA_VERSION_V1 => self.validate_v1_fields()?,
563            DISTRIBUTED_RUN_SCHEMA_VERSION_V2 => self.validate_v2_fields()?,
564            _ => {
565                return Err(format!(
566                    "unsupported distributed schema_version: {}",
567                    self.schema_version
568                ));
569            }
570        }
571        for (field_name, value) in [
572            ("job_id", self.job_id.as_str()),
573            ("run_id", self.run_id.as_str()),
574            ("cycle_name", self.cycle_name.as_str()),
575            ("idempotency_key", self.idempotency_key.as_str()),
576        ] {
577            require_non_empty(value, &format!("distributed envelope {field_name}"))?;
578        }
579        if self.cycle_index == 0 {
580            return Err(
581                "distributed envelope cycle_index must be between 1 and 4294967295".to_string(),
582            );
583        }
584        if self.lease_duration_ms == 0 {
585            return Err(
586                "distributed envelope lease_duration_ms must be a positive integer".to_string(),
587            );
588        }
589        self.recipe.validate()?;
590        if let Some(limits) = &self.budget_limits {
591            limits.validate()?;
592        }
593        Ok(())
594    }
595
596    fn validate_v1_fields(&self) -> Result<(), String> {
597        if self.root_run_id.is_some()
598            || self.trace_id.is_some()
599            || self.run_definition_schema.is_some()
600            || self.run_definition_digest.is_some()
601            || self.claim_mode.is_some()
602            || self.resume_attempt.is_some()
603            || self.checkpoint_config.is_some()
604            || self.recipe.capabilities.checkpoint_store_ref.is_some()
605            || self
606                .recipe
607                .capabilities
608                .checkpoint_event_store_ref
609                .is_some()
610            || !self
611                .recipe
612                .capabilities
613                .checkpoint_extension_refs
614                .is_empty()
615            || self
616                .recipe
617                .capabilities
618                .reconciliation_provider_ref
619                .is_some()
620        {
621            return Err("distributed v1 envelope cannot carry checkpoint v2 fields".to_string());
622        }
623        Ok(())
624    }
625
626    fn validate_v2_fields(&self) -> Result<(), String> {
627        for (field_name, value) in [
628            ("root_run_id", self.root_run_id.as_deref()),
629            ("trace_id", self.trace_id.as_deref()),
630        ] {
631            let value = value.ok_or_else(|| format!("distributed v2 requires {field_name}"))?;
632            require_non_empty(value, &format!("distributed envelope {field_name}"))?;
633        }
634        if self.run_definition_schema.as_deref() != Some(RUN_DEFINITION_SCHEMA) {
635            return Err("checkpoint_definition_schema_unsupported".to_string());
636        }
637        let digest = self
638            .run_definition_digest
639            .as_deref()
640            .ok_or_else(|| "distributed v2 requires run_definition_digest".to_string())?;
641        validate_sha256(digest, "run_definition_digest").map_err(|error| error.to_string())?;
642        if self.claim_mode.is_none() {
643            return Err("checkpoint_claim_mode_invalid".to_string());
644        }
645        let resume_attempt = self
646            .resume_attempt
647            .ok_or_else(|| "distributed v2 requires resume_attempt".to_string())?;
648        if resume_attempt == 0 || resume_attempt > MAX_WIRE_INTEGER {
649            return Err("checkpoint_resume_attempt_invalid".to_string());
650        }
651        let config = self
652            .checkpoint_config
653            .as_ref()
654            .ok_or_else(|| "distributed v2 requires checkpoint_config".to_string())?;
655        config.validate()?;
656        if self.recipe.capabilities.checkpoint_store_ref.is_none() {
657            return Err("distributed v2 requires checkpoint_store_ref".to_string());
658        }
659        for namespace in &config.required_extension_namespaces {
660            if !self
661                .recipe
662                .capabilities
663                .checkpoint_extension_refs
664                .iter()
665                .any(|reference| reference.namespace == *namespace && reference.required)
666            {
667                return Err(format!(
668                    "required checkpoint extension {namespace} is unavailable"
669                ));
670            }
671        }
672        if self
673            .deadline_unix_ms
674            .is_some_and(|value| value > MAX_WIRE_INTEGER)
675            || self.lease_duration_ms > MAX_WIRE_INTEGER
676        {
677            return Err("distributed v2 lease values must be JSON-safe integers".to_string());
678        }
679        Ok(())
680    }
681
682    pub fn ensure_not_expired_at(&self, now_ms: u64) -> Result<(), String> {
683        if self
684            .deadline_unix_ms
685            .is_some_and(|deadline| deadline <= now_ms)
686        {
687            return Err(format!(
688                "distributed job {} deadline has expired",
689                self.job_id
690            ));
691        }
692        Ok(())
693    }
694
695    pub fn ensure_not_expired(&self) -> Result<(), String> {
696        self.ensure_not_expired_at(now_unix_ms()?)
697    }
698
699    pub fn remaining_millis_at(&self, now_ms: u64) -> Option<u64> {
700        self.deadline_unix_ms
701            .map(|deadline| deadline.saturating_sub(now_ms))
702    }
703
704    pub fn to_dict(&self) -> Value {
705        let mut value = serde_json::json!({
706            "schema_version": self.schema_version,
707            "job_id": self.job_id,
708            "run_id": self.run_id,
709            "task": self.task.to_dict(),
710            "budget_limits": self.budget_limits,
711            "recipe": self.recipe.to_dict(),
712            "cycle_name": self.cycle_name,
713            "cycle_index": self.cycle_index,
714            "idempotency_key": self.idempotency_key,
715            "deadline_unix_ms": self.deadline_unix_ms,
716            "lease_duration_ms": self.lease_duration_ms,
717        });
718        if self.is_checkpoint_v2() {
719            let object = value
720                .as_object_mut()
721                .expect("distributed envelope is always an object");
722            object.insert(
723                "root_run_id".to_string(),
724                serde_json::to_value(&self.root_run_id)
725                    .expect("distributed identity always serializes"),
726            );
727            object.insert(
728                "trace_id".to_string(),
729                serde_json::to_value(&self.trace_id)
730                    .expect("distributed identity always serializes"),
731            );
732            object.insert(
733                "run_definition_schema".to_string(),
734                serde_json::to_value(&self.run_definition_schema)
735                    .expect("run definition schema always serializes"),
736            );
737            object.insert(
738                "run_definition_digest".to_string(),
739                serde_json::to_value(&self.run_definition_digest)
740                    .expect("run definition digest always serializes"),
741            );
742            object.insert(
743                "claim_mode".to_string(),
744                serde_json::to_value(self.claim_mode).expect("claim mode always serializes"),
745            );
746            object.insert(
747                "resume_attempt".to_string(),
748                serde_json::to_value(self.resume_attempt)
749                    .expect("resume attempt always serializes"),
750            );
751            object.insert(
752                "checkpoint_config".to_string(),
753                serde_json::to_value(&self.checkpoint_config)
754                    .expect("checkpoint config always serializes"),
755            );
756            normalize_integral_float(
757                object
758                    .get_mut("recipe")
759                    .and_then(Value::as_object_mut)
760                    .expect("runtime recipe is always an object"),
761                "timeout_seconds",
762            );
763            if let Some(capabilities) = object
764                .get_mut("recipe")
765                .and_then(Value::as_object_mut)
766                .and_then(|recipe| recipe.get_mut("capabilities"))
767                .and_then(Value::as_object_mut)
768            {
769                normalize_integral_float(capabilities, "approval_timeout_seconds");
770            }
771        }
772        value
773    }
774
775    pub fn from_dict(payload: &Value) -> Result<Self, String> {
776        if !payload.is_object() {
777            return Err("distributed envelope must be an object".to_string());
778        }
779        if let Some(budget_limits) = payload
780            .get("budget_limits")
781            .filter(|budget_limits| !budget_limits.is_null())
782        {
783            serde_json::from_value::<RunBudgetLimits>(budget_limits.clone()).map_err(|error| {
784                format!(
785                    "distributed envelope budget limit must be between 0 and {MAX_WIRE_INTEGER}: {error}"
786                )
787            })?;
788        }
789        let schema_version = payload
790            .get("schema_version")
791            .and_then(Value::as_str)
792            .ok_or_else(|| "unsupported distributed schema_version".to_string())?;
793        if !matches!(
794            schema_version,
795            DISTRIBUTED_RUN_SCHEMA_VERSION_V1 | DISTRIBUTED_RUN_SCHEMA_VERSION_V2
796        ) {
797            return Err(format!(
798                "unsupported distributed schema_version: {schema_version}"
799            ));
800        }
801        if schema_version == DISTRIBUTED_RUN_SCHEMA_VERSION_V2 {
802            let has_v2_shape = [
803                "root_run_id",
804                "trace_id",
805                "run_definition_schema",
806                "run_definition_digest",
807                "claim_mode",
808                "resume_attempt",
809                "checkpoint_config",
810            ]
811            .iter()
812            .any(|field| payload.get(*field).is_some());
813            if !has_v2_shape {
814                return Err(format!(
815                    "unsupported distributed schema_version: {schema_version}"
816                ));
817            }
818            validate_v2_discriminator_fields(payload)?;
819        }
820        let envelope: Self =
821            serde_json::from_value(payload.clone()).map_err(|error| error.to_string())?;
822        envelope.validate()?;
823        Ok(envelope)
824    }
825}
826
827impl Serialize for DistributedRunEnvelope {
828    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
829    where
830        S: serde::Serializer,
831    {
832        if self.is_checkpoint_v2() {
833            return self.to_dict().serialize(serializer);
834        }
835        #[derive(Serialize)]
836        struct V1Envelope<'a> {
837            schema_version: &'a str,
838            job_id: &'a str,
839            run_id: &'a str,
840            task: &'a AgentTask,
841            budget_limits: &'a Option<RunBudgetLimits>,
842            recipe: &'a RuntimeRecipe,
843            cycle_name: &'a str,
844            cycle_index: u32,
845            idempotency_key: &'a str,
846            deadline_unix_ms: Option<u64>,
847            lease_duration_ms: u64,
848        }
849        V1Envelope {
850            schema_version: &self.schema_version,
851            job_id: &self.job_id,
852            run_id: &self.run_id,
853            task: &self.task,
854            budget_limits: &self.budget_limits,
855            recipe: &self.recipe,
856            cycle_name: &self.cycle_name,
857            cycle_index: self.cycle_index,
858            idempotency_key: &self.idempotency_key,
859            deadline_unix_ms: self.deadline_unix_ms,
860            lease_duration_ms: self.lease_duration_ms,
861        }
862        .serialize(serializer)
863    }
864}
865
866fn validate_v2_discriminator_fields(payload: &Value) -> Result<(), String> {
867    if payload.get("run_definition_schema").and_then(Value::as_str) != Some(RUN_DEFINITION_SCHEMA) {
868        return Err("checkpoint_definition_schema_unsupported".to_string());
869    }
870    if payload.get("checkpoint_config").is_none_or(Value::is_null) {
871        return Err("distributed v2 requires checkpoint_config".to_string());
872    }
873    if !matches!(
874        payload.get("claim_mode").and_then(Value::as_str),
875        Some("continue" | "recovery")
876    ) {
877        return Err("checkpoint_claim_mode_invalid".to_string());
878    }
879    Ok(())
880}
881
882fn normalize_integral_float(object: &mut serde_json::Map<String, Value>, field: &str) {
883    let Some(value) = object.get(field).and_then(Value::as_f64) else {
884        return;
885    };
886    if value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= MAX_WIRE_INTEGER as f64
887    {
888        object.insert(field.to_string(), Value::from(value as u64));
889    }
890}
891
892pub fn now_unix_ms() -> Result<u64, String> {
893    SystemTime::now()
894        .duration_since(UNIX_EPOCH)
895        .map_err(|error| error.to_string())?
896        .as_millis()
897        .try_into()
898        .map_err(|_| "system clock milliseconds exceed u64".to_string())
899}
900
901fn require_non_empty(value: &str, field_name: &str) -> Result<(), String> {
902    if value.trim().is_empty() {
903        Err(format!("{field_name} must be a non-empty string"))
904    } else {
905        Ok(())
906    }
907}
908
909fn validate_sorted_unique(
910    values: &[String],
911    field_name: &str,
912    validate: impl Fn(&str) -> Result<(), String>,
913) -> Result<(), String> {
914    for value in values {
915        validate(value)?;
916    }
917    if values.windows(2).any(|window| window[0] >= window[1]) {
918        return Err(format!("{field_name} must be sorted and unique"));
919    }
920    Ok(())
921}
922
923fn validate_json_pointer(pointer: &str) -> Result<(), String> {
924    if pointer.is_empty() {
925        return Ok(());
926    }
927    if !pointer.starts_with('/') {
928        return Err("credential_slots must contain RFC 6901 JSON pointers".to_string());
929    }
930    let bytes = pointer.as_bytes();
931    let mut index = 0;
932    while index < bytes.len() {
933        if bytes[index] == b'~' {
934            let Some(next) = bytes.get(index + 1) else {
935                return Err("credential_slots must contain RFC 6901 JSON pointers".to_string());
936            };
937            if !matches!(*next, b'0' | b'1') {
938                return Err("credential_slots must contain RFC 6901 JSON pointers".to_string());
939            }
940            index += 1;
941        }
942        index += 1;
943    }
944    Ok(())
945}
946
947fn utf16_cmp(left: &str, right: &str) -> std::cmp::Ordering {
948    left.encode_utf16().cmp(right.encode_utf16())
949}