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";
19pub 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}