1#![allow(dead_code)]
12
13#[path = "adapters/adapter.rs"]
14mod adapter;
15#[path = "adapters/api.rs"]
16mod adapter_api;
17#[path = "adapters/conformance.rs"]
18mod adapter_conformance;
19#[cfg(test)]
20#[path = "internal/testing/adapter_contract_tests.rs"]
21mod adapter_contract_tests;
22#[cfg(feature = "experimental-public-api")]
23#[path = "runtime_core/execution/adapter_execution_contracts.rs"]
24mod adapter_execution_contracts;
25#[path = "adapters/sdk.rs"]
26mod adapter_sdk;
27mod adapters;
28#[path = "internal/analysis/adaptive_scheduler.rs"]
29mod adaptive_scheduler;
30#[path = "internal/workflow/ai_operator_assist.rs"]
31mod ai_operator_assist;
32#[path = "internal/control/api.rs"]
33mod api;
34mod artifacts;
35#[path = "adapters/async_adapter.rs"]
36mod async_adapter;
37#[path = "internal/identity/auth_identity.rs"]
38mod auth_identity;
39#[path = "internal/identity/authz_policy.rs"]
40mod authz_policy;
41mod backend;
42#[path = "backend/runtime/backend_cluster.rs"]
43mod backend_cluster;
44#[path = "backend/runtime/batch_execution.rs"]
45mod batch_execution;
46mod builtins;
47mod cache;
48#[path = "internal/control/clock.rs"]
49mod clock;
50#[path = "internal/control/config.rs"]
51mod config;
52#[cfg(feature = "experimental-public-api")]
53#[path = "runtime_core/execution/container_evidence_contracts.rs"]
54mod container_evidence_contracts;
55#[path = "backend/runtime/container_execution.rs"]
56mod container_execution;
57#[path = "diagnostics/runtime/control_plane.rs"]
58mod control_plane;
59#[path = "diagnostics/runtime/control_plane_api.rs"]
60mod control_plane_api;
61#[path = "backend/distributed/coordination.rs"]
62mod coordination;
63#[path = "internal/analysis/cost_optimization.rs"]
64mod cost_optimization;
65#[path = "runtime_core/execution/cron_calendar.rs"]
66mod cron_calendar;
67#[path = "internal/analysis/dataset_semantics.rs"]
68mod dataset_semantics;
69mod diagnostics;
70#[path = "backend/distributed/distributed.rs"]
71mod distributed;
72#[path = "backend/distributed/distribution_readiness.rs"]
73mod distribution_readiness;
74#[cfg(feature = "experimental-public-api")]
75#[path = "runtime_core/execution/durable_queue_contracts.rs"]
76mod durable_queue_contracts;
77#[path = "runtime_core/execution/engine.rs"]
78mod engine;
79mod error;
80#[path = "runtime_core/execution/flow.rs"]
81mod execution;
82#[path = "backend/runtime/execution_backend.rs"]
83mod execution_backend;
84#[path = "runtime_core/execution/context.rs"]
85mod execution_context;
86#[path = "runtime_core/planning/execution_plan.rs"]
87mod execution_plan;
88#[path = "internal/ext/extension_catalog.rs"]
89mod extension_catalog;
90#[path = "adapters/external.rs"]
91mod external_adapter;
92#[path = "runtime_core/execution/failure_summary.rs"]
93mod failure_summary;
94#[path = "backend/distributed/federated_scheduling.rs"]
95mod federated_scheduling;
96#[path = "adapters/file_transform.rs"]
97mod file_transform_adapter;
98#[path = "internal/ext/formal_verification.rs"]
99mod formal_verification;
100#[path = "backend/distributed/geo_federation.rs"]
101mod geo_federation;
102#[path = "backend/distributed/ha_scheduler.rs"]
103mod ha_scheduler;
104#[path = "adapters/http.rs"]
105mod http_adapter;
106#[path = "backend/distributed/infrastructure.rs"]
107mod infrastructure;
108mod internal;
109#[path = "runtime_core/governance/invariants.rs"]
110mod invariants;
111#[cfg(test)]
112#[path = "internal/testing/invariants_tests.rs"]
113mod invariants_tests;
114#[path = "internal/control/io.rs"]
115mod io;
116#[path = "backend/runtime/kubernetes_execution.rs"]
117mod kubernetes_execution;
118#[path = "backend/runtime/local_executor.rs"]
119mod local_executor;
120#[path = "backend/runtime/local_worker_pool.rs"]
121mod local_worker_pool;
122#[path = "internal/control/node_execution_contract.rs"]
123mod node_execution_contract;
124#[path = "internal/control/node_execution_types.rs"]
125mod node_execution_types;
126#[path = "runtime_core/execution/node_result.rs"]
127mod node_result;
128#[path = "diagnostics/runtime/observability.rs"]
129mod observability;
130#[path = "diagnostics/runtime/observability_deep.rs"]
131mod observability_deep;
132#[cfg(feature = "experimental-public-api")]
133#[path = "runtime_core/execution/observability_taxonomy_contracts.rs"]
134mod observability_taxonomy_contracts;
135#[path = "diagnostics/runtime/operations_governance.rs"]
136mod operations_governance;
137#[path = "artifacts/storage/path_authorization.rs"]
138mod path_authorization;
139#[path = "runtime_core/planning/path_resolution.rs"]
140mod path_resolution;
141#[path = "internal/perf/performance_capacity.rs"]
142mod performance_capacity;
143#[path = "runtime_core/planning/planner.rs"]
144mod planner;
145#[cfg(feature = "experimental-public-api")]
146#[path = "runtime_core/planning/planner_admission_contracts.rs"]
147mod planner_admission_contracts;
148#[path = "runtime_core/planning/planner_analysis.rs"]
149mod planner_analysis;
150mod policy;
151#[path = "adapters/python.rs"]
152mod python_adapter;
153#[path = "artifacts/storage/recovery.rs"]
154mod recovery;
155#[path = "adapters/runtime_registry.rs"]
156mod registry;
157#[path = "backend/runtime/remote_execution_model.rs"]
158mod remote_execution_model;
159#[path = "backend/runtime/remote_executor.rs"]
160mod remote_executor;
161mod replay;
162#[path = "runtime_core/execution/run_context.rs"]
163mod run_context;
164#[path = "runtime_core/execution/run_state.rs"]
165mod run_state;
166#[path = "internal/control/runtime.rs"]
167mod runtime;
168#[cfg(test)]
169#[path = "internal/testing/runtime_boundary_tests.rs"]
170mod runtime_boundary_tests;
171#[path = "internal/control/runtime_controls.rs"]
172mod runtime_controls;
173mod runtime_core;
174#[cfg(test)]
175#[path = "internal/testing/runtime_policy_trace_tests.rs"]
176mod runtime_policy_trace_tests;
177#[path = "runtime_core/governance/semantics.rs"]
178mod runtime_semantics;
179#[path = "runtime_core/governance/sacred_execution.rs"]
180mod sacred_execution;
181#[path = "runtime_core/execution/scheduler.rs"]
182mod scheduler;
183#[path = "runtime_core/execution/scheduler_workload.rs"]
184mod scheduler_workload;
185#[path = "internal/identity/secrets_security.rs"]
186mod secrets_security;
187#[path = "internal/identity/security_env.rs"]
188mod security_env;
189#[path = "internal/control/selectors.rs"]
190mod selectors;
191#[path = "artifacts/storage/semantic_lineage.rs"]
192mod semantic_lineage;
193#[path = "internal/control/services.rs"]
194mod services;
195pub mod simulated_platform;
196#[path = "backend/runtime/slurm_execution.rs"]
197mod slurm_execution;
198#[path = "runtime_core/execution/state_machine.rs"]
199mod state_machine;
200#[cfg(test)]
201#[path = "internal/testing/state_machine_tests.rs"]
202mod state_machine_tests;
203#[path = "artifacts/storage/store.rs"]
204mod store;
205#[path = "backend/runtime/subprocess.rs"]
206mod subprocess;
207#[path = "internal/identity/supply_chain_trust.rs"]
208mod supply_chain_trust;
209#[path = "internal/identity/tenancy.rs"]
210mod tenancy;
211#[cfg(test)]
212#[path = "internal/testing/test_support.rs"]
213mod test_support;
214#[path = "artifacts/storage/trace.rs"]
215mod trace;
216#[path = "artifacts/storage/upgrade_compatibility.rs"]
217mod upgrade_compatibility;
218#[path = "internal/workflow/workflow_product.rs"]
219mod workflow_product;
220#[cfg(feature = "experimental-public-api")]
221#[path = "runtime_core/execution/write_boundary_contracts.rs"]
222mod write_boundary_contracts;
223use adapter::{Adapter, AdapterId, EffectSet, NodeCtx};
224#[doc(hidden)]
225pub use adapter::{AdapterDescriptor, CacheCompatibilityMode};
226#[doc(hidden)]
227pub use adapter_conformance::{
228 build_adapter_conformance_suite, generate_adapter_reference_markdown,
229 validate_output_schema_compatibility, AdapterConformanceSuiteReport,
230 AdapterOutputSchemaCompatibilityReport, AdapterReferenceDocument, AdapterScenarioObservation,
231 AdapterScenarioResult, AdapterScenarioStatus,
232};
233#[doc(hidden)]
234pub use adapter_sdk::{
235 AdapterCapabilities, AdapterContext, AdapterPlugin, BackendPlugin, PluginManifest,
236};
237#[doc(hidden)]
238pub use async_adapter::AsyncAdapter;
239#[doc(hidden)]
240pub use backend::fake::{
241 fake_batch_backend_reference, fake_batch_executor_contract, FakeBatchExecutor,
242 FakeBatchExecutorContract, FakeBatchJobRecord, FakeBatchJobStatus,
243};
244#[doc(hidden)]
245pub use backend_cluster::{
246 artifact_collection_state, backend_ready_for_admission, canonical_k8s_terminal_events,
247 capture_hpc_scheduler_version, classify_hpc_failure, classify_k8s_failure,
248 effective_hpc_retry_policy, equivalent_to_local, hpc_array_job_supported,
249 hpc_environment_fingerprint, hpc_log_collection_semantics, hpc_poll_response_recovered,
250 hpc_replay_fidelity_from_module_fingerprints, hpc_resource_fingerprint,
251 hpc_scratch_staging_semantics, k8s_capability_declaration, kubernetes_adapter_contract,
252 map_node_policy_to_k8s_job, map_node_resources_to_k8s, map_node_to_hpc_queue_partition,
253 map_timeout_to_hpc_walltime, matches_placement_policy, normalize_backend_failure,
254 outputs_logs_equivalent, quota_saturation_percent, reconcile_k8s_watch_stream,
255 reject_unsupported_hpc_scheduler_features, reject_unsupported_k8s_fields,
256 replay_allowed_across_backends, scratch_retention_required, slurm_adapter_design_contract,
257 staged_input_cleanup_required, validate_k8s_injection, workdir_semantics,
258 AdapterExecutionOutcome, ArtifactCollectionState, BackendCapabilityDescriptor,
259 BackendCleanupGuarantee, BackendConformanceSuite, BackendFailureMappingRule,
260 BackendLogCollectionContract, BackendMaintenanceMode, BackendOutageSimulationFixture,
261 BackendProductionReadinessChecklist, BackendQuotaMetrics, BackendReadinessProbe,
262 CrossBackendReplayRule, GenericBatchExecutorContract, HpcFailureClassification,
263 HpcLogCollectionSemantics, HpcNodeExecutionContract, HpcQueuePartitionMapping,
264 HpcReplayFidelity, HpcResourceFingerprintInput, HpcRetryPolicyDecision,
265 HpcSchedulerVersionMetadata, HpcScratchStagingSemantics, ImageResolutionProvenance,
266 K8sBackendVersionMetadata, K8sCapabilityDeclaration, K8sFailureClass, K8sInjectionAvailability,
267 K8sInjectionRequest, K8sJobPolicyMapping, K8sResourceMapping, K8sResourceRequest,
268 K8sWatchEvent, KubernetesAdapterContractReport, KubernetesExecutorContractV2, NodeAffinityHint,
269 NodeExecutionContract, PlacementPolicyRule, QueueBackendRoutingPolicy,
270 RemoteArtifactStagingProtocol, SlurmAdapterDesignContractReport, SlurmExecutorContract,
271 WorkdirSemantics, WorkdirVolumeKind,
272};
273#[doc(hidden)]
274pub use batch_execution::{
275 cancel_batch_attempt, duplicate_status_delivery_detected, execution_mode_report,
276 heartbeat_stale, restart_recovery_supported, retry_attempt, validate_batch_metadata,
277 BatchAttemptState, BatchHeartbeat, BatchJobMetadata, BatchLifecycleEvent, BatchModeReport,
278};
279use bijux_dag_artifacts::schema::{
280 validate_output_schema_descriptor, ArtifactSchemaDescriptor, SchemaValidationMode,
281};
282#[doc(hidden)]
283pub use bijux_dag_artifacts::ContainerImageReferencePolicy;
284use bijux_dag_artifacts::{
285 artifact_size_bytes, sha256_artifact_path, write_inputs_index, write_outputs_index,
286 AdapterInfo, ArtifactError, CacheIdentity, CacheProof, ContainerTrace, DeclaredOutputArtifact,
287 FailureClass, FailureInfo, InputCollection, InputCollectionItem, InputFile, InputsIndex,
288 NodeCounts, NodeLifecycleTransition, NodeLogEvidence, NodeTrace, OutputSummary, OutputsIndex,
289 ReplayProvenance, Resources as TraceResources, RunDir, RunDirLayout, RunOutputFile,
290 RunOutputsIndex, TraceOutputArtifact, TriggerEvaluation,
291};
292use bijux_dag_core::{
293 Effect, FileOutput, Graph, GraphError, Node, NodeKind, OutputKind, OutputSpec, RetryPolicy,
294 SemanticNodeKind, Severity,
295};
296#[doc(hidden)]
297pub use cache::{
298 cache_entry_has_required_proof, cache_entry_manifest_version_supported,
299 cache_explainability_proof_from_meta, cache_key_explanation, cache_key_input_from_meta,
300 cache_metadata_version_supported, CacheEntryManifest, CacheExplainabilityProof, CacheKeyInput,
301 CacheManifestOutput, CACHE_ENTRY_MANIFEST_VERSION, CACHE_METADATA_VERSION,
302};
303use clock::{Clock, SystemClock};
304#[doc(hidden)]
305pub use container_execution::{
306 container_engine_discovery, container_env_isolated, container_gpu_runtime_args,
307 container_network_policy_args, container_volume_contract, map_local_path_to_container,
308 supported_container_engines, validate_container_contract, validate_container_mount_contract,
309 validate_container_relative_path, ContainerExecutionContract, ContainerMount,
310};
311#[doc(hidden)]
312pub use coordination::{
313 merge_timeout_and_exit_events, thread_safety_audit, RunSummaryCounters,
314 RuntimeCoordinationSnapshot, RuntimeCoordinationState, ThreadSafetyAuditRecord,
315 TraceWriteRecord,
316};
317#[doc(hidden)]
318pub use execution_backend::{
319 backend_registry, bind_backend_or_error, execute_with_backend, BackendBindingRequest,
320 BackendCapabilities, BackendContext, BackendError, BackendKind, BackendLifecycleResult,
321 EngineOutcome, ExecutionAttemptRecord, ExecutionBackend, ExecutionBackendCapabilityDescriptor,
322 FakeBackend, ProcessLikeBackend,
323};
324#[doc(hidden)]
325pub use execution_context::{ExecutionContext, NodeExecutionContext};
326#[doc(hidden)]
327pub use execution_plan::{ExecutionPlan, PlannedDependency, PlannedNode};
328#[doc(hidden)]
329pub use extension_catalog::{
330 compute_platform_maturity, detect_extension_compatibility_issues,
331 extension_discovery_inventory, extension_failure_isolated, extension_point_status_report,
332 internal_hook_ready_for_promotion, negotiate_plugin_version, register_extension,
333 validate_extension_descriptor, validate_plugin_conformance, CapabilityRange,
334 CodeGenerationHook, DslExtensionPoint, ExtensionCompatibilityIssue, ExtensionDescriptor,
335 ExtensionDiscoveryRecord, ExtensionPointStatus, ExtensionRegistration, ExtensionStabilityLevel,
336 InternalHookPromotionChecklist, OfficialPluginPolicy, PlatformMaturityScorecard,
337 PluginBoundaryKind, PluginConformanceSuiteResult, PluginIsolationPolicy, PluginLifecycleState,
338 PluginLoadingMode, PluginMetadata, PluginTrustPolicy,
339};
340#[doc(hidden)]
341pub use external_adapter::{
342 probe_external_adapters, ExternalAdapterHandshakeReport, ExternalAdapterHandshakeStatus,
343};
344use file_transform_adapter::FileTransformAdapter;
345#[doc(hidden)]
346pub use formal_verification::{
347 artifact_integrity_holds, build_counterexample, invariant_catalog_default,
348 lineage_invariants_hold, machine_checkable_invariants, policy_invariants_hold,
349 replay_determinism_holds, verification_gate_passed, verification_maturity_label,
350 AdversarialFixtureSet, ArtifactIntegrityInvariant, CounterexampleReport, DiffSemanticSpec,
351 FormalAssuranceRoadmap, FuzzingStrategy, HaVerificationHarness, InvariantDefinition,
352 LineageInvariantProof, ModelTestSuite, PolicyInvariantProof, PropertyTestSuite,
353 ReplayDeterminismInvariant, SchedulerStateSpaceCheck, VerificationGate,
354 VerificationMaturityLabel, VerifiedCoreScope,
355};
356use http_adapter::HttpRequestAdapter;
357#[doc(hidden)]
358pub use infrastructure::{
359 negotiate_backend_capabilities, BackendCapabilities as InfrastructureBackendCapabilities,
360 BackendCapabilityRequirement, BackendExecutionCompletion, BackendExecutionRequest,
361 CapabilityDecision, ExecutorBackend,
362};
363#[doc(hidden)]
364pub use invariants::{
365 run_summary_invariant_ok, terminal_run_has_terminal_node, trace_time_order_ok, RunNodeCounts,
366 INVARIANT_REGISTRY,
367};
368use io::{Fs, StdFs};
369#[doc(hidden)]
370pub use kubernetes_execution::{
371 build_kubernetes_execution_request, kubernetes_pod_status_from_node_result,
372 map_kubernetes_pod_status_to_node_status, validate_kubernetes_execution_request,
373 KubernetesBackendExecutor, KubernetesExecutionRequest, KubernetesExecutionResult,
374 KubernetesJobRecord, KubernetesLogCapture, KubernetesPodLifecycleEvent, KubernetesPodPhase,
375 KubernetesPodStatus, KubernetesVolumeMount, KubernetesWorkloadDescriptor,
376 KubernetesWorkloadKind, KubernetesWorkspaceTransfer, KubernetesWorkspaceTransferMode,
377 MockKubernetesBackend, SystemKubernetesBackend, SystemKubernetesBackendConfig,
378 SystemKubernetesPaths,
379};
380#[doc(hidden)]
381pub use local_executor::LocalExecutor;
382#[doc(hidden)]
383pub use local_worker_pool::{
384 LocalWorkerAssignment, LocalWorkerCompletion, LocalWorkerExecution, LocalWorkerPool,
385 LocalWorkerState, LocalWorkerStatus,
386};
387#[doc(hidden)]
388pub use node_execution_contract::{
389 build_retry_policy, build_task_contract, default_forced_cleanup, evaluate_retry_decision,
390 retry_backoff_ms as contract_retry_backoff_ms, retry_jitter_ms as contract_retry_jitter_ms,
391 retry_observation, retry_observation_from_failure, retry_wait_ms as contract_retry_wait_ms,
392 validate_task_contracts, BackoffStrategy, ForcedCancellationCleanup, IdempotencyMode,
393 NodeProvenance, OutputMaterializationPolicy, RetryDecision, RetryFailureObservation,
394 RetryPolicyV2, RuntimeState, SideEffectClassification, TaskContract, TaskFailureReason,
395 TaskInputDescriptor, TaskIsolationMode, TaskOutputDescriptor, TaskResultEnvelope,
396 TimeoutPolicy, TimeoutRetryPolicy,
397};
398#[doc(hidden)]
399pub use node_execution_types::{
400 check_replay_adapter_compatibility, compatibility_matrix_report,
401 compatibility_score_for_contract, compute_task_contract_fingerprint,
402 default_task_type_registry, generate_task_contract_markdown, validate_cross_node_compatibility,
403 validate_parameter_defaults, AdapterCapabilityDeclaration, CollectionType, CompatibilityScore,
404 NullabilityContract, OutputEvolutionMarker, PartitionCollectionContract,
405 PolymorphicTaskContract, PolymorphicVariant, ResourceReference, ScalarType, SchemaReference,
406 SecretReference, TaskCompatibilityMatrixReport, TaskCompatibilityRelationship,
407 TaskContractDiagnostic, TaskContractFingerprint, TaskTypeRegistry, TypeCoercionRule,
408 VersionedTypeRule,
409};
410#[doc(hidden)]
411pub use observability::{
412 canonicalize_event_records, category_from_runtime_event_name, current_process_memory_bytes,
413 event_contains_sensitive_material, event_names_emitted_once, reconstruct_timeline_from_events,
414 required_event_fields_present, serialize_timeline_export, summarize_failure_root_causes,
415 validate_required_event_names, validate_required_timeline_labels,
416 verify_event_log_completeness, write_timeline_export, EventCategory,
417 EventLogCompletenessReport, EventRecord, EventSink, FileEventSink, InMemoryMetricsRegistry,
418 MetricsRegistry, NodeMetrics, RemoteCollectorSink, RunMetrics, SchedulerMetrics, SpanKind,
419 StdoutEventSink, TimelineEntry, TimelineExport, TraceSpan, REQUIRED_RUNTIME_EVENT_NAMES,
420};
421#[doc(hidden)]
422pub use observability_deep::{
423 build_diagnostics, build_topology_overlay, detect_metric_drift, observability_contract_status,
424 redact_event_details, render_timeline_text, root_cause_graph, sample_events, AlertRule,
425 DiagnosticRecord, DiagnosticsKind, DriftDetectionReport, EventCorrelation,
426 ExplainArtifactReport, ExplainNodeReport, ExplainRunReport, ExplainScheduleReport,
427 FailureCauseCode, MetricsExportFormat, ObservabilityContractStatus, RedactionPolicy,
428 ReplaySpanLink, SamplingPolicy, TimelineTextSummary, TopologyOverlay, TopologyOverlayNode,
429};
430#[doc(hidden)]
431pub use path_authorization::{authorize_input_path, authorize_output_path};
432#[doc(hidden)]
433pub use path_resolution::AbsolutePathPolicy;
434pub(crate) use path_resolution::{
435 bind_path_variables_in_value, collect_container_argv_path_usages,
436 collect_container_workdir_usage, collect_resolved_path_usages, resolve_container_argv,
437 resolve_container_workdir, NodePathBindings, ResolvedPathUsage,
438};
439#[doc(hidden)]
440pub use performance_capacity::{
441 build_cost_model, build_performance_maturity_report, compile_environment_profiles,
442 derive_autoscaling_hint, detect_performance_regression, forecast_storage_growth,
443 synthetic_large_dag_profiles, ArtifactStoreBenchmarkResult, AutoscalingHint, BenchmarkResult,
444 CapacityModel, EnvironmentScaleProfile, PerformanceGate, PerformanceMaturityReport,
445 SchedulerScalabilityResult, StorageCostModel, StorageGrowthForecast, SyntheticDagProfile,
446};
447#[doc(hidden)]
448pub use planner::build_plan;
449#[doc(hidden)]
450pub use planner_analysis::{
451 build_backfill_plan, build_planner_analysis, build_replay_plan_annotations,
452 compare_plan_equivalence, compute_downstream_run_closure, compute_partial_run_closure,
453 compute_upstream_run_closure, diff_plans, explain_plan, fingerprint_plan, PlannerBackfillPlan,
454 PlannerBlockedNodeEstimate, PlannerBuildResult, PlannerCriticalPathEstimate,
455 PlannerCriticalPathNode, PlannerDurationSource, PlannerEquivalenceClass,
456 PlannerEquivalenceReport, PlannerExecutionCostEstimate, PlannerExplainReport,
457 PlannerGuardrails, PlannerNodeAction, PlannerNodeAnnotation, PlannerNodePathPreview,
458 PlannerPhase, PlannerPlanDiff, PlannerPriorityInheritance, PlannerResourceBottleneck,
459 PlannerSchedulingBound, PlannerSchedulingSimulation,
460};
461#[doc(hidden)]
462pub use policy::policy_allows_effects;
463use python_adapter::PythonFunctionAdapter;
464#[doc(hidden)]
465pub use recovery::{
466 check_run_consistency, detect_stuck_run, evaluate_pause_state, reconcile_orphaned_node,
467 should_quarantine_run, validate_and_repair_run_metadata, BranchRecoveryMode,
468 CheckpointResumeContract, ConsistencyCheckReport, DegradedExecutionPolicy, InterruptionClass,
469 ManualInterventionRecord, NodeControlMode, NodeHeartbeatPolicy, OperatorRetryPolicy,
470 PersistedRunSnapshotRef, RecoveryAcceptanceSuite, RecoveryFaultBoundary,
471 RecoveryFaultInjection, RecoverySimulationScenario, ResilientLogRecord, ResumePolicy,
472 RunPauseMode, RunPausePolicy, RunQuarantineRecord, RunRepairOutcome, SchedulerRecoveryAction,
473 SchedulerRecoveryRule, StuckRunPolicy,
474};
475use registry::{build_registry, AdapterRegistry};
476#[doc(hidden)]
477pub use remote_execution_model::{
478 execute_remote_payload_in_place, execution_mode_status, remote_handoff_valid,
479 remote_input_artifact_digest_matches, serialize_node_result_payload,
480 validate_remote_execution_fingerprint_set, validate_remote_execution_payload,
481 validate_remote_execution_workspace, validate_remote_identity, validate_remote_input_artifact,
482 ExecutionModeStatus, MockRemoteWorker, RemoteArtifactHandoff, RemoteExecutionFingerprintSet,
483 RemoteExecutionIdentity, RemoteExecutionWorkspace, RemoteInputArtifact,
484 RemoteNodeExecutionPayload, RemoteNodeExecutionResult, RemoteObservabilityHandoff,
485 RemoteWorkerExecutor,
486};
487#[doc(hidden)]
488pub use remote_executor::{
489 RemoteExecutionReceipt, RemoteExecutionRequest, RemoteExecutorSubmitter,
490};
491#[doc(hidden)]
492pub use run_state::{
493 imported_run_distinguishable, node_transition_invariant_id, run_transition_invariant_id,
494 terminal_transition_audit_events, validate_node_transition, validate_run_transition,
495 verify_post_run_state_consistency, NodeState, NodeTransition, PartialRerunContract,
496 ReplayNodeAction, ReplayNodeProvenance, ResumeFailureMode, ResumeSummary, RunAttempt,
497 RunCompactionPolicy, RunComparison, RunId, RunSnapshot, RunState, RunSummaryV2, RunTransition,
498 StateConsistencyReport, TransitionAuditEvent, TransitionCause, INV_NODE_TERMINAL_NO_REVERT,
499 INV_RUN_FAILED_CAUSAL_FAILURE,
500};
501#[doc(hidden)]
502pub use runtime_controls::{
503 audit_dispatch_discipline, audit_run_event_log, build_cancellation_audit_report,
504 build_execution_isolation_report, build_heartbeat_audit_report,
505 build_manual_intervention_audit_report, build_pause_resume_audit_report,
506 build_policy_enforcement_report, build_retry_decision_report, build_timeout_audit_report,
507 build_transition_audit_report, CancellationAuditReport, DispatchAuditReport, DispatchKeyRecord,
508 EventLogAuditReport, ExecutionIsolationNodeReport, ExecutionIsolationReport,
509 HeartbeatAuditReport, ManualInterventionAuditReport, PauseResumeAuditReport,
510 PolicyEnforcementReport, PolicyEnforcementSurfaceReport, PolicyGuardSemanticsReport,
511 RetryDecisionReport, TimeoutAuditReport, TransitionAuditReport,
512};
513#[doc(hidden)]
514pub use runtime_semantics::*;
515#[doc(hidden)]
516pub use scheduler::{
517 advance_backfill_operation, apply_submission_status_updates, build_schedule_override_status,
518 build_schedule_queue_state, build_scheduler, cancel_backfill_operation,
519 compile_backfill_operation, compile_submission_request, deterministic_tick_order,
520 dispatch_schedule_queue_runs, dry_run_schedule, evaluate_schedule_submissions,
521 evaluate_schedule_submissions_with_overrides, failure_allows_downstream_readiness,
522 failure_mode_name, pause_backfill_operation, pause_schedule, record_schedule_override,
523 replay_scheduler_checkpoint, resume_backfill_operation, resume_schedule,
524 retry_failed_backfill_runs, scheduler_contract_profile, scheduler_debug_event_log,
525 scheduler_invariant_violations, scheduler_invariants_hold, summarize_backfill_operation,
526 validate_cron_expression, validate_schedule_policy_combination, validate_schedule_registry,
527 BackfillAdvanceReport, BackfillAdvanceRequest, BackfillAuditRecord, BackfillFailurePolicy,
528 BackfillLifecycleStatus, BackfillOperation, BackfillOperationSummary, BackfillPartitionSummary,
529 BackfillRequest, BackfillRunRecord, BackfillRunStatus, BackfillStatusUpdate,
530 BackfillStatusUpdateBatch, CatchUpPolicy, ConcurrencyPolicyLayers, DependencyCompletionRecord,
531 DependencyCounter, DependencyTriggerCondition, DeterministicScheduler, ExecutionCheckpoint,
532 ExecutionSubmissionRequest, FailurePropagationMode, ManualSubmissionRequest,
533 NoopSchedulerEventHook, PriorityClass, QueueIdentity, QueueIsolationPolicy, ReadyQueue,
534 ReadyTieBreak, ScheduleAuditRecord, ScheduleDefinition, ScheduleDispatchRecord,
535 ScheduleDispatchReport, ScheduleDryRunPreview, ScheduleEvaluationInputs,
536 ScheduleEvaluationReport, ScheduleEventLineage, ScheduleEventRecord, ScheduleInputSource,
537 SchedulePriorityDispatchPolicy, ScheduleQueueRunRecord, ScheduleQueueState,
538 ScheduleQueueStateEntry, ScheduleQueueTenantState, ScheduleRegistry, ScheduleSubmissionLedger,
539 ScheduleSubmissionLedgerEntry, ScheduleSubmissionStatus, ScheduleSubmissionStatusUpdate,
540 ScheduleSubmissionStatusUpdateBatch, ScheduledSubmission, Scheduler, SchedulerContractProfile,
541 SchedulerEvent, SchedulerEventHook, SchedulerEventKind, SchedulerFairness, SchedulerModel,
542 SchedulerPolicy, SchedulerPriorityModel, SchedulerState, SchedulerUnit, SignalRecord,
543 SubmissionTriggerKind, ThroughputScheduler, TriggerSpec,
544};
545#[doc(hidden)]
546pub use scheduler_workload::{
547 apply_backfill_throttling, compute_partition_backfill_batches, deduplicate_trigger_events,
548 detect_cron_conflicts, evaluate_sla_metrics, is_suppressed_by_calendar, materialize_next_runs,
549 run_batches, weighted_priority_tie_break_order, BackfillThrottlingPolicy, BlackoutWindow,
550 ConcurrencyScope, CronConflict, CrossSchedulerCompatibility, DagCalendar,
551 DependencyTriggerBufferPolicy, EnvironmentSuppression, FairnessAlgorithm, HolidayPolicy,
552 MaterializedRunPreview, PartitionBackfillOrchestration, QueueAdmissionPolicy, RunBatchPolicy,
553 ScheduleOverrideAction, ScheduleOverrideRecord, ScheduleOverrideState, ScheduleOverrideStatus,
554 ScheduleSuppressionAnnotation, SchedulerAlertRule, SchedulerMaturityMatrix,
555 SchedulerSlaMetrics, SchedulingSimulationSuite, ServiceClass, SlaPolicy,
556 StarvationPreventionPolicy, TriggerDedupDecision, WeightedPriorityPolicy,
557};
558#[doc(hidden)]
559pub use secrets_security::{
560 incident_response_actions, leak_conformance_check, redact_secret_payload, secret_readiness,
561 secret_scope_allows, secure_cleanup_required, secure_mode_effective, select_secret_version,
562 should_materialize_secret_artifact, summarize_sensitive_classes, taint_from_secret_usage,
563 validate_secret_delivery_mode, SecretArtifactPolicy, SecretDeliveryPolicy, SecretInjectionMode,
564 SecretIntegrationReadiness, SecretLeakIncident, SecretMaskingPolicy, SecretResolutionTiming,
565 SecretRotationRule, SecretScopeRule, SecretSource, SecretTaintRecord, SecretUsageAuditEvent,
566 SecretVersionSelection, SecureExecutionMode, SecureTeardownPolicy, SecureWorkspaceRule,
567 SensitiveArtifactClass, SensitiveArtifactRestriction,
568};
569#[doc(hidden)]
570pub use security_env::{
571 declared_environment, effective_env_allowlist, is_allowed_env_key, is_denied_env_key,
572 missing_required_env_keys, shape_environment,
573};
574#[doc(hidden)]
575pub use semantic_lineage::{
576 detect_lineage_conflicts, export_lineage_format, lineage_quality_score,
577 policy_hook_allows_operation, recommended_replay_set, summarize_lineage,
578 ArtifactRelationshipType, ArtifactSemanticTag, CrossRunLineageStitch, FieldLevelLineageHook,
579 LineageConfidence, LineageConflict, LineageExportFormat, LineageImpactReport,
580 LineageMaterializationRule, LineageQualityScore, LineageReconciliationPlan, LineageSummary,
581 LineageSummaryNode, PolicyLineageHookInput, RetentionProtectionRule, ReverseImpactReport,
582 SemanticDependencyClass, SemanticLineageExplain, SemanticRelationship,
583};
584use serde_json::Value;
585use sha2::{Digest, Sha256};
586#[doc(hidden)]
587pub use slurm_execution::{
588 build_slurm_execution_request, build_slurm_scheduler_request,
589 map_slurm_job_status_to_node_status, validate_slurm_execution_request,
590 validate_slurm_scheduler_request, MockSlurmBackend, SlurmBackendExecutor,
591 SlurmExecutionRequest, SlurmExecutionResult, SlurmJobLifecycleEvent, SlurmJobRecord,
592 SlurmJobStatus, SlurmLogCapture, SlurmSchedulerRequest, SystemSlurmBackend,
593 SystemSlurmBackendConfig, SystemSlurmPaths,
594};
595#[doc(hidden)]
596pub use state_machine::{
597 failure_propagation_is_deterministic, node_transition_allowed, run_transition_allowed,
598 NodeLifecycleState, RunLifecycleState,
599};
600use std::collections::{BTreeMap, HashMap};
601use std::io::{self as std_io, Read, Seek, SeekFrom, Write};
602#[cfg(unix)]
603use std::os::unix::process::CommandExt;
604use std::path::{Path, PathBuf};
605use std::process::Stdio;
606use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
607use std::sync::{Arc, Mutex, OnceLock, Weak};
608use std::time::Duration;
609#[doc(hidden)]
610pub use store::{validate_storage_relative_path, ArtifactStore, CacheStore, StorageHealthReport};
611use store::{ArtifactStore as RuntimeArtifactStore, CacheStore as RuntimeCacheStore};
612#[doc(hidden)]
613pub use upgrade_compatibility::{
614 build_compatibility_dashboard, classify_compatibility, evaluate_release_gate,
615 simulate_migration_impact, validate_upgrade_path, CompatibilityAcceptanceSuite,
616 CompatibilityClass, CompatibilityDashboard, CompatibilityPolicy, CompatibilityRule,
617 CrossVersionMatrixRow, DeprecationDiagnostic, DowngradeRiskReport,
618 DurableStateMigrationContract, FeatureFlagRecord, FeatureLifecycleState, LongTermSupportPolicy,
619 ManifestMigrationPlan, MigrationImpactEstimate, PluginVersionWindow, ReleaseGateOutcome,
620 SchedulerStateCompatibilityCheck, UpgradePathPolicy, UpgradeRolloutPlan,
621};
622
623pub mod stable {
625 pub use crate::{
626 adapter_conformance_suite, build_plan, build_planner_analysis, build_scheduler,
627 cache_key_explanation, registered_adapter_descriptors,
628 registered_adapter_reference_document, registered_adapters, trace_time_order_ok,
629 validate_node_transition, validate_run_transition, verify_post_run_state_consistency,
630 AbsolutePathPolicy, CacheKeyInput, CacheMode, ExecutionBackendTarget, ExecutionContext,
631 NodeExecutionContext, NodeLifecycleState, PlannerGuardrails, RunLifecycleState, Runtime,
632 RuntimeConfig, RuntimeError, SchedulerPolicy, SelectorSet, SlurmRuntimeConfig,
633 };
634}
635
636pub mod prelude {
638 pub use crate::stable::{
639 build_plan, build_planner_analysis, build_scheduler, AbsolutePathPolicy, CacheMode,
640 ExecutionBackendTarget, ExecutionContext, NodeExecutionContext, PlannerGuardrails, Runtime,
641 RuntimeConfig, RuntimeError, SchedulerPolicy, SelectorSet, SlurmRuntimeConfig,
642 };
643}
644
645#[cfg(feature = "experimental-public-api")]
647pub mod experimental {
648 pub mod adapter_execution {
649 pub use crate::adapter_execution_contracts::*;
650 }
651 pub mod write_boundaries {
652 pub use crate::write_boundary_contracts::*;
653 }
654 pub mod planner_admission {
655 pub use crate::planner_admission_contracts::*;
656 }
657 pub mod durable_queue {
658 pub use crate::durable_queue_contracts::*;
659 }
660 pub mod container_evidence {
661 pub use crate::container_evidence_contracts::*;
662 }
663 pub mod observability_taxonomy {
664 pub use crate::observability_taxonomy_contracts::*;
665 }
666}
667
668#[derive(Debug, thiserror::Error)]
670pub enum RuntimeError {
671 #[error("graph error: {0}")]
672 Graph(#[from] GraphError),
673 #[error("artifact error: {0}")]
674 Artifact(#[from] ArtifactError),
675 #[error("io error: {0}")]
676 Io(#[from] std_io::Error),
677 #[error("json error: {0}")]
678 Json(#[from] serde_json::Error),
679 #[error("executor error: {0}")]
680 Executor(String),
681}
682
683#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
685pub enum NodeStatus {
686 Success,
687 Failed,
688 Skipped,
689 Cached,
690 Cancelled,
691}
692
693pub struct RunContext {
695 pub run_dir: Arc<RunDir>,
696 pub replay_source_run_dir: Option<PathBuf>,
697 pub graph_fingerprint: Arc<Mutex<HashMap<String, String>>>,
698 pub node_definition_fingerprints: Arc<HashMap<String, String>>,
699 pub declared_environment_fingerprints: Arc<HashMap<String, String>>,
700 pub params_fingerprints: Arc<HashMap<String, String>>,
701 pub command_fingerprints: Arc<HashMap<String, Option<String>>>,
702 pub planner_contract_version: String,
703 pub execution_fingerprint: String,
704 pub evidence_fingerprint: String,
705 pub execution_contract_fingerprint: String,
706 pub resolved_params: HashMap<String, Value>,
707 pub effective_cache_dir: Option<PathBuf>,
708 pub fs: Arc<dyn Fs>,
709 pub clock: Arc<dyn Clock>,
710 pub store: RuntimeArtifactStore,
711 pub policy: PolicyConfig,
712 pub absolute_path_policy: AbsolutePathPolicy,
713 pub cancellation_requested: Arc<std::sync::atomic::AtomicBool>,
714}
715
716#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
718pub struct NodeResult {
719 pub status: NodeStatus,
720 pub stdout_path: String,
721 pub stderr_path: String,
722 pub outputs_dir: String,
723 pub output_evidence: Vec<TraceOutputArtifact>,
724 pub failure: Option<FailureInfo>,
725 pub attempts: u32,
726 pub attempt_events: Vec<AttemptEvent>,
727 pub container_meta: Option<bijux_dag_artifacts::ContainerTrace>,
728 pub adapter_binary_sha256: Option<String>,
729}
730
731#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
732struct MapExecutionSummary {
733 schema_version: String,
734 map_node_id: String,
735 input_port: String,
736 item_count: usize,
737 successful_item_count: usize,
738 failed_item_count: usize,
739 cancelled_item_count: usize,
740 items: Vec<MapExecutionItemSummary>,
741}
742
743#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
744struct MapExecutionItemSummary {
745 item_id: String,
746 item_sha256: String,
747 status: String,
748 run_dir: String,
749 #[serde(default, skip_serializing_if = "Vec::is_empty")]
750 outputs: Vec<MapExecutionOutputSummary>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 failure: Option<FailureInfo>,
753}
754
755#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
756struct MapExecutionOutputSummary {
757 output_name: String,
758 item_path: String,
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub(crate) enum ReduceExecutionMode {
763 AllSuccess,
764 Partial,
765}
766
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
768pub(crate) enum ReduceEmptyPolicy {
769 Forbid,
770 Allow,
771 Skip,
772}
773
774#[derive(Debug, Clone, Copy, PartialEq, Eq)]
775pub(crate) struct ReduceExecutionConfig {
776 pub mode: ReduceExecutionMode,
777 pub empty_policy: ReduceEmptyPolicy,
778}
779
780#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
781struct ReduceExecutionSummary {
782 schema_version: String,
783 reduce_node_id: String,
784 mode: String,
785 empty_policy: String,
786 usable_input_count: usize,
787 failed_input_count: usize,
788 skipped_input_count: usize,
789 cancelled_input_count: usize,
790 collection: InputCollection,
791}
792
793#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
795pub struct AttemptEvent {
796 pub attempt: u32,
797 pub started_unix_ms: u128,
798 pub finished_unix_ms: u128,
799 pub status: NodeStatus,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub stdout_path: Option<String>,
802 #[serde(default, skip_serializing_if = "Option::is_none")]
803 pub stderr_path: Option<String>,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub failure: Option<FailureInfo>,
806 #[serde(default, skip_serializing_if = "Option::is_none")]
807 pub scheduled_backoff_ms: Option<u64>,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub retry_decision: Option<RetryDecision>,
810}
811
812#[derive(Debug)]
813pub(crate) enum ControlledCommandResult {
814 Exited(ControlledCommandOutput),
815 TimedOut(ControlledCommandOutput),
816 Cancelled(ControlledCommandOutput),
817}
818
819impl ControlledCommandResult {
820 fn output(&self) -> &ControlledCommandOutput {
821 match self {
822 Self::Exited(output) | Self::TimedOut(output) | Self::Cancelled(output) => output,
823 }
824 }
825
826 pub(crate) fn persist_streams(
827 &self,
828 fs: &dyn Fs,
829 stdout_path: &Path,
830 stderr_path: &Path,
831 ) -> Result<(), RuntimeError> {
832 let output = self.output();
833 output.stdout.copy_to(fs, stdout_path)?;
834 output.stderr.copy_to(fs, stderr_path)?;
835 Ok(())
836 }
837}
838
839#[derive(Debug)]
840pub(crate) struct ControlledCommandOutput {
841 status: std::process::ExitStatus,
842 stdout: ControlledCommandStream,
843 stderr: ControlledCommandStream,
844}
845
846impl ControlledCommandOutput {
847 fn read_tail_bytes(&self, max_bytes: u64) -> Result<Vec<u8>, RuntimeError> {
848 self.stderr.read_tail_bytes(max_bytes).map_err(RuntimeError::Io)
849 }
850
851 fn exit_code(&self) -> Option<i32> {
852 controlled_exit_code(self.status)
853 }
854}
855
856#[derive(Debug)]
857struct ControlledCommandStream {
858 path: PathBuf,
859}
860
861impl ControlledCommandStream {
862 fn copy_to(&self, fs: &dyn Fs, destination: &Path) -> Result<(), RuntimeError> {
863 fs.copy(&self.path, destination).map(|_| ()).map_err(RuntimeError::Io)
864 }
865
866 fn read_tail_bytes(&self, max_bytes: u64) -> std_io::Result<Vec<u8>> {
867 read_file_tail_bytes(&self.path, max_bytes)
868 }
869
870 fn append_cleanup_diagnostics(&self, cleanup_diagnostics: &[String]) -> std_io::Result<()> {
871 if cleanup_diagnostics.is_empty() {
872 return Ok(());
873 }
874
875 let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
876 if file.metadata()?.len() > 0 {
877 file.write_all(b"\n")?;
878 }
879 for diagnostic in cleanup_diagnostics {
880 file.write_all(b"[bijux cleanup] ")?;
881 file.write_all(diagnostic.as_bytes())?;
882 file.write_all(b"\n")?;
883 }
884 Ok(())
885 }
886}
887
888impl Drop for ControlledCommandStream {
889 fn drop(&mut self) {
890 let _ = std::fs::remove_file(&self.path);
891 }
892}
893
894#[derive(Debug, Clone, Copy, PartialEq, Eq)]
895enum ControlledCommandOutcomeKind {
896 Exited,
897 TimedOut,
898 Cancelled,
899}
900
901#[derive(Debug)]
902struct ControlledCommandTermination {
903 status: std::process::ExitStatus,
904 cleanup_diagnostics: Vec<String>,
905}
906
907impl ControlledCommandTermination {
908 fn new(status: std::process::ExitStatus) -> Self {
909 Self { status, cleanup_diagnostics: Vec::new() }
910 }
911}
912
913#[derive(Clone)]
915pub struct ConstAdapter;
916
917impl Adapter for ConstAdapter {
918 fn id(&self) -> AdapterId {
919 AdapterId { id: "const".to_string(), version: "0.1".to_string() }
920 }
921
922 fn supported_kinds(&self) -> Vec<String> {
923 vec!["const".to_string()]
924 }
925
926 fn required_effects(&self) -> EffectSet {
927 EffectSet::default()
928 }
929
930 fn produces_outputs_schema_version(&self) -> String {
931 "v0.1".to_string()
932 }
933
934 fn execute(&self, ctx: &NodeCtx) -> Result<NodeResult, RuntimeError> {
935 let node = ctx.node;
936 let exec = ctx.exec;
937 let params = ctx.params;
938 let node_dir = exec.run_dir.node_dir(&node.id);
939 let work_dir = exec.run_dir.node_work_dir(&node.id);
940 exec.fs.create_dir_all(exec.run_dir.node_outputs_dir(&node.id).as_path())?;
941 exec.fs.create_dir_all(&node_dir)?;
942 exec.fs.create_dir_all(&work_dir)?;
943 let stdout_path = exec.run_dir.node_stdout_path(&node.id);
944 let stderr_path = exec.run_dir.node_stderr_path(&node.id);
945 let outputs_dir = exec.run_dir.node_outputs_dir(&node.id);
946 if let Err(failure) = preflight_declared_output_targets(&outputs_dir, &node.outputs) {
947 return node_failure_result(
948 exec.fs.as_ref(),
949 &stdout_path,
950 &stderr_path,
951 &outputs_dir,
952 NodeStatus::Failed,
953 failure,
954 b"declared output path preflight failed",
955 );
956 }
957
958 let value = params.get("value").cloned().unwrap_or(Value::Null);
959 let target = node
960 .outputs
961 .iter()
962 .find(|o| o.name == "value")
963 .or_else(|| node.outputs.first())
964 .ok_or_else(|| RuntimeError::Executor("no outputs declared".to_string()))?;
965 let out_path = authorized_declared_output_path(&outputs_dir, target)
966 .map_err(|failure| RuntimeError::Executor(failure.message))?;
967 if let Some(parent) = out_path.parent() {
968 exec.fs.create_dir_all(parent)?;
969 }
970 exec.fs.write(&out_path, &serde_json::to_vec_pretty(&value)?)?;
971 exec.fs.write(&stdout_path, b"")?;
972 exec.fs.write(&stderr_path, b"")?;
973 let output_report = inspect_declared_outputs(&outputs_dir, &node.outputs);
974 let fp = node_fingerprint_from_ctx(exec, &node.id);
975 write_outputs_index(&outputs_dir, &node.id, &fp, &output_report.present_outputs)?;
976
977 Ok(NodeResult {
978 status: NodeStatus::Success,
979 stdout_path: stdout_path.display().to_string(),
980 stderr_path: stderr_path.display().to_string(),
981 outputs_dir: outputs_dir.display().to_string(),
982 output_evidence: output_report.output_evidence,
983 failure: None,
984 attempts: 1,
985 attempt_events: Vec::new(),
986 container_meta: None,
987 adapter_binary_sha256: None,
988 })
989 }
990}
991
992#[derive(Clone)]
994pub struct ShellAdapter;
995
996fn shell_argv_failure(message: impl Into<String>, reason: &'static str) -> FailureInfo {
997 FailureInfo::new(
998 FailureClass::User,
999 "User",
1000 "EXEC_ERROR",
1001 message,
1002 Some(serde_json::json!({
1003 "field": "argv",
1004 "reason": reason,
1005 })),
1006 )
1007}
1008
1009fn shell_argv(params: &Value) -> Result<Vec<String>, FailureInfo> {
1010 let Some(argv_value) = params.get("argv") else {
1011 return Err(shell_argv_failure("argv is required", "missing"));
1012 };
1013 let Some(argv) = argv_value.as_array() else {
1014 return Err(shell_argv_failure("argv must be an array of strings", "expected_array"));
1015 };
1016 if argv.is_empty() {
1017 return Err(shell_argv_failure("argv must not be empty", "empty"));
1018 }
1019
1020 let mut args = Vec::with_capacity(argv.len());
1021 for (index, value) in argv.iter().enumerate() {
1022 let Some(arg) = value.as_str() else {
1023 return Err(FailureInfo::new(
1024 FailureClass::User,
1025 "User",
1026 "EXEC_ERROR",
1027 format!("argv[{index}] must be a string"),
1028 Some(serde_json::json!({
1029 "field": "argv",
1030 "reason": "non_string_entry",
1031 "index": index,
1032 })),
1033 ));
1034 };
1035 if index == 0 && arg.trim().is_empty() {
1036 return Err(shell_argv_failure(
1037 "argv[0] must resolve to a non-empty executable",
1038 "blank_executable",
1039 ));
1040 }
1041 args.push(arg.to_string());
1042 }
1043 Ok(args)
1044}
1045
1046fn node_failure_result(
1047 fs: &dyn Fs,
1048 stdout_path: &Path,
1049 stderr_path: &Path,
1050 outputs_dir: &Path,
1051 status: NodeStatus,
1052 failure: FailureInfo,
1053 stderr_contents: &[u8],
1054) -> Result<NodeResult, RuntimeError> {
1055 fs.write(stdout_path, b"")?;
1056 fs.write(stderr_path, stderr_contents)?;
1057 Ok(NodeResult {
1058 status,
1059 stdout_path: stdout_path.display().to_string(),
1060 stderr_path: stderr_path.display().to_string(),
1061 outputs_dir: outputs_dir.display().to_string(),
1062 output_evidence: Vec::new(),
1063 failure: Some(failure),
1064 attempts: 1,
1065 attempt_events: Vec::new(),
1066 container_meta: None,
1067 adapter_binary_sha256: None,
1068 })
1069}
1070
1071pub(crate) fn authorized_declared_output_path(
1072 output_root: &Path,
1073 output: &FileOutput,
1074) -> Result<PathBuf, FailureInfo> {
1075 crate::path_authorization::authorize_declared_output_target(output_root, &output.path).map_err(
1076 |message| {
1077 FailureInfo::new(
1078 FailureClass::User,
1079 "User",
1080 "OUTPUT_PATH_INVALID",
1081 message,
1082 Some(serde_json::json!({
1083 "output": output.name,
1084 "path": output.path,
1085 })),
1086 )
1087 },
1088 )
1089}
1090
1091pub(crate) fn preflight_declared_output_targets(
1092 output_root: &Path,
1093 outputs: &[FileOutput],
1094) -> Result<(), FailureInfo> {
1095 for output in outputs {
1096 authorized_declared_output_path(output_root, output)?;
1097 }
1098 Ok(())
1099}
1100
1101impl Adapter for ShellAdapter {
1102 fn id(&self) -> AdapterId {
1103 AdapterId { id: "shell".to_string(), version: "0.1".to_string() }
1104 }
1105
1106 fn supported_kinds(&self) -> Vec<String> {
1107 vec!["shell".to_string()]
1108 }
1109
1110 fn required_effects(&self) -> EffectSet {
1111 EffectSet { filesystem: true, env: false, network: false, clock: false }
1112 }
1113
1114 fn produces_outputs_schema_version(&self) -> String {
1115 "v0.1".to_string()
1116 }
1117
1118 fn execute(&self, ctx: &NodeCtx) -> Result<NodeResult, RuntimeError> {
1119 let node = ctx.node;
1120 let exec = ctx.exec;
1121 let params = ctx.params;
1122 let node_dir = exec.run_dir.node_dir(&node.id);
1123 let outputs_dir = exec.run_dir.node_outputs_dir(&node.id);
1124 let work_dir = exec.run_dir.node_work_dir(&node.id);
1125 exec.fs.create_dir_all(&outputs_dir)?;
1126 exec.fs.create_dir_all(&node_dir)?;
1127 exec.fs.create_dir_all(&work_dir)?;
1128 let stdout_path = exec.run_dir.node_stdout_path(&node.id);
1129 let stderr_path = exec.run_dir.node_stderr_path(&node.id);
1130 if let Err(failure) = preflight_declared_output_targets(&outputs_dir, &node.outputs) {
1131 return node_failure_result(
1132 exec.fs.as_ref(),
1133 &stdout_path,
1134 &stderr_path,
1135 &outputs_dir,
1136 NodeStatus::Failed,
1137 failure,
1138 b"declared output path preflight failed",
1139 );
1140 }
1141 let args = match shell_argv(params) {
1142 Ok(args) => args,
1143 Err(failure) => {
1144 let stderr_message = failure.message.clone();
1145 return node_failure_result(
1146 exec.fs.as_ref(),
1147 &stdout_path,
1148 &stderr_path,
1149 &outputs_dir,
1150 NodeStatus::Failed,
1151 failure,
1152 stderr_message.as_bytes(),
1153 );
1154 }
1155 };
1156
1157 let env_allowlist = effective_env_allowlist(node);
1158 let mut cmd = subprocess::command(&args[0]);
1159 cmd.args(&args[1..]);
1160 cmd.current_dir(&work_dir);
1161 apply_shaped_env(&mut cmd, exec.policy.clean_env, &env_allowlist, &[]);
1162 apply_temp_env(&mut cmd, &exec.run_dir.node_temp_dir(&node.id));
1163
1164 let output = match command_output_with_controls(
1165 &mut cmd,
1166 effective_node_timeout_ms(node, params),
1167 Some(exec.cancellation_requested.as_ref()),
1168 ) {
1169 Ok(output) => output,
1170 Err(RuntimeError::Io(error)) if error.kind() == std_io::ErrorKind::NotFound => {
1171 let failure = FailureInfo::new(
1172 FailureClass::Infrastructure,
1173 "Infrastructure",
1174 "MISSING_EXECUTABLE",
1175 format!("executable could not be resolved: {}", args[0]),
1176 Some(serde_json::json!({
1177 "executable": args[0],
1178 "io_error_kind": "not_found",
1179 "os_error_code": error.raw_os_error(),
1180 })),
1181 );
1182 let stderr_message = failure.message.clone();
1183 return node_failure_result(
1184 exec.fs.as_ref(),
1185 &stdout_path,
1186 &stderr_path,
1187 &outputs_dir,
1188 NodeStatus::Failed,
1189 failure,
1190 stderr_message.as_bytes(),
1191 );
1192 }
1193 Err(error) => return Err(error),
1194 };
1195
1196 output.persist_streams(exec.fs.as_ref(), &stdout_path, &stderr_path)?;
1197 match output {
1198 ControlledCommandResult::TimedOut(output) => {
1199 return Ok(NodeResult {
1200 status: NodeStatus::Failed,
1201 stdout_path: stdout_path.display().to_string(),
1202 stderr_path: stderr_path.display().to_string(),
1203 outputs_dir: outputs_dir.display().to_string(),
1204 output_evidence: Vec::new(),
1205 failure: Some(FailureInfo::new(
1206 FailureClass::Timeout,
1207 "Timeout",
1208 "EXEC_TIMEOUT",
1209 "execution timed out after configured node timeout",
1210 Some(serde_json::json!({ "exit_code": output.exit_code() })),
1211 )),
1212 attempts: 1,
1213 attempt_events: Vec::new(),
1214 container_meta: None,
1215 adapter_binary_sha256: None,
1216 });
1217 }
1218 ControlledCommandResult::Cancelled(output) => {
1219 return Ok(NodeResult {
1220 status: NodeStatus::Cancelled,
1221 stdout_path: stdout_path.display().to_string(),
1222 stderr_path: stderr_path.display().to_string(),
1223 outputs_dir: outputs_dir.display().to_string(),
1224 output_evidence: Vec::new(),
1225 failure: Some(FailureInfo::new(
1226 FailureClass::Execution,
1227 "Execution",
1228 "EXEC_CANCELLED",
1229 "execution cancelled by operator",
1230 Some(serde_json::json!({ "exit_code": output.exit_code() })),
1231 )),
1232 attempts: 1,
1233 attempt_events: Vec::new(),
1234 container_meta: None,
1235 adapter_binary_sha256: None,
1236 });
1237 }
1238 ControlledCommandResult::Exited(output) => {
1239 let success = output.status.success();
1240 let exit_code = output.exit_code();
1241 if !success {
1242 return Ok(NodeResult {
1243 status: NodeStatus::Failed,
1244 stdout_path: stdout_path.display().to_string(),
1245 stderr_path: stderr_path.display().to_string(),
1246 outputs_dir: outputs_dir.display().to_string(),
1247 output_evidence: Vec::new(),
1248 failure: Some(FailureInfo::new(
1249 FailureClass::Execution,
1250 "Execution",
1251 "EXEC_FAIL",
1252 "command failed",
1253 Some(serde_json::json!({ "exit_code": exit_code })),
1254 )),
1255 attempts: 1,
1256 attempt_events: Vec::new(),
1257 container_meta: None,
1258 adapter_binary_sha256: None,
1259 });
1260 }
1261 }
1262 }
1263
1264 let output_report = inspect_declared_outputs(&outputs_dir, &node.outputs);
1265 if let Some(failure) = output_report.failure {
1266 return Ok(NodeResult {
1267 status: NodeStatus::Failed,
1268 stdout_path: stdout_path.display().to_string(),
1269 stderr_path: stderr_path.display().to_string(),
1270 outputs_dir: outputs_dir.display().to_string(),
1271 output_evidence: output_report.output_evidence,
1272 failure: Some(failure),
1273 attempts: 1,
1274 attempt_events: Vec::new(),
1275 container_meta: None,
1276 adapter_binary_sha256: None,
1277 });
1278 }
1279 let fp = node_fingerprint_from_ctx(exec, &node.id);
1280 write_outputs_index(&outputs_dir, &node.id, &fp, &output_report.present_outputs)?;
1281
1282 Ok(NodeResult {
1283 status: NodeStatus::Success,
1284 stdout_path: stdout_path.display().to_string(),
1285 stderr_path: stderr_path.display().to_string(),
1286 outputs_dir: outputs_dir.display().to_string(),
1287 output_evidence: output_report.output_evidence,
1288 failure: None,
1289 attempts: 1,
1290 attempt_events: Vec::new(),
1291 container_meta: None,
1292 adapter_binary_sha256: None,
1293 })
1294 }
1295}
1296
1297#[derive(Clone)]
1299pub struct ContainerAdapter;
1300
1301impl Adapter for ContainerAdapter {
1302 fn id(&self) -> AdapterId {
1303 AdapterId { id: "container".to_string(), version: "0.1".to_string() }
1304 }
1305
1306 fn supported_kinds(&self) -> Vec<String> {
1307 vec!["container".to_string()]
1308 }
1309
1310 fn required_effects(&self) -> EffectSet {
1311 EffectSet { filesystem: true, env: false, network: false, clock: false }
1312 }
1313
1314 fn produces_outputs_schema_version(&self) -> String {
1315 "v0.1".to_string()
1316 }
1317
1318 fn execute(&self, ctx: &NodeCtx) -> Result<NodeResult, RuntimeError> {
1319 let graph = ctx.graph;
1320 let node = ctx.node;
1321 let exec = ctx.exec;
1322 let params = ctx.params;
1323 let spec = node
1324 .container
1325 .as_ref()
1326 .ok_or_else(|| RuntimeError::Executor("missing container spec".to_string()))?;
1327
1328 let node_dir = exec.run_dir.node_dir(&node.id);
1329 let inputs_dir = exec.run_dir.node_inputs_dir(&node.id);
1330 let outputs_dir = exec.run_dir.node_outputs_dir(&node.id);
1331 let work_dir = exec.run_dir.node_work_dir(&node.id);
1332 exec.fs.create_dir_all(&inputs_dir)?;
1333 exec.fs.create_dir_all(&outputs_dir)?;
1334 exec.fs.create_dir_all(&node_dir)?;
1335 exec.fs.create_dir_all(&work_dir)?;
1336 let stdout_path = exec.run_dir.node_stdout_path(&node.id);
1337 let stderr_path = exec.run_dir.node_stderr_path(&node.id);
1338 if let Err(failure) = preflight_declared_output_targets(&outputs_dir, &node.outputs) {
1339 return node_failure_result(
1340 exec.fs.as_ref(),
1341 &stdout_path,
1342 &stderr_path,
1343 &outputs_dir,
1344 NodeStatus::Failed,
1345 failure,
1346 b"declared output path preflight failed",
1347 );
1348 }
1349
1350 let engine = spec.engine.as_str();
1351 if let Err(failure) = enforce_container_image_reference_policy(
1352 spec.image.as_str(),
1353 exec.policy.container_image_reference_policy,
1354 ) {
1355 exec.fs.write(&stdout_path, b"")?;
1356 exec.fs.write(&stderr_path, failure.message.as_bytes())?;
1357 return Ok(NodeResult {
1358 status: NodeStatus::Failed,
1359 stdout_path: stdout_path.display().to_string(),
1360 stderr_path: stderr_path.display().to_string(),
1361 outputs_dir: outputs_dir.display().to_string(),
1362 output_evidence: Vec::new(),
1363 failure: Some(failure),
1364 attempts: 1,
1365 attempt_events: Vec::new(),
1366 container_meta: Some(container_trace(spec, engine, None, None)),
1367 adapter_binary_sha256: None,
1368 });
1369 }
1370 let engine_version = match container_execution::container_engine_discovery(engine) {
1371 Ok(version) => version,
1372 Err(message) => {
1373 exec.fs.write(&stdout_path, b"")?;
1374 exec.fs.write(&stderr_path, message.as_bytes())?;
1375 return Ok(NodeResult {
1376 status: NodeStatus::Failed,
1377 stdout_path: stdout_path.display().to_string(),
1378 stderr_path: stderr_path.display().to_string(),
1379 outputs_dir: outputs_dir.display().to_string(),
1380 output_evidence: Vec::new(),
1381 failure: Some(FailureInfo::new(
1382 FailureClass::Infrastructure,
1383 "Infrastructure",
1384 "CONTAINER_ENGINE_UNAVAILABLE",
1385 message.clone(),
1386 Some(serde_json::json!({ "engine": engine })),
1387 )),
1388 attempts: 1,
1389 attempt_events: Vec::new(),
1390 container_meta: Some(container_trace(spec, engine, None, None)),
1391 adapter_binary_sha256: None,
1392 });
1393 }
1394 };
1395 let mounts = container_execution::container_volume_contract(&node_dir);
1396 if let Err(message) =
1397 container_execution::validate_container_mount_contract(&mounts, &node_dir)
1398 {
1399 exec.fs.write(&stdout_path, b"")?;
1400 exec.fs.write(&stderr_path, message.as_bytes())?;
1401 return Ok(NodeResult {
1402 status: NodeStatus::Failed,
1403 stdout_path: stdout_path.display().to_string(),
1404 stderr_path: stderr_path.display().to_string(),
1405 outputs_dir: outputs_dir.display().to_string(),
1406 output_evidence: Vec::new(),
1407 failure: Some(FailureInfo::new(
1408 FailureClass::User,
1409 "User",
1410 "CONTAINER_VOLUME_CONTRACT_INVALID",
1411 message,
1412 Some(serde_json::json!({ "engine": engine })),
1413 )),
1414 attempts: 1,
1415 attempt_events: Vec::new(),
1416 container_meta: Some(container_trace(
1417 spec,
1418 engine,
1419 None,
1420 Some(engine_version.clone()),
1421 )),
1422 adapter_binary_sha256: None,
1423 });
1424 }
1425
1426 let mut cmd = subprocess::command(engine);
1427 cmd.arg("run").arg("--rm");
1428
1429 let deny_network = !node.effects.contains(&Effect::Network) || exec.policy.deny_network;
1430 let network_args =
1431 match container_execution::container_network_policy_args(engine, deny_network) {
1432 Ok(args) => args,
1433 Err(message) => {
1434 exec.fs.write(&stdout_path, b"")?;
1435 exec.fs.write(&stderr_path, message.as_bytes())?;
1436 return Ok(NodeResult {
1437 status: NodeStatus::Failed,
1438 stdout_path: stdout_path.display().to_string(),
1439 stderr_path: stderr_path.display().to_string(),
1440 outputs_dir: outputs_dir.display().to_string(),
1441 output_evidence: Vec::new(),
1442 failure: Some(FailureInfo::new(
1443 FailureClass::Policy,
1444 "Policy",
1445 "POLICY_UNENFORCEABLE",
1446 message,
1447 Some(serde_json::json!({ "engine": engine, "effect": "network" })),
1448 )),
1449 attempts: 1,
1450 attempt_events: Vec::new(),
1451 container_meta: Some(container_trace(
1452 spec,
1453 engine,
1454 None,
1455 Some(engine_version.clone()),
1456 )),
1457 adapter_binary_sha256: None,
1458 });
1459 }
1460 };
1461 for arg in network_args {
1462 cmd.arg(arg);
1463 }
1464 let gpu_devices = bijux_dag_core::resources::node_gpu_devices(node);
1465 let gpu_args = match container_execution::container_gpu_runtime_args(engine, gpu_devices) {
1466 Ok(args) => args,
1467 Err(message) => {
1468 exec.fs.write(&stdout_path, b"")?;
1469 exec.fs.write(&stderr_path, message.as_bytes())?;
1470 return Ok(NodeResult {
1471 status: NodeStatus::Failed,
1472 stdout_path: stdout_path.display().to_string(),
1473 stderr_path: stderr_path.display().to_string(),
1474 outputs_dir: outputs_dir.display().to_string(),
1475 output_evidence: Vec::new(),
1476 failure: Some(FailureInfo::new(
1477 FailureClass::Infrastructure,
1478 "Infrastructure",
1479 "CONTAINER_GPU_UNSUPPORTED",
1480 message,
1481 Some(serde_json::json!({ "engine": engine, "gpu_devices": gpu_devices })),
1482 )),
1483 attempts: 1,
1484 attempt_events: Vec::new(),
1485 container_meta: Some(container_trace(
1486 spec,
1487 engine,
1488 None,
1489 Some(engine_version.clone()),
1490 )),
1491 adapter_binary_sha256: None,
1492 });
1493 }
1494 };
1495 for arg in gpu_args {
1496 cmd.arg(arg);
1497 }
1498 for mount in &mounts {
1499 let mode = if mount.readonly { "ro" } else { "rw" };
1500 cmd.args(["-v", &format!("{}:{}:{}", mount.local_path, mount.container_path, mode)]);
1501 }
1502
1503 let container_bindings = NodePathBindings::for_container();
1504 let workdir = resolve_container_workdir(
1505 spec.workdir.as_deref(),
1506 &container_bindings,
1507 exec.absolute_path_policy,
1508 )
1509 .map_err(RuntimeError::Executor)?;
1510 cmd.args(["--workdir", &workdir]);
1511
1512 let env_allowlist = effective_env_allowlist(node);
1513 for (key, val) in shaped_environment(exec.policy.clean_env, &env_allowlist, &[]) {
1514 cmd.arg("-e").arg(format!("{}={}", key, val));
1515 }
1516 let temp_dir = container_temp_dir(&workdir);
1517 cmd.arg("-e").arg(format!("TMPDIR={temp_dir}"));
1518 cmd.arg("-e").arg(format!("TMP={temp_dir}"));
1519 cmd.arg("-e").arg(format!("TEMP={temp_dir}"));
1520
1521 cmd.arg(&spec.image);
1522 let stable_argv = bijux_dag_core::resolve::resolve_command_argv_templates(
1523 graph, node, &spec.argv, params,
1524 )
1525 .map_err(|error| RuntimeError::Executor(error.to_string()))?;
1526 for part in &resolve_container_argv(&stable_argv, &container_bindings)
1527 .map_err(RuntimeError::Executor)?
1528 {
1529 cmd.arg(part);
1530 }
1531
1532 let output = command_output_with_controls(
1533 &mut cmd,
1534 effective_node_timeout_ms(node, params),
1535 Some(exec.cancellation_requested.as_ref()),
1536 )?;
1537 output.persist_streams(exec.fs.as_ref(), &stdout_path, &stderr_path)?;
1538 let timeout_failure = || {
1539 FailureInfo::new(
1540 FailureClass::Timeout,
1541 "Timeout",
1542 "EXEC_TIMEOUT",
1543 "container execution timed out after configured node timeout",
1544 None,
1545 )
1546 };
1547 let cancelled_failure = || {
1548 FailureInfo::new(
1549 FailureClass::Execution,
1550 "Execution",
1551 "EXEC_CANCELLED",
1552 "execution cancelled by operator",
1553 None,
1554 )
1555 };
1556 let exit_code = match output {
1557 ControlledCommandResult::TimedOut(output) => {
1558 return Ok(NodeResult {
1559 status: NodeStatus::Failed,
1560 stdout_path: stdout_path.display().to_string(),
1561 stderr_path: stderr_path.display().to_string(),
1562 outputs_dir: outputs_dir.display().to_string(),
1563 output_evidence: Vec::new(),
1564 failure: Some(timeout_failure()),
1565 attempts: 1,
1566 attempt_events: Vec::new(),
1567 container_meta: Some(container_trace(
1568 spec,
1569 engine,
1570 output.exit_code(),
1571 Some(engine_version.clone()),
1572 )),
1573 adapter_binary_sha256: None,
1574 });
1575 }
1576 ControlledCommandResult::Cancelled(output) => {
1577 return Ok(NodeResult {
1578 status: NodeStatus::Cancelled,
1579 stdout_path: stdout_path.display().to_string(),
1580 stderr_path: stderr_path.display().to_string(),
1581 outputs_dir: outputs_dir.display().to_string(),
1582 output_evidence: Vec::new(),
1583 failure: Some(cancelled_failure()),
1584 attempts: 1,
1585 attempt_events: Vec::new(),
1586 container_meta: Some(container_trace(
1587 spec,
1588 engine,
1589 output.exit_code(),
1590 Some(engine_version.clone()),
1591 )),
1592 adapter_binary_sha256: None,
1593 });
1594 }
1595 ControlledCommandResult::Exited(output) => {
1596 let success = output.status.success();
1597 let exit_code = output.exit_code();
1598 if !success {
1599 return Ok(NodeResult {
1600 status: NodeStatus::Failed,
1601 stdout_path: stdout_path.display().to_string(),
1602 stderr_path: stderr_path.display().to_string(),
1603 outputs_dir: outputs_dir.display().to_string(),
1604 output_evidence: Vec::new(),
1605 failure: Some(FailureInfo::new(
1606 FailureClass::Execution,
1607 "Execution",
1608 "EXEC_FAIL",
1609 "container command failed",
1610 Some(serde_json::json!({ "exit_code": exit_code })),
1611 )),
1612 attempts: 1,
1613 attempt_events: Vec::new(),
1614 container_meta: Some(container_trace(
1615 spec,
1616 engine,
1617 exit_code,
1618 Some(engine_version.clone()),
1619 )),
1620 adapter_binary_sha256: None,
1621 });
1622 }
1623 exit_code
1624 }
1625 };
1626
1627 let output_report = inspect_declared_outputs(&outputs_dir, &node.outputs);
1628 if let Some(failure) = output_report.failure {
1629 return Ok(NodeResult {
1630 status: NodeStatus::Failed,
1631 stdout_path: stdout_path.display().to_string(),
1632 stderr_path: stderr_path.display().to_string(),
1633 outputs_dir: outputs_dir.display().to_string(),
1634 output_evidence: output_report.output_evidence,
1635 failure: Some(failure),
1636 attempts: 1,
1637 attempt_events: Vec::new(),
1638 container_meta: Some(container_trace(
1639 spec,
1640 engine,
1641 exit_code,
1642 Some(engine_version.clone()),
1643 )),
1644 adapter_binary_sha256: None,
1645 });
1646 }
1647 let fp = node_fingerprint_from_ctx(exec, &node.id);
1648 write_outputs_index(&outputs_dir, &node.id, &fp, &output_report.present_outputs)?;
1649
1650 Ok(NodeResult {
1651 status: NodeStatus::Success,
1652 stdout_path: stdout_path.display().to_string(),
1653 stderr_path: stderr_path.display().to_string(),
1654 outputs_dir: outputs_dir.display().to_string(),
1655 output_evidence: output_report.output_evidence,
1656 failure: None,
1657 attempts: 1,
1658 attempt_events: Vec::new(),
1659 container_meta: Some(container_trace(spec, engine, exit_code, Some(engine_version))),
1660 adapter_binary_sha256: None,
1661 })
1662 }
1663}
1664
1665#[derive(Debug, Clone, PartialEq, Eq)]
1667pub enum CacheMode {
1668 Off,
1669 Read,
1670 ReadWrite,
1671}
1672
1673#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1675#[serde(rename_all = "snake_case")]
1676pub enum RunTimeoutBehavior {
1677 FinishRunning,
1678 CancelRunning,
1679}
1680
1681struct CacheRead {
1682 hit: bool,
1683 proof: Option<CacheProof>,
1684}
1685
1686fn cache_hit_proof(cache_read: CacheRead) -> Result<Option<CacheProof>, RuntimeError> {
1687 match (cache_read.hit, cache_read.proof) {
1688 (true, Some(proof)) => Ok(Some(proof)),
1689 (true, None) => {
1690 Err(RuntimeError::Executor("cache hit missing verification proof".to_string()))
1691 }
1692 (false, proof) => Ok(proof),
1693 }
1694}
1695
1696#[derive(Clone)]
1698pub struct RuntimeConfig {
1699 pub jobs: usize,
1700 pub cpu_budget: Option<u32>,
1701 pub memory_budget_mb: Option<u32>,
1702 pub gpu_device_budget: Option<u32>,
1703 pub named_resource_capacities: BTreeMap<String, u32>,
1704 pub run_timeout_ms: Option<u64>,
1705 pub run_timeout_behavior: RunTimeoutBehavior,
1706 pub node_timeout_ms: Option<u64>,
1707 pub materialize_inputs: MaterializeMode,
1708 pub cache_mode: CacheMode,
1709 pub cache_dir: Option<PathBuf>,
1710 pub remote_cache_dir: Option<PathBuf>,
1711 pub run_root: Option<PathBuf>,
1712 pub absolute_path_policy: AbsolutePathPolicy,
1713 pub run_id: Option<String>,
1714 pub resume_run_id: Option<String>,
1715 pub resume_failure_mode: ResumeFailureMode,
1716 pub parent_run_id: Option<String>,
1717 pub replay_source_run_dir: Option<PathBuf>,
1718 pub submission_source: String,
1719 pub trigger_source: String,
1720 pub operator: String,
1721 pub labels: Vec<String>,
1722 pub latest_symlink: Option<PathBuf>,
1723 pub policy: PolicyConfig,
1724 pub selectors: SelectorSet,
1725 pub upstream_selection_targets: Vec<String>,
1726 pub downstream_selection_roots: Vec<String>,
1727 pub partial_rerun_dependency_closure: bool,
1728 pub scheduler_policy: SchedulerPolicy,
1729 pub failure_propagation: FailurePropagationMode,
1730 pub execution_backend: ExecutionBackendTarget,
1731 pub kubernetes: KubernetesRuntimeConfig,
1732 pub slurm: SlurmRuntimeConfig,
1733}
1734
1735#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
1737#[serde(rename_all = "snake_case")]
1738pub enum ExecutionBackendTarget {
1739 #[default]
1740 Local,
1741 Kubernetes,
1742 Slurm,
1743}
1744
1745#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1747pub struct KubernetesRuntimeConfig {
1748 pub default_namespace: String,
1749 pub shared_volume_claim: String,
1750 pub shared_local_root: PathBuf,
1751 pub poll_interval_ms: u64,
1752 #[serde(default, skip_serializing_if = "Option::is_none")]
1753 pub kubectl_command: Option<String>,
1754}
1755
1756impl Default for KubernetesRuntimeConfig {
1757 fn default() -> Self {
1758 Self {
1759 default_namespace: "bijux".to_string(),
1760 shared_volume_claim: String::new(),
1761 shared_local_root: PathBuf::new(),
1762 poll_interval_ms: 250,
1763 kubectl_command: None,
1764 }
1765 }
1766}
1767
1768#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1770pub struct SlurmRuntimeConfig {
1771 pub default_queue: String,
1772 pub default_partition: String,
1773 pub poll_interval_ms: u64,
1774 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1775 pub worker_command: Vec<String>,
1776 #[serde(default, skip_serializing_if = "Option::is_none")]
1777 pub sbatch_command: Option<String>,
1778 #[serde(default, skip_serializing_if = "Option::is_none")]
1779 pub sacct_command: Option<String>,
1780}
1781
1782impl Default for SlurmRuntimeConfig {
1783 fn default() -> Self {
1784 Self {
1785 default_queue: "general".to_string(),
1786 default_partition: "cpu".to_string(),
1787 poll_interval_ms: 250,
1788 worker_command: Vec::new(),
1789 sbatch_command: None,
1790 sacct_command: None,
1791 }
1792 }
1793}
1794
1795impl Default for RuntimeConfig {
1796 fn default() -> Self {
1797 Self {
1798 jobs: 1,
1799 cpu_budget: None,
1800 memory_budget_mb: None,
1801 gpu_device_budget: None,
1802 named_resource_capacities: BTreeMap::new(),
1803 run_timeout_ms: None,
1804 run_timeout_behavior: RunTimeoutBehavior::FinishRunning,
1805 node_timeout_ms: None,
1806 materialize_inputs: MaterializeMode::Copy,
1807 cache_mode: CacheMode::Off,
1808 cache_dir: None,
1809 remote_cache_dir: None,
1810 run_root: None,
1811 absolute_path_policy: AbsolutePathPolicy::AllowLiteral,
1812 run_id: None,
1813 resume_run_id: None,
1814 resume_failure_mode: ResumeFailureMode::RerunIncomplete,
1815 parent_run_id: None,
1816 replay_source_run_dir: None,
1817 submission_source: "manual".to_string(),
1818 trigger_source: "cli".to_string(),
1819 operator: "unknown".to_string(),
1820 labels: Vec::new(),
1821 latest_symlink: None,
1822 policy: PolicyConfig::default(),
1823 selectors: SelectorSet::default(),
1824 upstream_selection_targets: Vec::new(),
1825 downstream_selection_roots: Vec::new(),
1826 partial_rerun_dependency_closure: true,
1827 scheduler_policy: SchedulerPolicy::default(),
1828 failure_propagation: FailurePropagationMode::ContinueIndependent,
1829 execution_backend: ExecutionBackendTarget::Local,
1830 kubernetes: KubernetesRuntimeConfig::default(),
1831 slurm: SlurmRuntimeConfig::default(),
1832 }
1833 }
1834}
1835
1836#[derive(Debug, Clone, Default)]
1838pub struct SelectorSet {
1839 pub include: Vec<Selector>,
1840 pub exclude: Vec<Selector>,
1841}
1842
1843#[derive(Debug, Clone)]
1845pub enum Selector {
1846 Id(String),
1847 IdPrefix(String),
1848 Tag(String),
1849 Kind(String),
1850}
1851
1852#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1854pub enum MaterializeMode {
1855 Copy,
1856 Hardlink,
1857 Symlink,
1858}
1859
1860#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1862pub struct PolicyConfig {
1863 pub deny_network: bool,
1864 pub deny_env: bool,
1865 pub deny_clock: bool,
1866 pub clean_env: bool,
1867 pub container_image_reference_policy: ContainerImageReferencePolicy,
1868}
1869
1870impl Default for PolicyConfig {
1871 fn default() -> Self {
1872 Self {
1873 deny_network: false,
1874 deny_env: false,
1875 deny_clock: false,
1876 clean_env: true,
1877 container_image_reference_policy: ContainerImageReferencePolicy::RequireDigest,
1878 }
1879 }
1880}
1881
1882pub struct Runtime {
1884 registry: AdapterRegistry,
1885 fs: Arc<dyn Fs>,
1886 clock: Arc<dyn Clock>,
1887 init_error: Option<String>,
1888}
1889
1890impl Runtime {
1891 pub fn new() -> Self {
1893 let registry_result = build_registry(vec![
1894 Arc::new(ConstAdapter),
1895 Arc::new(FileTransformAdapter),
1896 Arc::new(HttpRequestAdapter),
1897 Arc::new(ShellAdapter),
1898 Arc::new(PythonFunctionAdapter),
1899 Arc::new(ContainerAdapter),
1900 ]);
1901 let (registry, init_error) = match registry_result {
1902 Ok(reg) => (reg, None),
1903 Err(err) => (AdapterRegistry::new(), Some(err.to_string())),
1904 };
1905 Self { registry, fs: Arc::new(StdFs), clock: Arc::new(SystemClock), init_error }
1906 }
1907
1908 #[allow(dead_code)]
1909 pub(crate) fn with_io(fs: Arc<dyn Fs>, clock: Arc<dyn Clock>) -> Self {
1910 let mut runtime = Self::new();
1911 runtime.fs = fs;
1912 runtime.clock = clock;
1913 runtime
1914 }
1915
1916 fn adapter_for_kind(&self, kind: &NodeKind) -> Result<Arc<dyn Adapter>, RuntimeError> {
1917 self.registry.resolve(kind.as_str())
1918 }
1919
1920 fn adapter_meta_for_kind(&self, kind: &NodeKind) -> (String, String) {
1921 self.registry
1922 .resolve(kind.as_str())
1923 .map(|a| {
1924 let id = a.id();
1925 (id.id, id.version)
1926 })
1927 .unwrap_or_else(|_| ("unknown".to_string(), "unknown".to_string()))
1928 }
1929
1930 fn adapter_schema_for_kind(&self, kind: &NodeKind) -> String {
1931 self.registry
1932 .resolve(kind.as_str())
1933 .map(|a| a.produces_outputs_schema_version())
1934 .unwrap_or_else(|_| "unknown".to_string())
1935 }
1936
1937 pub fn run(
1939 &self,
1940 graph: &Graph,
1941 out_dir: impl AsRef<Path>,
1942 options: RuntimeConfig,
1943 ) -> Result<PathBuf, RuntimeError> {
1944 if let Some(err) = &self.init_error {
1945 return Err(RuntimeError::Executor(err.clone()));
1946 }
1947 let diags = graph.validate_with_warnings();
1948 if diags.iter().any(|d| d.severity == Severity::Error) {
1949 return Err(GraphError::ValidationFailed.into());
1950 }
1951 engine::execute(self, graph, out_dir, options)
1952 }
1953}
1954
1955impl Default for Runtime {
1956 fn default() -> Self {
1957 Self::new()
1958 }
1959}
1960
1961fn validate_gpu_runtime_capacity(
1962 plan: &ExecutionPlan,
1963 options: &RuntimeConfig,
1964) -> Result<(), RuntimeError> {
1965 let gpu_device_budget =
1966 options.scheduler_policy.gpu_device_budget.or(options.gpu_device_budget);
1967 let mut required_nodes = Vec::new();
1968 let mut oversized_nodes = Vec::new();
1969
1970 for node in &plan.nodes {
1971 let requested = bijux_dag_core::resources::node_gpu_devices(node);
1972 if requested == 0 {
1973 continue;
1974 }
1975 required_nodes.push((node.id.clone(), requested));
1976 if gpu_device_budget.is_some_and(|budget| requested > budget) {
1977 oversized_nodes.push((node.id.clone(), requested));
1978 }
1979 }
1980
1981 if required_nodes.is_empty() {
1982 return Ok(());
1983 }
1984
1985 let Some(gpu_device_budget) = gpu_device_budget.filter(|budget| *budget > 0) else {
1986 let requested = required_nodes
1987 .iter()
1988 .map(|(node_id, requested)| format!("{node_id}={requested}"))
1989 .collect::<Vec<_>>()
1990 .join(", ");
1991 return Err(RuntimeError::Executor(format!(
1992 "selected nodes require gpu devices ({requested}), but runtime gpu_device_budget is unset"
1993 )));
1994 };
1995
1996 if oversized_nodes.is_empty() {
1997 return Ok(());
1998 }
1999
2000 let requested = oversized_nodes
2001 .iter()
2002 .map(|(node_id, requested)| format!("{node_id}={requested}"))
2003 .collect::<Vec<_>>()
2004 .join(", ");
2005 Err(RuntimeError::Executor(format!(
2006 "selected nodes require more gpu devices than runtime gpu_device_budget={gpu_device_budget}: {requested}"
2007 )))
2008}
2009
2010fn validate_named_resource_runtime_capacity(
2011 plan: &ExecutionPlan,
2012 options: &RuntimeConfig,
2013) -> Result<(), RuntimeError> {
2014 let mut capacities = options.named_resource_capacities.clone();
2015 for (name, amount) in &options.scheduler_policy.named_resource_capacities {
2016 capacities.insert(name.clone(), *amount);
2017 }
2018
2019 let mut missing = BTreeMap::<String, Vec<String>>::new();
2020 let mut oversized = Vec::new();
2021 for node in &plan.nodes {
2022 for (name, requested) in bijux_dag_core::resources::node_named_resources(node) {
2023 match capacities.get(&name).copied().filter(|capacity| *capacity > 0) {
2024 Some(capacity) if requested > capacity => {
2025 oversized.push((node.id.clone(), name, requested, capacity));
2026 }
2027 Some(_) => {}
2028 None => {
2029 missing.entry(name).or_default().push(format!("{}={requested}", node.id));
2030 }
2031 }
2032 }
2033 }
2034
2035 if !missing.is_empty() {
2036 let requested = missing
2037 .into_iter()
2038 .map(|(name, nodes)| format!("{name}({})", nodes.join(", ")))
2039 .collect::<Vec<_>>()
2040 .join(", ");
2041 return Err(RuntimeError::Executor(format!(
2042 "selected nodes require named resources without runtime capacity: {requested}"
2043 )));
2044 }
2045
2046 if oversized.is_empty() {
2047 return Ok(());
2048 }
2049
2050 let requested = oversized
2051 .into_iter()
2052 .map(|(node_id, name, requested, capacity)| {
2053 format!("{node_id}:{name}={requested} exceeds capacity {capacity}")
2054 })
2055 .collect::<Vec<_>>()
2056 .join(", ");
2057 Err(RuntimeError::Executor(format!(
2058 "selected nodes require more named resources than runtime capacities allow: {requested}"
2059 )))
2060}
2061
2062#[allow(clippy::too_many_arguments)]
2063fn write_trace(
2064 ctx: &RunContext,
2065 graph: &Graph,
2066 node_id: &str,
2067 status: NodeStatus,
2068 failure: Option<FailureInfo>,
2069 output_evidence: Vec<TraceOutputArtifact>,
2070 started_unix_ms: u128,
2071 finished_unix_ms: u128,
2072 attempt: u32,
2073 cache_proof: Option<CacheProof>,
2074 adapter_id: &str,
2075 adapter_version: &str,
2076 adapter_outputs_schema_version: &str,
2077 container_meta: Option<ContainerTrace>,
2078 adapter_binary_sha256: Option<String>,
2079 trigger_evaluation: Option<TriggerEvaluation>,
2080 branch_decision: Option<String>,
2081 skip_reason: Option<bijux_dag_artifacts::SkipReason>,
2082 transition_cause: Option<String>,
2083 lifecycle_state: Option<String>,
2084 lifecycle_transitions: Vec<NodeLifecycleTransition>,
2085 replay_provenance: Option<ReplayProvenance>,
2086) -> Result<(), RuntimeError> {
2087 let node = graph
2088 .nodes
2089 .iter()
2090 .find(|n| n.id == node_id)
2091 .ok_or_else(|| RuntimeError::Executor("missing node".to_string()))?;
2092 ctx.store.ensure_node_dir(node_id)?;
2093 write_resolved_params(ctx, node_id)?;
2094 let inputs_index =
2095 if ctx.fs.metadata(ctx.run_dir.node_inputs_index_path(node_id).as_path()).is_ok() {
2096 Some("inputs/index.json".to_string())
2097 } else {
2098 None
2099 };
2100 let outputs = if output_evidence.is_empty() {
2101 inspect_declared_outputs(ctx.run_dir.node_outputs_dir(node_id).as_path(), &node.outputs)
2102 .output_evidence
2103 } else {
2104 output_evidence
2105 };
2106 let cache_identity = Some(cache_identity_for_trace(
2107 ctx,
2108 node_id,
2109 adapter_id,
2110 adapter_version,
2111 adapter_binary_sha256.as_deref(),
2112 adapter_outputs_schema_version,
2113 )?);
2114 let exit_code = terminal_exit_code(node, &status, failure.as_ref(), container_meta.as_ref());
2115 let stdout = collect_node_log_evidence(
2116 ctx.fs.as_ref(),
2117 &ctx.run_dir,
2118 &ctx.run_dir.node_stdout_path(node_id),
2119 );
2120 let stderr = collect_node_log_evidence(
2121 ctx.fs.as_ref(),
2122 &ctx.run_dir,
2123 &ctx.run_dir.node_stderr_path(node_id),
2124 );
2125 let trace = NodeTrace {
2126 node_id: node_id.to_string(),
2127 status: status_string(&status),
2128 started_unix_ms,
2129 finished_unix_ms,
2130 attempt,
2131 fingerprint: node_fingerprint_from_ctx(ctx, node_id),
2132 planner_contract_version: Some(ctx.planner_contract_version.clone()),
2133 execution_fingerprint: Some(ctx.execution_fingerprint.clone()),
2134 evidence_fingerprint: Some(ctx.evidence_fingerprint.clone()),
2135 adapter_id: adapter_id.to_string(),
2136 adapter_version: adapter_version.to_string(),
2137 adapter_outputs_schema_version: adapter_outputs_schema_version.to_string(),
2138 adapter_binary_sha256,
2139 resources: node.resources.as_ref().map(|r| TraceResources {
2140 cpu: r.cpu,
2141 mem_mb: r.mem_mb,
2142 gpu_devices: r.gpu_devices,
2143 }),
2144 inputs_index,
2145 resolved_params: ctx.resolved_params.get(node_id).cloned(),
2146 exit_code,
2147 stdout,
2148 stderr,
2149 outputs,
2150 container: container_meta,
2151 cache_proof,
2152 cache_identity,
2153 branch_decision,
2154 trigger_evaluation,
2155 skip_reason,
2156 failure,
2157 transition_cause,
2158 lifecycle_state,
2159 lifecycle_transitions,
2160 replay_provenance,
2161 };
2162 let data = serde_json::to_vec_pretty(&trace)?;
2163 ctx.store.write_trace(node_id, &data)?;
2164 Ok(())
2165}
2166
2167const NODE_LOG_TAIL_LINE_LIMIT: usize = 20;
2168const NODE_LOG_TAIL_READ_BYTES: u64 = 16 * 1024;
2169
2170fn terminal_exit_code(
2171 node: &Node,
2172 status: &NodeStatus,
2173 failure: Option<&FailureInfo>,
2174 container_meta: Option<&ContainerTrace>,
2175) -> Option<i32> {
2176 if let Some(exit_code) = failure.and_then(failure_exit_code) {
2177 return Some(exit_code);
2178 }
2179 if let Some(exit_code) = container_meta.and_then(|trace| trace.exit_code) {
2180 return Some(exit_code);
2181 }
2182 if supports_terminal_exit_code(node) && matches!(status, NodeStatus::Success) {
2183 return Some(0);
2184 }
2185 None
2186}
2187
2188fn supports_terminal_exit_code(node: &Node) -> bool {
2189 matches!(
2190 node.kind,
2191 NodeKind::Shell | NodeKind::Python | NodeKind::Container | NodeKind::External(_)
2192 )
2193}
2194
2195fn failure_exit_code(failure: &FailureInfo) -> Option<i32> {
2196 failure
2197 .details
2198 .as_ref()
2199 .and_then(|details| details.get("exit_code"))
2200 .and_then(Value::as_i64)
2201 .and_then(|code| i32::try_from(code).ok())
2202}
2203
2204fn collect_node_log_evidence(
2205 fs: &dyn Fs,
2206 run_dir: &RunDir,
2207 path: &Path,
2208) -> Option<NodeLogEvidence> {
2209 let size_bytes = fs.metadata(path).ok()?.len();
2210 let tail_lines =
2211 read_log_tail_lines(path, NODE_LOG_TAIL_LINE_LIMIT, NODE_LOG_TAIL_READ_BYTES).ok()?;
2212 Some(NodeLogEvidence { path: run_relative_path(run_dir, path), size_bytes, tail_lines })
2213}
2214
2215fn run_relative_path(run_dir: &RunDir, path: &Path) -> String {
2216 path.strip_prefix(run_dir.staging_path())
2217 .unwrap_or(path)
2218 .display()
2219 .to_string()
2220 .replace('\\', "/")
2221}
2222
2223fn read_log_tail_lines(
2224 path: &Path,
2225 max_lines: usize,
2226 max_bytes: u64,
2227) -> std_io::Result<Vec<String>> {
2228 let mut file = std::fs::File::open(path)?;
2229 let file_len = file.metadata()?.len();
2230 let start = file_len.saturating_sub(max_bytes);
2231 file.seek(SeekFrom::Start(start))?;
2232 let mut buffer = Vec::new();
2233 file.read_to_end(&mut buffer)?;
2234
2235 let content = String::from_utf8_lossy(&buffer);
2236 let mut lines = content.lines().map(ToString::to_string).collect::<Vec<_>>();
2237 if start > 0 && !content.starts_with('\n') && !lines.is_empty() {
2238 lines.remove(0);
2239 }
2240 if lines.len() > max_lines {
2241 lines = lines.split_off(lines.len() - max_lines);
2242 }
2243 Ok(lines)
2244}
2245
2246fn status_string(status: &NodeStatus) -> String {
2247 match status {
2248 NodeStatus::Success => "success".to_string(),
2249 NodeStatus::Failed => "failed".to_string(),
2250 NodeStatus::Skipped => "skipped".to_string(),
2251 NodeStatus::Cached => "cached".to_string(),
2252 NodeStatus::Cancelled => "cancelled".to_string(),
2253 }
2254}
2255
2256pub(crate) fn node_state_string(state: &NodeState) -> String {
2257 match state {
2258 NodeState::Pending => "pending",
2259 NodeState::Eligible => "eligible",
2260 NodeState::Queued => "queued",
2261 NodeState::Running => "running",
2262 NodeState::Success => "success",
2263 NodeState::Failed => "failed",
2264 NodeState::Skipped => "skipped",
2265 NodeState::Cached => "cached",
2266 NodeState::Cancelled => "cancelled",
2267 NodeState::TimedOut => "timed_out",
2268 }
2269 .to_string()
2270}
2271
2272pub(crate) fn trace_lifecycle_state_string(state: &NodeState) -> String {
2273 match state {
2274 NodeState::Pending => "pending",
2275 NodeState::Eligible => "ready",
2276 NodeState::Queued => "queued",
2277 NodeState::Running => "running",
2278 NodeState::Success => "succeeded",
2279 NodeState::Failed => "failed",
2280 NodeState::Skipped => "skipped",
2281 NodeState::Cached => "cached",
2282 NodeState::Cancelled => "cancelled",
2283 NodeState::TimedOut => "timed_out",
2284 }
2285 .to_string()
2286}
2287
2288pub(crate) fn transition_cause_string(cause: &TransitionCause) -> String {
2289 match cause {
2290 TransitionCause::Submission => "submission",
2291 TransitionCause::PlanningCompleted => "planning_completed",
2292 TransitionCause::SchedulerEligible => "scheduler_eligible",
2293 TransitionCause::SchedulerQueued => "scheduler_queued",
2294 TransitionCause::ExecutionStarted => "execution_started",
2295 TransitionCause::ExecutionSucceeded => "execution_succeeded",
2296 TransitionCause::ExecutionFailed => "execution_failed",
2297 TransitionCause::CachedReuse => "cached_reuse",
2298 TransitionCause::PolicyDenied => "policy_denied",
2299 TransitionCause::DependencyFailed => "dependency_failed",
2300 TransitionCause::SelectionFiltered => "selection_filtered",
2301 TransitionCause::ExecutionAborted => "execution_aborted",
2302 TransitionCause::CancelRequested => "cancel_requested",
2303 TransitionCause::TimeoutExceeded => "timeout_exceeded",
2304 TransitionCause::ReplayReused => "replay_reused",
2305 TransitionCause::ReplayReexecuted => "replay_reexecuted",
2306 TransitionCause::ResumeRequested => "resume_requested",
2307 }
2308 .to_string()
2309}
2310
2311pub(crate) fn transition_cause_for_status(status: &NodeStatus) -> &'static str {
2312 match status {
2313 NodeStatus::Success => "ExecutionSucceeded",
2314 NodeStatus::Failed => "ExecutionFailed",
2315 NodeStatus::Skipped => "SelectionFiltered",
2316 NodeStatus::Cached => "CachedReuse",
2317 NodeStatus::Cancelled => "CancelRequested",
2318 }
2319}
2320
2321pub(crate) fn transition_cause_for_failure(failure: Option<&FailureInfo>) -> &'static str {
2322 match failure {
2323 Some(failure) if failure.kind == "Policy" => "PolicyDenied",
2324 Some(failure) if failure.code == "UPSTREAM_FAILED" => "DependencyFailed",
2325 Some(failure) if failure.code == "RUN_ABORTED" => "ExecutionAborted",
2326 Some(failure) if failure.code == "EXEC_CANCELLED" => "CancelRequested",
2327 Some(failure) if failure.code == "RUN_TIMEOUT" => "TimeoutExceeded",
2328 Some(failure) if failure.code == "EXEC_TIMEOUT" => "TimeoutExceeded",
2329 Some(failure) if failure.code == "CONTAINER_ENGINE_UNAVAILABLE" => "InfrastructureFailed",
2330 Some(failure) if failure.code == "OUTPUT_MISSING" => "MissingRequiredOutput",
2331 Some(failure) if failure.code == "INPUT_MISSING" => "MissingRequiredInput",
2332 Some(failure) if failure.kind == "Infrastructure" => "InfrastructureFailed",
2333 _ => "ExecutionFailed",
2334 }
2335}
2336
2337pub(crate) fn transition_cause_for_skip_reason(reason: &str) -> &'static str {
2338 match reason {
2339 "filtered"
2340 | "not_selected_by_include_selector"
2341 | "excluded_by_selector"
2342 | "not_selected_by_dependency_closure" => "SelectionFiltered",
2343 "branch_decision_not_selected" => "BranchDecisionFiltered",
2344 "upstream_failed" | "isolated_branch_failure" => "DependencyFailed",
2345 "cancelled" => "CancelRequested",
2346 _ => "SelectionFiltered",
2347 }
2348}
2349
2350pub(crate) fn failure_propagation_cause(failure: Option<&FailureInfo>) -> &'static str {
2351 match transition_cause_for_failure(failure) {
2352 "PolicyDenied" => "policy_denied",
2353 "DependencyFailed" => "upstream_failed",
2354 "ExecutionAborted" => "execution_aborted",
2355 "CancelRequested" => "cancel_requested",
2356 "TimeoutExceeded" => "timeout_exceeded",
2357 "InfrastructureFailed" => "infrastructure_failed",
2358 "MissingRequiredOutput" => "missing_required_output",
2359 "MissingRequiredInput" => "missing_required_input",
2360 _ => "execution_failed",
2361 }
2362}
2363
2364fn write_resolved_params(ctx: &RunContext, node_id: &str) -> Result<(), RuntimeError> {
2365 let mut params = ctx.resolved_params.get(node_id).cloned().unwrap_or(Value::Null);
2366 sort_value_maps(&mut params);
2367 let data = serde_json::to_vec_pretty(¶ms)?;
2368 ctx.store.write_resolved_params(node_id, &data)?;
2369 Ok(())
2370}
2371
2372fn write_attempt_events(
2373 ctx: &RunContext,
2374 node_id: &str,
2375 attempt_events: &[AttemptEvent],
2376) -> Result<(), RuntimeError> {
2377 let data = serde_json::to_vec_pretty(attempt_events)?;
2378 ctx.store.write_attempts(node_id, &data)?;
2379 Ok(())
2380}
2381
2382fn attempt_log_relative_path(attempt: u32, file_name: &str) -> String {
2383 format!("attempts/{attempt}/{file_name}")
2384}
2385
2386fn persist_attempt_logs(
2387 ctx: &RunContext,
2388 node_id: &str,
2389 attempt: u32,
2390 stdout_path: &str,
2391 stderr_path: &str,
2392) -> Result<(String, String), RuntimeError> {
2393 let attempt_dir = ctx.run_dir.node_attempt_dir(node_id, attempt);
2394 let attempt_stdout_path = ctx.run_dir.node_attempt_stdout_path(node_id, attempt);
2395 let attempt_stderr_path = ctx.run_dir.node_attempt_stderr_path(node_id, attempt);
2396 ctx.fs.create_dir_all(&attempt_dir)?;
2397 match ctx.fs.copy(Path::new(stdout_path), &attempt_stdout_path) {
2398 Ok(_) => {}
2399 Err(error) if error.kind() == std_io::ErrorKind::NotFound => {
2400 ctx.fs.write(&attempt_stdout_path, b"")?;
2401 }
2402 Err(error) => return Err(RuntimeError::Io(error)),
2403 }
2404 match ctx.fs.copy(Path::new(stderr_path), &attempt_stderr_path) {
2405 Ok(_) => {}
2406 Err(error) if error.kind() == std_io::ErrorKind::NotFound => {
2407 ctx.fs.write(&attempt_stderr_path, b"")?;
2408 }
2409 Err(error) => return Err(RuntimeError::Io(error)),
2410 }
2411 Ok((
2412 attempt_log_relative_path(attempt, "stdout.log"),
2413 attempt_log_relative_path(attempt, "stderr.log"),
2414 ))
2415}
2416
2417#[allow(dead_code)]
2418fn node_timeout_ms(
2419 node: &Node,
2420 resolved_params: &Value,
2421 default_ms: Option<u64>,
2422) -> Option<Duration> {
2423 let param_timeout = resolved_params.get("timeout_ms").and_then(|v| v.as_u64());
2424 let ms = node.timeout_ms.or(param_timeout).or(default_ms);
2425 ms.map(Duration::from_millis)
2426}
2427
2428fn node_cpu(graph: &Graph, node_id: &str) -> u32 {
2429 graph
2430 .nodes
2431 .iter()
2432 .find(|n| n.id == node_id)
2433 .and_then(|n| n.resources.as_ref().map(|r| r.cpu))
2434 .unwrap_or(1)
2435 .max(1)
2436}
2437
2438fn map_execution_summary_path(ctx: &RunContext, node_id: &str) -> PathBuf {
2439 ctx.run_dir.node_dir(node_id).join("map.execution.json")
2440}
2441
2442fn map_execution_version() -> String {
2443 "map-execution/v0.1".to_string()
2444}
2445
2446fn reduce_collection_manifest_name() -> &'static str {
2447 "reduce.collection.json"
2448}
2449
2450fn reduce_execution_summary_path(ctx: &RunContext, node_id: &str) -> PathBuf {
2451 ctx.run_dir.node_dir(node_id).join("reduce.execution.json")
2452}
2453
2454fn reduce_execution_version() -> String {
2455 "reduce-execution/v0.1".to_string()
2456}
2457
2458fn reduce_mode_label(mode: ReduceExecutionMode) -> &'static str {
2459 match mode {
2460 ReduceExecutionMode::AllSuccess => "all_success",
2461 ReduceExecutionMode::Partial => "partial",
2462 }
2463}
2464
2465fn reduce_empty_policy_label(policy: ReduceEmptyPolicy) -> &'static str {
2466 match policy {
2467 ReduceEmptyPolicy::Forbid => "forbid",
2468 ReduceEmptyPolicy::Allow => "allow",
2469 ReduceEmptyPolicy::Skip => "skip",
2470 }
2471}
2472
2473pub(crate) fn reduce_execution_config(node: &Node) -> Result<ReduceExecutionConfig, FailureInfo> {
2474 let reduce = match &node.params {
2475 bijux_dag_core::ParamValue::Object(params) => match params.get("reduce") {
2476 Some(bijux_dag_core::ParamValue::Object(reduce)) => Some(reduce),
2477 Some(_) => {
2478 return Err(FailureInfo::new(
2479 FailureClass::User,
2480 "User",
2481 "REDUCE_CONFIG_INVALID",
2482 format!("reduce params on node {} must be an object", node.id),
2483 Some(serde_json::json!({ "node_id": node.id })),
2484 ));
2485 }
2486 None => None,
2487 },
2488 _ => None,
2489 };
2490
2491 let mode = match reduce.and_then(|value| value.get("mode")) {
2492 None => ReduceExecutionMode::AllSuccess,
2493 Some(bijux_dag_core::ParamValue::Literal(Value::String(value)))
2494 if value == "all_success" =>
2495 {
2496 ReduceExecutionMode::AllSuccess
2497 }
2498 Some(bijux_dag_core::ParamValue::Literal(Value::String(value))) if value == "partial" => {
2499 ReduceExecutionMode::Partial
2500 }
2501 Some(_) => {
2502 return Err(FailureInfo::new(
2503 FailureClass::User,
2504 "User",
2505 "REDUCE_MODE_INVALID",
2506 format!("reduce.mode on node {} must be 'all_success' or 'partial'", node.id),
2507 Some(serde_json::json!({ "node_id": node.id })),
2508 ));
2509 }
2510 };
2511
2512 let empty_policy = match reduce.and_then(|value| value.get("empty")) {
2513 None => ReduceEmptyPolicy::Forbid,
2514 Some(bijux_dag_core::ParamValue::Literal(Value::String(value))) if value == "forbid" => {
2515 ReduceEmptyPolicy::Forbid
2516 }
2517 Some(bijux_dag_core::ParamValue::Literal(Value::String(value))) if value == "allow" => {
2518 ReduceEmptyPolicy::Allow
2519 }
2520 Some(bijux_dag_core::ParamValue::Literal(Value::String(value))) if value == "skip" => {
2521 ReduceEmptyPolicy::Skip
2522 }
2523 Some(_) => {
2524 return Err(FailureInfo::new(
2525 FailureClass::User,
2526 "User",
2527 "REDUCE_EMPTY_POLICY_INVALID",
2528 format!("reduce.empty on node {} must be 'forbid', 'allow', or 'skip'", node.id),
2529 Some(serde_json::json!({ "node_id": node.id })),
2530 ));
2531 }
2532 };
2533
2534 Ok(ReduceExecutionConfig { mode, empty_policy })
2535}
2536
2537fn map_input_port(node: &Node, params: &Value) -> Result<String, FailureInfo> {
2538 if let Some(input) =
2539 params.get("map").and_then(|value| value.get("input")).and_then(Value::as_str)
2540 {
2541 if node.inputs.iter().any(|candidate| candidate == input) {
2542 return Ok(input.to_string());
2543 }
2544 return Err(FailureInfo::new(
2545 FailureClass::User,
2546 "User",
2547 "MAP_INPUT_INVALID",
2548 format!("map input '{}' is not declared on node {}", input, node.id),
2549 Some(serde_json::json!({
2550 "node_id": node.id,
2551 "input": input,
2552 "declared_inputs": node.inputs,
2553 })),
2554 ));
2555 }
2556
2557 match node.inputs.as_slice() {
2558 [input] => Ok(input.clone()),
2559 [] => Err(FailureInfo::new(
2560 FailureClass::User,
2561 "User",
2562 "MAP_INPUT_MISSING",
2563 format!("map node {} requires at least one declared input", node.id),
2564 Some(serde_json::json!({ "node_id": node.id })),
2565 )),
2566 _ => Err(FailureInfo::new(
2567 FailureClass::User,
2568 "User",
2569 "MAP_INPUT_AMBIGUOUS",
2570 format!(
2571 "map node {} requires params.map.input when more than one input is declared",
2572 node.id
2573 ),
2574 Some(serde_json::json!({
2575 "node_id": node.id,
2576 "declared_inputs": node.inputs,
2577 })),
2578 )),
2579 }
2580}
2581
2582fn map_input_binding(
2583 graph: &Graph,
2584 node_id: &str,
2585 input_port: &str,
2586) -> Result<(String, String), FailureInfo> {
2587 let edge = graph
2588 .edges
2589 .iter()
2590 .find(|edge| edge.to.node_id == node_id && edge.to.port == input_port)
2591 .ok_or_else(|| {
2592 FailureInfo::new(
2593 FailureClass::User,
2594 "User",
2595 "MAP_INPUT_UNBOUND",
2596 format!("map input {}.{} is not bound to an upstream output", node_id, input_port),
2597 Some(serde_json::json!({
2598 "node_id": node_id,
2599 "input_port": input_port,
2600 })),
2601 )
2602 })?;
2603 Ok((edge.from.node_id.clone(), edge.from.port.clone()))
2604}
2605
2606fn load_map_items(
2607 ctx: &RunContext,
2608 graph: &Graph,
2609 node: &Node,
2610 input_port: &str,
2611) -> Result<Vec<Value>, FailureInfo> {
2612 let (source_node_id, _) = map_input_binding(graph, &node.id, input_port)?;
2613 let item_path = ctx.run_dir.node_inputs_dir(&node.id).join(&source_node_id).join(input_port);
2614 let raw = ctx.fs.read_to_string(&item_path).map_err(|error| {
2615 FailureInfo::new(
2616 FailureClass::User,
2617 "User",
2618 "MAP_INPUT_UNREADABLE",
2619 format!("map input could not be read from {}: {}", item_path.display(), error),
2620 Some(serde_json::json!({
2621 "node_id": node.id,
2622 "input_port": input_port,
2623 "source_node_id": source_node_id,
2624 })),
2625 )
2626 })?;
2627 let payload = serde_json::from_str::<Value>(&raw).map_err(|error| {
2628 FailureInfo::new(
2629 FailureClass::User,
2630 "User",
2631 "MAP_INPUT_INVALID",
2632 format!("map input for {} must be valid json array: {}", node.id, error),
2633 Some(serde_json::json!({
2634 "node_id": node.id,
2635 "input_port": input_port,
2636 "source_node_id": source_node_id,
2637 })),
2638 )
2639 })?;
2640 payload.as_array().cloned().ok_or_else(|| {
2641 FailureInfo::new(
2642 FailureClass::User,
2643 "User",
2644 "MAP_INPUT_INVALID",
2645 format!("map input for {} must be a json array", node.id),
2646 Some(serde_json::json!({
2647 "node_id": node.id,
2648 "input_port": input_port,
2649 "source_node_id": source_node_id,
2650 })),
2651 )
2652 })
2653}
2654
2655fn read_inputs_index(ctx: &RunContext, node_id: &str) -> Result<InputsIndex, RuntimeError> {
2656 let raw = ctx.fs.read_to_string(&ctx.run_dir.node_inputs_index_path(node_id))?;
2657 serde_json::from_str(&raw).map_err(RuntimeError::from)
2658}
2659
2660fn map_item_identity(index: usize, item: &Value) -> Result<(String, String), RuntimeError> {
2661 let item_bytes = serde_json::to_vec(item)?;
2662 let item_sha256 = sha256_bytes(&item_bytes);
2663 Ok((format!("position-{index:06}-{}", &item_sha256[..8]), item_sha256))
2664}
2665
2666fn write_item_inputs_index(
2667 ctx: &RunContext,
2668 graph: &Graph,
2669 node: &Node,
2670 input_port: &str,
2671 item_run_dir: &RunDir,
2672 item_value: &Value,
2673) -> Result<InputsIndex, RuntimeError> {
2674 let parent_inputs_dir = ctx.run_dir.node_inputs_dir(&node.id);
2675 let item_inputs_dir = item_run_dir.node_inputs_dir(&node.id);
2676 copy_dir_all(ctx.fs.as_ref(), &parent_inputs_dir, &item_inputs_dir)?;
2677
2678 let parent_index = read_inputs_index(ctx, &node.id)?;
2679 let (source_node_id, source_output_name) = map_input_binding(graph, &node.id, input_port)
2680 .map_err(|failure| RuntimeError::Executor(failure.message))?;
2681 let item_input_path = item_inputs_dir.join(&source_node_id).join(input_port);
2682 if let Some(parent) = item_input_path.parent() {
2683 ctx.fs.create_dir_all(parent)?;
2684 }
2685 let item_bytes = serde_json::to_vec_pretty(item_value)?;
2686 ctx.fs.write(&item_input_path, &item_bytes)?;
2687 let item_sha256 = sha256_bytes(&item_bytes);
2688 let source_node_fingerprint = node_fingerprint_from_ctx(ctx, &source_node_id);
2689 let local_path = format!("{source_node_id}/{input_port}");
2690
2691 let mut updated = false;
2692 let mut files = parent_index
2693 .files
2694 .into_iter()
2695 .map(|mut file| {
2696 if file.local_path == local_path {
2697 file.source_sha256.clone_from(&item_sha256);
2698 file.source_node_id.clone_from(&source_node_id);
2699 file.source_node_fingerprint.clone_from(&source_node_fingerprint);
2700 file.source_output_name.clone_from(&source_output_name);
2701 file.materialization_mode = "copy".to_string();
2702 updated = true;
2703 }
2704 file
2705 })
2706 .collect::<Vec<_>>();
2707 if !updated {
2708 files.push(InputFile {
2709 local_path,
2710 source_sha256: item_sha256,
2711 source_node_id,
2712 source_node_fingerprint,
2713 source_output_name,
2714 materialization_mode: "copy".to_string(),
2715 });
2716 }
2717 files.sort_by(|left, right| left.local_path.cmp(&right.local_path));
2718 let index = InputsIndex { collections: Vec::new(), files };
2719 write_inputs_index(&item_inputs_dir, &index)?;
2720 Ok(index)
2721}
2722
2723fn write_map_summary(
2724 ctx: &RunContext,
2725 node_id: &str,
2726 summary: &MapExecutionSummary,
2727) -> Result<(), RuntimeError> {
2728 let bytes = serde_json::to_vec_pretty(summary)?;
2729 ctx.fs.write(&map_execution_summary_path(ctx, node_id), &bytes)?;
2730 Ok(())
2731}
2732
2733fn aggregate_map_item_outputs(
2734 fs: &dyn Fs,
2735 node: &Node,
2736 parent_outputs_dir: &Path,
2737 item_node_outputs_dir: &Path,
2738 item_id: &str,
2739) -> Result<Vec<MapExecutionOutputSummary>, RuntimeError> {
2740 let mut outputs = Vec::new();
2741 for output in &node.outputs {
2742 if output.expects_file() {
2743 return Err(RuntimeError::Executor(format!(
2744 "map node {} output {} must be a directory output",
2745 node.id, output.name
2746 )));
2747 }
2748 let item_output_path = authorized_declared_output_path(item_node_outputs_dir, output)
2749 .map_err(|failure| RuntimeError::Executor(failure.message))?;
2750 let aggregate_root = authorized_declared_output_path(parent_outputs_dir, output)
2751 .map_err(|failure| RuntimeError::Executor(failure.message))?;
2752 let aggregate_item_path = aggregate_root.join("items").join(item_id);
2753 fs.create_dir_all(&aggregate_root)?;
2754 copy_dir_all(fs, &item_output_path, &aggregate_item_path)?;
2755 outputs.push(MapExecutionOutputSummary {
2756 output_name: output.name.clone(),
2757 item_path: format!("{}/items/{}", output.path, item_id),
2758 });
2759 }
2760 Ok(outputs)
2761}
2762
2763fn execute_map_node(
2764 adapter: &dyn Adapter,
2765 graph: &Graph,
2766 node: &Node,
2767 params: &Value,
2768 ctx: &RunContext,
2769 retry: &RetryPolicy,
2770) -> Result<NodeResult, RuntimeError> {
2771 prepare_node_execution_dirs(ctx, &node.id)?;
2772 let started = ctx.clock.now_unix_ms();
2773 let stdout_path = ctx.run_dir.node_stdout_path(&node.id);
2774 let stderr_path = ctx.run_dir.node_stderr_path(&node.id);
2775 let parent_outputs_dir = ctx.run_dir.node_outputs_dir(&node.id);
2776
2777 let input_port = match map_input_port(node, params) {
2778 Ok(input_port) => input_port,
2779 Err(failure) => {
2780 let message = failure.message.clone();
2781 let mut result = node_failure_result(
2782 ctx.fs.as_ref(),
2783 &stdout_path,
2784 &stderr_path,
2785 &parent_outputs_dir,
2786 NodeStatus::Failed,
2787 failure,
2788 message.as_bytes(),
2789 )?;
2790 let finished = ctx.clock.now_unix_ms();
2791 let (attempt_stdout_path, attempt_stderr_path) =
2792 persist_attempt_logs(ctx, &node.id, 1, &result.stdout_path, &result.stderr_path)?;
2793 result.attempts = 1;
2794 result.attempt_events = vec![AttemptEvent {
2795 attempt: 1,
2796 started_unix_ms: started,
2797 finished_unix_ms: finished,
2798 status: result.status.clone(),
2799 stdout_path: Some(attempt_stdout_path),
2800 stderr_path: Some(attempt_stderr_path),
2801 failure: result.failure.clone(),
2802 scheduled_backoff_ms: None,
2803 retry_decision: None,
2804 }];
2805 return Ok(result);
2806 }
2807 };
2808
2809 let items = load_map_items(ctx, graph, node, &input_port)
2810 .map_err(|failure| RuntimeError::Executor(failure.message))?;
2811 for output in &node.outputs {
2812 let aggregate_root = authorized_declared_output_path(&parent_outputs_dir, output)
2813 .map_err(|failure| RuntimeError::Executor(failure.message))?;
2814 ctx.fs.create_dir_all(&aggregate_root)?;
2815 }
2816
2817 let map_runs_dir = ctx.run_dir.node_dir(&node.id).join("mapped_items");
2818 ctx.fs.create_dir_all(&map_runs_dir)?;
2819 let resolved = graph.resolve_graph()?;
2820 let base_node_definition_fp = node_definition_fingerprint_from_ctx(ctx, &node.id);
2821 let base_declared_env_fp = declared_environment_fingerprint_from_ctx(ctx, &node.id);
2822 let base_fp =
2823 sha256_bytes(format!("{base_node_definition_fp}:{base_declared_env_fp}").as_bytes());
2824
2825 let mut summaries = Vec::new();
2826 let mut successful_item_count = 0usize;
2827 let mut failed_item_count = 0usize;
2828 let mut cancelled_item_count = 0usize;
2829
2830 for (index, item) in items.into_iter().enumerate() {
2831 let (item_id, item_sha256) = map_item_identity(index, &item)?;
2832 let item_layout =
2833 RunDirLayout::preview(&map_runs_dir, Some(&item_id)).map_err(|error| {
2834 RuntimeError::Executor(format!("invalid map item identity {}: {}", item_id, error))
2835 })?;
2836 let item_run_dir = RunDir::create_with_id(&map_runs_dir, &item_id)?;
2837 let item_inputs =
2838 write_item_inputs_index(ctx, graph, node, &input_port, &item_run_dir, &item)?;
2839 let params_template =
2840 resolved.resolved_params.get(&node.id).cloned().unwrap_or(Value::Null);
2841 let item_bindings =
2842 NodePathBindings::for_host(&item_layout, &node.id, ctx.effective_cache_dir.as_deref());
2843 let item_params = bind_path_variables_in_value(¶ms_template, &item_bindings)
2844 .map_err(RuntimeError::Executor)?;
2845 let item_params_fingerprint = params_fingerprint(&item_params)?;
2846 let item_command_fingerprint = command_fingerprint(graph, node, &item_params)?;
2847 let item_fp = node_fingerprint_with_inputs(&base_fp, &item_inputs)?;
2848 let item_run_dir_arc = Arc::new(item_run_dir.clone());
2849 let item_ctx = RunContext {
2850 run_dir: Arc::clone(&item_run_dir_arc),
2851 replay_source_run_dir: ctx.replay_source_run_dir.clone(),
2852 graph_fingerprint: Arc::new(Mutex::new(HashMap::from([(node.id.clone(), item_fp)]))),
2853 node_definition_fingerprints: Arc::new(HashMap::from([(
2854 node.id.clone(),
2855 base_node_definition_fp.clone(),
2856 )])),
2857 declared_environment_fingerprints: Arc::new(HashMap::from([(
2858 node.id.clone(),
2859 base_declared_env_fp.clone(),
2860 )])),
2861 params_fingerprints: Arc::new(HashMap::from([(
2862 node.id.clone(),
2863 item_params_fingerprint,
2864 )])),
2865 command_fingerprints: Arc::new(HashMap::from([(
2866 node.id.clone(),
2867 item_command_fingerprint,
2868 )])),
2869 planner_contract_version: ctx.planner_contract_version.clone(),
2870 execution_fingerprint: ctx.execution_fingerprint.clone(),
2871 evidence_fingerprint: ctx.evidence_fingerprint.clone(),
2872 execution_contract_fingerprint: ctx.execution_contract_fingerprint.clone(),
2873 resolved_params: HashMap::from([(node.id.clone(), item_params.clone())]),
2874 effective_cache_dir: ctx.effective_cache_dir.clone(),
2875 fs: Arc::clone(&ctx.fs),
2876 clock: Arc::clone(&ctx.clock),
2877 store: RuntimeArtifactStore::new(item_run_dir_arc, Arc::clone(&ctx.fs)),
2878 policy: ctx.policy.clone(),
2879 absolute_path_policy: ctx.absolute_path_policy,
2880 cancellation_requested: Arc::clone(&ctx.cancellation_requested),
2881 };
2882 let mut item_node = node.clone();
2883 item_node.semantic_kind = bijux_dag_core::SemanticNodeKind::Task;
2884 let item_result =
2885 execute_with_retries(adapter, graph, &item_node, &item_params, &item_ctx, retry)?;
2886 let item_final_dir = item_run_dir.finalize()?;
2887 let item_outputs_dir = item_final_dir.join("nodes").join(&node.id).join("outputs");
2888 let outputs = if item_result.status == NodeStatus::Success {
2889 successful_item_count += 1;
2890 aggregate_map_item_outputs(
2891 ctx.fs.as_ref(),
2892 node,
2893 &parent_outputs_dir,
2894 &item_outputs_dir,
2895 &item_id,
2896 )?
2897 } else {
2898 if item_result.status == NodeStatus::Cancelled {
2899 cancelled_item_count += 1;
2900 } else {
2901 failed_item_count += 1;
2902 }
2903 Vec::new()
2904 };
2905 summaries.push(MapExecutionItemSummary {
2906 item_id,
2907 item_sha256,
2908 status: status_string(&item_result.status),
2909 run_dir: item_final_dir
2910 .strip_prefix(ctx.run_dir.node_dir(&node.id))
2911 .unwrap_or(item_final_dir.as_path())
2912 .to_string_lossy()
2913 .replace('\\', "/"),
2914 outputs,
2915 failure: item_result.failure.clone(),
2916 });
2917 }
2918
2919 summaries.sort_by(|left, right| left.item_id.cmp(&right.item_id));
2920 let status = if failed_item_count > 0 {
2921 NodeStatus::Failed
2922 } else if cancelled_item_count > 0 {
2923 NodeStatus::Cancelled
2924 } else {
2925 NodeStatus::Success
2926 };
2927 let failure = match status {
2928 NodeStatus::Failed => Some(FailureInfo::new(
2929 FailureClass::Execution,
2930 "Execution",
2931 "MAP_ITEMS_FAILED",
2932 format!(
2933 "map node {} failed for {} of {} items",
2934 node.id,
2935 failed_item_count,
2936 summaries.len()
2937 ),
2938 Some(serde_json::json!({
2939 "failed_items": summaries
2940 .iter()
2941 .filter(|item| item.status == "failed")
2942 .map(|item| serde_json::json!({
2943 "item_id": item.item_id,
2944 "failure": item.failure,
2945 }))
2946 .collect::<Vec<_>>(),
2947 })),
2948 )),
2949 NodeStatus::Cancelled => Some(FailureInfo::new(
2950 FailureClass::Execution,
2951 "Execution",
2952 "MAP_ITEMS_CANCELLED",
2953 format!("map node {} cancelled while processing {} items", node.id, summaries.len()),
2954 Some(serde_json::json!({
2955 "cancelled_items": cancelled_item_count,
2956 })),
2957 )),
2958 _ => None,
2959 };
2960 let summary = MapExecutionSummary {
2961 schema_version: map_execution_version(),
2962 map_node_id: node.id.clone(),
2963 input_port,
2964 item_count: summaries.len(),
2965 successful_item_count,
2966 failed_item_count,
2967 cancelled_item_count,
2968 items: summaries,
2969 };
2970 write_map_summary(ctx, &node.id, &summary)?;
2971
2972 let finished = ctx.clock.now_unix_ms();
2973 let output_report = inspect_declared_outputs(&parent_outputs_dir, &node.outputs);
2974 if let Some(output_failure) = output_report.failure {
2975 let message = output_failure.message.clone();
2976 let mut result = node_failure_result(
2977 ctx.fs.as_ref(),
2978 &stdout_path,
2979 &stderr_path,
2980 &parent_outputs_dir,
2981 NodeStatus::Failed,
2982 output_failure,
2983 message.as_bytes(),
2984 )?;
2985 let (attempt_stdout_path, attempt_stderr_path) =
2986 persist_attempt_logs(ctx, &node.id, 1, &result.stdout_path, &result.stderr_path)?;
2987 result.attempts = 1;
2988 result.attempt_events = vec![AttemptEvent {
2989 attempt: 1,
2990 started_unix_ms: started,
2991 finished_unix_ms: finished,
2992 status: result.status.clone(),
2993 stdout_path: Some(attempt_stdout_path),
2994 stderr_path: Some(attempt_stderr_path),
2995 failure: result.failure.clone(),
2996 scheduled_backoff_ms: None,
2997 retry_decision: None,
2998 }];
2999 return Ok(result);
3000 }
3001
3002 let stdout = format!(
3003 "mapped {} items for {} (success={}, failed={}, cancelled={})\n",
3004 summary.item_count,
3005 summary.map_node_id,
3006 summary.successful_item_count,
3007 summary.failed_item_count,
3008 summary.cancelled_item_count,
3009 );
3010 let stderr = if matches!(status, NodeStatus::Failed | NodeStatus::Cancelled) {
3011 summary
3012 .items
3013 .iter()
3014 .filter_map(|item| {
3015 item.failure.as_ref().map(|failure| {
3016 format!("{}: {} ({})", item.item_id, failure.message, failure.code)
3017 })
3018 })
3019 .collect::<Vec<_>>()
3020 .join("\n")
3021 } else {
3022 String::new()
3023 };
3024 ctx.fs.write(&stdout_path, stdout.as_bytes())?;
3025 ctx.fs.write(&stderr_path, stderr.as_bytes())?;
3026 let fp = node_fingerprint_from_ctx(ctx, &node.id);
3027 write_outputs_index(&parent_outputs_dir, &node.id, &fp, &output_report.present_outputs)?;
3028 let (attempt_stdout_path, attempt_stderr_path) = persist_attempt_logs(
3029 ctx,
3030 &node.id,
3031 1,
3032 &stdout_path.display().to_string(),
3033 &stderr_path.display().to_string(),
3034 )?;
3035 Ok(NodeResult {
3036 status: status.clone(),
3037 stdout_path: stdout_path.display().to_string(),
3038 stderr_path: stderr_path.display().to_string(),
3039 outputs_dir: parent_outputs_dir.display().to_string(),
3040 output_evidence: output_report.output_evidence,
3041 failure: failure.clone(),
3042 attempts: 1,
3043 attempt_events: vec![AttemptEvent {
3044 attempt: 1,
3045 started_unix_ms: started,
3046 finished_unix_ms: finished,
3047 status: status.clone(),
3048 stdout_path: Some(attempt_stdout_path),
3049 stderr_path: Some(attempt_stderr_path),
3050 failure,
3051 scheduled_backoff_ms: None,
3052 retry_decision: None,
3053 }],
3054 container_meta: None,
3055 adapter_binary_sha256: adapter.binary_hash(),
3056 })
3057}
3058
3059fn execute_with_retries(
3060 adapter: &dyn Adapter,
3061 graph: &Graph,
3062 node: &Node,
3063 params: &Value,
3064 ctx: &RunContext,
3065 retry: &RetryPolicy,
3066) -> Result<NodeResult, RuntimeError> {
3067 if node.semantic_kind == bijux_dag_core::SemanticNodeKind::Map {
3068 return execute_map_node(adapter, graph, node, params, ctx, retry);
3069 }
3070 execute_with_retry_operation(ctx, node, retry, |_attempt| {
3071 let node_ctx = NodeCtx { graph, node, exec: ctx, params };
3072 Ok(match adapter.execute(&node_ctx) {
3073 Ok(result) => result,
3074 Err(error) => failed_node_result_from_runtime_error(ctx, node, error),
3075 })
3076 })
3077}
3078
3079pub(crate) fn execute_with_retry_operation<F>(
3080 ctx: &RunContext,
3081 node: &Node,
3082 _retry: &RetryPolicy,
3083 mut operation: F,
3084) -> Result<NodeResult, RuntimeError>
3085where
3086 F: FnMut(u32) -> Result<NodeResult, RuntimeError>,
3087{
3088 let mut attempt = 0u32;
3089 let retry_policy = build_retry_policy(node);
3090 let mut attempt_events = Vec::new();
3091 loop {
3092 attempt += 1;
3093 prepare_node_execution_dirs(ctx, &node.id)?;
3094 let started = ctx.clock.now_unix_ms();
3095 let mut result = operation(attempt)
3096 .unwrap_or_else(|error| failed_node_result_from_runtime_error(ctx, node, error));
3097 let finished = ctx.clock.now_unix_ms();
3098 let retry_decision = result.failure.as_ref().and_then(|failure| {
3099 (result.status == NodeStatus::Failed).then(|| {
3100 evaluate_retry_decision(
3101 &node.id,
3102 &retry_policy,
3103 attempt,
3104 &retry_observation_from_failure(failure),
3105 )
3106 })
3107 });
3108 let retry_allowed = retry_decision.as_ref().is_some_and(|decision| decision.retry_allowed);
3109 let scheduled_backoff_ms = retry_decision
3110 .as_ref()
3111 .filter(|decision| decision.retry_allowed)
3112 .and_then(|_| {
3113 result.failure.as_ref().map(|failure| {
3114 contract_retry_wait_ms(
3115 &node.id,
3116 &retry_policy,
3117 attempt,
3118 failure.operator_class().as_str(),
3119 )
3120 })
3121 })
3122 .filter(|wait| *wait > 0);
3123 let (attempt_stdout_path, attempt_stderr_path) =
3124 persist_attempt_logs(ctx, &node.id, attempt, &result.stdout_path, &result.stderr_path)?;
3125 attempt_events.push(AttemptEvent {
3126 attempt,
3127 started_unix_ms: started,
3128 finished_unix_ms: finished,
3129 status: result.status.clone(),
3130 stdout_path: Some(attempt_stdout_path),
3131 stderr_path: Some(attempt_stderr_path),
3132 failure: result.failure.clone(),
3133 scheduled_backoff_ms,
3134 retry_decision: retry_decision.clone(),
3135 });
3136 result.attempts = attempt;
3137 if result.status != NodeStatus::Failed {
3138 result.attempt_events = attempt_events;
3139 return Ok(result);
3140 }
3141 if !retry_allowed {
3142 result.attempt_events = attempt_events;
3143 return Ok(result);
3144 }
3145 let wait = scheduled_backoff_ms.unwrap_or(0);
3146 if wait > 0 {
3147 std::thread::sleep(Duration::from_millis(wait));
3148 }
3149 }
3150}
3151
3152pub(crate) fn failed_node_result_from_runtime_error(
3153 ctx: &RunContext,
3154 node: &Node,
3155 error: RuntimeError,
3156) -> NodeResult {
3157 let node_dir = ctx.run_dir.node_dir(&node.id);
3158 let outputs_dir = ctx.run_dir.node_outputs_dir(&node.id);
3159 let stdout_path = ctx.run_dir.node_stdout_path(&node.id);
3160 let stderr_path = ctx.run_dir.node_stderr_path(&node.id);
3161 let (class, kind, code, message) = match error {
3162 RuntimeError::Graph(err) => (FailureClass::User, "User", "GRAPH_ERROR", err.to_string()),
3163 RuntimeError::Artifact(err) => {
3164 (FailureClass::Infrastructure, "Infrastructure", "ARTIFACT_ERROR", err.to_string())
3165 }
3166 RuntimeError::Io(err) if err.kind() == std_io::ErrorKind::NotFound => {
3167 (FailureClass::Infrastructure, "Infrastructure", "MISSING_EXECUTABLE", err.to_string())
3168 }
3169 RuntimeError::Io(err) => {
3170 (FailureClass::Infrastructure, "Infrastructure", "IO_ERROR", err.to_string())
3171 }
3172 RuntimeError::Json(err) => {
3173 (FailureClass::Infrastructure, "Infrastructure", "JSON_ERROR", err.to_string())
3174 }
3175 RuntimeError::Executor(message) => {
3176 if message.contains("timed out") {
3177 (FailureClass::Timeout, "Timeout", "EXEC_TIMEOUT", message)
3178 } else if message.contains("cancelled") {
3179 (FailureClass::Execution, "Execution", "EXEC_CANCELLED", message)
3180 } else if matches!(
3181 message.as_str(),
3182 "missing argv" | "empty argv" | "argv must be strings" | "missing container spec"
3183 ) {
3184 (FailureClass::User, "User", "EXEC_ERROR", message)
3185 } else {
3186 (FailureClass::Execution, "Execution", "EXEC_ERROR", message)
3187 }
3188 }
3189 };
3190 let _ = ctx.fs.create_dir_all(&node_dir);
3191 let _ = ctx.fs.create_dir_all(&outputs_dir);
3192 let _ = ctx.fs.write(&stdout_path, b"");
3193 let _ = ctx.fs.write(&stderr_path, message.as_bytes());
3194 NodeResult {
3195 status: if code == "EXEC_CANCELLED" { NodeStatus::Cancelled } else { NodeStatus::Failed },
3196 stdout_path: stdout_path.display().to_string(),
3197 stderr_path: stderr_path.display().to_string(),
3198 outputs_dir: outputs_dir.display().to_string(),
3199 output_evidence: Vec::new(),
3200 failure: Some(FailureInfo::new(class, kind, code, message, None)),
3201 attempts: 1,
3202 attempt_events: Vec::new(),
3203 container_meta: None,
3204 adapter_binary_sha256: None,
3205 }
3206}
3207
3208fn append_event(file: &mut std::fs::File, value: serde_json::Value) -> Result<(), RuntimeError> {
3209 let line = serde_json::to_string(&value)?;
3210 writeln!(file, "{}", line)?;
3211 Ok(())
3212}
3213
3214fn cache_mode_string(mode: &CacheMode) -> Option<String> {
3215 match mode {
3216 CacheMode::Off => None,
3217 CacheMode::Read => Some("read".to_string()),
3218 CacheMode::ReadWrite => Some("readwrite".to_string()),
3219 }
3220}
3221
3222fn build_git_sha() -> Option<&'static str> {
3223 option_env!("BIJUX_DAG_BUILD_GIT_SHA").filter(|value| !value.trim().is_empty())
3224}
3225
3226fn compose_tool_version(package_version: &str, build_git_sha: Option<&str>) -> String {
3227 match build_git_sha {
3228 Some(commit) => format!("{package_version}+{commit}"),
3229 None => package_version.to_string(),
3230 }
3231}
3232
3233fn tool_version() -> String {
3234 compose_tool_version(env!("CARGO_PKG_VERSION"), build_git_sha())
3235}
3236
3237pub(crate) fn runtime_fingerprint(adapters: &[AdapterInfo]) -> String {
3238 let payload = serde_json::json!({
3239 "tool_version": tool_version(),
3240 "adapters": adapters,
3241 });
3242 sha256_bytes(payload.to_string().as_bytes())
3243}
3244
3245pub(crate) fn policy_fingerprint(policy: &PolicyConfig) -> String {
3246 let payload = serde_json::json!({
3247 "deny_network": policy.deny_network,
3248 "deny_env": policy.deny_env,
3249 "deny_clock": policy.deny_clock,
3250 "clean_env": policy.clean_env,
3251 "container_image_reference_policy": container_image_reference_policy_label(
3252 policy.container_image_reference_policy
3253 ),
3254 });
3255 sha256_bytes(payload.to_string().as_bytes())
3256}
3257
3258fn selector_label(selector: &Selector) -> String {
3259 match selector {
3260 Selector::Id(v) => format!("id:{v}"),
3261 Selector::IdPrefix(v) => format!("id_prefix:{v}"),
3262 Selector::Tag(v) => format!("tag:{v}"),
3263 Selector::Kind(v) => format!("kind:{v}"),
3264 }
3265}
3266
3267pub(crate) fn requested_selector_label(scope: &str, selector: &Selector) -> String {
3268 format!("{scope}:{}", selector_label(selector))
3269}
3270
3271pub(crate) fn requested_downstream_root_label(node_id: &str) -> String {
3272 format!("from-node:{node_id}")
3273}
3274
3275pub(crate) fn requested_upstream_target_label(node_id: &str) -> String {
3276 format!("to-node:{node_id}")
3277}
3278
3279fn materialize_mode_label(mode: MaterializeMode) -> &'static str {
3280 match mode {
3281 MaterializeMode::Copy => "copy",
3282 MaterializeMode::Hardlink => "hardlink",
3283 MaterializeMode::Symlink => "symlink",
3284 }
3285}
3286
3287fn container_image_reference_policy_label(policy: ContainerImageReferencePolicy) -> &'static str {
3288 match policy {
3289 ContainerImageReferencePolicy::RequireDigest => "require_digest",
3290 ContainerImageReferencePolicy::AllowUnpinned => "allow_unpinned",
3291 }
3292}
3293
3294fn failure_propagation_label(mode: &FailurePropagationMode) -> &'static str {
3295 match mode {
3296 FailurePropagationMode::FailFast => "fail_fast",
3297 FailurePropagationMode::IsolateBranch => "isolate_branch",
3298 FailurePropagationMode::ContinueIndependent => "continue_independent",
3299 FailurePropagationMode::QuorumLikeFuture => "quorum_like_future",
3300 }
3301}
3302
3303fn execution_contract_fingerprint(options: &RuntimeConfig) -> String {
3304 let payload = serde_json::json!({
3305 "node_timeout_ms": options.node_timeout_ms,
3306 "materialize_inputs": materialize_mode_label(options.materialize_inputs),
3307 });
3308 sha256_bytes(payload.to_string().as_bytes())
3309}
3310
3311fn params_fingerprint(params: &Value) -> Result<String, RuntimeError> {
3312 let mut normalized = params.clone();
3313 sort_value_maps(&mut normalized);
3314 Ok(sha256_bytes(&serde_json::to_vec(&normalized)?))
3315}
3316
3317fn command_fingerprint(
3318 graph: &Graph,
3319 node: &Node,
3320 params: &Value,
3321) -> Result<Option<String>, RuntimeError> {
3322 let command_surface = if matches!(node.kind, NodeKind::Shell) {
3323 Some(serde_json::json!({
3324 "kind": "shell",
3325 "argv": params.get("argv").cloned().unwrap_or(Value::Null),
3326 }))
3327 } else if matches!(node.kind, NodeKind::FileTransform) {
3328 Some(serde_json::json!({
3329 "kind": "file_transform",
3330 "params": params,
3331 }))
3332 } else if matches!(node.kind, NodeKind::Python) {
3333 Some(serde_json::json!({
3334 "kind": "python",
3335 "params": params,
3336 }))
3337 } else if matches!(node.kind, NodeKind::Http) {
3338 Some(serde_json::json!({
3339 "kind": "http",
3340 "method": params.get("method").cloned().unwrap_or(Value::Null),
3341 "url": params.get("url").cloned().unwrap_or(Value::Null),
3342 "headers": params.get("headers").cloned().unwrap_or(Value::Null),
3343 "body": params.get("body").cloned().unwrap_or(Value::Null),
3344 }))
3345 } else if let Some(container) = node.container.as_ref() {
3346 let argv = bijux_dag_core::resolve::resolve_command_argv_templates(
3347 graph,
3348 node,
3349 &container.argv,
3350 params,
3351 )
3352 .map_err(|error| RuntimeError::Executor(error.to_string()))?;
3353 Some(serde_json::json!({
3354 "kind": "container",
3355 "engine": container.engine,
3356 "image": container.image,
3357 "workdir": container.workdir,
3358 "argv": argv,
3359 }))
3360 } else {
3361 None
3362 };
3363
3364 command_surface
3365 .map(|surface| serde_json::to_vec(&surface).map(|bytes| sha256_bytes(&bytes)))
3366 .transpose()
3367 .map_err(RuntimeError::from)
3368}
3369
3370fn cache_key_input_for_run(
3371 options: &RuntimeConfig,
3372 node: &Node,
3373 execution_fingerprint: &str,
3374 ctx: &RunContext,
3375 adapter_id: &str,
3376 adapter_version: &str,
3377 adapter_binary_sha256: Option<&str>,
3378 adapter_outputs_schema_version: &str,
3379) -> Result<CacheKeyInput, RuntimeError> {
3380 Ok(CacheKeyInput {
3381 execution_fingerprint: execution_fingerprint.to_string(),
3382 node_definition_fingerprint: node_definition_fingerprint_from_ctx(ctx, &node.id),
3383 declared_environment_fingerprint: declared_environment_fingerprint_from_ctx(ctx, &node.id),
3384 input_lineage_fingerprint: input_lineage_fingerprint_from_run(ctx, &node.id)?,
3385 adapter_id: adapter_id.to_string(),
3386 adapter_version: adapter_version.to_string(),
3387 adapter_binary_sha256: adapter_binary_sha256.map(ToString::to_string),
3388 output_schema_version: adapter_outputs_schema_version.to_string(),
3389 policy_fingerprint: policy_fingerprint(&options.policy),
3390 execution_contract_fingerprint: execution_contract_fingerprint(options),
3391 backend_class: "local".to_string(),
3392 })
3393}
3394
3395#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
3397pub struct AdapterAdmissionEntry {
3398 pub node_id: String,
3399 pub node_kind: String,
3400 pub supported: bool,
3401 pub adapter_id: Option<String>,
3402 pub adapter_version: Option<String>,
3403 pub reasons: Vec<String>,
3404}
3405
3406#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
3408pub struct AdapterAdmissionReport {
3409 pub supported: bool,
3410 pub entries: Vec<AdapterAdmissionEntry>,
3411}
3412
3413pub fn registered_adapters() -> Vec<AdapterInfo> {
3415 let registry = build_registry(vec![
3416 Arc::new(ConstAdapter),
3417 Arc::new(FileTransformAdapter),
3418 Arc::new(HttpRequestAdapter),
3419 Arc::new(ShellAdapter),
3420 Arc::new(PythonFunctionAdapter),
3421 Arc::new(ContainerAdapter),
3422 ])
3423 .unwrap_or_else(|_| AdapterRegistry::new());
3424 registry.list()
3425}
3426
3427pub fn registered_adapter_descriptors() -> Vec<adapter::AdapterDescriptor> {
3429 let registry = build_registry(vec![
3430 Arc::new(ConstAdapter),
3431 Arc::new(FileTransformAdapter),
3432 Arc::new(HttpRequestAdapter),
3433 Arc::new(ShellAdapter),
3434 Arc::new(PythonFunctionAdapter),
3435 Arc::new(ContainerAdapter),
3436 ])
3437 .unwrap_or_else(|_| AdapterRegistry::new());
3438 registry.descriptors()
3439}
3440
3441pub fn adapter_conformance_suite() -> Result<Vec<AdapterConformanceSuiteReport>, RuntimeError> {
3443 let mut descriptors = registered_adapter_descriptors();
3444 for handshake in probe_external_adapters()? {
3445 if let Some(descriptor) = handshake.descriptor {
3446 descriptors.push(descriptor);
3447 }
3448 }
3449 descriptors.sort_by(|left, right| (&left.id, &left.version).cmp(&(&right.id, &right.version)));
3450 Ok(descriptors
3451 .into_iter()
3452 .map(|descriptor| build_adapter_conformance_suite(&descriptor))
3453 .collect())
3454}
3455
3456pub fn registered_adapter_reference_document() -> AdapterReferenceDocument {
3458 let mut descriptors = registered_adapter_descriptors();
3459 descriptors.sort_by(|left, right| (&left.id, &left.version).cmp(&(&right.id, &right.version)));
3460 let conformance = descriptors.iter().map(build_adapter_conformance_suite).collect::<Vec<_>>();
3461 AdapterReferenceDocument {
3462 descriptors,
3463 conformance,
3464 slurm: slurm_adapter_design_contract(),
3465 kubernetes: kubernetes_adapter_contract(),
3466 fake_batch: fake_batch_executor_contract(),
3467 }
3468}
3469
3470pub fn adapter_admission_matrix(graph: &Graph) -> AdapterAdmissionReport {
3472 let descriptors = registered_adapter_descriptors();
3473 let mut by_kind = std::collections::BTreeMap::new();
3474 for descriptor in &descriptors {
3475 for kind in &descriptor.supported_kinds {
3476 by_kind.insert(kind.clone(), descriptor.clone());
3477 }
3478 }
3479
3480 let mut entries = Vec::new();
3481 for node in &graph.nodes {
3482 let kind = node.kind.as_str().to_string();
3483 let descriptor = by_kind.get(&kind);
3484 let mut reasons = Vec::new();
3485 if descriptor.is_none() {
3486 reasons.push(format!("no registered adapter supports node kind {}", kind));
3487 }
3488 if let Some(descriptor) = descriptor {
3489 let conformance = adapter_conformance::validate_descriptor(descriptor);
3490 reasons.extend(conformance.violations);
3491 if matches!(node.kind, NodeKind::Container) {
3492 let Some(spec) = node.container.as_ref() else {
3493 reasons.push("container node missing container spec".to_string());
3494 entries.push(AdapterAdmissionEntry {
3495 node_id: node.id.clone(),
3496 node_kind: kind,
3497 supported: reasons.is_empty(),
3498 adapter_id: Some(descriptor.id.clone()),
3499 adapter_version: Some(descriptor.version.clone()),
3500 reasons,
3501 });
3502 continue;
3503 };
3504 if let Err(error) = container_execution::container_engine_discovery(&spec.engine) {
3505 reasons.push(error);
3506 }
3507 if let Err(error) = container_execution::container_network_policy_args(
3508 &spec.engine,
3509 !node.effects.contains(&Effect::Network),
3510 ) {
3511 reasons.push(error);
3512 }
3513 let mounts = container_execution::container_volume_contract(Path::new(
3514 "/synthetic-node-root",
3515 ));
3516 if let Err(error) = container_execution::validate_container_mount_contract(
3517 &mounts,
3518 Path::new("/synthetic-node-root"),
3519 ) {
3520 reasons.push(error);
3521 }
3522 }
3523 }
3524 let supported = reasons.is_empty();
3525 entries.push(AdapterAdmissionEntry {
3526 node_id: node.id.clone(),
3527 node_kind: kind,
3528 supported,
3529 adapter_id: descriptor.map(|value| value.id.clone()),
3530 adapter_version: descriptor.map(|value| value.version.clone()),
3531 reasons,
3532 });
3533 }
3534 let supported = entries.iter().all(|entry| entry.supported);
3535 AdapterAdmissionReport { supported, entries }
3536}
3537
3538pub fn adapter_registry_dump() -> serde_json::Value {
3540 let adapters = registered_adapters();
3541 serde_json::json!({
3542 "count": adapters.len(),
3543 "adapters": adapters
3544 })
3545}
3546
3547fn materialize_inputs(
3548 ctx: &RunContext,
3549 graph: &Graph,
3550 node: &Node,
3551 mode: MaterializeMode,
3552 parent_statuses: &HashMap<String, NodeStatus>,
3553) -> Result<InputsIndex, RuntimeError> {
3554 let node_id = &node.id;
3555 let inputs_dir = ctx.run_dir.node_inputs_dir(node_id);
3556 recreate_dir(ctx.fs.as_ref(), &inputs_dir)?;
3557 let mut files = Vec::new();
3558 let mut materialized_inputs = BTreeMap::<(String, String, String), (String, String)>::new();
3559 for edge in &graph.edges {
3560 if edge.to.node_id != *node_id {
3561 continue;
3562 }
3563 let from_node = graph
3564 .nodes
3565 .iter()
3566 .find(|n| n.id == edge.from.node_id)
3567 .ok_or_else(|| RuntimeError::Executor("missing source node".to_string()))?;
3568 let out = from_node
3569 .outputs
3570 .iter()
3571 .find(|o| o.name == edge.from.port)
3572 .ok_or_else(|| RuntimeError::Executor("missing output port".to_string()))?;
3573 let mut src_path = authorized_declared_output_path(
3574 ctx.run_dir.node_outputs_dir(&edge.from.node_id).as_path(),
3575 out,
3576 )
3577 .map_err(|failure| RuntimeError::Executor(failure.message))?;
3578 let mut from_fp = node_fingerprint_from_ctx(ctx, &edge.from.node_id);
3579 if ctx.fs.metadata(&src_path).is_err() {
3580 if let Some(source_run_dir) = ctx.replay_source_run_dir.as_deref() {
3581 let replay_outputs_dir =
3582 source_run_dir.join("nodes").join(&edge.from.node_id).join("outputs");
3583 let replay_src_path = authorized_declared_output_path(&replay_outputs_dir, out)
3584 .map_err(|failure| RuntimeError::Executor(failure.message))?;
3585 if ctx.fs.metadata(&replay_src_path).is_ok() {
3586 src_path = replay_src_path;
3587 if from_fp.is_empty() {
3588 from_fp = replay_source_node_fingerprint(
3589 ctx.fs.as_ref(),
3590 source_run_dir,
3591 &edge.from.node_id,
3592 )
3593 .unwrap_or_default();
3594 }
3595 }
3596 }
3597 }
3598 let dst_dir = inputs_dir.join(&edge.from.node_id);
3599 ctx.fs.create_dir_all(&dst_dir)?;
3600 let dst_path = dst_dir.join(&edge.to.port);
3601 if let Some(parent) = dst_path.parent() {
3602 ctx.fs.create_dir_all(parent)?;
3603 }
3604 if ctx.fs.metadata(&src_path).is_ok() {
3605 let source_sha256 = sha256_artifact_path(&src_path).map_err(RuntimeError::Artifact)?;
3606 materialize_file(ctx.fs.as_ref(), &src_path, &dst_path, mode)?;
3607 let local_sha256 = materialized_input_sha256(ctx.fs.as_ref(), &dst_path)
3608 .map_err(RuntimeError::Artifact)?;
3609 if local_sha256 != source_sha256 {
3610 return Err(RuntimeError::Executor(format!(
3611 "materialized input hash mismatch for {} -> {}",
3612 src_path.display(),
3613 dst_path.display()
3614 )));
3615 }
3616 let rel = dst_path.strip_prefix(&inputs_dir).unwrap_or(&dst_path);
3617 let rel_str = rel.to_string_lossy().to_string();
3618 materialized_inputs.insert(
3619 (edge.to.port.clone(), edge.from.node_id.clone(), edge.from.port.clone()),
3620 (rel_str.clone(), source_sha256.clone()),
3621 );
3622 files.push(InputFile {
3623 local_path: rel_str,
3624 source_sha256,
3625 source_node_id: edge.from.node_id.clone(),
3626 source_node_fingerprint: from_fp,
3627 source_output_name: edge.from.port.clone(),
3628 materialization_mode: materialize_mode_label(mode).to_string(),
3629 });
3630 }
3631 }
3632 files.sort_by(|a, b| a.local_path.cmp(&b.local_path));
3633 let mut collections = Vec::new();
3634 if node.semantic_kind == SemanticNodeKind::Reduce {
3635 let summary = build_reduce_summary(graph, node, parent_statuses, &materialized_inputs)?;
3636 write_reduce_collection_manifest(ctx, node_id, &summary.collection)?;
3637 write_reduce_summary(ctx, node_id, &summary)?;
3638 collections.push(summary.collection);
3639 }
3640 let index = InputsIndex { collections, files };
3641 write_inputs_index(&inputs_dir, &index)?;
3642 Ok(index)
3643}
3644
3645fn replay_source_node_fingerprint(
3646 fs: &dyn Fs,
3647 source_run_dir: &Path,
3648 node_id: &str,
3649) -> Option<String> {
3650 let trace_path = source_run_dir.join("nodes").join(node_id).join("trace.json");
3651 let bytes = fs.read(&trace_path).ok()?;
3652 let trace: Value = serde_json::from_slice(&bytes).ok()?;
3653 trace.get("fingerprint").and_then(Value::as_str).map(str::to_string)
3654}
3655
3656fn cache_dir_from_env() -> Option<PathBuf> {
3657 std::env::var("BIJUX_DAG_CACHE_DIR").ok().map(PathBuf::from)
3658}
3659
3660#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
3661struct ReduceDependencyBinding {
3662 input_port: String,
3663 source_node_id: String,
3664 source_output_name: String,
3665}
3666
3667fn reduce_dependency_bindings(graph: &Graph, node: &Node) -> Vec<ReduceDependencyBinding> {
3668 let input_positions = node
3669 .inputs
3670 .iter()
3671 .enumerate()
3672 .map(|(index, input)| (input.clone(), index))
3673 .collect::<BTreeMap<_, _>>();
3674 let mut bindings = graph
3675 .edges
3676 .iter()
3677 .filter(|edge| edge.to.node_id == node.id)
3678 .map(|edge| ReduceDependencyBinding {
3679 input_port: edge.to.port.clone(),
3680 source_node_id: edge.from.node_id.clone(),
3681 source_output_name: edge.from.port.clone(),
3682 })
3683 .collect::<Vec<_>>();
3684 bindings.sort_by(|left, right| {
3685 input_positions
3686 .get(&left.input_port)
3687 .unwrap_or(&usize::MAX)
3688 .cmp(input_positions.get(&right.input_port).unwrap_or(&usize::MAX))
3689 .then_with(|| left.input_port.cmp(&right.input_port))
3690 .then_with(|| left.source_node_id.cmp(&right.source_node_id))
3691 .then_with(|| left.source_output_name.cmp(&right.source_output_name))
3692 });
3693 bindings
3694}
3695
3696fn write_reduce_collection_manifest(
3697 ctx: &RunContext,
3698 node_id: &str,
3699 collection: &InputCollection,
3700) -> Result<(), RuntimeError> {
3701 let bytes = serde_json::to_vec_pretty(collection)?;
3702 let path = ctx.run_dir.node_inputs_dir(node_id).join(reduce_collection_manifest_name());
3703 ctx.fs.write(&path, &bytes)?;
3704 Ok(())
3705}
3706
3707fn write_reduce_summary(
3708 ctx: &RunContext,
3709 node_id: &str,
3710 summary: &ReduceExecutionSummary,
3711) -> Result<(), RuntimeError> {
3712 let bytes = serde_json::to_vec_pretty(summary)?;
3713 ctx.fs.write(&reduce_execution_summary_path(ctx, node_id), &bytes)?;
3714 Ok(())
3715}
3716
3717fn build_reduce_summary(
3718 graph: &Graph,
3719 node: &Node,
3720 parent_statuses: &HashMap<String, NodeStatus>,
3721 materialized_inputs: &BTreeMap<(String, String, String), (String, String)>,
3722) -> Result<ReduceExecutionSummary, RuntimeError> {
3723 let config = reduce_execution_config(node)
3724 .map_err(|failure| RuntimeError::Executor(failure.message.clone()))?;
3725 let mut usable_input_count = 0usize;
3726 let mut failed_input_count = 0usize;
3727 let mut skipped_input_count = 0usize;
3728 let mut cancelled_input_count = 0usize;
3729 let mut items = Vec::new();
3730
3731 for binding in reduce_dependency_bindings(graph, node) {
3732 let status = parent_statuses.get(&binding.source_node_id).cloned().ok_or_else(|| {
3733 RuntimeError::Executor(format!(
3734 "missing terminal status for reduce dependency {} -> {}",
3735 binding.source_node_id, node.id
3736 ))
3737 })?;
3738 let key = (
3739 binding.input_port.clone(),
3740 binding.source_node_id.clone(),
3741 binding.source_output_name.clone(),
3742 );
3743 let (local_path, source_sha256) = materialized_inputs
3744 .get(&key)
3745 .cloned()
3746 .map(|(path, sha)| (Some(path), Some(sha)))
3747 .unwrap_or((None, None));
3748 match status {
3749 NodeStatus::Success | NodeStatus::Cached => usable_input_count += 1,
3750 NodeStatus::Failed => failed_input_count += 1,
3751 NodeStatus::Skipped => skipped_input_count += 1,
3752 NodeStatus::Cancelled => cancelled_input_count += 1,
3753 }
3754 items.push(InputCollectionItem {
3755 input_port: binding.input_port,
3756 source_node_id: binding.source_node_id,
3757 source_output_name: binding.source_output_name,
3758 status: status_string(&status),
3759 local_path,
3760 source_sha256,
3761 });
3762 }
3763
3764 let collection = InputCollection {
3765 name: "reduce_inputs".to_string(),
3766 semantic_kind: "reduce".to_string(),
3767 manifest_path: reduce_collection_manifest_name().to_string(),
3768 mode: Some(reduce_mode_label(config.mode).to_string()),
3769 empty_policy: Some(reduce_empty_policy_label(config.empty_policy).to_string()),
3770 items,
3771 };
3772 Ok(ReduceExecutionSummary {
3773 schema_version: reduce_execution_version(),
3774 reduce_node_id: node.id.clone(),
3775 mode: reduce_mode_label(config.mode).to_string(),
3776 empty_policy: reduce_empty_policy_label(config.empty_policy).to_string(),
3777 usable_input_count,
3778 failed_input_count,
3779 skipped_input_count,
3780 cancelled_input_count,
3781 collection,
3782 })
3783}
3784
3785#[derive(Debug, Clone)]
3786struct OutputInspectionReport {
3787 pub(crate) output_evidence: Vec<TraceOutputArtifact>,
3788 pub(crate) present_outputs: Vec<DeclaredOutputArtifact>,
3789 pub(crate) failure: Option<FailureInfo>,
3790}
3791
3792fn output_kind_label(kind: &OutputKind) -> &'static str {
3793 match kind {
3794 OutputKind::File => "file",
3795 OutputKind::Directory => "directory",
3796 OutputKind::Value => "value",
3797 OutputKind::Table => "table",
3798 OutputKind::Log => "log",
3799 OutputKind::Binary => "binary",
3800 OutputKind::Bundle => "bundle",
3801 }
3802}
3803
3804pub(crate) fn declared_output_artifacts(node: &Node) -> Vec<DeclaredOutputArtifact> {
3805 node.outputs
3806 .iter()
3807 .map(|output| DeclaredOutputArtifact {
3808 name: output.name.clone(),
3809 path: output.path.clone(),
3810 kind: output_kind_label(&output.kind).to_string(),
3811 media_type: output.effective_media_type(),
3812 promotable: output.promotable,
3813 })
3814 .collect()
3815}
3816
3817fn is_managed_output_metadata_path(rel: &str) -> bool {
3818 rel == "index.json"
3819}
3820
3821pub(crate) fn inspect_declared_outputs(
3822 dir: &Path,
3823 outputs: &[OutputSpec],
3824) -> OutputInspectionReport {
3825 let mut declared = Vec::new();
3826 let mut present_outputs = Vec::new();
3827 for output in outputs {
3828 declared.push(output.clone());
3829 let schema = ArtifactSchemaDescriptor {
3830 name: format!("bijux.output.{}", output_kind_label(&output.kind)),
3831 version: "v0.1".to_string(),
3832 media_type: output.effective_media_type(),
3833 encoding: "identity".to_string(),
3834 validation_mode: SchemaValidationMode::Strict,
3835 };
3836 if let Err(message) = validate_output_schema_descriptor(&schema) {
3837 return OutputInspectionReport {
3838 output_evidence: Vec::new(),
3839 present_outputs: Vec::new(),
3840 failure: Some(FailureInfo::new(
3841 FailureClass::User,
3842 "User",
3843 "OUTPUT_SCHEMA_INVALID",
3844 message,
3845 None,
3846 )),
3847 };
3848 }
3849 }
3850
3851 let mut output_evidence = Vec::new();
3852 for output in &declared {
3853 let path = match authorized_declared_output_path(dir, output) {
3854 Ok(path) => path,
3855 Err(failure) => {
3856 return OutputInspectionReport {
3857 output_evidence,
3858 present_outputs,
3859 failure: Some(failure),
3860 };
3861 }
3862 };
3863 if path.is_symlink() {
3864 return OutputInspectionReport {
3865 output_evidence,
3866 present_outputs,
3867 failure: Some(FailureInfo::new(
3868 FailureClass::User,
3869 "User",
3870 "OUTPUT_PATH_INVALID",
3871 format!("output must not be a symlink: {}", output.path),
3872 None,
3873 )),
3874 };
3875 }
3876 if !path.exists() {
3877 output_evidence.push(TraceOutputArtifact {
3878 name: output.name.clone(),
3879 path: output.path.clone(),
3880 kind: output_kind_label(&output.kind).to_string(),
3881 required: output.required,
3882 present: false,
3883 media_type: output.effective_media_type(),
3884 size_bytes: None,
3885 sha256: None,
3886 promotable: output.promotable,
3887 });
3888 if output.required {
3889 return OutputInspectionReport {
3890 output_evidence,
3891 present_outputs,
3892 failure: Some(FailureInfo::new(
3893 FailureClass::User,
3894 "User",
3895 "OUTPUT_MISSING",
3896 format!("missing required output: {}", output.path),
3897 Some(serde_json::json!({ "output": output.name })),
3898 )),
3899 };
3900 }
3901 continue;
3902 }
3903 if path.is_symlink() {
3904 return OutputInspectionReport {
3905 output_evidence,
3906 present_outputs,
3907 failure: Some(FailureInfo::new(
3908 FailureClass::User,
3909 "User",
3910 "OUTPUT_PATH_INVALID",
3911 format!("output must not be a symlink: {}", output.path),
3912 None,
3913 )),
3914 };
3915 }
3916 if output.expects_directory() && !path.is_dir() {
3917 return OutputInspectionReport {
3918 output_evidence,
3919 present_outputs,
3920 failure: Some(FailureInfo::new(
3921 FailureClass::User,
3922 "User",
3923 "OUTPUT_PATH_INVALID",
3924 format!("output must be a directory: {}", output.path),
3925 Some(serde_json::json!({ "output": output.name })),
3926 )),
3927 };
3928 }
3929 if output.expects_file() && !path.is_file() {
3930 return OutputInspectionReport {
3931 output_evidence,
3932 present_outputs,
3933 failure: Some(FailureInfo::new(
3934 FailureClass::User,
3935 "User",
3936 "OUTPUT_PATH_INVALID",
3937 format!("output must be a file: {}", output.path),
3938 Some(serde_json::json!({ "output": output.name })),
3939 )),
3940 };
3941 }
3942 let size_bytes = match artifact_size_bytes(&path) {
3943 Ok(size_bytes) => size_bytes,
3944 Err(error) => {
3945 return OutputInspectionReport {
3946 output_evidence,
3947 present_outputs,
3948 failure: Some(FailureInfo::new(
3949 FailureClass::User,
3950 "User",
3951 "OUTPUT_PATH_INVALID",
3952 error.to_string(),
3953 Some(serde_json::json!({ "output": output.name })),
3954 )),
3955 };
3956 }
3957 };
3958 let sha256 = match sha256_artifact_path(&path) {
3959 Ok(sha256) => sha256,
3960 Err(error) => {
3961 return OutputInspectionReport {
3962 output_evidence,
3963 present_outputs,
3964 failure: Some(FailureInfo::new(
3965 FailureClass::User,
3966 "User",
3967 "OUTPUT_PATH_INVALID",
3968 error.to_string(),
3969 Some(serde_json::json!({ "output": output.name })),
3970 )),
3971 };
3972 }
3973 };
3974 let media_type = output.effective_media_type();
3975 output_evidence.push(TraceOutputArtifact {
3976 name: output.name.clone(),
3977 path: output.path.clone(),
3978 kind: output_kind_label(&output.kind).to_string(),
3979 required: output.required,
3980 present: true,
3981 media_type: media_type.clone(),
3982 size_bytes: Some(size_bytes),
3983 sha256: Some(sha256.clone()),
3984 promotable: output.promotable,
3985 });
3986 present_outputs.push(DeclaredOutputArtifact {
3987 name: output.name.clone(),
3988 path: output.path.clone(),
3989 kind: output_kind_label(&output.kind).to_string(),
3990 media_type,
3991 promotable: output.promotable,
3992 });
3993 }
3994
3995 let mut actual = std::collections::BTreeSet::new();
3996 collect_relative_artifacts(dir, dir, &mut actual);
3997 for rel in actual {
3998 if is_managed_output_metadata_path(&rel) {
3999 continue;
4000 }
4001 let declared_match = declared.iter().any(|output| {
4002 rel == output.path
4003 || (matches!(output.kind, OutputKind::Directory)
4004 && rel.starts_with(&format!("{}/", output.path)))
4005 });
4006 if !declared_match {
4007 return OutputInspectionReport {
4008 output_evidence,
4009 present_outputs,
4010 failure: Some(FailureInfo::new(
4011 FailureClass::User,
4012 "User",
4013 "OUTPUT_UNDECLARED",
4014 format!("undeclared output path: {}", rel),
4015 None,
4016 )),
4017 };
4018 }
4019 }
4020
4021 OutputInspectionReport { output_evidence, present_outputs, failure: None }
4022}
4023
4024#[allow(clippy::too_many_arguments)]
4025fn try_cache_read(
4026 options: &RuntimeConfig,
4027 node: &Node,
4028 node_fingerprint: &str,
4029 ctx: &RunContext,
4030 fs: Arc<dyn Fs>,
4031 adapter_id: &str,
4032 adapter_version: &str,
4033 adapter_binary_sha256: Option<&str>,
4034 adapter_outputs_schema_version: &str,
4035) -> Result<CacheRead, RuntimeError> {
4036 if options.cache_mode == CacheMode::Off {
4037 return Ok(CacheRead { hit: false, proof: None });
4038 }
4039 if !node.cache.enabled {
4040 return Ok(CacheRead { hit: false, proof: None });
4041 }
4042 let cache_dir = options.cache_dir.clone().or_else(cache_dir_from_env);
4043 let cache_store = match cache_dir {
4044 Some(d) => Some(RuntimeCacheStore::new(d, Arc::clone(&fs))),
4045 None => return Ok(CacheRead { hit: false, proof: None }),
4046 };
4047 if options.cache_mode == CacheMode::Read || options.cache_mode == CacheMode::ReadWrite {
4048 let key_input = cache_key_input_for_run(
4049 options,
4050 node,
4051 node_fingerprint,
4052 ctx,
4053 adapter_id,
4054 adapter_version,
4055 adapter_binary_sha256,
4056 adapter_outputs_schema_version,
4057 )?;
4058 let key = cache_key_explanation(&key_input).key;
4059 let store = cache_store.as_ref().unwrap();
4060 let entry = store.entry(&key);
4061 let mut local_corrupt_entry: Option<PathBuf> = None;
4062 let mut local_corrupt_proof: Option<CacheProof> = None;
4063 if store.fs().metadata(&entry).is_ok() {
4064 if !verify_cache_entry(store.fs(), &entry, node, &key_input)? {
4065 local_corrupt_entry = Some(entry.clone());
4066 local_corrupt_proof = Some(CacheProof {
4067 hit: false,
4068 key: key.clone(),
4069 source: "local".to_string(),
4070 verified: false,
4071 reason: "corrupt".to_string(),
4072 corrupt_detected: true,
4073 });
4074 } else {
4075 let source = cache_source_from_meta(store.fs(), &entry)
4076 .unwrap_or_else(|| "local".to_string());
4077 prepare_node_execution_dirs(ctx, &node.id)?;
4078 let node_dir = ctx.run_dir.node_dir(&node.id);
4079 copy_dir_all(
4080 store.fs(),
4081 entry.join("outputs"),
4082 ctx.run_dir.node_outputs_dir(&node.id),
4083 )?;
4084 copy_dir_all(store.fs(), entry.join("logs"), node_dir.clone())?;
4085 return Ok(CacheRead {
4086 hit: true,
4087 proof: Some(CacheProof {
4088 hit: true,
4089 key,
4090 source,
4091 verified: true,
4092 reason: "hit".to_string(),
4093 corrupt_detected: false,
4094 }),
4095 });
4096 }
4097 }
4098 if let Some(remote_dir) = options.remote_cache_dir.as_ref() {
4099 let remote_entry = remote_dir.join(&key);
4100 if store.fs().metadata(&remote_entry).is_ok() {
4101 if !verify_cache_entry(store.fs(), &remote_entry, node, &key_input)? {
4102 return Ok(CacheRead {
4103 hit: false,
4104 proof: Some(CacheProof {
4105 hit: false,
4106 key,
4107 source: "remote".to_string(),
4108 verified: false,
4109 reason: "remote_corrupt".to_string(),
4110 corrupt_detected: true,
4111 }),
4112 });
4113 }
4114 prepare_node_execution_dirs(ctx, &node.id)?;
4115 let node_dir = ctx.run_dir.node_dir(&node.id);
4116 copy_dir_all(
4117 store.fs(),
4118 remote_entry.join("outputs"),
4119 ctx.run_dir.node_outputs_dir(&node.id),
4120 )?;
4121 copy_dir_all(store.fs(), remote_entry.join("logs"), node_dir.clone())?;
4122 if let Some(corrupt_entry) = local_corrupt_entry.as_ref() {
4123 let _ = store.fs().remove_dir_all(corrupt_entry);
4124 }
4125 if let Some(local_dir) = options.cache_dir.as_ref() {
4126 let local_entry = local_dir.join(&key);
4127 if let Ok(outcome) = copy_cache_entry_atomically(
4128 store.fs(),
4129 &remote_entry,
4130 &local_entry,
4131 "hydrate",
4132 ) {
4133 if matches!(outcome, CachePublishOutcome::Published)
4134 && !verify_cache_entry(store.fs(), &local_entry, node, &key_input)?
4135 {
4136 let _ = store.fs().remove_dir_all(&local_entry);
4137 }
4138 }
4139 }
4140 return Ok(CacheRead {
4141 hit: true,
4142 proof: Some(CacheProof {
4143 hit: true,
4144 key,
4145 source: "remote".to_string(),
4146 verified: true,
4147 reason: format!("fetched:{}", cache_dir_id(remote_dir)),
4148 corrupt_detected: false,
4149 }),
4150 });
4151 }
4152 }
4153 if let Some(proof) = local_corrupt_proof {
4154 return Ok(CacheRead { hit: false, proof: Some(proof) });
4155 }
4156 }
4157 Ok(CacheRead { hit: false, proof: None })
4158}
4159
4160fn prepare_node_execution_dirs(ctx: &RunContext, node_id: &str) -> Result<(), RuntimeError> {
4161 let node_dir = ctx.run_dir.node_dir(node_id);
4162 ctx.fs.create_dir_all(&node_dir)?;
4163 recreate_dir(ctx.fs.as_ref(), &ctx.run_dir.node_outputs_dir(node_id))?;
4164 ctx.fs.create_dir_all(&ctx.run_dir.node_work_dir(node_id))?;
4165 recreate_dir(ctx.fs.as_ref(), &ctx.run_dir.node_temp_dir(node_id))?;
4166 Ok(())
4167}
4168
4169fn recreate_dir(fs: &dyn Fs, path: &Path) -> std_io::Result<()> {
4170 match fs.metadata(path) {
4171 Ok(metadata) => {
4172 if metadata.is_dir() {
4173 fs.remove_dir_all(path)?;
4174 } else {
4175 fs.remove_file(path)?;
4176 }
4177 }
4178 Err(err) if err.kind() == std_io::ErrorKind::NotFound => {}
4179 Err(err) => return Err(err),
4180 }
4181 fs.create_dir_all(path)
4182}
4183
4184pub(crate) fn apply_temp_env(cmd: &mut std::process::Command, temp_dir: &Path) {
4185 let temp_dir = temp_dir.display().to_string();
4186 cmd.env("TMPDIR", &temp_dir);
4187 cmd.env("TMP", &temp_dir);
4188 cmd.env("TEMP", &temp_dir);
4189}
4190
4191fn container_temp_dir(workdir: &str) -> String {
4192 format!("{workdir}/temp")
4193}
4194
4195#[allow(clippy::too_many_arguments)]
4196fn try_cache_write(
4197 options: &RuntimeConfig,
4198 node: &Node,
4199 node_fingerprint: &str,
4200 ctx: &RunContext,
4201 fs: Arc<dyn Fs>,
4202 adapter_id: &str,
4203 adapter_version: &str,
4204 adapter_binary_sha256: Option<&str>,
4205 adapter_outputs_schema_version: &str,
4206) -> Result<(), RuntimeError> {
4207 if options.cache_mode != CacheMode::ReadWrite {
4208 return Ok(());
4209 }
4210 if !node.cache.enabled {
4211 return Ok(());
4212 }
4213 let local_cache_dir = options.cache_dir.clone().or_else(cache_dir_from_env);
4214 let remote_cache_dir = options.remote_cache_dir.clone();
4215 let staging_root = match local_cache_dir.clone().or_else(|| remote_cache_dir.clone()) {
4216 Some(d) => d,
4217 None => return Ok(()),
4218 };
4219 let store = RuntimeCacheStore::new(staging_root.clone(), Arc::clone(&fs));
4220 let key_input = cache_key_input_for_run(
4221 options,
4222 node,
4223 node_fingerprint,
4224 ctx,
4225 adapter_id,
4226 adapter_version,
4227 adapter_binary_sha256,
4228 adapter_outputs_schema_version,
4229 )?;
4230 let key = cache_key_explanation(&key_input).key;
4231 let staging_entry = cache_staging_entry_path(&staging_root, &key, "publish");
4232 populate_cache_entry_dir(store.fs(), &staging_entry, node, ctx, &key_input, &key)?;
4233
4234 let mut canonical_entry: Option<PathBuf> = None;
4235 if let Some(local_dir) = local_cache_dir.as_ref() {
4236 let local_entry = local_dir.join(&key);
4237 let _ = if local_dir == &staging_root {
4238 publish_staged_cache_entry(store.fs(), &staging_entry, &local_entry)?
4239 } else {
4240 copy_cache_entry_atomically(store.fs(), &staging_entry, &local_entry, "publish")?
4241 };
4242 canonical_entry = Some(local_entry);
4243 }
4244 if canonical_entry.is_none() {
4245 if let Some(remote_dir) = remote_cache_dir.as_ref() {
4246 let remote_entry = remote_dir.join(&key);
4247 let _ = if remote_dir == &staging_root {
4248 publish_staged_cache_entry(store.fs(), &staging_entry, &remote_entry)?
4249 } else {
4250 copy_cache_entry_atomically(store.fs(), &staging_entry, &remote_entry, "publish")?
4251 };
4252 canonical_entry = Some(remote_entry);
4253 }
4254 }
4255 if let (Some(source_entry), Some(remote_dir)) =
4256 (canonical_entry.as_ref(), remote_cache_dir.as_ref())
4257 {
4258 let remote_entry = remote_dir.join(&key);
4259 if &remote_entry != source_entry {
4260 let _ =
4261 copy_cache_entry_atomically(store.fs(), source_entry, &remote_entry, "publish")?;
4262 }
4263 }
4264 if store.fs().metadata(&staging_entry).is_ok() {
4265 let _ = store.fs().remove_dir_all(&staging_entry);
4266 }
4267 Ok(())
4268}
4269
4270fn populate_cache_entry_dir(
4271 fs: &dyn Fs,
4272 entry: &Path,
4273 node: &Node,
4274 ctx: &RunContext,
4275 key_input: &CacheKeyInput,
4276 key: &str,
4277) -> Result<(), RuntimeError> {
4278 if fs.metadata(entry).is_ok() {
4279 fs.remove_dir_all(entry)?;
4280 }
4281 fs.create_dir_all(entry.join("outputs").as_path())?;
4282 fs.create_dir_all(entry.join("logs").as_path())?;
4283 let manifest = cache_entry_manifest_for_node(node, key);
4284 let meta = serde_json::json!({
4285 "cache_metadata_version": crate::cache::CACHE_METADATA_VERSION,
4286 "cache_key": key,
4287 "node_id": node.id,
4288 "node_fingerprint": key_input.execution_fingerprint,
4289 "node_definition_fingerprint": key_input.node_definition_fingerprint,
4290 "declared_environment_fingerprint": key_input.declared_environment_fingerprint,
4291 "input_lineage_fingerprint": key_input.input_lineage_fingerprint,
4292 "params_fingerprint": params_fingerprint_from_ctx(ctx, &node.id),
4293 "command_fingerprint": command_fingerprint_from_ctx(ctx, &node.id),
4294 "adapter_id": key_input.adapter_id,
4295 "adapter_version": key_input.adapter_version,
4296 "adapter_binary_sha256": key_input.adapter_binary_sha256,
4297 "produces_outputs_schema_version": key_input.output_schema_version,
4298 "policy_fingerprint": key_input.policy_fingerprint,
4299 "execution_contract_fingerprint": key_input.execution_contract_fingerprint,
4300 "backend_class": key_input.backend_class,
4301 "created_unix_ms": ctx.clock.now_unix_ms(),
4302 "cache_source": "local",
4303 "schema_version": "v0.1",
4304 });
4305 fs.write(entry.join("manifest.json").as_path(), &serde_json::to_vec_pretty(&manifest)?)?;
4306 fs.write(entry.join("meta.json").as_path(), &serde_json::to_vec_pretty(&meta)?)?;
4307 copy_dir_all(fs, ctx.run_dir.node_outputs_dir(&node.id), entry.join("outputs"))?;
4308 let node_dir = ctx.run_dir.node_dir(&node.id);
4309 let _ = fs.copy(
4310 node_dir.join("stdout.log").as_path(),
4311 entry.join("logs").join("stdout.log").as_path(),
4312 );
4313 let _ = fs.copy(
4314 node_dir.join("stderr.log").as_path(),
4315 entry.join("logs").join("stderr.log").as_path(),
4316 );
4317 let _ = fs.copy(
4318 node_dir.join("trace.json").as_path(),
4319 entry.join("logs").join("trace.json").as_path(),
4320 );
4321 Ok(())
4322}
4323
4324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4325enum CachePublishOutcome {
4326 Published,
4327 AlreadyPresent,
4328}
4329
4330fn publish_staged_cache_entry(
4331 fs: &dyn Fs,
4332 staging_entry: &Path,
4333 target_entry: &Path,
4334) -> std_io::Result<CachePublishOutcome> {
4335 if fs.metadata(target_entry).is_ok() {
4336 let _ = fs.remove_dir_all(staging_entry);
4337 return Ok(CachePublishOutcome::AlreadyPresent);
4338 }
4339 if let Some(parent) = target_entry.parent() {
4340 fs.create_dir_all(parent)?;
4341 }
4342 match fs.rename(staging_entry, target_entry) {
4343 Ok(()) => Ok(CachePublishOutcome::Published),
4344 Err(error) => {
4345 let target_exists = fs.metadata(target_entry).is_ok();
4346 let _ = fs.remove_dir_all(staging_entry);
4347 if target_exists {
4348 Ok(CachePublishOutcome::AlreadyPresent)
4349 } else {
4350 Err(error)
4351 }
4352 }
4353 }
4354}
4355
4356fn copy_cache_entry_atomically(
4357 fs: &dyn Fs,
4358 src_entry: &Path,
4359 target_entry: &Path,
4360 operation: &str,
4361) -> std_io::Result<CachePublishOutcome> {
4362 let cache_root = target_entry.parent().ok_or_else(|| {
4363 std_io::Error::new(std_io::ErrorKind::InvalidInput, "cache target missing parent directory")
4364 })?;
4365 let key = target_entry.file_name().and_then(|value| value.to_str()).ok_or_else(|| {
4366 std_io::Error::new(std_io::ErrorKind::InvalidInput, "cache target missing cache key")
4367 })?;
4368 let staging_entry = cache_staging_entry_path(cache_root, key, operation);
4369 copy_dir_all(fs, src_entry, &staging_entry)?;
4370 publish_staged_cache_entry(fs, &staging_entry, target_entry)
4371}
4372
4373fn cache_staging_entry_path(cache_root: &Path, cache_key: &str, operation: &str) -> PathBuf {
4374 let nonce = std::time::SystemTime::now()
4375 .duration_since(std::time::UNIX_EPOCH)
4376 .map(|duration| duration.as_nanos())
4377 .unwrap_or(0);
4378 cache_root.join(format!(".cache-{cache_key}-{operation}-{}-{nonce}", std::process::id()))
4379}
4380
4381fn cache_entry_manifest_for_node(node: &Node, cache_key: &str) -> CacheEntryManifest {
4382 let mut outputs = node
4383 .outputs
4384 .iter()
4385 .map(|output| CacheManifestOutput {
4386 name: output.name.clone(),
4387 path: output.path.clone(),
4388 kind: output_kind_label(&output.kind).to_string(),
4389 media_type: output.effective_media_type(),
4390 required: output.required,
4391 })
4392 .collect::<Vec<_>>();
4393 outputs.sort_by(|a, b| a.path.cmp(&b.path));
4394 CacheEntryManifest {
4395 manifest_version: CACHE_ENTRY_MANIFEST_VERSION.to_string(),
4396 cache_key: cache_key.to_string(),
4397 node_id: node.id.clone(),
4398 outputs,
4399 }
4400}
4401
4402fn verify_cache_entry(
4403 fs: &dyn Fs,
4404 entry: &Path,
4405 node: &Node,
4406 expected_input: &CacheKeyInput,
4407) -> Result<bool, RuntimeError> {
4408 let index_path = entry.join("outputs").join("index.json");
4409 if fs.metadata(&index_path).is_err() {
4410 return Ok(false);
4411 }
4412 let manifest_path = entry.join("manifest.json");
4413 if fs.metadata(&manifest_path).is_err() {
4414 return Ok(false);
4415 }
4416 let meta_path = entry.join("meta.json");
4417 if fs.metadata(&meta_path).is_err() {
4418 return Ok(false);
4419 }
4420 let meta: serde_json::Value = serde_json::from_str(&fs.read_to_string(&meta_path)?)?;
4421 if !cache_metadata_version_supported(&meta) || !cache_entry_has_required_proof(&meta) {
4422 return Ok(false);
4423 }
4424 let expected_key = cache_key_explanation(expected_input).key;
4425 if meta.get("cache_key").and_then(|v| v.as_str()) != Some(expected_key.as_str()) {
4426 return Ok(false);
4427 }
4428 let manifest: CacheEntryManifest = serde_json::from_str(&fs.read_to_string(&manifest_path)?)?;
4429 if !cache_entry_manifest_version_supported(&manifest) {
4430 return Ok(false);
4431 }
4432 let expected_manifest = cache_entry_manifest_for_node(node, &expected_key);
4433 if manifest != expected_manifest {
4434 return Ok(false);
4435 }
4436 if meta.get("node_fingerprint").and_then(|v| v.as_str())
4437 != Some(expected_input.execution_fingerprint.as_str())
4438 {
4439 return Ok(false);
4440 }
4441 if meta.get("node_definition_fingerprint").and_then(|v| v.as_str())
4442 != Some(expected_input.node_definition_fingerprint.as_str())
4443 {
4444 return Ok(false);
4445 }
4446 if meta.get("declared_environment_fingerprint").and_then(|v| v.as_str())
4447 != Some(expected_input.declared_environment_fingerprint.as_str())
4448 {
4449 return Ok(false);
4450 }
4451 if meta.get("input_lineage_fingerprint").and_then(|v| v.as_str())
4452 != Some(expected_input.input_lineage_fingerprint.as_str())
4453 {
4454 return Ok(false);
4455 }
4456 if meta.get("adapter_id").and_then(|v| v.as_str()) != Some(expected_input.adapter_id.as_str()) {
4457 return Ok(false);
4458 }
4459 if meta.get("adapter_version").and_then(|v| v.as_str())
4460 != Some(expected_input.adapter_version.as_str())
4461 {
4462 return Ok(false);
4463 }
4464 if meta.get("adapter_binary_sha256").and_then(|v| v.as_str())
4465 != expected_input.adapter_binary_sha256.as_deref()
4466 {
4467 return Ok(false);
4468 }
4469 let produced_output_schema_version = meta
4470 .get("produces_outputs_schema_version")
4471 .and_then(|v| v.as_str())
4472 .or_else(|| meta.get("output_schema_version").and_then(|v| v.as_str()))
4473 .unwrap_or_default();
4474 let schema_compatibility = validate_output_schema_compatibility(
4475 CacheCompatibilityMode::FingerprintExact,
4476 produced_output_schema_version,
4477 expected_input.output_schema_version.as_str(),
4478 );
4479 if !schema_compatibility.compatible {
4480 return Ok(false);
4481 }
4482 if meta.get("policy_fingerprint").and_then(|v| v.as_str())
4483 != Some(expected_input.policy_fingerprint.as_str())
4484 {
4485 return Ok(false);
4486 }
4487 if meta.get("execution_contract_fingerprint").and_then(|v| v.as_str())
4488 != Some(expected_input.execution_contract_fingerprint.as_str())
4489 {
4490 return Ok(false);
4491 }
4492 if meta.get("backend_class").and_then(|v| v.as_str())
4493 != Some(expected_input.backend_class.as_str())
4494 {
4495 return Ok(false);
4496 }
4497 let data = fs.read_to_string(&index_path)?;
4498 let index: OutputsIndex = serde_json::from_str(&data)?;
4499 for expected_output in &manifest.outputs {
4500 let indexed = index.files.iter().find(|file| file.path == expected_output.path);
4501 if expected_output.required && indexed.is_none() {
4502 return Ok(false);
4503 }
4504 let Some(file) = indexed else {
4505 continue;
4506 };
4507 if file.name != expected_output.name
4508 || file.kind != expected_output.kind
4509 || file.media_type != expected_output.media_type
4510 || file.node_id != node.id
4511 || file.node_fingerprint != expected_input.execution_fingerprint
4512 {
4513 return Ok(false);
4514 }
4515 let path = entry.join("outputs").join(&file.path);
4516 if fs.metadata(&path).is_err() {
4517 return Ok(false);
4518 }
4519 let sha = sha256_artifact_path(&path).map_err(RuntimeError::Artifact)?;
4520 if sha != file.sha256 {
4521 return Ok(false);
4522 }
4523 }
4524 for file in index.files {
4525 if !manifest.outputs.iter().any(|output| {
4526 output.path == file.path
4527 && output.name == file.name
4528 && output.kind == file.kind
4529 && output.media_type == file.media_type
4530 }) {
4531 return Ok(false);
4532 }
4533 }
4534 Ok(true)
4535}
4536
4537pub(crate) fn sha256_bytes(bytes: &[u8]) -> String {
4538 let mut hasher = Sha256::new();
4539 hasher.update(bytes);
4540 let result = hasher.finalize();
4541 hex::encode(result)
4542}
4543
4544fn cache_identity_for_trace(
4545 ctx: &RunContext,
4546 node_id: &str,
4547 adapter_id: &str,
4548 adapter_version: &str,
4549 adapter_binary_sha256: Option<&str>,
4550 adapter_outputs_schema_version: &str,
4551) -> Result<CacheIdentity, RuntimeError> {
4552 let key_input = CacheKeyInput {
4553 execution_fingerprint: node_fingerprint_from_ctx(ctx, node_id),
4554 node_definition_fingerprint: node_definition_fingerprint_from_ctx(ctx, node_id),
4555 declared_environment_fingerprint: declared_environment_fingerprint_from_ctx(ctx, node_id),
4556 input_lineage_fingerprint: input_lineage_fingerprint_from_run(ctx, node_id)?,
4557 adapter_id: adapter_id.to_string(),
4558 adapter_version: adapter_version.to_string(),
4559 adapter_binary_sha256: adapter_binary_sha256.map(ToString::to_string),
4560 output_schema_version: adapter_outputs_schema_version.to_string(),
4561 policy_fingerprint: policy_fingerprint(&ctx.policy),
4562 execution_contract_fingerprint: ctx.execution_contract_fingerprint.clone(),
4563 backend_class: "local".to_string(),
4564 };
4565 Ok(CacheIdentity {
4566 cache_key: cache_key_explanation(&key_input).key,
4567 node_definition_fingerprint: key_input.node_definition_fingerprint,
4568 declared_environment_fingerprint: key_input.declared_environment_fingerprint,
4569 input_lineage_fingerprint: key_input.input_lineage_fingerprint,
4570 adapter_binary_sha256: key_input.adapter_binary_sha256,
4571 params_fingerprint: params_fingerprint_from_ctx(ctx, node_id),
4572 command_fingerprint: command_fingerprint_from_ctx(ctx, node_id),
4573 policy_fingerprint: key_input.policy_fingerprint,
4574 execution_contract_fingerprint: key_input.execution_contract_fingerprint,
4575 backend_class: key_input.backend_class,
4576 })
4577}
4578
4579pub(crate) fn node_fingerprint_from_ctx(ctx: &RunContext, node_id: &str) -> String {
4580 ctx.graph_fingerprint.lock().ok().and_then(|map| map.get(node_id).cloned()).unwrap_or_default()
4581}
4582
4583fn node_definition_fingerprint_from_ctx(ctx: &RunContext, node_id: &str) -> String {
4584 ctx.node_definition_fingerprints.get(node_id).cloned().unwrap_or_default()
4585}
4586
4587fn declared_environment_fingerprint_from_ctx(ctx: &RunContext, node_id: &str) -> String {
4588 ctx.declared_environment_fingerprints.get(node_id).cloned().unwrap_or_default()
4589}
4590
4591fn params_fingerprint_from_ctx(ctx: &RunContext, node_id: &str) -> String {
4592 ctx.params_fingerprints.get(node_id).cloned().unwrap_or_default()
4593}
4594
4595fn command_fingerprint_from_ctx(ctx: &RunContext, node_id: &str) -> Option<String> {
4596 ctx.command_fingerprints.get(node_id).cloned().flatten()
4597}
4598
4599fn set_node_fingerprint(ctx: &RunContext, node_id: &str, fp: String) {
4600 if let Ok(mut map) = ctx.graph_fingerprint.lock() {
4601 map.insert(node_id.to_string(), fp);
4602 }
4603}
4604
4605fn node_fingerprint_with_inputs(
4606 base_fp: &str,
4607 inputs: &InputsIndex,
4608) -> Result<String, RuntimeError> {
4609 let value = if inputs.collections.is_empty() {
4610 serde_json::json!({
4611 "base": base_fp,
4612 "inputs": &inputs.files,
4613 })
4614 } else {
4615 serde_json::json!({
4616 "base": base_fp,
4617 "inputs": &inputs.files,
4618 "collections": &inputs.collections,
4619 })
4620 };
4621 Ok(sha256_bytes(&serde_json::to_vec_pretty(&value)?))
4622}
4623
4624fn input_lineage_fingerprint(inputs: &InputsIndex) -> Result<String, RuntimeError> {
4625 if inputs.collections.is_empty() {
4626 return Ok(sha256_bytes(&serde_json::to_vec(&inputs.files)?));
4627 }
4628 Ok(sha256_bytes(&serde_json::to_vec(inputs)?))
4629}
4630
4631fn input_lineage_fingerprint_from_run(
4632 ctx: &RunContext,
4633 node_id: &str,
4634) -> Result<String, RuntimeError> {
4635 let index_path = ctx.run_dir.node_inputs_dir(node_id).join("index.json");
4636 if ctx.fs.metadata(&index_path).is_err() {
4637 return input_lineage_fingerprint(&InputsIndex {
4638 collections: Vec::new(),
4639 files: Vec::new(),
4640 });
4641 }
4642 let raw = ctx.fs.read_to_string(&index_path)?;
4643 let index: InputsIndex = serde_json::from_str(&raw)?;
4644 input_lineage_fingerprint(&index)
4645}
4646
4647fn cache_dir_id(path: &Path) -> String {
4648 path.file_name()
4649 .map(|s| s.to_string_lossy().to_string())
4650 .unwrap_or_else(|| path.display().to_string())
4651}
4652
4653fn cache_source_from_meta(fs: &dyn Fs, entry: &Path) -> Option<String> {
4654 let meta_path = entry.join("meta.json");
4655 let data = fs.read_to_string(&meta_path).ok()?;
4656 let meta: serde_json::Value = serde_json::from_str(&data).ok()?;
4657 meta.get("cache_source").and_then(|v| v.as_str()).map(|s| s.to_string())
4658}
4659
4660fn sort_value_maps(value: &mut Value) {
4661 match value {
4662 Value::Object(map) => {
4663 let mut sorted: BTreeMap<String, Value> = BTreeMap::new();
4664 let entries = std::mem::take(map);
4665 for (k, mut v) in entries {
4666 sort_value_maps(&mut v);
4667 sorted.insert(k, v);
4668 }
4669 let mut new_map = serde_json::Map::new();
4670 for (k, v) in sorted {
4671 new_map.insert(k, v);
4672 }
4673 *map = new_map;
4674 }
4675 Value::Array(arr) => {
4676 for v in arr.iter_mut() {
4677 sort_value_maps(v);
4678 }
4679 }
4680 _ => {}
4681 }
4682}
4683
4684pub(crate) fn validate_outputs_dir(dir: &Path, outputs: &[FileOutput]) -> Option<FailureInfo> {
4685 inspect_declared_outputs(dir, outputs).failure
4686}
4687
4688fn collect_relative_artifacts(
4689 root: &Path,
4690 current: &Path,
4691 out: &mut std::collections::BTreeSet<String>,
4692) {
4693 let entries = match std::fs::read_dir(current) {
4694 Ok(entries) => entries,
4695 Err(_) => return,
4696 };
4697 for entry in entries.flatten() {
4698 let path = entry.path();
4699 if std::fs::symlink_metadata(&path)
4700 .map(|meta| meta.file_type().is_symlink())
4701 .unwrap_or(false)
4702 {
4703 continue;
4704 }
4705 if path.is_dir() {
4706 collect_relative_artifacts(root, &path, out);
4707 continue;
4708 }
4709 if let Ok(rel) = path.strip_prefix(root) {
4710 out.insert(rel.to_string_lossy().replace('\\', "/"));
4711 }
4712 }
4713}
4714
4715pub(crate) fn apply_shaped_env(
4716 cmd: &mut std::process::Command,
4717 clean_env: bool,
4718 allowlist: &[String],
4719 denylist: &[String],
4720) {
4721 cmd.env_clear();
4722 for (key, value) in shaped_environment(clean_env, allowlist, denylist) {
4723 cmd.env(key, value);
4724 }
4725}
4726
4727pub(crate) fn shaped_environment(
4728 clean_env: bool,
4729 allowlist: &[String],
4730 denylist: &[String],
4731) -> BTreeMap<String, String> {
4732 let ambient: BTreeMap<String, String> = std::env::vars().collect();
4733 declared_environment(&ambient, clean_env, allowlist, denylist)
4734}
4735
4736pub(crate) fn command_output_with_timeout(
4737 cmd: &mut std::process::Command,
4738 timeout_ms: Option<u64>,
4739) -> Result<ControlledCommandResult, RuntimeError> {
4740 command_output_with_controls(cmd, timeout_ms, None)
4741}
4742
4743fn cancellation_registry() -> &'static Mutex<Vec<Weak<AtomicBool>>> {
4744 static REGISTRY: OnceLock<Mutex<Vec<Weak<AtomicBool>>>> = OnceLock::new();
4745 REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
4746}
4747
4748fn broadcast_runtime_cancellation() {
4749 let mut registry = cancellation_registry().lock().expect("cancellation registry");
4750 registry.retain(|entry| {
4751 if let Some(flag) = entry.upgrade() {
4752 flag.store(true, Ordering::SeqCst);
4753 true
4754 } else {
4755 false
4756 }
4757 });
4758}
4759
4760pub(crate) fn install_runtime_cancellation_handler() {
4761 static INSTALLED: OnceLock<()> = OnceLock::new();
4762 INSTALLED.get_or_init(|| {
4763 let _ = ctrlc::set_handler(broadcast_runtime_cancellation);
4764 });
4765}
4766
4767pub(crate) fn register_runtime_cancellation_flag(flag: &Arc<AtomicBool>) {
4768 let mut registry = cancellation_registry().lock().expect("cancellation registry");
4769 registry.retain(|entry| entry.strong_count() > 0);
4770 registry.push(Arc::downgrade(flag));
4771}
4772
4773pub(crate) fn command_output_with_controls(
4774 cmd: &mut std::process::Command,
4775 timeout_ms: Option<u64>,
4776 cancellation_requested: Option<&AtomicBool>,
4777) -> Result<ControlledCommandResult, RuntimeError> {
4778 cmd.stdout(Stdio::piped());
4779 cmd.stderr(Stdio::piped());
4780 configure_controlled_subprocess(cmd);
4781 let mut child = cmd.spawn().map_err(RuntimeError::Io)?;
4782 let stdout = child
4783 .stdout
4784 .take()
4785 .ok_or_else(|| RuntimeError::Executor("failed to capture process stdout".to_string()))?;
4786 let stderr = child
4787 .stderr
4788 .take()
4789 .ok_or_else(|| RuntimeError::Executor("failed to capture process stderr".to_string()))?;
4790 let stdout_reader = spawn_output_reader(stdout, "stdout")?;
4791 let stderr_reader = spawn_output_reader(stderr, "stderr")?;
4792 let timeout_limit = timeout_ms.map(|limit| (limit, std::time::Instant::now()));
4793 let (termination, outcome_kind) = loop {
4794 if let Some(status) = child.try_wait().map_err(RuntimeError::Io)? {
4795 break (
4796 ControlledCommandTermination::new(status),
4797 ControlledCommandOutcomeKind::Exited,
4798 );
4799 }
4800 if cancellation_requested.is_some_and(|requested| requested.load(Ordering::SeqCst)) {
4801 let termination = terminate_child_best_effort(&mut child).map_err(RuntimeError::Io)?;
4802 break (termination, ControlledCommandOutcomeKind::Cancelled);
4803 }
4804 if timeout_limit
4805 .is_some_and(|(limit_ms, started)| started.elapsed().as_millis() > limit_ms as u128)
4806 {
4807 let termination = terminate_child_best_effort(&mut child).map_err(RuntimeError::Io)?;
4808 break (termination, ControlledCommandOutcomeKind::TimedOut);
4809 }
4810 std::thread::sleep(Duration::from_millis(10));
4811 };
4812 let stdout = join_output_reader(stdout_reader, "stdout")?;
4813 let stderr = join_output_reader(stderr_reader, "stderr")?;
4814 stderr
4815 .append_cleanup_diagnostics(&termination.cleanup_diagnostics)
4816 .map_err(RuntimeError::Io)?;
4817 let output = ControlledCommandOutput { status: termination.status, stdout, stderr };
4818 Ok(match outcome_kind {
4819 ControlledCommandOutcomeKind::Exited => ControlledCommandResult::Exited(output),
4820 ControlledCommandOutcomeKind::Cancelled => ControlledCommandResult::Cancelled(output),
4821 ControlledCommandOutcomeKind::TimedOut => ControlledCommandResult::TimedOut(output),
4822 })
4823}
4824
4825fn configure_controlled_subprocess(cmd: &mut std::process::Command) {
4826 #[cfg(unix)]
4827 {
4828 cmd.process_group(0);
4829 }
4830}
4831
4832struct ControlledCommandReader {
4833 path: PathBuf,
4834 handle: std::thread::JoinHandle<std_io::Result<()>>,
4835}
4836
4837fn spawn_output_reader<T>(
4838 mut stream: T,
4839 stream_name: &str,
4840) -> std_io::Result<ControlledCommandReader>
4841where
4842 T: Read + Send + 'static,
4843{
4844 let (mut file, path) = create_capture_file(stream_name)?;
4845 let handle = std::thread::spawn(move || {
4846 std_io::copy(&mut stream, &mut file)?;
4847 Ok(())
4848 });
4849 Ok(ControlledCommandReader { path, handle })
4850}
4851
4852fn join_output_reader(
4853 reader: ControlledCommandReader,
4854 stream_name: &str,
4855) -> Result<ControlledCommandStream, RuntimeError> {
4856 reader
4857 .handle
4858 .join()
4859 .map_err(|_| {
4860 RuntimeError::Executor(format!("failed to join {stream_name} capture thread"))
4861 })?
4862 .map_err(RuntimeError::Io)?;
4863 Ok(ControlledCommandStream { path: reader.path })
4864}
4865
4866fn terminate_child_best_effort(
4867 child: &mut std::process::Child,
4868) -> std_io::Result<ControlledCommandTermination> {
4869 if let Some(status) = child.try_wait()? {
4870 return Ok(ControlledCommandTermination::new(status));
4871 }
4872
4873 #[cfg(unix)]
4874 {
4875 return terminate_process_group_best_effort(child);
4876 }
4877
4878 #[cfg(not(unix))]
4879 {
4880 let _ = child.kill();
4881 Ok(ControlledCommandTermination::new(child.wait()?))
4882 }
4883}
4884
4885#[cfg(unix)]
4886fn terminate_process_group_best_effort(
4887 child: &mut std::process::Child,
4888) -> std_io::Result<ControlledCommandTermination> {
4889 const SIGNAL_GRACE_PERIOD: Duration = Duration::from_millis(250);
4890
4891 let process_group_id = child.id();
4892 let mut termination = ControlledCommandTermination::new(unreachable_exit_status());
4893
4894 if let Err(error) = signal_process_group(process_group_id, nix::sys::signal::Signal::SIGTERM) {
4895 termination.cleanup_diagnostics.push(format!(
4896 "failed to send SIGTERM to subprocess group {process_group_id}: {error}"
4897 ));
4898 }
4899 let leader_status = wait_for_child_exit(child, SIGNAL_GRACE_PERIOD)?;
4900
4901 if process_group_exists(process_group_id)? {
4902 if let Err(error) =
4903 signal_process_group(process_group_id, nix::sys::signal::Signal::SIGKILL)
4904 {
4905 termination.cleanup_diagnostics.push(format!(
4906 "failed to send SIGKILL to subprocess group {process_group_id}: {error}"
4907 ));
4908 }
4909 }
4910
4911 if let Some(status) = leader_status {
4912 termination.status = status;
4913 return Ok(termination);
4914 }
4915 if let Some(status) = wait_for_child_exit(child, SIGNAL_GRACE_PERIOD)? {
4916 termination.status = status;
4917 return Ok(termination);
4918 }
4919
4920 if let Err(error) = child.kill() {
4921 termination
4922 .cleanup_diagnostics
4923 .push(format!("failed to kill subprocess leader {process_group_id}: {error}"));
4924 }
4925 termination.status = child.wait()?;
4926 Ok(termination)
4927}
4928
4929#[cfg(unix)]
4930fn signal_process_group(
4931 process_group_id: u32,
4932 signal: nix::sys::signal::Signal,
4933) -> std_io::Result<()> {
4934 nix::sys::signal::killpg(process_group_pid(process_group_id)?, signal)
4935 .map_err(std_io::Error::from)
4936}
4937
4938#[cfg(unix)]
4939fn process_group_exists(process_group_id: u32) -> std_io::Result<bool> {
4940 match nix::sys::signal::killpg(process_group_pid(process_group_id)?, None) {
4941 Ok(()) => Ok(true),
4942 Err(nix::errno::Errno::ESRCH) => Ok(false),
4943 Err(error) => Err(std_io::Error::from(error)),
4944 }
4945}
4946
4947#[cfg(unix)]
4948fn process_group_pid(process_group_id: u32) -> std_io::Result<nix::unistd::Pid> {
4949 let process_group_id = i32::try_from(process_group_id).map_err(|error| {
4950 std_io::Error::new(
4951 std_io::ErrorKind::InvalidInput,
4952 format!("process group id exceeds the platform pid range: {error}"),
4953 )
4954 })?;
4955 Ok(nix::unistd::Pid::from_raw(process_group_id))
4956}
4957
4958fn controlled_exit_code(status: std::process::ExitStatus) -> Option<i32> {
4959 if let Some(code) = status.code() {
4960 return Some(code);
4961 }
4962
4963 #[cfg(unix)]
4964 {
4965 use std::os::unix::process::ExitStatusExt;
4966 return status.signal().map(|signal| 128 + signal);
4967 }
4968
4969 #[cfg(not(unix))]
4970 {
4971 None
4972 }
4973}
4974
4975fn wait_for_child_exit(
4976 child: &mut std::process::Child,
4977 grace_period: Duration,
4978) -> std_io::Result<Option<std::process::ExitStatus>> {
4979 let deadline = std::time::Instant::now() + grace_period;
4980 loop {
4981 if let Some(status) = child.try_wait()? {
4982 return Ok(Some(status));
4983 }
4984 if std::time::Instant::now() >= deadline {
4985 return Ok(None);
4986 }
4987 std::thread::sleep(Duration::from_millis(10));
4988 }
4989}
4990
4991fn create_capture_file(stream_name: &str) -> std_io::Result<(std::fs::File, PathBuf)> {
4992 static CAPTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
4993
4994 for _ in 0..32 {
4995 let sequence = CAPTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
4996 let timestamp = std::time::SystemTime::now()
4997 .duration_since(std::time::UNIX_EPOCH)
4998 .unwrap_or_default()
4999 .as_nanos();
5000 let path = std::env::temp_dir().join(format!(
5001 "bijux-dag-{stream_name}-{}-{timestamp}-{sequence}.log",
5002 std::process::id()
5003 ));
5004 match std::fs::OpenOptions::new().create_new(true).read(true).write(true).open(&path) {
5005 Ok(file) => return Ok((file, path)),
5006 Err(error) if error.kind() == std_io::ErrorKind::AlreadyExists => {}
5007 Err(error) => return Err(error),
5008 }
5009 }
5010
5011 Err(std_io::Error::new(
5012 std_io::ErrorKind::AlreadyExists,
5013 format!("failed to allocate unique capture path for {stream_name}"),
5014 ))
5015}
5016
5017fn read_file_tail_bytes(path: &Path, max_bytes: u64) -> std_io::Result<Vec<u8>> {
5018 let mut file = std::fs::File::open(path)?;
5019 let file_len = file.metadata()?.len();
5020 let start = file_len.saturating_sub(max_bytes);
5021 file.seek(SeekFrom::Start(start))?;
5022 let mut buffer = Vec::new();
5023 file.read_to_end(&mut buffer)?;
5024 Ok(buffer)
5025}
5026
5027#[cfg(unix)]
5028fn unreachable_exit_status() -> std::process::ExitStatus {
5029 std::os::unix::process::ExitStatusExt::from_raw(0)
5030}
5031
5032pub(crate) fn effective_node_timeout_ms(node: &Node, params: &Value) -> Option<u64> {
5033 node.timeout_ms.or_else(|| params.get("timeout_ms").and_then(|v| v.as_u64()))
5034}
5035
5036fn enforce_container_image_reference_policy(
5037 image_reference: &str,
5038 policy: ContainerImageReferencePolicy,
5039) -> Result<(), FailureInfo> {
5040 if matches!(policy, ContainerImageReferencePolicy::AllowUnpinned)
5041 || container_image_reference_has_digest(image_reference)
5042 {
5043 return Ok(());
5044 }
5045
5046 Err(FailureInfo::new(
5047 FailureClass::Policy,
5048 "Policy",
5049 "POLICY_CONTAINER_IMAGE_REFERENCE_DENIED",
5050 "container image reference must include an @sha256 digest under the active policy",
5051 Some(serde_json::json!({
5052 "image": image_reference,
5053 "container_image_reference_policy": container_image_reference_policy_label(policy),
5054 })),
5055 ))
5056}
5057
5058fn container_image_reference_has_digest(image_reference: &str) -> bool {
5059 image_reference.contains("@sha256:")
5060}
5061
5062fn container_trace(
5063 spec: &bijux_dag_core::ContainerSpec,
5064 engine: &str,
5065 exit_code: Option<i32>,
5066 engine_version: Option<String>,
5067) -> ContainerTrace {
5068 let image_digest =
5069 subprocess::output(engine, &["image", "inspect", "--format", "{{.Id}}", &spec.image])
5070 .ok()
5071 .and_then(|out| {
5072 if out.status.success() {
5073 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
5074 if s.is_empty() {
5075 None
5076 } else {
5077 Some(s)
5078 }
5079 } else {
5080 None
5081 }
5082 });
5083 ContainerTrace {
5084 image: spec.image.clone(),
5085 image_digest,
5086 engine: engine.to_string(),
5087 engine_version,
5088 exit_code,
5089 }
5090}
5091
5092fn collect_outputs_summary(
5093 fs: &dyn Fs,
5094 run_dir: &RunDir,
5095) -> Result<Vec<OutputSummary>, RuntimeError> {
5096 let mut out = Vec::new();
5097 let nodes_dir = run_dir.staging_path().join("nodes");
5098 if fs.metadata(&nodes_dir).is_ok() {
5099 for entry in fs.read_dir(&nodes_dir)? {
5100 let index_path = entry.path().join("outputs").join("index.json");
5101 if fs.metadata(&index_path).is_ok() {
5102 let data = fs.read_to_string(&index_path)?;
5103 let index: OutputsIndex = serde_json::from_str(&data)?;
5104 for f in index.files {
5105 out.push(OutputSummary {
5106 node_id: f.node_id,
5107 node_fingerprint: f.node_fingerprint,
5108 name: f.name,
5109 path: f.path,
5110 kind: f.kind,
5111 media_type: f.media_type,
5112 size_bytes: f.size_bytes,
5113 sha256: f.sha256,
5114 promotable: f.promotable,
5115 });
5116 }
5117 }
5118 }
5119 }
5120 out.sort_by(|a, b| {
5121 (a.node_id.clone(), a.path.clone()).cmp(&(b.node_id.clone(), b.path.clone()))
5122 });
5123 Ok(out)
5124}
5125
5126fn build_run_outputs_index(
5127 run_dir: &RunDir,
5128 outputs: &[OutputSummary],
5129) -> Result<RunOutputsIndex, RuntimeError> {
5130 let mut files = Vec::new();
5131 for out in outputs {
5132 let rel = run_dir.node_output_relpath(&out.node_id, &out.path);
5133 files.push(RunOutputFile {
5134 node_id: out.node_id.clone(),
5135 node_fingerprint: out.node_fingerprint.clone(),
5136 name: out.name.clone(),
5137 kind: out.kind.clone(),
5138 media_type: out.media_type.clone(),
5139 size_bytes: out.size_bytes,
5140 sha256: out.sha256.clone(),
5141 path: rel,
5142 promotable: out.promotable,
5143 });
5144 }
5145 files.sort_by(|a, b| a.path.cmp(&b.path));
5146 Ok(RunOutputsIndex { files })
5147}
5148
5149fn rustc_version() -> String {
5150 if let Ok(out) = subprocess::output("rustc", &["--version"]) {
5151 if out.status.success() {
5152 return String::from_utf8_lossy(&out.stdout).trim().to_string();
5153 }
5154 }
5155 "unknown".to_string()
5156}
5157
5158fn count_nodes(status_map: &HashMap<String, NodeStatus>) -> NodeCounts {
5159 let mut counts = NodeCounts { success: 0, failed: 0, skipped: 0, cached: 0, cancelled: 0 };
5160 for status in status_map.values() {
5161 match status {
5162 NodeStatus::Success => counts.success += 1,
5163 NodeStatus::Failed => counts.failed += 1,
5164 NodeStatus::Skipped => counts.skipped += 1,
5165 NodeStatus::Cached => counts.cached += 1,
5166 NodeStatus::Cancelled => counts.cancelled += 1,
5167 }
5168 }
5169 counts
5170}
5171
5172fn copy_dir_all(fs: &dyn Fs, src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std_io::Result<()> {
5173 let src = src.as_ref();
5174 let dst = dst.as_ref();
5175 fs.create_dir_all(dst)?;
5176 for entry in fs.read_dir(src)? {
5177 let ty = entry.file_type()?;
5178 let dst_path = dst.join(entry.file_name());
5179 if ty.is_dir() {
5180 copy_dir_all(fs, entry.path(), dst_path)?;
5181 } else {
5182 let _ = fs.copy(entry.path().as_path(), dst_path.as_path())?;
5183 }
5184 }
5185 Ok(())
5186}
5187
5188fn materialize_file(
5189 fs: &dyn Fs,
5190 src: &Path,
5191 dst: &Path,
5192 mode: MaterializeMode,
5193) -> std_io::Result<()> {
5194 if src.is_dir() {
5195 if matches!(mode, MaterializeMode::Symlink) && fs.symlink(src, dst).is_ok() {
5196 return Ok(());
5197 }
5198 fs.create_dir_all(dst)?;
5199 for entry in fs.read_dir(src)? {
5200 let child_dst = dst.join(entry.file_name());
5201 materialize_file(fs, entry.path().as_path(), child_dst.as_path(), mode)?;
5202 }
5203 return Ok(());
5204 }
5205 if fs.metadata(dst).is_ok() {
5206 let _ = fs.remove_file(dst);
5207 }
5208 match mode {
5209 MaterializeMode::Copy => {
5210 let _ = fs.copy(src, dst)?;
5211 }
5212 MaterializeMode::Hardlink => {
5213 if fs.hard_link(src, dst).is_err() {
5214 let _ = fs.copy(src, dst)?;
5215 }
5216 }
5217 MaterializeMode::Symlink => {
5218 if fs.symlink(src, dst).is_err() {
5219 let _ = fs.copy(src, dst)?;
5220 }
5221 }
5222 }
5223 Ok(())
5224}
5225
5226fn materialized_input_sha256(fs: &dyn Fs, path: &Path) -> Result<String, ArtifactError> {
5227 let resolved = fs.canonicalize(path)?;
5228 sha256_artifact_path(&resolved)
5229}
5230
5231#[cfg(test)]
5232mod cache_read_contract_tests {
5233 use super::*;
5234
5235 #[test]
5236 fn cache_hit_requires_proof() {
5237 let err = cache_hit_proof(CacheRead { hit: true, proof: None }).expect_err("invalid hit");
5238 assert!(err.to_string().contains("missing verification proof"));
5239
5240 let proof = CacheProof {
5241 hit: true,
5242 key: "k".to_string(),
5243 source: "local".to_string(),
5244 verified: true,
5245 reason: "hit".to_string(),
5246 corrupt_detected: false,
5247 };
5248 let hit_proof =
5249 cache_hit_proof(CacheRead { hit: true, proof: Some(proof) }).expect("valid hit");
5250 let hit_proof = hit_proof.expect("proof");
5251 assert!(hit_proof.hit);
5252 assert_eq!(hit_proof.key, "k");
5253 }
5254
5255 #[test]
5256 fn compose_tool_version_uses_build_git_sha_when_available() {
5257 assert_eq!(compose_tool_version("0.4.0", Some("abc1234")), "0.4.0+abc1234");
5258 assert_eq!(compose_tool_version("0.4.0", None), "0.4.0");
5259 }
5260
5261 #[test]
5262 fn runtime_fingerprint_stays_stable_across_working_directories() {
5263 use std::sync::{Mutex, OnceLock};
5264
5265 static WORKING_DIRECTORY_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5266
5267 let _guard = WORKING_DIRECTORY_LOCK
5268 .get_or_init(|| Mutex::new(()))
5269 .lock()
5270 .expect("working directory lock");
5271 let original_dir = std::env::current_dir().expect("current directory");
5272 let temp_dir = tempfile::tempdir().expect("temporary directory");
5273 let adapters = vec![AdapterInfo {
5274 adapter_id: "shell".to_string(),
5275 adapter_version: "1.0.0".to_string(),
5276 effects: vec!["local".to_string()],
5277 }];
5278
5279 let original_fingerprint = runtime_fingerprint(&adapters);
5280 std::env::set_current_dir(temp_dir.path()).expect("switch to temp directory");
5281 let moved_fingerprint = runtime_fingerprint(&adapters);
5282 std::env::set_current_dir(&original_dir).expect("restore original directory");
5283
5284 assert_eq!(original_fingerprint, moved_fingerprint);
5285 }
5286}
5287
5288#[cfg(test)]
5289mod controlled_command_cleanup_contract_tests {
5290 use super::*;
5291 use std::sync::{Arc, Mutex, OnceLock};
5292
5293 fn cleanup_test_lock() -> &'static Mutex<()> {
5294 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5295 LOCK.get_or_init(|| Mutex::new(()))
5296 }
5297
5298 #[cfg(unix)]
5299 fn nested_background_marker_command(marker_path: &Path) -> std::process::Command {
5300 let mut cmd = std::process::Command::new("/bin/sh");
5301 cmd.arg("-c")
5302 .arg("( /bin/sh -c 'sleep 1; printf orphan > \"$MARKER_PATH\"' & wait ) & sleep 5");
5303 cmd.env("MARKER_PATH", marker_path);
5304 cmd
5305 }
5306
5307 #[cfg(unix)]
5308 #[test]
5309 fn timeout_kills_nested_background_descendants() {
5310 let _guard = cleanup_test_lock().lock().expect("cleanup test lock");
5311 let temp_dir = tempfile::tempdir().expect("temp dir");
5312 let marker_path = temp_dir.path().join("orphan.txt");
5313 let mut cmd = nested_background_marker_command(&marker_path);
5314
5315 let output = command_output_with_timeout(&mut cmd, Some(100)).expect("timeout result");
5316 assert!(matches!(output, ControlledCommandResult::TimedOut(_)));
5317
5318 std::thread::sleep(Duration::from_millis(1_500));
5319 assert!(!marker_path.exists(), "timed out subprocess group left a descendant running");
5320 }
5321
5322 #[cfg(unix)]
5323 #[test]
5324 fn cancellation_kills_nested_background_descendants() {
5325 let _guard = cleanup_test_lock().lock().expect("cleanup test lock");
5326 let temp_dir = tempfile::tempdir().expect("temp dir");
5327 let marker_path = temp_dir.path().join("orphan.txt");
5328 let mut cmd = nested_background_marker_command(&marker_path);
5329 let cancellation_requested = Arc::new(AtomicBool::new(false));
5330 let trigger = Arc::clone(&cancellation_requested);
5331 let notifier = std::thread::spawn(move || {
5332 std::thread::sleep(Duration::from_millis(100));
5333 trigger.store(true, Ordering::SeqCst);
5334 });
5335
5336 let output =
5337 command_output_with_controls(&mut cmd, None, Some(cancellation_requested.as_ref()))
5338 .expect("cancelled result");
5339 notifier.join().expect("cancellation notifier");
5340 assert!(matches!(output, ControlledCommandResult::Cancelled(_)));
5341
5342 std::thread::sleep(Duration::from_millis(1_500));
5343 assert!(!marker_path.exists(), "cancelled subprocess group left a descendant running");
5344 }
5345
5346 #[cfg(unix)]
5347 #[test]
5348 fn termination_is_harmless_after_process_exits() {
5349 let _guard = cleanup_test_lock().lock().expect("cleanup test lock");
5350 let mut child = std::process::Command::new("/bin/sh")
5351 .arg("-c")
5352 .arg("exit 0")
5353 .spawn()
5354 .expect("spawn child");
5355
5356 std::thread::sleep(Duration::from_millis(50));
5357 let termination = terminate_child_best_effort(&mut child).expect("terminate exited child");
5358
5359 assert!(termination.status.success());
5360 assert!(termination.cleanup_diagnostics.is_empty());
5361 }
5362}
5363
5364#[cfg(test)]
5365include!("internal/testing/tests_runtime.in.rs");