1use serde::Serialize;
31use serde_json::Value;
32use std::collections::HashMap;
33use std::time::{Duration, Instant};
34
35use traverse_contracts::{CanonicalProposal, EffectClass, ParallelSchedule, ProposalNode};
36
37use crate::proposal::{
38 AuthorizationSummary, ProposalNodeOutcome, ProposalNodeStatus, ProposalTerminalState,
39 ProposalTrace, ResolvedProposalNode, assemble_node_input, build_node_execution_request,
40};
41use crate::{Runtime, RuntimeResultStatus};
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct ParallelAuthorizationError {
49 pub code: ParallelAuthorizationErrorCode,
50 pub message: String,
51 pub path: String,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ParallelAuthorizationErrorCode {
57 ConcurrentSideEffectDenied,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ParallelAuthorizationFailure {
62 pub errors: Vec<ParallelAuthorizationError>,
63}
64
65pub fn enforce_pure_read_only_parallelism(
73 schedule: &ParallelSchedule,
74 resolved_nodes: &[ResolvedProposalNode],
75) -> Result<(), ParallelAuthorizationFailure> {
76 let effect_class_by_node: HashMap<&str, EffectClass> = resolved_nodes
77 .iter()
78 .map(|node| (node.node_id.as_str(), node.contract.risk.effect_class))
79 .collect();
80
81 let mut errors = Vec::new();
82 for (wave_index, wave) in schedule.waves.iter().enumerate() {
83 if wave.len() <= 1 {
84 continue;
85 }
86 for node_id in wave {
87 if effect_class_by_node.get(node_id.as_str()) != Some(&EffectClass::PureRead) {
88 errors.push(ParallelAuthorizationError {
89 code: ParallelAuthorizationErrorCode::ConcurrentSideEffectDenied,
90 message: format!(
91 "node '{node_id}' does not have effect_class pure_read and cannot run \
92 concurrently with other nodes in wave {wave_index}"
93 ),
94 path: format!("$.schedule.waves[{wave_index}]"),
95 });
96 }
97 }
98 }
99
100 if errors.is_empty() {
101 Ok(())
102 } else {
103 Err(ParallelAuthorizationFailure { errors })
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct ParallelExecutionLimits {
115 pub max_wall_time: Duration,
118 pub max_wave_payload_bytes: usize,
121 pub max_concurrent_nodes: usize,
125}
126
127pub const DEFAULT_MAX_WALL_TIME_MS: u64 = 30_000;
128pub const DEFAULT_MAX_WAVE_PAYLOAD_BYTES: usize = 1_048_576;
129
130impl Default for ParallelExecutionLimits {
131 fn default() -> Self {
132 Self {
133 max_wall_time: Duration::from_millis(DEFAULT_MAX_WALL_TIME_MS),
134 max_wave_payload_bytes: DEFAULT_MAX_WAVE_PAYLOAD_BYTES,
135 max_concurrent_nodes: traverse_contracts::DEFAULT_MAX_CONCURRENT_NODES,
136 }
137 }
138}
139
140#[must_use]
155#[allow(clippy::too_many_lines)]
156pub fn execute_parallel_proposal<E: crate::LocalExecutor>(
157 runtime: &Runtime<E>,
158 canonical: &CanonicalProposal,
159 schedule: &ParallelSchedule,
160 authorization: AuthorizationSummary,
161 proposal_digest: &str,
162 snapshot_digest: &str,
163 limits: &ParallelExecutionLimits,
164) -> ProposalTrace
165where
166 Runtime<E>: Sync,
167{
168 let nodes_by_id: HashMap<&str, &ProposalNode> = canonical
169 .proposal
170 .nodes
171 .iter()
172 .map(|node| (node.node_id.as_str(), node))
173 .collect();
174
175 let mut outputs: HashMap<String, Value> = HashMap::new();
176 let mut outcomes: Vec<ProposalNodeOutcome> = Vec::with_capacity(canonical.proposal.nodes.len());
177 let mut failed = false;
178 let mut cancelled = false;
179 let start = Instant::now();
180 let batch_size = limits.max_concurrent_nodes.max(1);
181
182 for wave in &schedule.waves {
183 if failed || cancelled {
184 push_skipped_wave(&mut outcomes, wave, &nodes_by_id);
185 continue;
186 }
187 if start.elapsed() > limits.max_wall_time {
188 cancelled = true;
189 push_skipped_wave(&mut outcomes, wave, &nodes_by_id);
190 continue;
191 }
192
193 let wave_inputs: Vec<(String, Value)> = wave
194 .iter()
195 .map(|node_id| {
196 (
197 node_id.clone(),
198 assemble_node_input(canonical, node_id, &outputs),
199 )
200 })
201 .collect();
202 let wave_payload_bytes: usize = wave_inputs
203 .iter()
204 .map(|(_, input)| input.to_string().len())
205 .sum();
206 if wave_payload_bytes > limits.max_wave_payload_bytes {
207 cancelled = true;
208 push_skipped_wave(&mut outcomes, wave, &nodes_by_id);
209 continue;
210 }
211
212 for batch in wave_inputs.chunks(batch_size) {
213 for (node_id, outcome, output) in
214 dispatch_batch(runtime, canonical, &nodes_by_id, batch)
215 {
216 if outcome.status == ProposalNodeStatus::Failed {
217 failed = true;
218 }
219 if let Some(output) = output {
220 outputs.insert(node_id, output);
221 }
222 outcomes.push(outcome);
223 }
224 }
225 }
226
227 let terminal_state = if failed {
228 ProposalTerminalState::Failed
229 } else if cancelled {
230 ProposalTerminalState::Cancelled
231 } else {
232 ProposalTerminalState::Succeeded
233 };
234
235 ProposalTrace {
236 proposal_id: canonical.proposal.proposal_id.clone(),
237 proposal_digest: proposal_digest.to_string(),
238 snapshot_digest: snapshot_digest.to_string(),
239 authorization,
240 node_outcomes: outcomes,
241 mapping_paths: canonical
242 .proposal
243 .mappings
244 .iter()
245 .map(|m| (m.source_path.clone(), m.target_path.clone()))
246 .collect(),
247 terminal_state,
248 }
249}
250
251fn dispatch_batch<'a, E: crate::LocalExecutor>(
261 runtime: &Runtime<E>,
262 canonical: &CanonicalProposal,
263 nodes_by_id: &HashMap<&'a str, &'a ProposalNode>,
264 batch: &[(String, Value)],
265) -> Vec<(String, ProposalNodeOutcome, Option<Value>)>
266where
267 Runtime<E>: Sync,
268{
269 let dispatchable: Vec<(&str, &'a ProposalNode, &Value)> = batch
270 .iter()
271 .filter_map(|(node_id, input)| {
272 nodes_by_id
273 .get(node_id.as_str())
274 .map(|node| (node_id.as_str(), *node, input))
275 })
276 .collect();
277
278 let joined: Vec<(
279 String,
280 &'a ProposalNode,
281 std::thread::Result<crate::RuntimeExecutionOutcome>,
282 )> = std::thread::scope(|scope| {
283 let handles: Vec<(
284 String,
285 &'a ProposalNode,
286 std::thread::ScopedJoinHandle<'_, crate::RuntimeExecutionOutcome>,
287 )> = dispatchable
288 .iter()
289 .map(|(node_id, node, input)| {
290 let owned_node_id = (*node_id).to_string();
291 let spawn_node_id = owned_node_id.clone();
292 let node = *node;
293 let input = (*input).clone();
294 let handle = scope.spawn(move || {
295 let request =
296 build_node_execution_request(canonical, node, &spawn_node_id, input);
297 runtime.execute(request)
298 });
299 (owned_node_id, node, handle)
300 })
301 .collect();
302 handles
303 .into_iter()
304 .map(|(node_id, node, handle)| (node_id, node, handle.join()))
305 .collect()
306 });
307
308 joined
309 .into_iter()
310 .map(|(node_id, node, joined_result)| match joined_result {
311 Ok(outcome) => match outcome.result.status {
312 RuntimeResultStatus::Completed => (
313 node_id.clone(),
314 succeeded_outcome(&node_id, node),
315 outcome.result.output.clone(),
316 ),
317 RuntimeResultStatus::Error => (
318 node_id.clone(),
319 failed_outcome(&node_id, node, &outcome),
320 None,
321 ),
322 },
323 Err(_) => (node_id.clone(), panicked_outcome(&node_id, node), None),
324 })
325 .collect()
326}
327
328fn succeeded_outcome(node_id: &str, node: &ProposalNode) -> ProposalNodeOutcome {
329 ProposalNodeOutcome {
330 node_id: node_id.to_string(),
331 capability_id: node.capability_id.clone(),
332 capability_version: node.capability_version.clone(),
333 artifact_digest: node.artifact_digest.clone(),
334 status: ProposalNodeStatus::Succeeded,
335 error_code: None,
336 }
337}
338
339fn failed_outcome(
340 node_id: &str,
341 node: &ProposalNode,
342 outcome: &crate::RuntimeExecutionOutcome,
343) -> ProposalNodeOutcome {
344 ProposalNodeOutcome {
345 node_id: node_id.to_string(),
346 capability_id: node.capability_id.clone(),
347 capability_version: node.capability_version.clone(),
348 artifact_digest: node.artifact_digest.clone(),
349 status: ProposalNodeStatus::Failed,
350 error_code: outcome
351 .result
352 .error
353 .as_ref()
354 .map(|error| format!("{:?}", error.code)),
355 }
356}
357
358fn panicked_outcome(node_id: &str, node: &ProposalNode) -> ProposalNodeOutcome {
362 ProposalNodeOutcome {
363 node_id: node_id.to_string(),
364 capability_id: node.capability_id.clone(),
365 capability_version: node.capability_version.clone(),
366 artifact_digest: node.artifact_digest.clone(),
367 status: ProposalNodeStatus::Failed,
368 error_code: Some("executor_panicked".to_string()),
369 }
370}
371
372fn push_skipped_wave(
373 outcomes: &mut Vec<ProposalNodeOutcome>,
374 wave: &[String],
375 nodes_by_id: &HashMap<&str, &ProposalNode>,
376) {
377 for node_id in wave {
378 let Some(node) = nodes_by_id.get(node_id.as_str()) else {
379 continue;
380 };
381 outcomes.push(ProposalNodeOutcome {
382 node_id: node_id.clone(),
383 capability_id: node.capability_id.clone(),
384 capability_version: node.capability_version.clone(),
385 artifact_digest: node.artifact_digest.clone(),
386 status: ProposalNodeStatus::SkippedAfterEarlierFailure,
387 error_code: None,
388 });
389 }
390}
391
392#[cfg(test)]
393#[allow(clippy::expect_used)]
394#[allow(clippy::panic)]
395mod tests {
396 use super::*;
397 use crate::security::RuntimeSecurityConfig;
398 use crate::{
399 LocalExecutionFailure, LocalExecutionFailureCode, LocalExecutionOutput, LocalExecutor,
400 };
401 use serde_json::json;
402 use std::collections::{HashMap as StdHashMap, HashSet};
403 use std::sync::{Arc, Mutex};
404 use traverse_contracts::{
405 BinaryFormat as ContractBinaryFormat, CapabilityContract, DataFlowPolicy, DeterminismClass,
406 Entrypoint, EntrypointKind, Execution, ExecutionConstraints, ExecutionTarget,
407 FilesystemAccess, HostApiAccess, Lifecycle, ManifestReference, NetworkAccess, Owner,
408 ParallelScheduleLimits, ProposalEdge, ProposalLimits, ReliabilityMetadata, RiskMetadata,
409 SchemaContainer, ServiceType, SideEffect, SideEffectKind, WorkflowProposal,
410 canonicalize_proposal, compute_parallel_schedule,
411 };
412 use traverse_registry::{
413 ArtifactDigests, BinaryFormat as RegistryBinaryFormat, BinaryReference,
414 CapabilityArtifactRecord, CapabilityRegistration, CapabilityRegistry,
415 ComposabilityMetadata, CompositionKind, CompositionPattern, ImplementationKind,
416 RegistryProvenance, RegistryScope, SourceKind, SourceReference,
417 };
418
419 fn risk(effect_class: EffectClass) -> RiskMetadata {
420 RiskMetadata {
421 effect_class,
422 determinism_class: DeterminismClass::Deterministic,
423 data_flow: DataFlowPolicy::default(),
424 reliability: ReliabilityMetadata {
425 idempotency_required: false,
426 retryable: true,
427 compensation_available: false,
428 },
429 }
430 }
431
432 fn contract(capability_id: &str, effect_class: EffectClass) -> CapabilityContract {
433 let (namespace, name) = capability_id
434 .rsplit_once('.')
435 .unwrap_or(("test", capability_id));
436 CapabilityContract {
437 kind: "capability_contract".to_string(),
438 schema_version: "1.0.0".to_string(),
439 id: capability_id.to_string(),
440 namespace: namespace.to_string(),
441 name: name.to_string(),
442 version: "1.0.0".to_string(),
443 lifecycle: Lifecycle::Active,
444 owner: Owner {
445 team: "traverse-core".to_string(),
446 contact: "enrico.piovesan10@gmail.com".to_string(),
447 },
448 summary: "Test capability for parallel proposal scheduling.".to_string(),
449 description: "Portable test capability used to validate parallel scheduling."
450 .to_string(),
451 inputs: SchemaContainer { schema: json!({}) },
452 outputs: SchemaContainer { schema: json!({}) },
453 preconditions: Vec::new(),
454 postconditions: Vec::new(),
455 side_effects: vec![SideEffect {
456 kind: SideEffectKind::MemoryOnly,
457 description: "No durable side effect.".to_string(),
458 }],
459 emits: Vec::new(),
460 consumes: Vec::new(),
461 permissions: Vec::new(),
462 execution: Execution {
463 binary_format: ContractBinaryFormat::Wasm,
464 entrypoint: Entrypoint {
465 kind: EntrypointKind::WasiCommand,
466 command: "run".to_string(),
467 },
468 preferred_targets: vec![ExecutionTarget::Local],
469 constraints: ExecutionConstraints {
470 host_api_access: HostApiAccess::None,
471 network_access: NetworkAccess::Forbidden,
472 filesystem_access: FilesystemAccess::None,
473 },
474 },
475 policies: Vec::new(),
476 dependencies: Vec::new(),
477 provenance: traverse_contracts::Provenance {
478 source: traverse_contracts::ProvenanceSource::Greenfield,
479 author: "test".to_string(),
480 created_at: "2026-08-23T00:00:00Z".to_string(),
481 spec_ref: None,
482 adr_refs: Vec::new(),
483 exception_refs: Vec::new(),
484 },
485 evidence: Vec::new(),
486 service_type: ServiceType::Stateless,
487 permitted_targets: vec![ExecutionTarget::Local],
488 event_trigger: None,
489 connector_requirements: Vec::new(),
490 state_schema: None,
491 use_cases: Vec::new(),
492 risk: risk(effect_class),
493 }
494 }
495
496 fn artifact(digest: &str) -> CapabilityArtifactRecord {
497 CapabilityArtifactRecord {
498 artifact_ref: format!("artifact:{digest}"),
499 implementation_kind: ImplementationKind::Executable,
500 source: SourceReference {
501 kind: SourceKind::Git,
502 location: "https://example.invalid/repo".to_string(),
503 },
504 binary: Some(BinaryReference {
505 format: RegistryBinaryFormat::Wasm,
506 location: format!("artifacts/{digest}/capability.wasm"),
507 signature: None,
508 }),
509 workflow_ref: None,
510 digests: ArtifactDigests {
511 source_digest: format!("src-{digest}"),
512 binary_digest: Some(digest.to_string()),
513 },
514 provenance: RegistryProvenance {
515 source: "test".to_string(),
516 author: "test".to_string(),
517 created_at: "2026-08-23T00:00:00Z".to_string(),
518 },
519 }
520 }
521
522 fn registry_with(
523 entries: Vec<(CapabilityContract, CapabilityArtifactRecord)>,
524 ) -> CapabilityRegistry {
525 let mut registry = CapabilityRegistry::new();
526 for (contract, artifact) in entries {
527 let outcome = registry.register(CapabilityRegistration {
528 scope: RegistryScope::Public,
529 contract,
530 contract_path: "registry/test/contract.json".to_string(),
531 artifact,
532 registered_at: "2026-08-23T00:00:00Z".to_string(),
533 tags: Vec::new(),
534 composability: ComposabilityMetadata {
535 kind: CompositionKind::Atomic,
536 patterns: vec![CompositionPattern::Sequential],
537 provides: Vec::new(),
538 requires: Vec::new(),
539 },
540 governing_spec: "005-capability-registry".to_string(),
541 validator_version: "0.1.0".to_string(),
542 });
543 assert!(outcome.is_ok(), "registration must succeed: {outcome:?}");
544 }
545 registry
546 }
547
548 fn node(node_id: &str, capability_id: &str) -> ProposalNode {
549 ProposalNode {
550 node_id: node_id.to_string(),
551 capability_id: capability_id.to_string(),
552 capability_version: "1.0.0".to_string(),
553 artifact_digest: format!("digest-{node_id}"),
554 }
555 }
556
557 fn resolved(node_id: &str, effect_class: EffectClass) -> ResolvedProposalNode {
558 ResolvedProposalNode {
559 node_id: node_id.to_string(),
560 contract: contract(&format!("test.{node_id}"), effect_class),
561 }
562 }
563
564 fn diamond_workflow_proposal() -> WorkflowProposal {
567 WorkflowProposal {
568 kind: "workflow_proposal".to_string(),
569 schema_version: "1.0.0".to_string(),
570 proposal_id: "proposal-p2-001".to_string(),
571 workspace_id: "workspace-001".to_string(),
572 app_manifest: ManifestReference {
573 app_id: "test-app".to_string(),
574 app_version: "1.0.0".to_string(),
575 manifest_digest: "sha256:manifest-digest".to_string(),
576 },
577 nodes: vec![
578 node("a", "test.a"),
579 node("b", "test.b"),
580 node("c", "test.c"),
581 node("d", "test.d"),
582 ],
583 edges: vec![
584 ProposalEdge {
585 from_node_id: "a".to_string(),
586 to_node_id: "b".to_string(),
587 },
588 ProposalEdge {
589 from_node_id: "a".to_string(),
590 to_node_id: "c".to_string(),
591 },
592 ProposalEdge {
593 from_node_id: "b".to_string(),
594 to_node_id: "d".to_string(),
595 },
596 ProposalEdge {
597 from_node_id: "c".to_string(),
598 to_node_id: "d".to_string(),
599 },
600 ],
601 mappings: Vec::new(),
602 initial_input: json!({}),
603 }
604 }
605
606 fn diamond_canonical() -> CanonicalProposal {
607 canonicalize_proposal(diamond_workflow_proposal(), &ProposalLimits::default())
608 .expect("diamond proposal must canonicalize")
609 }
610
611 fn diamond_resolved_nodes(non_read_effect: Option<&str>) -> Vec<ResolvedProposalNode> {
612 ["a", "b", "c", "d"]
613 .iter()
614 .map(|id| {
615 let effect_class = if Some(*id) == non_read_effect {
616 EffectClass::ExternalEffect
617 } else {
618 EffectClass::PureRead
619 };
620 resolved(id, effect_class)
621 })
622 .collect()
623 }
624
625 #[test]
628 fn allows_a_diamond_schedule_when_every_concurrent_wave_is_pure_read() -> Result<(), String> {
629 let canonical = diamond_canonical();
630 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
631 .map_err(|e| format!("{e:?}"))?;
632 enforce_pure_read_only_parallelism(&schedule, &diamond_resolved_nodes(None))
633 .map_err(|e| format!("{e:?}"))
634 }
635
636 #[test]
637 fn denies_a_concurrent_wave_containing_a_non_pure_read_node() -> Result<(), String> {
638 let canonical = diamond_canonical();
639 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
640 .map_err(|e| format!("{e:?}"))?;
641 let failure =
642 enforce_pure_read_only_parallelism(&schedule, &diamond_resolved_nodes(Some("c")))
643 .expect_err("a non-pure_read node in a concurrent wave must be denied");
644 assert!(
645 failure
646 .errors
647 .iter()
648 .any(|e| e.code == ParallelAuthorizationErrorCode::ConcurrentSideEffectDenied)
649 );
650 Ok(())
651 }
652
653 #[test]
654 fn allows_a_non_pure_read_node_when_its_wave_has_no_sibling() -> Result<(), String> {
655 let canonical = diamond_canonical();
658 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
659 .map_err(|e| format!("{e:?}"))?;
660 enforce_pure_read_only_parallelism(&schedule, &diamond_resolved_nodes(Some("d")))
661 .map_err(|e| format!("{e:?}"))
662 }
663
664 #[derive(Default)]
667 struct ConcurrencyTracker {
668 current: Mutex<usize>,
669 max_seen: Mutex<usize>,
670 }
671
672 impl ConcurrencyTracker {
673 fn enter(&self) {
674 let mut current = self
675 .current
676 .lock()
677 .expect("tracker lock must not be poisoned");
678 *current += 1;
679 let mut max_seen = self
680 .max_seen
681 .lock()
682 .expect("tracker lock must not be poisoned");
683 if *current > *max_seen {
684 *max_seen = *current;
685 }
686 }
687
688 fn exit(&self) {
689 let mut current = self
690 .current
691 .lock()
692 .expect("tracker lock must not be poisoned");
693 *current -= 1;
694 }
695
696 fn max_seen(&self) -> usize {
697 *self
698 .max_seen
699 .lock()
700 .expect("tracker lock must not be poisoned")
701 }
702 }
703
704 #[derive(Default, Clone)]
705 struct ScriptedExecutor {
706 fail_capability_ids: HashSet<String>,
707 panic_capability_ids: HashSet<String>,
708 sleep_capability_ids: StdHashMap<String, std::time::Duration>,
709 concurrency: Arc<ConcurrencyTracker>,
710 }
711
712 impl LocalExecutor for ScriptedExecutor {
713 fn execute(
714 &self,
715 capability: &traverse_registry::ResolvedCapability,
716 input: &Value,
717 ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
718 self.concurrency.enter();
719 if self.panic_capability_ids.contains(&capability.contract.id) {
720 self.concurrency.exit();
721 panic!("scripted executor panic for test");
722 }
723 if let Some(duration) = self.sleep_capability_ids.get(&capability.contract.id) {
724 std::thread::sleep(*duration);
725 }
726 let result = if self.fail_capability_ids.contains(&capability.contract.id) {
727 Err(LocalExecutionFailure {
728 code: LocalExecutionFailureCode::ExecutionFailed,
729 message: "scripted failure".to_string(),
730 })
731 } else {
732 Ok(LocalExecutionOutput {
733 value: json!({"node": capability.contract.id, "received": input.clone()}),
734 emitted_events: Vec::new(),
735 })
736 };
737 self.concurrency.exit();
738 result
739 }
740 }
741
742 fn diamond_registry() -> CapabilityRegistry {
743 registry_with(vec![
744 (
745 contract("test.a", EffectClass::PureRead),
746 artifact("digest-a"),
747 ),
748 (
749 contract("test.b", EffectClass::PureRead),
750 artifact("digest-b"),
751 ),
752 (
753 contract("test.c", EffectClass::PureRead),
754 artifact("digest-c"),
755 ),
756 (
757 contract("test.d", EffectClass::PureRead),
758 artifact("digest-d"),
759 ),
760 ])
761 }
762
763 #[test]
764 fn executes_independent_branches_concurrently_with_a_deterministic_trace_order()
765 -> Result<(), String> {
766 let canonical = diamond_canonical();
767 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
768 .map_err(|e| format!("{e:?}"))?;
769
770 let mut sleep_capability_ids = StdHashMap::new();
771 sleep_capability_ids.insert("test.c".to_string(), std::time::Duration::from_millis(20));
772 let executor = ScriptedExecutor {
773 sleep_capability_ids,
774 ..ScriptedExecutor::default()
775 };
776 let runtime = Runtime::new(diamond_registry(), executor)
777 .with_security_config(RuntimeSecurityConfig::development());
778
779 let trace = execute_parallel_proposal(
780 &runtime,
781 &canonical,
782 &schedule,
783 AuthorizationSummary {
784 automatic: true,
785 approval_token_id: None,
786 },
787 "digest",
788 "snapshot-digest",
789 &ParallelExecutionLimits::default(),
790 );
791
792 assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded);
793 let node_order: Vec<&str> = trace
796 .node_outcomes
797 .iter()
798 .map(|o| o.node_id.as_str())
799 .collect();
800 assert_eq!(node_order, vec!["a", "b", "c", "d"]);
801 assert!(
802 trace
803 .node_outcomes
804 .iter()
805 .all(|o| o.status == ProposalNodeStatus::Succeeded)
806 );
807 Ok(())
808 }
809
810 #[test]
811 fn bounds_real_concurrency_to_the_configured_max_concurrent_nodes() -> Result<(), String> {
812 let canonical = diamond_canonical();
813 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
814 .map_err(|e| format!("{e:?}"))?;
815
816 let mut sleep_capability_ids = StdHashMap::new();
817 sleep_capability_ids.insert("test.b".to_string(), std::time::Duration::from_millis(15));
818 sleep_capability_ids.insert("test.c".to_string(), std::time::Duration::from_millis(15));
819 let executor = ScriptedExecutor {
820 sleep_capability_ids,
821 ..ScriptedExecutor::default()
822 };
823 let concurrency = Arc::clone(&executor.concurrency);
824 let runtime = Runtime::new(diamond_registry(), executor)
825 .with_security_config(RuntimeSecurityConfig::development());
826
827 let limits = ParallelExecutionLimits {
828 max_concurrent_nodes: 1,
829 ..ParallelExecutionLimits::default()
830 };
831 let trace = execute_parallel_proposal(
832 &runtime,
833 &canonical,
834 &schedule,
835 AuthorizationSummary {
836 automatic: true,
837 approval_token_id: None,
838 },
839 "digest",
840 "snapshot-digest",
841 &limits,
842 );
843
844 assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded);
845 assert_eq!(concurrency.max_seen(), 1);
846 Ok(())
847 }
848
849 #[test]
850 fn stops_advancing_after_a_node_failure_but_lets_the_dispatched_wave_finish()
851 -> Result<(), String> {
852 let canonical = diamond_canonical();
853 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
854 .map_err(|e| format!("{e:?}"))?;
855
856 let mut fail_capability_ids = HashSet::new();
857 fail_capability_ids.insert("test.c".to_string());
858 let executor = ScriptedExecutor {
859 fail_capability_ids,
860 ..ScriptedExecutor::default()
861 };
862 let runtime = Runtime::new(diamond_registry(), executor)
863 .with_security_config(RuntimeSecurityConfig::development());
864
865 let trace = execute_parallel_proposal(
866 &runtime,
867 &canonical,
868 &schedule,
869 AuthorizationSummary {
870 automatic: true,
871 approval_token_id: None,
872 },
873 "digest",
874 "snapshot-digest",
875 &ParallelExecutionLimits::default(),
876 );
877
878 assert_eq!(trace.terminal_state, ProposalTerminalState::Failed);
879 let outcome_by_node: StdHashMap<&str, ProposalNodeStatus> = trace
880 .node_outcomes
881 .iter()
882 .map(|o| (o.node_id.as_str(), o.status.clone()))
883 .collect();
884 assert_eq!(outcome_by_node["a"], ProposalNodeStatus::Succeeded);
885 assert_eq!(outcome_by_node["b"], ProposalNodeStatus::Succeeded);
886 assert_eq!(outcome_by_node["c"], ProposalNodeStatus::Failed);
887 assert_eq!(
888 outcome_by_node["d"],
889 ProposalNodeStatus::SkippedAfterEarlierFailure
890 );
891 Ok(())
892 }
893
894 #[test]
895 fn surfaces_an_executor_panic_as_a_failed_outcome_instead_of_dropping_it() -> Result<(), String>
896 {
897 let canonical = diamond_canonical();
898 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
899 .map_err(|e| format!("{e:?}"))?;
900
901 let mut panic_capability_ids = HashSet::new();
902 panic_capability_ids.insert("test.c".to_string());
903 let executor = ScriptedExecutor {
904 panic_capability_ids,
905 ..ScriptedExecutor::default()
906 };
907 let runtime = Runtime::new(diamond_registry(), executor)
908 .with_security_config(RuntimeSecurityConfig::development());
909
910 let trace = execute_parallel_proposal(
911 &runtime,
912 &canonical,
913 &schedule,
914 AuthorizationSummary {
915 automatic: true,
916 approval_token_id: None,
917 },
918 "digest",
919 "snapshot-digest",
920 &ParallelExecutionLimits::default(),
921 );
922
923 assert_eq!(trace.terminal_state, ProposalTerminalState::Failed);
924 let c_outcome = trace
925 .node_outcomes
926 .iter()
927 .find(|o| o.node_id == "c")
928 .expect("c must have an outcome, not be silently dropped");
929 assert_eq!(c_outcome.status, ProposalNodeStatus::Failed);
930 assert_eq!(c_outcome.error_code, Some("executor_panicked".to_string()));
931 Ok(())
932 }
933
934 #[test]
935 fn cancels_further_waves_once_the_wall_time_budget_is_exhausted() -> Result<(), String> {
936 let canonical = diamond_canonical();
937 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
938 .map_err(|e| format!("{e:?}"))?;
939
940 let mut sleep_capability_ids = StdHashMap::new();
941 sleep_capability_ids.insert("test.a".to_string(), std::time::Duration::from_millis(40));
942 let executor = ScriptedExecutor {
943 sleep_capability_ids,
944 ..ScriptedExecutor::default()
945 };
946 let runtime = Runtime::new(diamond_registry(), executor)
947 .with_security_config(RuntimeSecurityConfig::development());
948
949 let limits = ParallelExecutionLimits {
950 max_wall_time: std::time::Duration::from_millis(10),
951 ..ParallelExecutionLimits::default()
952 };
953 let trace = execute_parallel_proposal(
954 &runtime,
955 &canonical,
956 &schedule,
957 AuthorizationSummary {
958 automatic: true,
959 approval_token_id: None,
960 },
961 "digest",
962 "snapshot-digest",
963 &limits,
964 );
965
966 assert_eq!(trace.terminal_state, ProposalTerminalState::Cancelled);
967 let outcome_by_node: StdHashMap<&str, ProposalNodeStatus> = trace
968 .node_outcomes
969 .iter()
970 .map(|o| (o.node_id.as_str(), o.status.clone()))
971 .collect();
972 assert_eq!(outcome_by_node["a"], ProposalNodeStatus::Succeeded);
973 assert_eq!(
974 outcome_by_node["b"],
975 ProposalNodeStatus::SkippedAfterEarlierFailure
976 );
977 Ok(())
978 }
979
980 #[test]
981 fn cancels_a_wave_whose_assembled_payload_exceeds_the_configured_byte_budget()
982 -> Result<(), String> {
983 let canonical = diamond_canonical();
984 let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
985 .map_err(|e| format!("{e:?}"))?;
986 let executor = ScriptedExecutor::default();
987 let runtime = Runtime::new(diamond_registry(), executor)
988 .with_security_config(RuntimeSecurityConfig::development());
989
990 let limits = ParallelExecutionLimits {
991 max_wave_payload_bytes: 0,
992 ..ParallelExecutionLimits::default()
993 };
994 let trace = execute_parallel_proposal(
995 &runtime,
996 &canonical,
997 &schedule,
998 AuthorizationSummary {
999 automatic: true,
1000 approval_token_id: None,
1001 },
1002 "digest",
1003 "snapshot-digest",
1004 &limits,
1005 );
1006
1007 assert_eq!(trace.terminal_state, ProposalTerminalState::Cancelled);
1008 Ok(())
1009 }
1010
1011 #[test]
1012 fn execute_parallel_proposal_skips_an_unresolved_node_id_within_a_dispatched_wave()
1013 -> Result<(), String> {
1014 let mut proposal = diamond_workflow_proposal();
1020 proposal.nodes.retain(|n| n.node_id == "a");
1021 proposal.edges.clear();
1022 let canonical = canonicalize_proposal(proposal, &ProposalLimits::default())
1023 .map_err(|e| format!("{e:?}"))?;
1024 let schedule = ParallelSchedule {
1025 waves: vec![vec!["a".to_string(), "ghost".to_string()]],
1026 };
1027
1028 let executor = ScriptedExecutor::default();
1029 let runtime = Runtime::new(
1030 registry_with(vec![(
1031 contract("test.a", EffectClass::PureRead),
1032 artifact("digest-a"),
1033 )]),
1034 executor,
1035 )
1036 .with_security_config(RuntimeSecurityConfig::development());
1037
1038 let trace = execute_parallel_proposal(
1039 &runtime,
1040 &canonical,
1041 &schedule,
1042 AuthorizationSummary {
1043 automatic: true,
1044 approval_token_id: None,
1045 },
1046 "digest",
1047 "snapshot-digest",
1048 &ParallelExecutionLimits::default(),
1049 );
1050
1051 assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded);
1052 assert_eq!(trace.node_outcomes.len(), 1);
1053 assert_eq!(trace.node_outcomes[0].node_id, "a");
1054 Ok(())
1055 }
1056
1057 #[test]
1058 fn execute_parallel_proposal_skips_an_unresolved_node_id_in_a_skipped_wave()
1059 -> Result<(), String> {
1060 let mut proposal = diamond_workflow_proposal();
1061 proposal.nodes.retain(|n| n.node_id == "a");
1062 proposal.edges.clear();
1063 let canonical = canonicalize_proposal(proposal, &ProposalLimits::default())
1064 .map_err(|e| format!("{e:?}"))?;
1065 let schedule = ParallelSchedule {
1066 waves: vec![vec!["a".to_string()], vec!["ghost".to_string()]],
1067 };
1068
1069 let mut fail_capability_ids = HashSet::new();
1070 fail_capability_ids.insert("test.a".to_string());
1071 let executor = ScriptedExecutor {
1072 fail_capability_ids,
1073 ..ScriptedExecutor::default()
1074 };
1075 let runtime = Runtime::new(
1076 registry_with(vec![(
1077 contract("test.a", EffectClass::PureRead),
1078 artifact("digest-a"),
1079 )]),
1080 executor,
1081 )
1082 .with_security_config(RuntimeSecurityConfig::development());
1083
1084 let trace = execute_parallel_proposal(
1085 &runtime,
1086 &canonical,
1087 &schedule,
1088 AuthorizationSummary {
1089 automatic: true,
1090 approval_token_id: None,
1091 },
1092 "digest",
1093 "snapshot-digest",
1094 &ParallelExecutionLimits::default(),
1095 );
1096
1097 assert_eq!(trace.terminal_state, ProposalTerminalState::Failed);
1098 assert_eq!(trace.node_outcomes.len(), 1);
1099 assert_eq!(trace.node_outcomes[0].node_id, "a");
1100 Ok(())
1101 }
1102
1103 #[test]
1104 fn parallel_execution_limits_default_matches_the_documented_defaults() {
1105 let limits = ParallelExecutionLimits::default();
1106 assert_eq!(
1107 limits.max_wall_time,
1108 std::time::Duration::from_millis(DEFAULT_MAX_WALL_TIME_MS)
1109 );
1110 assert_eq!(
1111 limits.max_wave_payload_bytes,
1112 DEFAULT_MAX_WAVE_PAYLOAD_BYTES
1113 );
1114 assert_eq!(
1115 limits.max_concurrent_nodes,
1116 traverse_contracts::DEFAULT_MAX_CONCURRENT_NODES
1117 );
1118 }
1119}