Skip to main content

bijux_dag_app/
lib.rs

1//! Application orchestration and response shaping for the `bijux-dag` command surface.
2//!
3//! Prefer [`stable`] when browsing the long-lived app surface, [`prelude`] for
4//! command embedding helpers, and crate-root imports only when you already
5//! 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
8//! repository-owned contract helpers that are intentionally excluded from the
9//! default docs lane.
10//!
11#![allow(dead_code)]
12
13mod backend_capability_surface;
14mod cache;
15#[path = "cache/cmd.rs"]
16mod cache_cmd;
17#[path = "commands/cli_model.rs"]
18mod cli_model;
19#[cfg(feature = "experimental-public-api")]
20#[path = "commands/command_report_contracts.rs"]
21mod command_report_contracts;
22mod commands;
23#[path = "commands/config_resolution.rs"]
24mod config_resolution;
25#[path = "commands/config_surface.rs"]
26mod config_surface;
27#[path = "replay/diff.rs"]
28mod diff;
29#[path = "inspect/doctor_cmd.rs"]
30mod doctor_cmd;
31mod explain;
32#[path = "explain/cmd.rs"]
33mod explain_cmd;
34#[path = "commands/export_cmd.rs"]
35mod export_cmd;
36mod format;
37#[path = "read/fs_input.rs"]
38mod fs_input;
39mod graph;
40#[path = "graph/cmd.rs"]
41mod graph_cmd;
42#[path = "graph/helpers.rs"]
43mod graph_helpers;
44#[path = "commands/import_cmd.rs"]
45mod import_cmd;
46mod inspect;
47#[path = "inspect/service.rs"]
48mod inspect_service;
49#[path = "inspect/integrity_service.rs"]
50mod integrity_service;
51mod migrate;
52#[path = "inspect/node_execution_explanation.rs"]
53mod node_execution_explanation;
54#[path = "commands/output_contract.rs"]
55mod output_contract;
56mod read;
57#[path = "read/read_graph.rs"]
58mod read_graph;
59#[path = "commands/reference_docs.rs"]
60mod reference_docs;
61mod repair;
62#[path = "repair/service.rs"]
63mod repair_service;
64mod replay;
65#[path = "replay/cmd.rs"]
66mod replay_cmd;
67#[path = "replay/service.rs"]
68mod replay_service;
69mod routes;
70#[path = "commands/run_cmd.rs"]
71mod run_cmd;
72#[path = "inspect/run_comparison.rs"]
73mod run_comparison;
74#[path = "read/run_data.rs"]
75mod run_data;
76#[path = "inspect/run_failure_summary.rs"]
77mod run_failure_summary;
78#[path = "inspect/run_views.rs"]
79mod run_views;
80#[path = "read/runtime_inputs.rs"]
81mod runtime_inputs;
82#[path = "inspect/status_cmd.rs"]
83mod status_cmd;
84#[path = "graph/validate_cmd.rs"]
85mod validate_cmd;
86#[cfg(feature = "experimental-public-api")]
87#[path = "commands/workspace_compatibility_contracts.rs"]
88mod workspace_compatibility_contracts;
89mod write;
90
91#[doc(hidden)]
92pub use config_surface::{
93    config_fingerprint, default_runtime_config, normalize_runtime_config, policy_evaluation_trace,
94    resolve_effective_config, CacheModeSurface, MaterializeInputsSurface,
95    PartialRuntimeSurfaceConfig, PolicySurfaceConfig, RuntimeSurfaceConfig,
96};
97#[doc(hidden)]
98pub use integrity_service::inspect_artifact;
99#[doc(hidden)]
100pub use reference_docs::write_checked_in_cli_reference_docs;
101#[doc(hidden)]
102pub use run_comparison::runs_compare;
103#[doc(hidden)]
104pub use run_failure_summary::explain_failure;
105#[doc(hidden)]
106pub use run_views::{
107    doctor_run, explain_run_id, format_inspect_human, format_run_completion_human,
108    format_show_human, inspect_summary, list_runs, resolve_run_dir, run_completion_summary,
109    run_scheduler_checkpoint, run_timeline, run_tree, runs_failures, runs_flakes, runs_history,
110    runs_history_query, runs_summary, runs_trend,
111};
112
113/// Explicit long-lived command embedding and response-shaping surface.
114pub mod stable {
115    pub use crate::{
116        dag_command, dag_run, default_runtime_config, inspect_artifact, list_runs,
117        normalize_runtime_config, policy_evaluation_trace, resolve_effective_config,
118        resolve_run_dir, runs_summary, CacheModeSurface, MaterializeInputsSurface,
119        PartialRuntimeSurfaceConfig, PolicySurfaceConfig, RuntimeSurfaceConfig,
120    };
121}
122
123/// Common imports for embedding `bijux-dag` command orchestration.
124pub mod prelude {
125    pub use crate::stable::{
126        dag_command, dag_run, default_runtime_config, inspect_artifact, normalize_runtime_config,
127        resolve_effective_config, RuntimeSurfaceConfig,
128    };
129}
130
131/// Opt-in app contract helpers that are outside the stable command lane.
132#[cfg(feature = "experimental-public-api")]
133pub mod experimental {
134    pub mod command_reports {
135        pub use crate::command_report_contracts::*;
136    }
137    pub mod workspace_compatibility {
138        pub use crate::workspace_compatibility_contracts::*;
139    }
140}
141
142use crate::cache::{
143    cache_diff, cache_prune_simulate, cache_stats, explain_cache_key, explain_run_node_cache_miss,
144    pack_cache_entry, unpack_cache_entry, verify_cache_dirs,
145};
146use crate::cli_model::command_name as dag_command_name;
147use crate::integrity_service::{check_engine, hash_run_dir, verify_run};
148use base64::engine::general_purpose::STANDARD as BASE64;
149use base64::Engine;
150use bijux_dag_core::{Graph, GraphError, Severity, SPEC_VERSION};
151use bijux_dag_runtime::{CacheMode, Runtime, RuntimeConfig};
152use clap::{ArgMatches, CommandFactory, FromArgMatches};
153use commands::{
154    command_access_denial, hide_non_public_help, lane_label, CacheCommands, CommandAccessDenial,
155    Commands, ConfigCommands, DagCli, GraphFormatArg, HashCommands, MigrateCommands,
156    PolicyCommands,
157};
158use config_resolution::{
159    show_effective_config, show_effective_policy, ShowEffectiveConfigRequest,
160    ShowEffectivePolicyRequest,
161};
162use graph_helpers::*;
163use output_contract::{emit_json, LintDiagnostic};
164use run_data::env_cache_dir;
165use serde_json::{json, Value};
166use std::fs;
167use std::path::{Path, PathBuf};
168use std::process::ExitCode;
169use thiserror as _;
170
171pub fn dag_command() -> clap::Command {
172    let command = DagCli::command().name(dag_command_name()).subcommand_required(false);
173    hide_non_public_help(command, "")
174}
175
176pub fn dag_run(matches: &ArgMatches) -> Result<ExitCode, ExitCode> {
177    if matches.subcommand_name().is_none() {
178        let mut cmd = dag_command();
179        let _ = cmd.print_help();
180        println!();
181        return Ok(ExitCode::SUCCESS);
182    }
183    let cli = DagCli::from_arg_matches(matches).map_err(|_| ExitCode::from(2))?;
184    run(cli)
185}
186
187fn run(cli: DagCli) -> Result<ExitCode, ExitCode> {
188    if let Some(denial) = command_access_denial(&cli.command) {
189        return emit_command_access_denial(&cli, denial);
190    }
191    match &cli.command {
192        Commands::Init { dir } => {
193            let base = dir.clone().unwrap_or_else(|| PathBuf::from("."));
194            fs::create_dir_all(&base).map_err(|_| ExitCode::from(3))?;
195            let dag_path = base.join("dag.json");
196            if dag_path.exists() {
197                return Err(ExitCode::from(3));
198            }
199            let runs_dir = base.join("runs");
200            fs::create_dir_all(&runs_dir).map_err(|_| ExitCode::from(3))?;
201            let docs_spec_dir = base.join("docs").join("spec");
202            fs::create_dir_all(&docs_spec_dir).ok();
203            let dag = json!({
204              "spec": SPEC_VERSION,
205              "meta": {
206                "name": "hello-bijux-dag",
207                "description": "Starter Bijux DAG",
208                "owners": [],
209                "tags": []
210              },
211              "nodes": [
212                {
213                  "id": "const1",
214                  "kind": "const",
215                  "inputs": [],
216                  "outputs": [{"name": "out", "path": "out"}],
217                  "params": {"value": "hello"}
218                },
219                {
220                  "id": "echo",
221                  "kind": "shell",
222                  "inputs": ["in"],
223                  "outputs": [{"name": "out", "path": "out"}],
224                  "params": {"argv": ["/bin/sh","-c","cat ../inputs/const1/in > ../outputs/out"]},
225                  "effects": ["filesystem"]
226                }
227              ],
228              "edges": [
229                {"from": {"node_id": "const1", "port": "out"}, "to": {"node_id": "echo", "port": "in"}}
230              ]
231            });
232            fs::write(&dag_path, serde_json::to_vec_pretty(&dag).unwrap())
233                .map_err(|_| ExitCode::from(3))?;
234            if cli.json {
235                return emit_json(
236                    &cli,
237                    "dag.init",
238                    true,
239                    json!({"dag": dag_path, "runs": runs_dir}),
240                    Vec::new(),
241                    ExitCode::SUCCESS,
242                );
243            } else if !cli.quiet {
244                println!("created {}", dag_path.display());
245                println!("created {}", runs_dir.display());
246            }
247            Ok(ExitCode::SUCCESS)
248        }
249        Commands::Validate { dags, strict, print_fingerprints, explain } => {
250            routes::validate_routes::handle_validate_command(
251                &cli,
252                dags,
253                *strict,
254                *print_fingerprints,
255                *explain,
256            )
257        }
258        Commands::Canonicalize { dags } => {
259            let graph = load_graphs_or_emit(&cli, "dag.canonicalize", dags)?;
260            let json = graph.to_canonical_json().map_err(|_| ExitCode::from(3))?;
261            if cli.json {
262                return emit_json(
263                    &cli,
264                    "dag.canonicalize",
265                    true,
266                    json!({ "canonical": json }),
267                    Vec::new(),
268                    ExitCode::SUCCESS,
269                );
270            }
271            println!("{}", json);
272            Ok(ExitCode::SUCCESS)
273        }
274        Commands::Lint { dags, strict } => {
275            let strict = *strict;
276            let graph = load_graphs_or_emit(&cli, "dag.lint", dags)?;
277            let lint = lint_graph(&graph);
278            let has_warnings = !lint.is_empty();
279            if cli.json {
280                let diagnostics: Vec<Value> =
281                    lint.iter().map(|d| serde_json::to_value(d).unwrap()).collect();
282                let code =
283                    if strict && has_warnings { ExitCode::from(2) } else { ExitCode::SUCCESS };
284                return emit_json(
285                    &cli,
286                    "dag.lint",
287                    !(strict && has_warnings),
288                    json!({}),
289                    diagnostics,
290                    code,
291                );
292            } else {
293                for warn in &lint {
294                    println!("WARN {} {} {}", warn.code, warn.path, warn.message);
295                }
296            }
297            if strict && has_warnings {
298                return Err(ExitCode::from(2));
299            }
300            Ok(ExitCode::SUCCESS)
301        }
302        Commands::GraphLint { dags, strict } => {
303            let graph = load_graphs_or_emit(&cli, "dag.graph-lint", dags)?;
304            let lint = lint_graph(&graph);
305            let has_warnings = !lint.is_empty();
306            if cli.json {
307                let diagnostics: Vec<Value> =
308                    lint.iter().map(|d| serde_json::to_value(d).unwrap()).collect();
309                let code =
310                    if *strict && has_warnings { ExitCode::from(2) } else { ExitCode::SUCCESS };
311                return emit_json(
312                    &cli,
313                    "dag.graph-lint",
314                    !(*strict && has_warnings),
315                    json!({}),
316                    diagnostics,
317                    code,
318                );
319            }
320            for warn in &lint {
321                println!("WARN {} {} {}", warn.code, warn.path, warn.message);
322            }
323            if *strict && has_warnings {
324                return Err(ExitCode::from(2));
325            }
326            Ok(ExitCode::SUCCESS)
327        }
328        Commands::Fingerprint { dags, explain } => {
329            let graph = load_graphs_or_emit(&cli, "dag.fingerprint", dags)?;
330            let explained = graph.graph_fingerprint_explain().map_err(|_| ExitCode::from(3))?;
331            if cli.json {
332                return emit_json(
333                    &cli,
334                    "dag.fingerprint",
335                    true,
336                    if *explain {
337                        serde_json::to_value(&explained).map_err(|_| ExitCode::from(3))?
338                    } else {
339                        json!({"graph": explained.graph_id.as_str()})
340                    },
341                    Vec::new(),
342                    ExitCode::SUCCESS,
343                );
344            } else {
345                if *explain {
346                    println!("{}", explained.graph_id.as_str());
347                    println!("hash_algorithm={}", explained.hash_algorithm);
348                    println!("canonical_json_bytes_len={}", explained.canonical_json_bytes_len);
349                } else {
350                    println!("{}", explained.graph_id.as_str());
351                }
352            }
353            Ok(ExitCode::SUCCESS)
354        }
355        Commands::Hash { command } => match command {
356            HashCommands::Graph { dags, explain } => {
357                let graph = load_graphs_or_emit(&cli, "dag.hash.graph", dags)?;
358                let explained = graph.graph_fingerprint_explain().map_err(|_| ExitCode::from(3))?;
359                if cli.json {
360                    return emit_json(
361                        &cli,
362                        "dag.hash.graph",
363                        true,
364                        if *explain {
365                            serde_json::to_value(&explained).map_err(|_| ExitCode::from(3))?
366                        } else {
367                            json!({"graph_id": explained.graph_id.as_str()})
368                        },
369                        Vec::new(),
370                        ExitCode::SUCCESS,
371                    );
372                }
373                println!("{}", explained.graph_id.as_str());
374                if *explain {
375                    println!("hash_algorithm={}", explained.hash_algorithm);
376                    println!("canonical_json_bytes_len={}", explained.canonical_json_bytes_len);
377                }
378                Ok(ExitCode::SUCCESS)
379            }
380            HashCommands::Run { run_dir } => {
381                let digest = hash_run_dir(run_dir)?;
382                if cli.json {
383                    return emit_json(
384                        &cli,
385                        "dag.hash.run",
386                        true,
387                        json!({"run_hash": digest}),
388                        Vec::new(),
389                        ExitCode::SUCCESS,
390                    );
391                }
392                println!("{digest}");
393                Ok(ExitCode::SUCCESS)
394            }
395            HashCommands::Artifact { file } => {
396                let bytes = fs::read(file).map_err(|_| ExitCode::from(3))?;
397                let sha256 = bijux_dag_artifacts::hash::sha256_hex(&bytes);
398                if cli.json {
399                    return emit_json(
400                        &cli,
401                        "dag.hash.artifact",
402                        true,
403                        json!({
404                            "artifact_sha256": sha256,
405                            "bytes_len": bytes.len()
406                        }),
407                        Vec::new(),
408                        ExitCode::SUCCESS,
409                    );
410                }
411                println!("{sha256}");
412                Ok(ExitCode::SUCCESS)
413            }
414        },
415        Commands::ArtifactInspect { run_dir, artifact_id } => {
416            routes::artifact_routes::handle_artifact_inspect_command(&cli, run_dir, artifact_id)
417        }
418        Commands::Artifact { command } => {
419            routes::artifact_routes::handle_artifact_command(&cli, command)
420        }
421        Commands::ControlPlane { command } => {
422            routes::control_plane_routes::handle_control_plane_command(&cli, command)
423        }
424        Commands::StateStore { command } => {
425            routes::state_store_routes::handle_state_store_command(&cli, command)
426        }
427        Commands::Dataset { command } => {
428            routes::dataset_routes::handle_dataset_command(&cli, command)
429        }
430        Commands::Enterprise { command } => {
431            routes::enterprise_routes::handle_enterprise_command(&cli, command)
432        }
433        Commands::Fleet { command } => routes::fleet_routes::handle_fleet_command(&cli, command),
434        Commands::Governance { command } => {
435            routes::governance_routes::handle_governance_command(&cli, command)
436        }
437        Commands::Incident { command } => {
438            routes::incident_routes::handle_incident_command(&cli, command)
439        }
440        Commands::Lab { command } => match command {
441            commands::LabCommands::Federation { command } => {
442                routes::federation_routes::handle_federation_command(&cli, command)
443            }
444            commands::LabCommands::Incident { command } => {
445                routes::incident_routes::handle_incident_command(&cli, command)
446            }
447            commands::LabCommands::Enterprise { command } => {
448                routes::enterprise_routes::handle_enterprise_command(&cli, command)
449            }
450            commands::LabCommands::Release { command } => {
451                routes::release_routes::handle_release_command(&cli, command)
452            }
453            commands::LabCommands::Security { command } => {
454                routes::security_routes::handle_security_command(&cli, command)
455            }
456            commands::LabCommands::Durability { command } => {
457                routes::durability_routes::handle_durability_command(&cli, command)
458            }
459            commands::LabCommands::Performance { command } => {
460                routes::performance_routes::handle_performance_command(&cli, command)
461            }
462        },
463        Commands::Federation { command } => {
464            routes::federation_routes::handle_federation_command(&cli, command)
465        }
466        Commands::Security { command } => {
467            routes::security_routes::handle_security_command(&cli, command)
468        }
469        Commands::Durability { command } => {
470            routes::durability_routes::handle_durability_command(&cli, command)
471        }
472        Commands::Performance { command } => {
473            routes::performance_routes::handle_performance_command(&cli, command)
474        }
475        Commands::Release { command } => {
476            routes::release_routes::handle_release_command(&cli, command)
477        }
478        Commands::CanonicalBytes { dags } => {
479            let graph = load_graphs_or_emit(&cli, "dag.canonical-bytes", dags)?;
480            let bytes = graph.canonical_json_bytes().map_err(|_| ExitCode::from(3))?;
481            if cli.json {
482                return emit_json(
483                    &cli,
484                    "dag.canonical-bytes",
485                    true,
486                    json!({
487                        "bytes_len": bytes.len(),
488                        "utf8": String::from_utf8_lossy(&bytes),
489                    }),
490                    Vec::new(),
491                    ExitCode::SUCCESS,
492                );
493            }
494            println!("{}", String::from_utf8_lossy(&bytes));
495            Ok(ExitCode::SUCCESS)
496        }
497        Commands::CanonicalDiff { dag } => {
498            let input = read_file(dag)?;
499            let raw: Value = serde_json::from_str(&input).map_err(|_| ExitCode::from(2))?;
500            let graph = parse_graph(&input)?;
501            let canonical: Value =
502                serde_json::from_str(&graph.to_canonical_json().map_err(|_| ExitCode::from(3))?)
503                    .map_err(|_| ExitCode::from(3))?;
504            let mut changed_paths = Vec::new();
505            collect_json_diff_paths("", &raw, &canonical, &mut changed_paths);
506            if cli.json {
507                return emit_json(
508                    &cli,
509                    "dag.canonical-diff",
510                    true,
511                    json!({
512                        "changed_paths": changed_paths,
513                        "raw": raw,
514                        "canonical": canonical
515                    }),
516                    Vec::new(),
517                    ExitCode::SUCCESS,
518                );
519            }
520            for p in changed_paths {
521                println!("{p}");
522            }
523            Ok(ExitCode::SUCCESS)
524        }
525        Commands::ShowEffectiveGraph {
526            dags,
527            run_dir,
528            select,
529            exclude,
530            from_node,
531            to_node,
532            dependency_closure,
533        } => routes::graph_routes::handle_show_effective_graph_command(
534            &cli,
535            dags,
536            run_dir,
537            select,
538            exclude,
539            from_node,
540            to_node,
541            *dependency_closure,
542        ),
543        Commands::ExplainPlan {
544            dags,
545            out,
546            run_id,
547            cache_dir,
548            absolute_path_policy,
549            jobs,
550            cpu_budget,
551            memory_budget_mb,
552            gpu_device_budget,
553            resource_capacity,
554            from_node,
555            to_node,
556        } => {
557            let graph = load_graphs_or_emit(&cli, "dag.explain-plan", dags)?;
558            graph_helpers::validate_partial_selection_surface(from_node, to_node, &[], &[], false)?;
559            let (upstream_selection_targets, _) =
560                graph_helpers::resolve_upstream_run_selection(&graph, to_node)?;
561            let (downstream_selection_roots, _) =
562                graph_helpers::resolve_downstream_run_selection(&graph, from_node)?;
563            let preview_layout = routes::plan_routes::resolve_plan_preview_layout(
564                out.as_deref(),
565                run_id.as_deref(),
566            )?;
567            let named_resource_capacities =
568                routes::resource_capacity_args::parse_resource_capacities(resource_capacity)?;
569            let preview = routes::plan_routes::PlanPreviewConfig {
570                run_root: out.clone(),
571                run_id: preview_layout.as_ref().map(|layout| layout.run_id.clone()),
572                cache_dir: cache_dir.clone(),
573                absolute_path_policy: (*absolute_path_policy).into(),
574                jobs: *jobs,
575                cpu_budget: *cpu_budget,
576                memory_budget_mb: *memory_budget_mb,
577                gpu_device_budget: *gpu_device_budget,
578                named_resource_capacities,
579                upstream_selection_targets,
580                downstream_selection_roots,
581                selectors: bijux_dag_runtime::SelectorSet::default(),
582                dependency_closure: false,
583            };
584            let analysis = routes::plan_routes::build_default_planner_analysis(&graph, &preview)
585                .map_err(|_| ExitCode::from(3))?;
586            let payload = routes::plan_routes::plan_explain_payload(
587                &analysis,
588                preview_layout.as_ref(),
589                preview.absolute_path_policy,
590            );
591            if cli.json {
592                return emit_json(
593                    &cli,
594                    "dag.explain-plan",
595                    true,
596                    payload,
597                    Vec::new(),
598                    ExitCode::SUCCESS,
599                );
600            }
601            for line in routes::plan_routes::concise_plan_lines(&analysis) {
602                println!("{line}");
603            }
604            Ok(ExitCode::SUCCESS)
605        }
606        Commands::Plan { command } => routes::plan_routes::handle_plan_command(&cli, command),
607        Commands::Schedule { command } => {
608            routes::schedule_routes::handle_schedule_command(&cli, command)
609        }
610        Commands::Runtime { command } => {
611            routes::runtime_routes::handle_runtime_command(&cli, command)
612        }
613        Commands::Graph { dags, format } => {
614            let graph = load_graphs_or_emit(&cli, "dag.graph", dags)?;
615            match format {
616                GraphFormatArg::Dot => {
617                    let dot = graph_to_dot(&graph);
618                    if cli.json {
619                        return emit_json(
620                            &cli,
621                            "dag.graph",
622                            true,
623                            json!({ "dot": dot }),
624                            Vec::new(),
625                            ExitCode::SUCCESS,
626                        );
627                    }
628                    println!("{}", dot);
629                }
630            }
631            Ok(ExitCode::SUCCESS)
632        }
633        Commands::Replay { command } => routes::replay_routes::handle_replay_command(
634            &cli,
635            command.run_dir.as_deref(),
636            command.source_run_id.as_deref(),
637            command.source_run_root.as_deref(),
638            &command.out,
639            command.dry_run,
640            command.sandbox,
641            command.prove,
642            command.reuse_cache,
643            command.cache,
644            command.jobs,
645            command.run_id.clone(),
646            command.cpu_budget,
647            command.memory_budget_mb,
648            command.gpu_device_budget,
649            &command.resource_capacity,
650            command.deny_network,
651            command.deny_env,
652            command.deny_clock,
653            command.clean_env,
654            command.hermetic,
655            &command.from_node,
656            &command.select,
657            &command.exclude,
658            command.dependency_closure,
659            command.materialize_inputs,
660            command.remote_cache_dir.clone(),
661        ),
662        Commands::Prove { run_dir } => {
663            routes::prove_verify_routes::handle_prove_command(&cli, run_dir)
664        }
665        Commands::ProofSummary { run_dir } => {
666            routes::prove_verify_routes::handle_proof_summary_command(&cli, run_dir)
667        }
668        Commands::Runs { command } => routes::runs_routes::handle_runs_command(&cli, command),
669        Commands::Diff { run_a, run_b, mode, node, explain } => {
670            routes::diff_routes::handle_diff_command(
671                &cli,
672                run_a,
673                run_b,
674                *mode,
675                node.as_deref(),
676                *explain,
677                "dag.diff",
678            )
679        }
680        Commands::WhyRerun { run_a, run_b, node } => {
681            routes::diagnostics_routes::handle_why_rerun_command(
682                &cli,
683                run_a,
684                run_b,
685                node.as_deref(),
686            )
687        }
688        Commands::WhyCacheMissed {
689            key,
690            expected_adapter_id,
691            expected_adapter_version,
692            run_dir,
693            node,
694            cache_dir,
695        } => {
696            let payload = if let (Some(run_dir), Some(node_id)) =
697                (run_dir.as_ref(), node.as_deref())
698            {
699                explain_run_node_cache_miss(run_dir, node_id, cache_dir.as_deref())?
700            } else {
701                let key = key.as_deref().ok_or(ExitCode::from(3))?;
702                let expected_adapter_id =
703                    expected_adapter_id.as_deref().ok_or(ExitCode::from(3))?;
704                let expected_adapter_version =
705                    expected_adapter_version.as_deref().ok_or(ExitCode::from(3))?;
706                let dir = cache_dir
707                    .clone()
708                    .or_else(env_cache_dir)
709                    .unwrap_or_else(|| PathBuf::from(".bijux/cache"));
710                let report =
711                    explain_cache_key(&dir, key, expected_adapter_id, expected_adapter_version)?;
712                json!({
713                    "mode": "key",
714                    "cache_dir": dir,
715                    "key": key,
716                    "eligible": report["eligible"],
717                    "reasons": report["reasons"],
718                    "taxonomy": report["taxonomy"],
719                    "key_components": report["key_components"],
720                    "proof_verified": report["proof_verified"],
721                    "meta": report["meta"]
722                })
723            };
724            if cli.json {
725                return emit_json(
726                    &cli,
727                    "dag.why-cache-missed",
728                    true,
729                    payload,
730                    Vec::new(),
731                    ExitCode::SUCCESS,
732                );
733            }
734            println!("{}", serde_json::to_string_pretty(&payload).unwrap());
735            Ok(ExitCode::SUCCESS)
736        }
737        Commands::TraceArtifact { run_dir, artifact_id } => {
738            routes::diagnostics_routes::handle_trace_artifact_command(&cli, run_dir, artifact_id)
739        }
740        Commands::TraceNode { run_dir, id } => {
741            routes::diagnostics_routes::handle_trace_node_command(&cli, run_dir, id)
742        }
743        Commands::Run { command } => routes::run_routes::handle_run_command(
744            &cli,
745            routes::run_routes::RunRouteRequest {
746                dags: &command.dags,
747                out: &command.out,
748                input: &command.input,
749                inputs_file: command.inputs_file.clone(),
750                run_id: command.run_id.clone(),
751                resume_run: command.resume_run.clone(),
752                resume_failure_mode: command.resume_failure_mode,
753                latest: command.latest.clone(),
754                jobs: command.jobs,
755                cpu_budget: command.cpu_budget,
756                memory_budget_mb: command.memory_budget_mb,
757                gpu_device_budget: command.gpu_device_budget,
758                resource_capacity: &command.resource_capacity,
759                node_timeout_ms: command.node_timeout_ms,
760                run_timeout_ms: command.run_timeout_ms,
761                run_timeout_behavior: command.run_timeout_behavior,
762                deny_network: command.deny_network,
763                deny_env: command.deny_env,
764                deny_clock: command.deny_clock,
765                clean_env: command.clean_env,
766                hermetic: command.hermetic,
767                select: &command.select,
768                exclude: &command.exclude,
769                to_node: &command.to_node,
770                dependency_closure: command.dependency_closure,
771                materialize_inputs: command.materialize_inputs,
772                cache: command.cache,
773                cache_dir: command.cache_dir.clone(),
774                remote_cache_dir: command.remote_cache_dir.clone(),
775                absolute_path_policy: command.absolute_path_policy,
776                preflight_only: command.preflight_only,
777                explain_scheduling: command.explain_scheduling,
778                progress: command.progress,
779                backend: command.backend,
780                kubernetes_namespace: command.kubernetes_namespace.clone(),
781                kubernetes_volume_claim: command.kubernetes_volume_claim.clone(),
782                kubernetes_shared_root: command.kubernetes_shared_root.clone(),
783                slurm_queue: command.slurm_queue.clone(),
784                slurm_partition: command.slurm_partition.clone(),
785            },
786        ),
787        Commands::RunBundle { run_dir, out, redact } => {
788            routes::export_import_routes::handle_export_command(
789                &cli,
790                &Some(run_dir.clone()),
791                &None,
792                out,
793                false,
794                false,
795                false,
796                *redact,
797                true,
798                false,
799            )
800        }
801        Commands::Explain { run_dir, node } => {
802            routes::inspect_routes::handle_explain_command(&cli, run_dir, node)
803        }
804        Commands::Node { run_dir, id: node } => {
805            routes::inspect_routes::handle_node_command(&cli, run_dir, node)
806        }
807        Commands::Status { run_dir } => {
808            routes::inspect_routes::handle_status_command(&cli, run_dir)
809        }
810        Commands::Verify { run_dir, deep, strict } => {
811            routes::prove_verify_routes::handle_verify_command(&cli, run_dir, *deep, *strict)
812        }
813        Commands::Fsck { run_dir, strict } => {
814            routes::prove_verify_routes::handle_fsck_command(&cli, run_dir, *strict)
815        }
816        Commands::Doctor => {
817            let report = doctor_report()?;
818            let ok =
819                report.get("status").and_then(|v| v.as_str()).map(|v| v == "ok").unwrap_or(false);
820            if cli.json {
821                return emit_json(
822                    &cli,
823                    "dag.doctor",
824                    ok,
825                    report,
826                    Vec::new(),
827                    if ok { ExitCode::SUCCESS } else { ExitCode::from(3) },
828                );
829            } else {
830                println!("status: {}", report["status"]);
831            }
832            if !ok {
833                return Err(ExitCode::from(3));
834            }
835            Ok(ExitCode::SUCCESS)
836        }
837        Commands::CommandCatalog { groups, lanes } => {
838            routes::command_routes::handle_command_catalog_command(&cli, *groups, lanes)
839        }
840        Commands::Migrate { command } => {
841            let msg = match command {
842                MigrateCommands::Dag { file, from, to, dry_run } => {
843                    let result = migrate_dag(file, from, to)?;
844                    if *dry_run {
845                        format!("dry-run: {result}")
846                    } else {
847                        result
848                    }
849                }
850                MigrateCommands::Run { run_dir, from, to, dry_run } => {
851                    let result = migrate_run(run_dir, from, to)?;
852                    if *dry_run {
853                        format!("dry-run: {result}")
854                    } else {
855                        result
856                    }
857                }
858                MigrateCommands::Inspect { dag, run_dir, from, to } => {
859                    let report = match (dag, run_dir) {
860                        (Some(path), None) => inspect_migrate_dag(path, from, to)?,
861                        (None, Some(path)) => inspect_migrate_run(path, from, to)?,
862                        _ => return Err(ExitCode::from(2)),
863                    };
864                    if cli.json {
865                        return emit_json(
866                            &cli,
867                            "dag.migrate.inspect",
868                            true,
869                            report,
870                            Vec::new(),
871                            ExitCode::SUCCESS,
872                        );
873                    }
874                    println!("{}", serde_json::to_string_pretty(&report).unwrap());
875                    return Ok(ExitCode::SUCCESS);
876                }
877            };
878            if cli.json {
879                return emit_json(
880                    &cli,
881                    "dag.migrate",
882                    true,
883                    json!({ "message": msg }),
884                    Vec::new(),
885                    ExitCode::SUCCESS,
886                );
887            } else if !cli.quiet {
888                println!("{}", msg);
889            }
890            Ok(ExitCode::SUCCESS)
891        }
892        Commands::Cache { command } => match command {
893            CacheCommands::Ls { cache_dir } => {
894                let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
895                let mut entries_vec = Vec::new();
896                if dir.exists() {
897                    for entry in fs::read_dir(dir).map_err(|_| ExitCode::from(3))? {
898                        let entry = entry.map_err(|_| ExitCode::from(3))?;
899                        let name = entry.file_name().to_string_lossy().to_string();
900                        if cli.json {
901                            entries_vec.push(name);
902                        } else {
903                            println!("{}", name);
904                        }
905                    }
906                }
907                if cli.json {
908                    return emit_json(
909                        &cli,
910                        "dag.cache.ls",
911                        true,
912                        json!({ "entries": entries_vec }),
913                        Vec::new(),
914                        ExitCode::SUCCESS,
915                    );
916                }
917                Ok(ExitCode::SUCCESS)
918            }
919            CacheCommands::Pack { node_fp, out, cache_dir } => {
920                let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
921                let entry = dir.join(node_fp);
922                if !entry.exists() {
923                    return Err(ExitCode::from(3));
924                }
925                pack_cache_entry(&entry, out)?;
926                if cli.json {
927                    return emit_json(
928                        &cli,
929                        "dag.cache.pack",
930                        true,
931                        json!({ "pack": out }),
932                        Vec::new(),
933                        ExitCode::SUCCESS,
934                    );
935                } else if !cli.quiet {
936                    println!("pack: {}", out.display());
937                }
938                Ok(ExitCode::SUCCESS)
939            }
940            CacheCommands::Unpack { pack, cache_dir } => {
941                let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
942                unpack_cache_entry(pack, &dir)?;
943                if cli.json {
944                    return emit_json(
945                        &cli,
946                        "dag.cache.unpack",
947                        true,
948                        json!({ "pack": pack }),
949                        Vec::new(),
950                        ExitCode::SUCCESS,
951                    );
952                } else if !cli.quiet {
953                    println!("unpacked: {}", pack.display());
954                }
955                Ok(ExitCode::SUCCESS)
956            }
957            CacheCommands::Gc { cache_dir } => {
958                let _dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
959                if cli.json {
960                    return emit_json(
961                        &cli,
962                        "dag.cache.gc",
963                        true,
964                        json!({ "status": "stub" }),
965                        Vec::new(),
966                        ExitCode::SUCCESS,
967                    );
968                }
969                println!("cache gc stub");
970                Ok(ExitCode::SUCCESS)
971            }
972            CacheCommands::Verify { cache_dir, remote } => {
973                let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
974                let report = verify_cache_dirs(&dir, remote.as_ref().map(|v| v.as_path()))?;
975                let corrupt = report["corrupt_total"].as_u64().unwrap_or(0);
976                if cli.json {
977                    return emit_json(
978                        &cli,
979                        "dag.cache.verify",
980                        corrupt == 0,
981                        report,
982                        Vec::new(),
983                        if corrupt == 0 { ExitCode::SUCCESS } else { ExitCode::from(3) },
984                    );
985                } else {
986                    println!("local_checked: {}", report["local"]["checked"]);
987                    println!("local_corrupt: {}", report["local"]["corrupt"]);
988                    if let Some(keys) = report["local"]["corrupt_keys"].as_array() {
989                        if !keys.is_empty() {
990                            println!("local_corrupt_keys: {}", report["local"]["corrupt_keys"]);
991                        }
992                    }
993                    if let Some(remote_report) = report.get("remote") {
994                        println!("remote_checked: {}", remote_report["checked"]);
995                        println!("remote_corrupt: {}", remote_report["corrupt"]);
996                        if let Some(keys) = remote_report["corrupt_keys"].as_array() {
997                            if !keys.is_empty() {
998                                println!("remote_corrupt_keys: {}", remote_report["corrupt_keys"]);
999                            }
1000                        }
1001                    }
1002                }
1003                if corrupt > 0 {
1004                    return Err(ExitCode::from(3));
1005                }
1006                Ok(ExitCode::SUCCESS)
1007            }
1008            CacheCommands::Explain {
1009                cache_dir,
1010                key,
1011                expected_adapter_id,
1012                expected_adapter_version,
1013            } => {
1014                let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
1015                let report = explain_cache_key(
1016                    &dir,
1017                    key,
1018                    expected_adapter_id.as_deref().unwrap_or(""),
1019                    expected_adapter_version.as_deref().unwrap_or(""),
1020                )?;
1021                let hit = report["eligible"].as_bool().unwrap_or(false);
1022                if cli.json {
1023                    return emit_json(
1024                        &cli,
1025                        "dag.cache.explain",
1026                        true,
1027                        report,
1028                        Vec::new(),
1029                        ExitCode::SUCCESS,
1030                    );
1031                }
1032                println!("{}", serde_json::to_string_pretty(&report).unwrap());
1033                if !hit {
1034                    return Err(ExitCode::from(3));
1035                }
1036                Ok(ExitCode::SUCCESS)
1037            }
1038            CacheCommands::Stats { cache_dir } => {
1039                let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
1040                let report = cache_stats(&dir)?;
1041                if cli.json {
1042                    return emit_json(
1043                        &cli,
1044                        "dag.cache.stats",
1045                        true,
1046                        report,
1047                        Vec::new(),
1048                        ExitCode::SUCCESS,
1049                    );
1050                }
1051                println!("{}", serde_json::to_string_pretty(&report).unwrap());
1052                Ok(ExitCode::SUCCESS)
1053            }
1054            CacheCommands::PruneSimulate { cache_dir } => {
1055                let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
1056                let report = cache_prune_simulate(&dir)?;
1057                if cli.json {
1058                    return emit_json(
1059                        &cli,
1060                        "dag.cache.prune-simulate",
1061                        true,
1062                        report,
1063                        Vec::new(),
1064                        ExitCode::SUCCESS,
1065                    );
1066                }
1067                println!("{}", serde_json::to_string_pretty(&report).unwrap());
1068                Ok(ExitCode::SUCCESS)
1069            }
1070            CacheCommands::Diff { cache_dir, key_a, key_b } => {
1071                let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
1072                let report = cache_diff(&dir, &key_a, &key_b)?;
1073                let comparable = report["comparable"].as_bool().unwrap_or(false);
1074                if cli.json {
1075                    return emit_json(
1076                        &cli,
1077                        "dag.cache.diff",
1078                        true,
1079                        report,
1080                        Vec::new(),
1081                        ExitCode::SUCCESS,
1082                    );
1083                }
1084                println!("{}", serde_json::to_string_pretty(&report).unwrap());
1085                if !comparable {
1086                    return Err(ExitCode::from(3));
1087                }
1088                Ok(ExitCode::SUCCESS)
1089            }
1090        },
1091        Commands::Adapters { command } => {
1092            routes::adapter_routes::handle_adapters_command(&cli, command)
1093        }
1094        Commands::Export {
1095            run_dir,
1096            from_run,
1097            out,
1098            manifest_only,
1099            without_artifacts,
1100            provenance_only,
1101            redact,
1102            with_files,
1103            include_files,
1104        } => routes::export_import_routes::handle_export_command(
1105            &cli,
1106            run_dir,
1107            from_run,
1108            out,
1109            *manifest_only,
1110            *without_artifacts,
1111            *provenance_only,
1112            *redact,
1113            *with_files,
1114            *include_files,
1115        ),
1116        Commands::Import { file, verify_only } => {
1117            routes::export_import_routes::handle_import_command(&cli, file, *verify_only)
1118        }
1119        Commands::Version => {
1120            let v = format!("bijux-dag {} ({})", env!("CARGO_PKG_VERSION"), SPEC_VERSION);
1121            if cli.json {
1122                return emit_json(
1123                    &cli,
1124                    "dag.version",
1125                    true,
1126                    json!({ "version": v }),
1127                    Vec::new(),
1128                    ExitCode::SUCCESS,
1129                );
1130            }
1131            println!("{}", v);
1132            Ok(ExitCode::SUCCESS)
1133        }
1134        Commands::Capabilities { backend } => {
1135            routes::surface_routes::handle_capabilities_command(&cli, backend)
1136        }
1137        Commands::SemanticPortability { backend } => {
1138            routes::surface_routes::handle_semantic_portability_command(&cli, backend)
1139        }
1140        Commands::EquivalenceProof { run_a, run_b, backend_a, backend_b } => {
1141            routes::surface_routes::handle_equivalence_proof_command(
1142                &cli, run_a, run_b, backend_a, backend_b,
1143            )
1144        }
1145        Commands::VersionInspect { dag, run_dir, export_bundle } => {
1146            let provided =
1147                dag.is_some() as u8 + run_dir.is_some() as u8 + export_bundle.is_some() as u8;
1148            if provided != 1 {
1149                return Err(ExitCode::from(2));
1150            }
1151            let mut report = json!({
1152                "binary_version": env!("CARGO_PKG_VERSION"),
1153                "graph_schema_version": Value::Null,
1154                "run_dir_format_version": Value::Null,
1155                "export_bundle_format_version": Value::Null,
1156            });
1157            if let Some(path) = dag {
1158                let input = read_file(path)?;
1159                let graph = parse_graph(&input)?;
1160                report["graph_schema_version"] = json!(graph.spec);
1161                let supported_graph_spec =
1162                    graph.spec == "0.1" || graph.spec == "v0.1" || graph.spec == SPEC_VERSION;
1163                if !supported_graph_spec {
1164                    report["support_status"] = json!("unsupported-graph-schema");
1165                    if cli.json {
1166                        return emit_json(
1167                            &cli,
1168                            "dag.version-inspect",
1169                            false,
1170                            report,
1171                            vec![
1172                                json!({"message":"unsupported graph schema version","remediation":"use spec 0.1"} ),
1173                            ],
1174                            ExitCode::from(2),
1175                        );
1176                    }
1177                    return Err(ExitCode::from(2));
1178                }
1179            }
1180            if let Some(path) = run_dir {
1181                let manifest = read_file(&path.join("manifest.json"))?;
1182                let parsed: Value =
1183                    serde_json::from_str(&manifest).map_err(|_| ExitCode::from(3))?;
1184                let run_version = parsed
1185                    .get("manifest_version")
1186                    .cloned()
1187                    .unwrap_or_else(|| json!("run-manifest/v0.1"));
1188                report["run_dir_format_version"] = run_version.clone();
1189                report["graph_schema_version"] = parsed.get("spec").cloned().unwrap_or(Value::Null);
1190                if run_version != json!("run-manifest/v0.1") {
1191                    report["support_status"] = json!("unsupported-run-dir-version");
1192                    if cli.json {
1193                        return emit_json(
1194                            &cli,
1195                            "dag.version-inspect",
1196                            false,
1197                            report,
1198                            vec![
1199                                json!({"message":"unsupported run-dir format version","remediation":"use run-manifest/v0.1"} ),
1200                            ],
1201                            ExitCode::from(2),
1202                        );
1203                    }
1204                    return Err(ExitCode::from(2));
1205                }
1206            }
1207            if let Some(path) = export_bundle {
1208                let payload = read_file(path)?;
1209                let parsed: Value =
1210                    serde_json::from_str(&payload).map_err(|_| ExitCode::from(3))?;
1211                let bundle_version = parsed
1212                    .get("export_bundle_version")
1213                    .cloned()
1214                    .unwrap_or_else(|| json!("export-bundle/v0.1"));
1215                report["export_bundle_format_version"] = bundle_version.clone();
1216                report["run_dir_format_version"] = parsed
1217                    .get("manifest")
1218                    .and_then(|m| m.get("manifest_version"))
1219                    .cloned()
1220                    .unwrap_or(Value::Null);
1221                report["graph_schema_version"] = parsed
1222                    .get("manifest")
1223                    .and_then(|m| m.get("spec"))
1224                    .cloned()
1225                    .unwrap_or(Value::Null);
1226                if bundle_version != json!("export-bundle/v0.1") {
1227                    report["support_status"] = json!("unsupported-export-bundle-version");
1228                    if cli.json {
1229                        return emit_json(
1230                            &cli,
1231                            "dag.version-inspect",
1232                            false,
1233                            report,
1234                            vec![
1235                                json!({"message":"unsupported export bundle version","remediation":"use export-bundle/v0.1"} ),
1236                            ],
1237                            ExitCode::from(2),
1238                        );
1239                    }
1240                    return Err(ExitCode::from(2));
1241                }
1242            }
1243            if cli.json {
1244                return emit_json(
1245                    &cli,
1246                    "dag.version-inspect",
1247                    true,
1248                    report,
1249                    Vec::new(),
1250                    ExitCode::SUCCESS,
1251                );
1252            }
1253            println!("{}", serde_json::to_string_pretty(&report).unwrap());
1254            Ok(ExitCode::SUCCESS)
1255        }
1256        Commands::Config { command } => match command {
1257            ConfigCommands::ShowEffective { config, jobs, cache_mode, materialize_inputs } => {
1258                let effective = show_effective_config(ShowEffectiveConfigRequest {
1259                    config_path: config.as_deref(),
1260                    jobs: *jobs,
1261                    cache_mode: *cache_mode,
1262                    materialize_inputs: *materialize_inputs,
1263                })?;
1264                let payload = serde_json::to_value(&effective).map_err(|_| ExitCode::from(3))?;
1265                if cli.json {
1266                    return emit_json(
1267                        &cli,
1268                        "dag.config.show-effective",
1269                        true,
1270                        payload,
1271                        Vec::new(),
1272                        ExitCode::SUCCESS,
1273                    );
1274                }
1275                println!("{}", serde_json::to_string_pretty(&payload).unwrap());
1276                Ok(ExitCode::SUCCESS)
1277            }
1278        },
1279        Commands::Policy { command } => match command {
1280            PolicyCommands::ShowEffective {
1281                config,
1282                deny_network,
1283                deny_env,
1284                deny_clock,
1285                clean_env,
1286                allow_env,
1287            } => {
1288                let payload = show_effective_policy(ShowEffectivePolicyRequest {
1289                    config_path: config.as_deref(),
1290                    deny_network: *deny_network,
1291                    deny_env: *deny_env,
1292                    deny_clock: *deny_clock,
1293                    clean_env: *clean_env,
1294                    allow_env,
1295                })?;
1296                if cli.json {
1297                    return emit_json(
1298                        &cli,
1299                        "dag.policy.show-effective",
1300                        true,
1301                        payload,
1302                        Vec::new(),
1303                        ExitCode::SUCCESS,
1304                    );
1305                }
1306                println!("{}", serde_json::to_string_pretty(&payload).unwrap());
1307                Ok(ExitCode::SUCCESS)
1308            }
1309        },
1310    }
1311}
1312
1313fn emit_command_access_denial(
1314    cli: &DagCli,
1315    denial: CommandAccessDenial,
1316) -> Result<ExitCode, ExitCode> {
1317    let command = format!("dag.{}", denial.root_command);
1318    let lane = lane_label(denial.lane);
1319    let message = denial.message();
1320    let hint = format!(
1321        "set {}=1 to run this {} route intentionally or use `bijux-dag commands --lane {}` to inspect this non-stable access lane",
1322        denial.opt_in_env,
1323        lane,
1324        lane
1325    );
1326    if cli.json {
1327        return emit_json(
1328            cli,
1329            &command,
1330            false,
1331            json!({
1332                "command_family": denial.root_command,
1333                "lane": denial.lane,
1334                "access": "opt-in",
1335                "opt_in_env": denial.opt_in_env,
1336            }),
1337            vec![json!({
1338                "code": "release-boundary-opt-in",
1339                "message": message,
1340                "hint": hint,
1341            })],
1342            ExitCode::from(2),
1343        );
1344    }
1345    eprintln!("{message}");
1346    eprintln!("{hint}");
1347    Err(ExitCode::from(2))
1348}
1349
1350pub(crate) fn read_file(path: &Path) -> Result<String, ExitCode> {
1351    fs_input::read_utf8_file(path).map_err(|_| ExitCode::from(3))
1352}
1353
1354pub(crate) fn read_run_id(run_dir: &Path) -> Result<String, ExitCode> {
1355    let raw = read_file(&run_dir.join("manifest.json"))?;
1356    let value: Value = serde_json::from_str(&raw).map_err(|_| ExitCode::from(3))?;
1357    value
1358        .get("run_id")
1359        .and_then(Value::as_str)
1360        .map(std::string::ToString::to_string)
1361        .ok_or_else(|| ExitCode::from(3))
1362}
1363
1364pub(crate) fn selector_cli_string(selector: &bijux_dag_runtime::Selector) -> String {
1365    match selector {
1366        bijux_dag_runtime::Selector::Id(v) => format!("id:{v}"),
1367        bijux_dag_runtime::Selector::IdPrefix(v) => format!("id-prefix:{v}"),
1368        bijux_dag_runtime::Selector::Tag(v) => format!("tag:{v}"),
1369        bijux_dag_runtime::Selector::Kind(v) => format!("kind:{v}"),
1370    }
1371}
1372
1373pub(crate) fn parse_graph(input: &str) -> Result<Graph, ExitCode> {
1374    match read_graph::parse_graph_with_compat(input) {
1375        Ok(g) => Ok(g),
1376        Err(GraphError::Json(_)) => Err(ExitCode::from(2)),
1377        Err(GraphError::InvalidSpec(_)) => Err(ExitCode::from(1)),
1378        Err(_) => Err(ExitCode::from(3)),
1379    }
1380}
1381
1382pub(crate) fn load_graphs_or_emit(
1383    cli: &commands::DagCli,
1384    command_name: &str,
1385    dags: &[PathBuf],
1386) -> Result<Graph, ExitCode> {
1387    match read_graph::load_graphs(dags) {
1388        Ok(graph) => Ok(graph),
1389        Err(error) => {
1390            let code = error.exit_code();
1391            if cli.json {
1392                let _ = emit_json(
1393                    cli,
1394                    command_name,
1395                    false,
1396                    json!({
1397                        "error": error.to_string(),
1398                        "dags": dags,
1399                    }),
1400                    Vec::new(),
1401                    code,
1402                );
1403            } else if !cli.quiet {
1404                eprintln!("{error}");
1405            }
1406            Err(code)
1407        }
1408    }
1409}
1410
1411pub(crate) fn print_human_diff(diff: &serde_json::Value) {
1412    let manifest = diff["manifest"].as_object().map(|o| o.len()).unwrap_or(0);
1413    let graph_fp = diff["graph_fingerprint"].is_null();
1414    let nodes = diff["nodes"].as_object().map(|o| o.len()).unwrap_or(0);
1415    let outputs = diff["outputs"].as_object().map(|o| o.len()).unwrap_or(0);
1416    if manifest == 0 && graph_fp && nodes == 0 && outputs == 0 {
1417        println!("no differences");
1418        return;
1419    }
1420    println!("manifest changes: {}", manifest);
1421    println!("graph_fingerprint: {}", diff["graph_fingerprint"]);
1422    println!("nodes changed: {}", nodes);
1423    println!("outputs changed: {}", outputs);
1424}
1425
1426fn collect_json_diff_paths(path: &str, left: &Value, right: &Value, out: &mut Vec<String>) {
1427    match (left, right) {
1428        (Value::Object(a), Value::Object(b)) => {
1429            let mut keys = std::collections::BTreeSet::new();
1430            keys.extend(a.keys().cloned());
1431            keys.extend(b.keys().cloned());
1432            for key in keys {
1433                let child =
1434                    if path.is_empty() { format!("/{}", key) } else { format!("{}/{}", path, key) };
1435                match (a.get(&key), b.get(&key)) {
1436                    (Some(lv), Some(rv)) => collect_json_diff_paths(&child, lv, rv, out),
1437                    _ => out.push(child),
1438                }
1439            }
1440        }
1441        (Value::Array(a), Value::Array(b)) => {
1442            let max = a.len().max(b.len());
1443            for idx in 0..max {
1444                let child =
1445                    if path.is_empty() { format!("/{}", idx) } else { format!("{}/{}", path, idx) };
1446                match (a.get(idx), b.get(idx)) {
1447                    (Some(lv), Some(rv)) => collect_json_diff_paths(&child, lv, rv, out),
1448                    _ => out.push(child),
1449                }
1450            }
1451        }
1452        _ => {
1453            if left != right {
1454                out.push(if path.is_empty() { "/".to_string() } else { path.to_string() });
1455            }
1456        }
1457    }
1458}
1459
1460pub(crate) fn verify_bundle_invariants(bundle: &serde_json::Value) -> Vec<String> {
1461    let mut violations = Vec::new();
1462    if bundle.get("bundle_version").and_then(|v| v.as_str()) != Some("export-bundle/v0.1") {
1463        violations.push("INV-EXPORT-VERSION-001 unsupported or missing bundle_version".to_string());
1464    }
1465    match bundle.get("export_mode").and_then(|v| v.as_str()) {
1466        Some("manifest-only")
1467        | Some("with-files")
1468        | Some("without-artifacts")
1469        | Some("provenance-only") => {}
1470        _ => violations.push("INV-EXPORT-MODE-001 unsupported or missing export_mode".to_string()),
1471    }
1472    if bundle.get("manifest").is_none() {
1473        violations.push("INV-EXPORT-VERIFY-001 missing manifest".to_string());
1474    }
1475    if bundle.get("graph_snapshot").is_none() {
1476        violations.push("INV-EXPORT-VERIFY-001 missing graph_snapshot".to_string());
1477    }
1478    if bundle.get("node_traces").and_then(|v| v.as_object()).is_none() {
1479        violations.push("INV-EXPORT-VERIFY-001 missing node_traces map".to_string());
1480    }
1481    if bundle.get("outputs").and_then(|v| v.as_object()).is_none() {
1482        violations.push("INV-EXPORT-VERIFY-001 missing outputs map".to_string());
1483    }
1484    let files = bundle.get("files");
1485    if bundle.get("export_mode").and_then(|v| v.as_str()) == Some("manifest-only")
1486        && !matches!(files, None | Some(serde_json::Value::Null))
1487    {
1488        violations.push(
1489            "INV-EXPORT-MODE-001 manifest-only bundle must not include files payload".to_string(),
1490        );
1491    }
1492    if bundle.get("export_mode").and_then(|v| v.as_str()) == Some("with-files")
1493        && !files.is_some_and(|v| v.is_object())
1494    {
1495        violations.push("INV-EXPORT-MODE-001 with-files bundle must include files map".to_string());
1496    }
1497    if let Some(files_map) = files.and_then(|v| v.as_object()) {
1498        for (node_id, node_files) in files_map {
1499            let Some(node_files) = node_files.as_object() else {
1500                violations.push(format!(
1501                    "INV-EXPORT-FILES-001 files entry for node {node_id} must be object"
1502                ));
1503                continue;
1504            };
1505            for (path, encoded) in node_files {
1506                if encoded.as_str().is_none() {
1507                    violations.push(format!(
1508                        "INV-EXPORT-FILES-001 file payload for {node_id}/{path} must be base64 string"
1509                    ));
1510                    continue;
1511                }
1512                let value = encoded.as_str().unwrap_or_default();
1513                if BASE64.decode(value).is_err() {
1514                    violations.push(format!(
1515                        "INV-EXPORT-FILES-001 file payload for {node_id}/{path} is not valid base64"
1516                    ));
1517                }
1518            }
1519        }
1520    }
1521    if bundle.get("export_mode").and_then(|v| v.as_str()) == Some("without-artifacts") {
1522        if !bundle.get("outputs").is_some_and(|v| v.as_object().is_some_and(|m| m.is_empty())) {
1523            violations.push(
1524                "INV-EXPORT-MODE-001 without-artifacts bundle must include empty outputs map"
1525                    .to_string(),
1526            );
1527        }
1528        if !matches!(files, None | Some(serde_json::Value::Null)) {
1529            violations.push(
1530                "INV-EXPORT-MODE-001 without-artifacts bundle must not include files payload"
1531                    .to_string(),
1532            );
1533        }
1534    }
1535    if bundle.get("export_mode").and_then(|v| v.as_str()) == Some("provenance-only") {
1536        if !bundle.get("node_traces").is_some_and(|v| v.as_object().is_some_and(|m| m.is_empty())) {
1537            violations.push(
1538                "INV-EXPORT-MODE-001 provenance-only bundle must include empty node_traces map"
1539                    .to_string(),
1540            );
1541        }
1542        if !bundle.get("outputs").is_some_and(|v| v.as_object().is_some_and(|m| m.is_empty())) {
1543            violations.push(
1544                "INV-EXPORT-MODE-001 provenance-only bundle must include empty outputs map"
1545                    .to_string(),
1546            );
1547        }
1548    }
1549    if let Some(traces) = bundle.get("node_traces").and_then(|v| v.as_object()) {
1550        for (node_id, trace) in traces {
1551            let trace_node_id = trace.get("node_id").and_then(|v| v.as_str());
1552            if trace_node_id != Some(node_id.as_str()) {
1553                violations.push(format!(
1554                    "INV-TRACE-ATTEMPT-001 node_id mismatch for trace key {}",
1555                    node_id
1556                ));
1557            }
1558            if trace.get("status").and_then(|v| v.as_str()).is_none() {
1559                violations.push(format!(
1560                    "INV-TRACE-ATTEMPT-001 missing status for trace key {}",
1561                    node_id
1562                ));
1563            }
1564        }
1565    }
1566    violations
1567}
1568
1569pub(crate) fn build_run_proof_bundle(run_dir: &Path) -> Result<serde_json::Value, ExitCode> {
1570    let report = verify_run(run_dir, true, true)?;
1571    let status = report.get("status").and_then(Value::as_str).unwrap_or("error").to_string();
1572    let errors = report.get("errors").and_then(Value::as_array).cloned().unwrap_or_default();
1573    let invariant_violations =
1574        report.get("invariant_violations").and_then(Value::as_array).cloned().unwrap_or_default();
1575    let has_manifest = run_dir.join("manifest.json").exists();
1576    let has_snapshot = run_dir.join("graph.snapshot.json").exists();
1577    let has_outputs = run_dir.join("outputs").join("index.json").exists();
1578    let run_id = read_run_id(run_dir).unwrap_or_else(|_| "unknown".to_string());
1579    let proof_id = format!("proof-{}", run_id);
1580
1581    let mut incomplete_reasons: Vec<String> = Vec::new();
1582    if !has_manifest {
1583        incomplete_reasons.push("missing manifest".to_string());
1584    }
1585    if !has_snapshot {
1586        incomplete_reasons.push("missing graph snapshot".to_string());
1587    }
1588    if !has_outputs {
1589        incomplete_reasons.push("missing outputs index".to_string());
1590    }
1591    if !errors.is_empty() {
1592        incomplete_reasons.push("verification errors present".to_string());
1593    }
1594    if !invariant_violations.is_empty() {
1595        incomplete_reasons.push("invariant violations present".to_string());
1596    }
1597
1598    let provenance_path = run_dir.join("provenance.json");
1599    let backend_origin = if provenance_path.exists() {
1600        let raw = read_file(&provenance_path).unwrap_or_default();
1601        let value: Value = serde_json::from_str(&raw).unwrap_or_default();
1602        value.get("source").and_then(Value::as_str).unwrap_or("native-run").to_string()
1603    } else {
1604        "native-run".to_string()
1605    };
1606
1607    let complete = incomplete_reasons.is_empty() && status == "ok";
1608    Ok(json!({
1609        "schema_version": "proof-bundle/v0.1",
1610        "proof_id": proof_id,
1611        "run_id": run_id,
1612        "run_dir": run_dir,
1613        "backend_origin": backend_origin,
1614        "status": if complete { "complete" } else { "incomplete" },
1615        "complete": complete,
1616        "determinism": if complete { "verified" } else { "insufficient-evidence" },
1617        "integrity": if complete { "verified" } else { "insufficient-evidence" },
1618        "replay_evidence": {
1619            "available": has_manifest && has_snapshot,
1620            "level": if complete { "complete" } else { "partial" }
1621        },
1622        "integrity_evidence": {
1623            "available": has_outputs,
1624            "level": if complete { "complete" } else { "partial" }
1625        },
1626        "incomplete_reasons": incomplete_reasons,
1627        "verification_errors": errors,
1628        "invariant_violations": invariant_violations,
1629        "signing": {
1630            "signed": false,
1631            "signature_format": Value::Null,
1632            "signature": Value::Null,
1633            "trust_level": "unsigned"
1634        }
1635    }))
1636}
1637
1638#[cfg(test)]
1639mod invariant_bundle_tests {
1640    use super::verify_bundle_invariants;
1641    use serde_json::json;
1642
1643    #[test]
1644    fn bundle_invariants_accept_well_formed_bundle() {
1645        let bundle = json!({
1646            "bundle_version":"export-bundle/v0.1",
1647            "export_mode":"manifest-only",
1648            "manifest": {"status":"completed"},
1649            "graph_snapshot": {"nodes":[],"edges":[]},
1650            "node_traces": {
1651                "n1": {"node_id":"n1","status":"success"}
1652            },
1653            "outputs": {},
1654            "files": null
1655        });
1656        let violations = verify_bundle_invariants(&bundle);
1657        assert!(violations.is_empty());
1658    }
1659
1660    #[test]
1661    fn bundle_invariants_reject_missing_and_incoherent_fields() {
1662        let bundle = json!({
1663            "graph_snapshot": {},
1664            "node_traces": {
1665                "n1": {"node_id":"n2"}
1666            }
1667        });
1668        let violations = verify_bundle_invariants(&bundle);
1669        assert!(!violations.is_empty());
1670        assert!(violations.iter().any(|v| v.contains("INV-EXPORT-VERIFY-001")));
1671        assert!(violations.iter().any(|v| v.contains("INV-TRACE-ATTEMPT-001")));
1672    }
1673}
1674
1675#[cfg(test)]
1676mod cache_archive_hardening_tests {
1677    use super::ExitCode;
1678    use crate::cache::unpack_cache_archive_bounded;
1679    use tar::{Builder, Header};
1680
1681    fn unpack_status(bytes: Vec<u8>) -> Result<(), ExitCode> {
1682        let dec = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
1683        let mut archive = tar::Archive::new(dec);
1684        let dst = tempfile::tempdir().expect("tempdir");
1685        unpack_cache_archive_bounded(&mut archive, dst.path())
1686    }
1687
1688    #[test]
1689    fn cache_unpack_rejects_oversized_archive_entries() {
1690        let mut tar_bytes = Vec::new();
1691        {
1692            let enc = flate2::write::GzEncoder::new(&mut tar_bytes, flate2::Compression::default());
1693            let mut builder = Builder::new(enc);
1694            let mut header = Header::new_gnu();
1695            let payload = vec![0u8; (9 * 1024 * 1024) as usize];
1696            header.set_size(payload.len() as u64);
1697            header.set_mode(0o644);
1698            header.set_cksum();
1699            builder.append_data(&mut header, "meta.json", payload.as_slice()).expect("append");
1700            let enc = builder.into_inner().expect("encoder");
1701            enc.finish().expect("finish");
1702        }
1703        assert!(unpack_status(tar_bytes).is_err());
1704    }
1705
1706    #[test]
1707    fn cache_unpack_rejects_symlink_entries() {
1708        let mut tar_bytes = Vec::new();
1709        {
1710            let enc = flate2::write::GzEncoder::new(&mut tar_bytes, flate2::Compression::default());
1711            let mut builder = Builder::new(enc);
1712            let mut header = Header::new_gnu();
1713            header.set_entry_type(tar::EntryType::Symlink);
1714            header.set_size(0);
1715            header.set_mode(0o644);
1716            header.set_cksum();
1717            builder.append_link(&mut header, "bad-link", "/tmp/escape").expect("append symlink");
1718            let enc = builder.into_inner().expect("encoder");
1719            enc.finish().expect("finish");
1720        }
1721        assert!(unpack_status(tar_bytes).is_err());
1722    }
1723
1724    #[test]
1725    fn cache_unpack_accepts_regular_files_and_directories() {
1726        let mut tar_bytes = Vec::new();
1727        {
1728            let enc = flate2::write::GzEncoder::new(&mut tar_bytes, flate2::Compression::default());
1729            let mut builder = Builder::new(enc);
1730
1731            let mut dir_header = Header::new_gnu();
1732            dir_header.set_entry_type(tar::EntryType::Directory);
1733            dir_header.set_size(0);
1734            dir_header.set_mode(0o755);
1735            dir_header.set_cksum();
1736            builder.append_data(&mut dir_header, "node", std::io::empty()).expect("dir");
1737
1738            let mut file_header = Header::new_gnu();
1739            let body = br#"{"node_fingerprint":"k"}"#;
1740            file_header.set_size(body.len() as u64);
1741            file_header.set_mode(0o644);
1742            file_header.set_cksum();
1743            builder.append_data(&mut file_header, "meta.json", &body[..]).expect("file");
1744
1745            let enc = builder.into_inner().expect("encoder");
1746            enc.finish().expect("finish");
1747        }
1748        let status = unpack_status(tar_bytes);
1749        assert!(status.is_ok());
1750    }
1751}