Skip to main content

bijux_dag_runtime/
lib.rs

1//! Execution, replay, scheduling, and policy surfaces for Bijux DAG runs.
2//!
3//! Prefer [`stable`] when browsing the long-lived runtime surface, [`prelude`]
4//! for common execution workflows, and crate-root imports only when you
5//! already know the exact item you need. Broad compatibility re-exports remain
6//! callable for focused imports, but they are intentionally hidden from the
7//! default docs lane. The `experimental-public-api` feature enables opt-in
8//! runtime contract material that is intentionally excluded from the default
9//! docs lane.
10//!
11#![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
623/// Explicit long-lived execution, scheduling, and replay surface.
624pub 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
636/// Common imports for planning, scheduling, and executing local DAG runs.
637pub 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/// Opt-in contract and evidence helpers that are outside the stable runtime lane.
646#[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/// Runtime-level failure classification for planning, execution, and artifact work.
669#[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/// Terminal status reported for a node after execution or cache reuse.
684#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
685pub enum NodeStatus {
686    Success,
687    Failed,
688    Skipped,
689    Cached,
690    Cancelled,
691}
692
693/// Execution-scoped state shared across node adapter invocations for one run.
694pub 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/// Artifact, status, and failure evidence recorded for one node execution result.
717#[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/// Timestamped attempt evidence for a retried or single-shot node execution.
794#[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/// Built-in adapter that writes constant JSON payloads into declared outputs.
914#[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/// Built-in adapter that executes local shell commands inside the run boundary.
993#[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/// Built-in adapter that executes container workloads through a supported engine.
1298#[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/// Cache read and write policy for runtime execution.
1666#[derive(Debug, Clone, PartialEq, Eq)]
1667pub enum CacheMode {
1668    Off,
1669    Read,
1670    ReadWrite,
1671}
1672
1673/// Behavior to apply when a whole-run timeout is reached.
1674#[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/// Runtime configuration for planning, selection, caching, policy, and scheduling.
1697#[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/// Selected execution backend for node launches.
1736#[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/// Runtime configuration for the Kubernetes Job backend.
1746#[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/// Runtime configuration for the SLURM backend.
1769#[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/// Include and exclude selectors applied before execution begins.
1837#[derive(Debug, Clone, Default)]
1838pub struct SelectorSet {
1839    pub include: Vec<Selector>,
1840    pub exclude: Vec<Selector>,
1841}
1842
1843/// Node selection rule used for partial execution and rerun workflows.
1844#[derive(Debug, Clone)]
1845pub enum Selector {
1846    Id(String),
1847    IdPrefix(String),
1848    Tag(String),
1849    Kind(String),
1850}
1851
1852/// Input materialization strategy for upstream artifacts.
1853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1854pub enum MaterializeMode {
1855    Copy,
1856    Hardlink,
1857    Symlink,
1858}
1859
1860/// Policy flags that constrain ambient effects during runtime execution.
1861#[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
1882/// Runtime entrypoint for executing validated graphs against registered adapters.
1883pub 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    /// Builds a runtime with the default adapter registry, filesystem, and clock.
1892    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    /// Executes a validated graph with the supplied runtime configuration.
1938    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(&params)?;
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(&params_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/// Admission record for one graph node against the currently registered adapters.
3396#[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/// Summary of whether every node in a graph can be admitted by the runtime.
3407#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
3408pub struct AdapterAdmissionReport {
3409    pub supported: bool,
3410    pub entries: Vec<AdapterAdmissionEntry>,
3411}
3412
3413/// Lists the currently registered adapter records from the default runtime registry.
3414pub 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
3427/// Lists adapter descriptors that define the public runtime adapter contract surface.
3428pub 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
3441/// Builds conformance results for every registered adapter descriptor.
3442pub 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
3456/// Builds the checked-in adapter reference document payload from live descriptors.
3457pub 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
3470/// Evaluates whether each node in a graph is supported by the current adapter registry.
3471pub 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
3538/// Serializes the default adapter registry into a JSON inventory report.
3539pub 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");