From 4bb46aad9a64e73009248a3d983168b429729bed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 17:43:26 +0000 Subject: [PATCH 1/7] A7/A8: move workflow catalog and commands to DSL data Co-authored-by: Brian Searls --- dsl/config/workflow_catalog.dag | 147 ++++++ dsl/config/workflow_commands.dag | 125 ++++++ gunbc-dag/src/bin/workflow.rs | 8 +- gunbc-dag/src/workflow/catalog.rs | 277 ++++++------ gunbc-dag/src/workflow/commands.rs | 169 +++++++ gunbc-dag/src/workflow/mod.rs | 4 +- gunbc-dag/src/workflow/spec_builders.rs | 4 +- gunbc-dag/src/workflow/unit_commands.rs | 425 ------------------ .../workflow_tool_capability_contracts.rs | 10 +- 9 files changed, 599 insertions(+), 570 deletions(-) create mode 100644 dsl/config/workflow_catalog.dag create mode 100644 dsl/config/workflow_commands.dag create mode 100644 gunbc-dag/src/workflow/commands.rs delete mode 100644 gunbc-dag/src/workflow/unit_commands.rs diff --git a/dsl/config/workflow_catalog.dag b/dsl/config/workflow_catalog.dag new file mode 100644 index 00000000000..d6d8ac17a11 --- /dev/null +++ b/dsl/config/workflow_catalog.dag @@ -0,0 +1,147 @@ +// config/workflow_catalog.dag -- Workflow variant and default claim catalog. +// +// Replaces hardcoded Rust tables in `gunbc-dag/src/workflow/catalog.rs`. + +module config.workflow_catalog + +type WorkflowVariant { + canonical_name: String + aliases: List + file: String + pipeline: String + mode: String? + namespace: String + is_tool: Bool +} + +type StageClaim { + claim_id: String + access_mode: String +} + +type StageDefaultClaims { + stage_name: String + claims: List +} + +data workflow_variants: List = [ + // Core planner workflows + { + canonical_name: "ci", + aliases: [], + file: "ci.dag", + pipeline: "ci", + mode: None, + namespace: "ci", + is_tool: false + }, + { + canonical_name: "test-all", + aliases: ["test_all"], + file: "test_all.dag", + pipeline: "test_all", + mode: None, + namespace: "test_all", + is_tool: false + }, + + // Tool workflow variants + { + canonical_name: "gist", + aliases: ["gist_snapshot", "gist-snapshot"], + file: "gist.dag", + pipeline: "gist", + mode: "gist", + namespace: "gist", + is_tool: true + }, + { + canonical_name: "gist-diff", + aliases: ["gist_diff"], + file: "gist.dag", + pipeline: "gist", + mode: "gist-diff", + namespace: "gist", + is_tool: true + }, + { + canonical_name: "gist-recent", + aliases: ["gist_recent"], + file: "gist.dag", + pipeline: "gist", + mode: "gist-recent", + namespace: "gist", + is_tool: true + }, + { + canonical_name: "bootstrap", + aliases: [], + file: "bootstrap.dag", + pipeline: "bootstrap", + mode: None, + namespace: "bootstrap", + is_tool: true + }, + { + canonical_name: "makegen", + aliases: [], + file: "makegen.dag", + pipeline: "makegen", + mode: None, + namespace: "makegen", + is_tool: true + }, + { + canonical_name: "pragma", + aliases: [], + file: "pragma.dag", + pipeline: "pragma", + mode: None, + namespace: "pragma", + is_tool: true + }, + { + canonical_name: "deps", + aliases: [], + file: "deps.dag", + pipeline: "deps", + mode: None, + namespace: "deps", + is_tool: true + }, + { + canonical_name: "build-all", + aliases: ["build_all"], + file: "build_all.dag", + pipeline: "build_all", + mode: None, + namespace: "build_all", + is_tool: true + } +] + +// Default claims for well-known stages whose resource usage is known +// structurally but not yet expressible in DSL stage bodies. +data default_stage_claims: List = [ + { + stage_name: "build_compile", + claims: [ + { claim_id: "file:target", access_mode: "Write" }, + { claim_id: "tool:cargo", access_mode: "Read" } + ] + }, + { + stage_name: "test_run", + claims: [ + { claim_id: "file:target", access_mode: "Read" }, + { claim_id: "tool:cargo", access_mode: "Read" } + ] + }, + { + stage_name: "clippy_run", + claims: [ + { claim_id: "file:target", access_mode: "Read" }, + { claim_id: "tool:cargo", access_mode: "Read" } + ] + } +] diff --git a/dsl/config/workflow_commands.dag b/dsl/config/workflow_commands.dag new file mode 100644 index 00000000000..e9c891d0462 --- /dev/null +++ b/dsl/config/workflow_commands.dag @@ -0,0 +1,125 @@ +// config/workflow_commands.dag -- Declarative workflow unit command catalog. +// +// Replaces hardcoded command tables from `gunbc-dag/src/workflow/unit_commands.rs`. + +module config.workflow_commands + +type UnitCommandDef { + node_id: String + label: String + program: String + args: List +} + +type WorkflowCommandSet { + workflow: String + aliases: List + commands: List +} + +data workflow_command_sets: List = [ + { + workflow: "ci", + aliases: [], + commands: [ + { node_id: "ci.lint_upsert", label: "pragma ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"] }, + { node_id: "ci.codegen", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "ci.bootstrap", label: "bootstrap ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-bootstrap"] }, + { node_id: "ci.pragma", label: "pragma", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"] }, + { node_id: "ci.testgen", label: "testgen", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-testgen"] }, + { node_id: "ci.build_compile", label: "build", program: "cargo", args: ["build", "--workspace"] }, + { node_id: "ci.test_run", label: "test", program: "cargo", args: ["test", "--workspace"] }, + { node_id: "ci.clippy_run", label: "clippy", program: "cargo", args: ["clippy", "--all-targets", "--", "-D", "warnings"] }, + { node_id: "ci.guardrails", label: "guardrails", program: "cargo", args: ["test", "--workspace", "--lib", "--", "guardrail"] }, + { node_id: "ci.verify", label: "verify", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] } + ] + }, + { + workflow: "test-all", + aliases: ["test_all"], + commands: [ + { node_id: "test_all.lint_upsert", label: "pragma ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"] }, + { node_id: "test_all.codegen", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "test_all.testgen", label: "testgen", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-testgen"] }, + { node_id: "test_all.build_compile", label: "build", program: "cargo", args: ["build", "--workspace"] }, + { node_id: "test_all.verify_fix", label: "verify-fix", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "test_all.cargo_test_xl", label: "test-xl", program: "cargo", args: ["test", "--workspace", "--", "--include-ignored"] } + ] + }, + { + workflow: "gist", + aliases: ["gist-snapshot", "gist_snapshot"], + commands: [ + { node_id: "gist.compilation_ensure", label: "compilation ensure", program: "cargo", args: ["build", "--workspace", "--bins"] }, + { node_id: "gist.codegen_ensure", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "gist.gist_create", label: "gist snapshot upload", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-gist"] } + ] + }, + { + workflow: "gist-diff", + aliases: ["gist_diff"], + commands: [ + { node_id: "gist.compilation_ensure", label: "compilation ensure", program: "cargo", args: ["build", "--workspace", "--bins"] }, + { node_id: "gist.codegen_ensure", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "gist.gist_create", label: "gist diff upload", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-gist-diff"] } + ] + }, + { + workflow: "gist-recent", + aliases: ["gist_recent"], + commands: [ + { node_id: "gist.compilation_ensure", label: "compilation ensure", program: "cargo", args: ["build", "--workspace", "--bins"] }, + { node_id: "gist.codegen_ensure", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "gist.gist_create", label: "gist recent upload", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-gist-recent"] } + ] + }, + { + workflow: "bootstrap", + aliases: [], + commands: [ + { node_id: "bootstrap.compilation_ensure", label: "compilation ensure", program: "cargo", args: ["build", "--workspace", "--bins"] }, + { node_id: "bootstrap.codegen_ensure", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "bootstrap.upsert_makefile", label: "bootstrap ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-bootstrap"] }, + { node_id: "bootstrap.upsert_gitignore", label: "bootstrap ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-bootstrap"] } + ] + }, + { + workflow: "makegen", + aliases: [], + commands: [ + { node_id: "makegen.compilation_ensure", label: "compilation ensure", program: "cargo", args: ["build", "--workspace", "--bins"] }, + { node_id: "makegen.codegen_ensure", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "makegen.upsert_makefile", label: "makegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-makegen"] } + ] + }, + { + workflow: "pragma", + aliases: [], + commands: [ + { node_id: "pragma.compilation_ensure", label: "compilation ensure", program: "cargo", args: ["build", "--workspace", "--bins"] }, + { node_id: "pragma.codegen_ensure", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "pragma.upsert_clippy", label: "pragma ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"] }, + { node_id: "pragma.upsert_allowlist", label: "pragma ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"] }, + { node_id: "pragma.upsert_policy", label: "pragma ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"] } + ] + }, + { + workflow: "deps", + aliases: [], + commands: [ + { node_id: "deps.compilation_ensure", label: "compilation ensure", program: "cargo", args: ["build", "--workspace", "--bins"] }, + { node_id: "deps.codegen_ensure", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "deps.execute_installs", label: "deps", program: "cargo", args: ["run", "-p", "gunbc-deps", "--bin", "gunbc-deps", "--"] }, + { node_id: "deps.write_deps_toml", label: "deps", program: "cargo", args: ["run", "-p", "gunbc-deps", "--bin", "gunbc-deps", "--"] } + ] + }, + { + workflow: "build-all", + aliases: ["build_all"], + commands: [ + { node_id: "build_all.compilation_ensure", label: "compilation ensure", program: "cargo", args: ["build", "--workspace", "--bins"] }, + { node_id: "build_all.codegen_ensure", label: "codegen ensure", program: "cargo", args: ["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"] }, + { node_id: "build_all.build", label: "build-all", program: "cargo", args: ["build", "--workspace"] } + ] + } +] diff --git a/gunbc-dag/src/bin/workflow.rs b/gunbc-dag/src/bin/workflow.rs index b409e3aa565..30f008d029c 100644 --- a/gunbc-dag/src/bin/workflow.rs +++ b/gunbc-dag/src/bin/workflow.rs @@ -56,7 +56,7 @@ fn main() { "ci" => ci_workflow_spec(), "test-all" | "test_all" => test_all_workflow_spec(), name => tool_workflow_spec(name).map_err(|_| { - let mut all_names = vec!["ci", "test-all"]; + let mut all_names = vec!["ci".to_string(), "test-all".to_string()]; all_names.extend(all_tool_workflow_names()); format!( "unknown workflow '{}': expected one of {}", @@ -478,7 +478,7 @@ fn format_miss_reason(reason: &MissReason) -> String { } fn print_help() { - let mut all_names = vec!["ci", "test-all"]; + let mut all_names = vec!["ci".to_string(), "test-all".to_string()]; all_names.extend(all_tool_workflow_names()); println!("gunbc-workflow - workflow planner and executor"); println!(); @@ -706,10 +706,10 @@ mod tests { let args = parse_args(vec![ "gunbc-workflow".to_string(), "--plan".to_string(), - name.to_string(), + name.clone(), ]) .unwrap_or_else(|_| panic!("parse should succeed for {name}")); - assert_eq!(args.workflow, *name); + assert_eq!(args.workflow, name); } } } diff --git a/gunbc-dag/src/workflow/catalog.rs b/gunbc-dag/src/workflow/catalog.rs index 092e86e8a57..bae141f992f 100644 --- a/gunbc-dag/src/workflow/catalog.rs +++ b/gunbc-dag/src/workflow/catalog.rs @@ -2,12 +2,15 @@ use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use daglang_driver::{compile_from_context, DriverContext}; use daglang_syntax::{ ast::{Expr, Item, Literal, Stmt}, parser, }; -use gunbc_ir::{Dag, Edge, Node, Port}; +use gunbc_ir::{resource::AccessMode, Dag, Edge, Node, Port}; +use serde::Deserialize; use super::capabilities::{ CODEGEN_ENSURE_UNIT, CODEGEN_PROCESS_ID, COMPILATION_ENSURE_UNIT, COMPILATION_PROCESS_ID, @@ -20,111 +23,36 @@ use super::schema::{ WorkflowUnit, }; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Deserialize)] pub(super) struct WorkflowVariantDef { - pub canonical_name: &'static str, - pub aliases: &'static [&'static str], - pub file: &'static str, - pub pipeline: &'static str, - pub mode: Option<&'static str>, - pub namespace: &'static str, + pub canonical_name: String, + pub aliases: Vec, + pub file: String, + pub pipeline: String, + pub mode: Option, + pub namespace: String, pub is_tool: bool, } -const WORKFLOW_VARIANTS: &[WorkflowVariantDef] = &[ - // Core planner workflows - WorkflowVariantDef { - canonical_name: "ci", - aliases: &[], - file: "ci.dag", - pipeline: "ci", - mode: None, - namespace: "ci", - is_tool: false, - }, - WorkflowVariantDef { - canonical_name: "test-all", - aliases: &["test_all"], - file: "test_all.dag", - pipeline: "test_all", - mode: None, - namespace: "test_all", - is_tool: false, - }, - // Tool workflow variants - WorkflowVariantDef { - canonical_name: "gist", - aliases: &["gist_snapshot", "gist-snapshot"], - file: "gist.dag", - pipeline: "gist", - mode: Some("gist"), - namespace: "gist", - is_tool: true, - }, - WorkflowVariantDef { - canonical_name: "gist-diff", - aliases: &["gist_diff"], - file: "gist.dag", - pipeline: "gist", - mode: Some("gist-diff"), - namespace: "gist", - is_tool: true, - }, - WorkflowVariantDef { - canonical_name: "gist-recent", - aliases: &["gist_recent"], - file: "gist.dag", - pipeline: "gist", - mode: Some("gist-recent"), - namespace: "gist", - is_tool: true, - }, - WorkflowVariantDef { - canonical_name: "bootstrap", - aliases: &[], - file: "bootstrap.dag", - pipeline: "bootstrap", - mode: None, - namespace: "bootstrap", - is_tool: true, - }, - WorkflowVariantDef { - canonical_name: "makegen", - aliases: &[], - file: "makegen.dag", - pipeline: "makegen", - mode: None, - namespace: "makegen", - is_tool: true, - }, - WorkflowVariantDef { - canonical_name: "pragma", - aliases: &[], - file: "pragma.dag", - pipeline: "pragma", - mode: None, - namespace: "pragma", - is_tool: true, - }, - WorkflowVariantDef { - canonical_name: "deps", - aliases: &[], - file: "deps.dag", - pipeline: "deps", - mode: None, - namespace: "deps", - is_tool: true, - }, - WorkflowVariantDef { - canonical_name: "build-all", - aliases: &["build_all"], - file: "build_all.dag", - pipeline: "build_all", - mode: None, - namespace: "build_all", - is_tool: true, - }, -]; +#[derive(Debug, Clone, Deserialize)] +struct StageClaimDef { + claim_id: String, + access_mode: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct StageDefaultClaimsDef { + stage_name: String, + claims: Vec, +} + +#[derive(Debug, Clone)] +struct WorkflowCatalogData { + workflow_variants: Vec, + default_stage_claims: HashMap>, +} + +static WORKFLOW_CATALOG_DATA: OnceLock> = OnceLock::new(); #[derive(Debug, Clone)] struct StageTemplate { @@ -140,25 +68,113 @@ struct WorkflowTemplate { stages: Vec, } -pub(super) fn all_tool_workflow_names() -> Vec<&'static str> { - WORKFLOW_VARIANTS +#[allow(clippy::disallowed_methods)] +fn workflow_catalog_data() -> Result<&'static WorkflowCatalogData, String> { + WORKFLOW_CATALOG_DATA + .get_or_init(load_workflow_catalog_data) + .as_ref() + .map_err(|error| error.clone()) +} + +#[allow(clippy::disallowed_methods)] +fn load_workflow_catalog_data() -> Result { + let dsl_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dsl"); + let dag_file = dsl_root.join("config/workflow_catalog.dag"); + let context = DriverContext { + roots: vec![dsl_root], + target_file: Some(dag_file.clone()), + }; + let output = compile_from_context(&context) + .map_err(|error| format!("failed to compile {}: {error}", dag_file.display()))?; + + let variants_value = output + .data_values + .get("workflow_variants") + .cloned() + .ok_or_else(|| "missing data value 'workflow_variants'".to_string())?; + let workflow_variants: Vec = serde_json::from_value(variants_value) + .map_err(|error| format!("invalid workflow_variants data: {error}"))?; + if workflow_variants.is_empty() { + return Err("workflow catalog is empty".to_string()); + } + + let defaults_value = output + .data_values + .get("default_stage_claims") + .cloned() + .ok_or_else(|| "missing data value 'default_stage_claims'".to_string())?; + let defaults: Vec = serde_json::from_value(defaults_value) + .map_err(|error| format!("invalid default_stage_claims data: {error}"))?; + + let mut default_stage_claims = HashMap::new(); + for stage in defaults { + if default_stage_claims.contains_key(&stage.stage_name) { + return Err(format!( + "duplicate default_stage_claims entry for stage '{}'", + stage.stage_name + )); + } + let claims = stage + .claims + .into_iter() + .map(|claim| { + Ok(UnitClaim::new( + claim.claim_id, + parse_access_mode(&claim.access_mode)?, + )) + }) + .collect::, String>>()?; + default_stage_claims.insert(stage.stage_name, claims); + } + + Ok(WorkflowCatalogData { + workflow_variants, + default_stage_claims, + }) +} + +fn parse_access_mode(mode: &str) -> Result { + match mode.to_ascii_lowercase().as_str() { + "read" => Ok(AccessMode::Read), + "write" => Ok(AccessMode::Write), + "exclusive" => Ok(AccessMode::Exclusive), + other => Err(format!( + "unknown access mode '{other}' in workflow catalog; expected Read/Write/Exclusive" + )), + } +} + +pub(super) fn all_tool_workflow_names() -> Vec { + workflow_catalog_data() + .unwrap_or_else(|error| panic!("failed to load workflow catalog DSL data: {error}")) + .workflow_variants .iter() .filter(|variant| variant.is_tool) - .map(|variant| variant.canonical_name) + .map(|variant| variant.canonical_name.clone()) .collect() } -pub(super) fn all_known_workflow_names() -> Vec<&'static str> { - WORKFLOW_VARIANTS +pub(super) fn all_known_workflow_names() -> Vec { + workflow_catalog_data() + .unwrap_or_else(|error| panic!("failed to load workflow catalog DSL data: {error}")) + .workflow_variants .iter() - .map(|variant| variant.canonical_name) + .map(|variant| variant.canonical_name.clone()) .collect() } -pub(super) fn resolve_workflow_variant(name: &str) -> Option<&'static WorkflowVariantDef> { - WORKFLOW_VARIANTS +pub(super) fn resolve_workflow_variant(name: &str) -> Option { + let normalized = name.replace('_', "-"); + workflow_catalog_data() + .ok()? + .workflow_variants .iter() - .find(|variant| variant.canonical_name == name || variant.aliases.contains(&name)) + .find(|variant| { + variant.canonical_name == name + || variant.canonical_name == normalized + || variant.aliases.iter().any(|alias| alias == name || alias == &normalized) + }) + .cloned() } pub(super) fn build_workflow_spec( @@ -175,7 +191,7 @@ pub(super) fn build_workflow_spec( let templates = load_workflow_templates()?; let template = templates - .get(variant.file) + .get(variant.file.as_str()) .ok_or_else(|| format!("missing workflow template for file '{}'", variant.file))?; if template.pipeline_name != variant.pipeline { @@ -188,7 +204,7 @@ pub(super) fn build_workflow_spec( let active_stages: Vec<&StageTemplate> = template .stages .iter() - .filter(|stage| stage_is_enabled(stage, variant.mode)) + .filter(|stage| stage_is_enabled(stage, variant.mode.as_deref())) .collect(); if active_stages.is_empty() { @@ -257,7 +273,7 @@ pub(super) fn build_workflow_spec( } } - Ok(WorkflowSpec::new(variant.canonical_name, dag, 1)) + Ok(WorkflowSpec::new(variant.canonical_name.as_str(), dag, 1)) } pub(super) fn build_process_unit_registry() -> Result { @@ -267,9 +283,9 @@ pub(super) fn build_process_unit_registry() -> Result> = None; let mut codegen_claims: Option> = None; - for variant in WORKFLOW_VARIANTS { + for variant in &workflow_catalog_data()?.workflow_variants { let template = templates - .get(variant.file) + .get(variant.file.as_str()) .ok_or_else(|| format!("missing workflow template for file '{}'", variant.file))?; if template.pipeline_name != variant.pipeline { @@ -282,7 +298,7 @@ pub(super) fn build_process_unit_registry() -> Result Proc "codegen_ensure" => codegen_ref(), _ => { let node_id = format!("{}.{}", variant.namespace, stage_name); - ProcessUnitRef::new(variant.namespace, node_id) + ProcessUnitRef::new(variant.namespace.as_str(), node_id) } } } @@ -380,15 +396,15 @@ fn stage_is_enabled(stage: &StageTemplate, mode: Option<&str>) -> bool { } #[allow(clippy::disallowed_methods)] -fn load_workflow_templates() -> Result, String> { +fn load_workflow_templates() -> Result, String> { let mut templates = HashMap::new(); - for variant in WORKFLOW_VARIANTS { - if templates.contains_key(variant.file) { + for variant in &workflow_catalog_data()?.workflow_variants { + if templates.contains_key(&variant.file) { continue; } templates.insert( - variant.file, - parse_workflow_template(variant.file, variant.pipeline)?, + variant.file.clone(), + parse_workflow_template(&variant.file, &variant.pipeline)?, ); } Ok(templates) @@ -502,15 +518,12 @@ fn parse_stage_claims(_stmts: &[Stmt]) -> Vec { /// Default claims for well-known stages whose resource usage is known /// structurally but not yet expressible in DSL stage bodies. fn default_stage_claims(stage_name: &str) -> Vec { - match stage_name { - // cargo build writes to target/ - "build_compile" => vec![UnitClaim::write("file:target"), UnitClaim::read("tool:cargo")], - // cargo test reads target/ and test artifacts - "test_run" => vec![UnitClaim::read("file:target"), UnitClaim::read("tool:cargo")], - // clippy reads source + target - "clippy_run" => vec![UnitClaim::read("file:target"), UnitClaim::read("tool:cargo")], - _ => vec![], - } + workflow_catalog_data() + .unwrap_or_else(|error| panic!("failed to load workflow catalog DSL data: {error}")) + .default_stage_claims + .get(stage_name) + .cloned() + .unwrap_or_default() } fn workflow_file_path(file: &str) -> PathBuf { diff --git a/gunbc-dag/src/workflow/commands.rs b/gunbc-dag/src/workflow/commands.rs new file mode 100644 index 00000000000..518a03eee7e --- /dev/null +++ b/gunbc-dag/src/workflow/commands.rs @@ -0,0 +1,169 @@ +//! DSL-backed process-unit-to-command mappings for executable workflows. +//! +//! Report and aggregate nodes intentionally have no command and are treated +//! as no-ops by the executor. + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::OnceLock; + +use daglang_driver::{compile_from_context, DriverContext}; +use gunbc_ir::NodeId; +use serde::Deserialize; + +use super::executor::UnitCommand; + +#[derive(Debug, Clone, Deserialize)] +struct UnitCommandDef { + node_id: String, + label: String, + program: String, + args: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct WorkflowCommandSetDef { + workflow: String, + aliases: Vec, + commands: Vec, +} + +#[derive(Debug, Clone)] +struct WorkflowCommandCatalog { + by_workflow: HashMap>, +} + +static COMMAND_CATALOG: OnceLock> = OnceLock::new(); + +fn command_catalog() -> Result<&'static WorkflowCommandCatalog, String> { + COMMAND_CATALOG + .get_or_init(load_command_catalog) + .as_ref() + .map_err(|error| error.clone()) +} + +#[allow(clippy::disallowed_methods)] +fn load_command_catalog() -> Result { + let dsl_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dsl"); + let dag_file = dsl_root.join("config/workflow_commands.dag"); + let context = DriverContext { + roots: vec![dsl_root], + target_file: Some(dag_file.clone()), + }; + let output = compile_from_context(&context) + .map_err(|error| format!("failed to compile {}: {error}", dag_file.display()))?; + + let sets_value = output + .data_values + .get("workflow_command_sets") + .cloned() + .ok_or_else(|| "missing data value 'workflow_command_sets'".to_string())?; + let sets: Vec = serde_json::from_value(sets_value) + .map_err(|error| format!("invalid workflow_command_sets data: {error}"))?; + + let mut by_workflow: HashMap> = HashMap::new(); + for set in sets { + let mut commands: BTreeMap = BTreeMap::new(); + for command in set.commands { + let node_id = NodeId::from(command.node_id.clone()); + let unit_command = UnitCommand::new(command.label, command.program, command.args); + if commands.insert(node_id.clone(), unit_command).is_some() { + return Err(format!( + "duplicate command declaration for workflow '{}' node '{}'", + set.workflow, node_id.0 + )); + } + } + + let mut names = vec![set.workflow.clone(), set.workflow.replace('_', "-")]; + names.extend(set.aliases); + + for alias in names { + let normalized = alias.replace('_', "-"); + if by_workflow.contains_key(&normalized) { + return Err(format!( + "duplicate workflow command set mapping for alias '{}'", + normalized + )); + } + by_workflow.insert(normalized, commands.clone()); + } + } + + Ok(WorkflowCommandCatalog { by_workflow }) +} + +/// Build command map for CI workflow units. +pub fn ci_unit_commands() -> BTreeMap { + workflow_unit_commands("ci").expect("workflow commands should define 'ci'") +} + +/// Build command map for test-all workflow units. +pub fn test_all_unit_commands() -> BTreeMap { + workflow_unit_commands("test-all").expect("workflow commands should define 'test-all'") +} + +/// Build command map for a supported workflow name. +pub fn workflow_unit_commands( + workflow_name: &str, +) -> Result, String> { + let normalized = workflow_name.replace('_', "-"); + let commands = command_catalog()? + .by_workflow + .get(&normalized) + .cloned() + .ok_or_else(|| { + format!( + "workflow '{}' does not support execution mode; use --plan", + normalized + ) + })?; + Ok(commands) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ci_commands_cover_all_non_report_units() { + let commands = ci_unit_commands(); + assert_eq!(commands.len(), 10); + assert!(commands.contains_key(&NodeId::from("ci.codegen"))); + assert!(commands.contains_key(&NodeId::from("ci.build_compile"))); + assert!(commands.contains_key(&NodeId::from("ci.test_run"))); + assert!(commands.contains_key(&NodeId::from("ci.clippy_run"))); + assert!(!commands.contains_key(&NodeId::from("ci.report"))); + } + + #[test] + fn test_all_commands_cover_all_non_report_units() { + let commands = test_all_unit_commands(); + assert_eq!(commands.len(), 6); + assert!(commands.contains_key(&NodeId::from("test_all.codegen"))); + assert!(commands.contains_key(&NodeId::from("test_all.build_compile"))); + assert!(commands.contains_key(&NodeId::from("test_all.cargo_test_xl"))); + assert!(!commands.contains_key(&NodeId::from("test_all.report"))); + } + + #[test] + fn ci_verify_uses_codegen_dag_binary() { + let ci_commands = ci_unit_commands(); + let verify = ci_commands + .get(&NodeId::from("ci.verify")) + .expect("ci.verify command"); + assert_eq!(verify.program, "cargo"); + assert!( + verify.args.contains(&"gunbc-codegen-dag".to_string()), + "ci.verify must use codegen DAG wrapper binary for --mode support" + ); + } + + #[test] + fn workflow_unit_commands_supports_gist_aliases() { + let gist = workflow_unit_commands("gist").expect("gist"); + let snapshot = workflow_unit_commands("gist-snapshot").expect("snapshot"); + assert_eq!(gist.len(), snapshot.len()); + assert!(gist.contains_key(&NodeId::from("gist.gist_create"))); + } +} diff --git a/gunbc-dag/src/workflow/mod.rs b/gunbc-dag/src/workflow/mod.rs index 8386165b928..90908901cc8 100644 --- a/gunbc-dag/src/workflow/mod.rs +++ b/gunbc-dag/src/workflow/mod.rs @@ -5,6 +5,7 @@ mod catalog; pub mod admission; pub mod capabilities; pub mod coordination; +pub mod commands; pub mod errors; pub mod executor; pub mod global_plan; @@ -16,7 +17,6 @@ pub mod proof; pub mod schema; pub mod slo; pub mod spec_builders; -pub mod unit_commands; pub use admission::{ validate_conflicting_claims, validate_effectful_claim_declarations, validate_required_claims, @@ -63,4 +63,4 @@ pub use spec_builders::{ gist_diff_workflow_spec, gist_recent_workflow_spec, gist_workflow_spec, makegen_workflow_spec, pragma_workflow_spec, test_all_workflow_spec, tool_workflow_spec, }; -pub use unit_commands::{ci_unit_commands, test_all_unit_commands, workflow_unit_commands}; +pub use commands::{ci_unit_commands, test_all_unit_commands, workflow_unit_commands}; diff --git a/gunbc-dag/src/workflow/spec_builders.rs b/gunbc-dag/src/workflow/spec_builders.rs index c0e1d899091..43dc64f1d42 100644 --- a/gunbc-dag/src/workflow/spec_builders.rs +++ b/gunbc-dag/src/workflow/spec_builders.rs @@ -10,7 +10,7 @@ pub fn workflow_spec(name: &str) -> Result { } /// Return canonical tool workflow names. -pub fn all_tool_workflow_names() -> Vec<&'static str> { +pub fn all_tool_workflow_names() -> Vec { catalog::all_tool_workflow_names() } @@ -68,7 +68,7 @@ mod tests { #[test] fn all_tool_workflows_build_successfully() { for name in all_tool_workflow_names() { - tool_workflow_spec(name) + tool_workflow_spec(&name) .unwrap_or_else(|error| panic!("tool workflow '{name}' failed to build: {error}")); } } diff --git a/gunbc-dag/src/workflow/unit_commands.rs b/gunbc-dag/src/workflow/unit_commands.rs deleted file mode 100644 index ce43fd86e62..00000000000 --- a/gunbc-dag/src/workflow/unit_commands.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! Process-unit-to-command mappings for executable workflows (WF6+). -//! -//! Each workflow unit maps to a shell command that implements its behavior. -//! Report and aggregate nodes have no command (executed as no-ops by the executor). - -use std::collections::BTreeMap; - -use gunbc_ir::NodeId; - -use super::executor::UnitCommand; - -/// Build command map for CI workflow units. -/// -/// Each CI unit maps to the shell command that replaces the old per-node DAG -/// execution. Commands inherit stdio so output is visible in real-time. -pub fn ci_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - - commands.insert( - NodeId::from("ci.lint_upsert"), - UnitCommand::cargo( - "pragma ensure", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"], - ), - ); - commands.insert(NodeId::from("ci.codegen"), codegen_ensure_command()); - commands.insert( - NodeId::from("ci.bootstrap"), - UnitCommand::cargo( - "bootstrap ensure", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-bootstrap"], - ), - ); - commands.insert( - NodeId::from("ci.pragma"), - UnitCommand::cargo( - "pragma", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"], - ), - ); - commands.insert( - NodeId::from("ci.testgen"), - UnitCommand::cargo( - "testgen", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-testgen"], - ), - ); - commands.insert( - NodeId::from("ci.build_compile"), - UnitCommand::cargo("build", vec!["build", "--workspace"]), - ); - commands.insert( - NodeId::from("ci.test_run"), - UnitCommand::cargo("test", vec!["test", "--workspace"]), - ); - commands.insert( - NodeId::from("ci.clippy_run"), - UnitCommand::cargo( - "clippy", - vec!["clippy", "--all-targets", "--", "-D", "warnings"], - ), - ); - commands.insert( - NodeId::from("ci.guardrails"), - UnitCommand::cargo( - "guardrails", - vec!["test", "--workspace", "--lib", "--", "guardrail"], - ), - ); - // TODO(Phase 2): ci.verify needs --mode=verify support in the generated runner. - // The generated CLI runner for codegen-dag does not accept --mode; drift - // detection is temporarily unavailable. - commands.insert( - NodeId::from("ci.verify"), - UnitCommand::cargo( - "verify", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"], - ), - ); - // ci.report has no command — handled as no-op by the executor. - - commands -} - -/// Build command map for test-all workflow units. -/// -/// test-all shares many units with CI via the global planner's cross-workflow -/// dedup. Commands that match CI counterparts produce identical planner keys. -pub fn test_all_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - - commands.insert( - NodeId::from("test_all.lint_upsert"), - UnitCommand::cargo( - "pragma ensure", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"], - ), - ); - commands.insert(NodeId::from("test_all.codegen"), codegen_ensure_command()); - commands.insert( - NodeId::from("test_all.testgen"), - UnitCommand::cargo( - "testgen", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-testgen"], - ), - ); - commands.insert( - NodeId::from("test_all.build_compile"), - UnitCommand::cargo("build", vec!["build", "--workspace"]), - ); - commands.insert( - NodeId::from("test_all.verify_fix"), - UnitCommand::cargo( - "verify-fix", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"], - ), - ); - commands.insert( - NodeId::from("test_all.cargo_test_xl"), - UnitCommand::cargo( - "test-xl", - vec!["test", "--workspace", "--", "--include-ignored"], - ), - ); - // test_all.report has no command — handled as no-op by the executor. - - commands -} - -fn compilation_ensure_command() -> UnitCommand { - UnitCommand::cargo("compilation ensure", vec!["build", "--workspace", "--bins"]) -} - -fn codegen_ensure_command() -> UnitCommand { - UnitCommand::cargo( - "codegen ensure", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-codegen-dag"], - ) -} - -fn gist_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - commands.insert( - NodeId::from("gist.compilation_ensure"), - compilation_ensure_command(), - ); - commands.insert( - NodeId::from("gist.codegen_ensure"), - codegen_ensure_command(), - ); - commands.insert( - NodeId::from("gist.gist_create"), - UnitCommand::cargo( - "gist snapshot upload", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-gist"], - ), - ); - commands -} - -fn gist_diff_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - commands.insert( - NodeId::from("gist.compilation_ensure"), - compilation_ensure_command(), - ); - commands.insert( - NodeId::from("gist.codegen_ensure"), - codegen_ensure_command(), - ); - commands.insert( - NodeId::from("gist.gist_create"), - UnitCommand::cargo( - "gist diff upload", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-gist-diff"], - ), - ); - commands -} - -fn gist_recent_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - commands.insert( - NodeId::from("gist.compilation_ensure"), - compilation_ensure_command(), - ); - commands.insert( - NodeId::from("gist.codegen_ensure"), - codegen_ensure_command(), - ); - commands.insert( - NodeId::from("gist.gist_create"), - UnitCommand::cargo( - "gist recent upload", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-gist-recent"], - ), - ); - commands -} - -fn bootstrap_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - commands.insert( - NodeId::from("bootstrap.compilation_ensure"), - compilation_ensure_command(), - ); - commands.insert( - NodeId::from("bootstrap.codegen_ensure"), - codegen_ensure_command(), - ); - let bootstrap_cmd = UnitCommand::cargo( - "bootstrap ensure", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-bootstrap"], - ); - commands.insert( - NodeId::from("bootstrap.upsert_makefile"), - bootstrap_cmd.clone(), - ); - commands.insert(NodeId::from("bootstrap.upsert_gitignore"), bootstrap_cmd); - commands -} - -fn makegen_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - commands.insert( - NodeId::from("makegen.compilation_ensure"), - compilation_ensure_command(), - ); - commands.insert( - NodeId::from("makegen.codegen_ensure"), - codegen_ensure_command(), - ); - commands.insert( - NodeId::from("makegen.upsert_makefile"), - UnitCommand::cargo( - "makegen ensure", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-makegen"], - ), - ); - commands -} - -fn pragma_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - commands.insert( - NodeId::from("pragma.compilation_ensure"), - compilation_ensure_command(), - ); - commands.insert( - NodeId::from("pragma.codegen_ensure"), - codegen_ensure_command(), - ); - let pragma_cmd = UnitCommand::cargo( - "pragma ensure", - vec!["run", "-p", "gunbc-dag", "--bin", "gunbc-pragma"], - ); - commands.insert(NodeId::from("pragma.upsert_clippy"), pragma_cmd.clone()); - commands.insert(NodeId::from("pragma.upsert_allowlist"), pragma_cmd.clone()); - commands.insert(NodeId::from("pragma.upsert_policy"), pragma_cmd); - commands -} - -fn deps_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - commands.insert( - NodeId::from("deps.compilation_ensure"), - compilation_ensure_command(), - ); - commands.insert( - NodeId::from("deps.codegen_ensure"), - codegen_ensure_command(), - ); - let deps_cmd = UnitCommand::cargo( - "deps", - vec!["run", "-p", "gunbc-deps", "--bin", "gunbc-deps", "--"], - ); - commands.insert(NodeId::from("deps.execute_installs"), deps_cmd.clone()); - commands.insert(NodeId::from("deps.write_deps_toml"), deps_cmd); - commands -} - -fn build_all_unit_commands() -> BTreeMap { - let mut commands = BTreeMap::new(); - commands.insert( - NodeId::from("build_all.compilation_ensure"), - compilation_ensure_command(), - ); - commands.insert( - NodeId::from("build_all.codegen_ensure"), - codegen_ensure_command(), - ); - commands.insert( - NodeId::from("build_all.build"), - UnitCommand::cargo("build-all", vec!["build", "--workspace"]), - ); - commands -} - -/// Build command map for a supported workflow name. -pub fn workflow_unit_commands( - workflow_name: &str, -) -> Result, String> { - let normalized = workflow_name.replace('_', "-"); - let commands = match normalized.as_str() { - "ci" => ci_unit_commands(), - "test-all" => test_all_unit_commands(), - "gist" | "gist-snapshot" => gist_unit_commands(), - "gist-diff" => gist_diff_unit_commands(), - "gist-recent" => gist_recent_unit_commands(), - "bootstrap" => bootstrap_unit_commands(), - "makegen" => makegen_unit_commands(), - "pragma" => pragma_unit_commands(), - "deps" => deps_unit_commands(), - "build-all" => build_all_unit_commands(), - other => { - return Err(format!( - "workflow '{}' does not support execution mode; use --plan", - other - )) - } - }; - Ok(commands) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ci_commands_cover_all_non_report_units() { - let commands = ci_unit_commands(); - // 10 units have commands (report is a no-op). - assert_eq!(commands.len(), 10); - assert!(commands.contains_key(&NodeId::from("ci.codegen"))); - assert!(commands.contains_key(&NodeId::from("ci.build_compile"))); - assert!(commands.contains_key(&NodeId::from("ci.test_run"))); - assert!(commands.contains_key(&NodeId::from("ci.clippy_run"))); - // report is intentionally missing (no-op). - assert!(!commands.contains_key(&NodeId::from("ci.report"))); - } - - #[test] - fn test_all_commands_cover_all_non_report_units() { - let commands = test_all_unit_commands(); - // 6 units have commands (report is a no-op). - assert_eq!(commands.len(), 6); - assert!(commands.contains_key(&NodeId::from("test_all.codegen"))); - assert!(commands.contains_key(&NodeId::from("test_all.build_compile"))); - assert!(commands.contains_key(&NodeId::from("test_all.cargo_test_xl"))); - assert!(!commands.contains_key(&NodeId::from("test_all.report"))); - } - - #[test] - fn ci_build_command_targets_workspace() { - let commands = ci_unit_commands(); - let build = commands - .get(&NodeId::from("ci.build_compile")) - .expect("build command"); - assert_eq!(build.program, "cargo"); - assert!(build.args.contains(&"--workspace".to_string())); - } - - #[test] - fn workflow_unit_commands_supports_bootstrap() { - let commands = workflow_unit_commands("bootstrap").expect("bootstrap commands"); - assert!(commands.contains_key(&NodeId::from("bootstrap.compilation_ensure"))); - assert!(commands.contains_key(&NodeId::from("bootstrap.codegen_ensure"))); - assert!(commands.contains_key(&NodeId::from("bootstrap.upsert_makefile"))); - assert!(commands.contains_key(&NodeId::from("bootstrap.upsert_gitignore"))); - } - - #[test] - fn workflow_unit_commands_supports_pragma_terminal_branches() { - let commands = workflow_unit_commands("pragma").expect("pragma commands"); - assert!(commands.contains_key(&NodeId::from("pragma.upsert_clippy"))); - assert!(commands.contains_key(&NodeId::from("pragma.upsert_allowlist"))); - assert!(commands.contains_key(&NodeId::from("pragma.upsert_policy"))); - } - - #[test] - fn workflow_unit_commands_supports_deps_terminal_branches() { - let commands = workflow_unit_commands("deps").expect("deps commands"); - assert!(commands.contains_key(&NodeId::from("deps.execute_installs"))); - assert!(commands.contains_key(&NodeId::from("deps.write_deps_toml"))); - } - - #[test] - fn ci_verify_uses_codegen_dag_binary() { - let ci_commands = ci_unit_commands(); - let verify = ci_commands - .get(&NodeId::from("ci.verify")) - .expect("ci.verify command"); - assert_eq!(verify.program, "cargo"); - assert!( - verify.args.contains(&"gunbc-codegen-dag".to_string()), - "ci.verify must use codegen DAG wrapper binary for --mode support" - ); - - let ci_codegen = ci_commands - .get(&NodeId::from("ci.codegen")) - .expect("ci.codegen command"); - assert!( - ci_codegen.args.contains(&"gunbc-codegen-dag".to_string()), - "ci.codegen should use codegen DAG wrapper binary for ensure mode" - ); - let test_all_commands = test_all_unit_commands(); - let test_all_codegen = test_all_commands - .get(&NodeId::from("test_all.codegen")) - .expect("test_all.codegen command"); - assert!( - test_all_codegen - .args - .contains(&"gunbc-codegen-dag".to_string()), - "test_all.codegen should use codegen DAG wrapper binary" - ); - } - - #[test] - fn workflow_unit_commands_supports_gist_aliases() { - let gist = workflow_unit_commands("gist").expect("gist"); - let snapshot = workflow_unit_commands("gist-snapshot").expect("snapshot"); - assert_eq!(gist.len(), snapshot.len()); - assert!(gist.contains_key(&NodeId::from("gist.gist_create"))); - } -} diff --git a/gunbc-dag/tests/workflow_tool_capability_contracts.rs b/gunbc-dag/tests/workflow_tool_capability_contracts.rs index 99202c931ad..61f3accacbc 100644 --- a/gunbc-dag/tests/workflow_tool_capability_contracts.rs +++ b/gunbc-dag/tests/workflow_tool_capability_contracts.rs @@ -38,7 +38,7 @@ fn temp_root() -> std::path::PathBuf { #[test] fn all_tool_workflow_specs_build_without_error() { for name in all_tool_workflow_names() { - tool_workflow_spec(name) + tool_workflow_spec(&name) .unwrap_or_else(|error| panic!("tool workflow '{name}' failed to build: {error}")); } } @@ -46,11 +46,11 @@ fn all_tool_workflow_specs_build_without_error() { #[test] fn all_tool_workflow_specs_are_deterministic() { for name in all_tool_workflow_names() { - let a = tool_workflow_spec(name).expect(name); - let b = tool_workflow_spec(name).expect(name); + let a = tool_workflow_spec(&name).expect(&name); + let b = tool_workflow_spec(&name).expect(&name); assert_eq!( - a.dag.to_ascii(name), - b.dag.to_ascii(name), + a.dag.to_ascii(&name), + b.dag.to_ascii(&name), "workflow '{name}' is not deterministic" ); } From a31ac68d21e707d80ff6a447d961302fa6d217a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 17:52:26 +0000 Subject: [PATCH 2/7] Fix A7/A8 catalog borrow and command alias normalization Co-authored-by: Brian Searls --- gunbc-dag/src/workflow/catalog.rs | 2 +- gunbc-dag/src/workflow/commands.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/gunbc-dag/src/workflow/catalog.rs b/gunbc-dag/src/workflow/catalog.rs index bae141f992f..3de3119339c 100644 --- a/gunbc-dag/src/workflow/catalog.rs +++ b/gunbc-dag/src/workflow/catalog.rs @@ -231,7 +231,7 @@ pub(super) fn build_workflow_spec( WorkflowUnit::new(WorkflowOp::Report(ReportSpec::new(node_id))), ) } else { - let process_ref = process_ref_for_stage(variant, &stage.name); + let process_ref = process_ref_for_stage(&variant, &stage.name); let process_spec = registry.get(&process_ref).ok_or_else(|| { format!( "missing process unit registry entry for {}::{}", diff --git a/gunbc-dag/src/workflow/commands.rs b/gunbc-dag/src/workflow/commands.rs index 518a03eee7e..1ac6c3132f4 100644 --- a/gunbc-dag/src/workflow/commands.rs +++ b/gunbc-dag/src/workflow/commands.rs @@ -3,7 +3,7 @@ //! Report and aggregate nodes intentionally have no command and are treated //! as no-ops by the executor. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::PathBuf; use std::sync::OnceLock; @@ -75,11 +75,12 @@ fn load_command_catalog() -> Result { } } - let mut names = vec![set.workflow.clone(), set.workflow.replace('_', "-")]; + let mut names = vec![set.workflow]; names.extend(set.aliases); + let normalized_names: BTreeSet = + names.into_iter().map(|alias| alias.replace('_', "-")).collect(); - for alias in names { - let normalized = alias.replace('_', "-"); + for normalized in normalized_names { if by_workflow.contains_key(&normalized) { return Err(format!( "duplicate workflow command set mapping for alias '{}'", From 3a3995b654c0e2aae81019240495b0c459ce1e18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 17:56:38 +0000 Subject: [PATCH 3/7] A9: extract generic workflow engine to core/workflow Co-authored-by: Brian Searls --- Cargo.toml | 1 + core/workflow/Cargo.toml | 11 + .../workflow/src}/admission.rs | 4 +- .../workflow/src}/coordination.rs | 10 +- .../workflow => core/workflow/src}/errors.rs | 0 .../workflow/src}/executor.rs | 16 +- .../workflow/src}/global_plan.rs | 118 +++---- .../src/workflow => core/workflow/src}/key.rs | 0 core/workflow/src/lib.rs | 51 ++++ .../workflow => core/workflow/src}/planner.rs | 4 +- core/workflow/src/process_registry.rs | 226 ++++++++++++++ .../workflow/src}/projection.rs | 10 +- .../workflow => core/workflow/src}/proof.rs | 12 +- .../workflow => core/workflow/src}/schema.rs | 0 .../src/workflow => core/workflow/src}/slo.rs | 2 +- gunbc-dag/Cargo.toml | 1 + gunbc-dag/src/workflow/catalog.rs | 2 +- gunbc-dag/src/workflow/commands.rs | 3 +- gunbc-dag/src/workflow/mod.rs | 49 +-- gunbc-dag/src/workflow/process_registry.rs | 287 +----------------- gunbc-dag/src/workflow/spec_builders.rs | 2 +- 21 files changed, 397 insertions(+), 412 deletions(-) create mode 100644 core/workflow/Cargo.toml rename {gunbc-dag/src/workflow => core/workflow/src}/admission.rs (99%) rename {gunbc-dag/src/workflow => core/workflow/src}/coordination.rs (95%) rename {gunbc-dag/src/workflow => core/workflow/src}/errors.rs (100%) rename {gunbc-dag/src/workflow => core/workflow/src}/executor.rs (95%) rename {gunbc-dag/src/workflow => core/workflow/src}/global_plan.rs (57%) rename {gunbc-dag/src/workflow => core/workflow/src}/key.rs (100%) create mode 100644 core/workflow/src/lib.rs rename {gunbc-dag/src/workflow => core/workflow/src}/planner.rs (99%) create mode 100644 core/workflow/src/process_registry.rs rename {gunbc-dag/src/workflow => core/workflow/src}/projection.rs (92%) rename {gunbc-dag/src/workflow => core/workflow/src}/proof.rs (87%) rename {gunbc-dag/src/workflow => core/workflow/src}/schema.rs (100%) rename {gunbc-dag/src/workflow => core/workflow/src}/slo.rs (99%) diff --git a/Cargo.toml b/Cargo.toml index 19e0899f380..6c7f6d95508 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "core/exec", "core/cli", "core/codegen", + "core/workflow", "core/test", "core/testgen-registry", "core/testgen-registry-macros", diff --git a/core/workflow/Cargo.toml b/core/workflow/Cargo.toml new file mode 100644 index 00000000000..769acf4518f --- /dev/null +++ b/core/workflow/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "gunbc-workflow" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Generic workflow planner/executor engine" + +[dependencies] +gunbc-ir = { path = "../ir" } +gunbc-exec = { path = "../exec" } +serde = { workspace = true } diff --git a/gunbc-dag/src/workflow/admission.rs b/core/workflow/src/admission.rs similarity index 99% rename from gunbc-dag/src/workflow/admission.rs rename to core/workflow/src/admission.rs index 6a7b8637590..48947d4ac63 100644 --- a/gunbc-dag/src/workflow/admission.rs +++ b/core/workflow/src/admission.rs @@ -319,8 +319,8 @@ mod tests { use super::*; use gunbc_ir::{AccessMode, Edge, Node, Port}; - use crate::workflow::process_registry::{ProcessUnitRef, ProcessUnitSpec}; - use crate::workflow::schema::{ + use crate::process_registry::{ProcessUnitRef, ProcessUnitSpec}; + use crate::schema::{ required_input_contract, required_output_contract, WorkflowId, WorkflowSpec, }; diff --git a/gunbc-dag/src/workflow/coordination.rs b/core/workflow/src/coordination.rs similarity index 95% rename from gunbc-dag/src/workflow/coordination.rs rename to core/workflow/src/coordination.rs index d7f6c02403f..416753ec84d 100644 --- a/gunbc-dag/src/workflow/coordination.rs +++ b/core/workflow/src/coordination.rs @@ -89,7 +89,7 @@ mod tests { use super::*; use gunbc_ir::{Dag, Edge, Node, Port}; - use crate::workflow::schema::{ + use crate::schema::{ required_input_contract, required_output_contract, WorkflowId, WorkflowOp, WorkflowSpec, WorkflowUnit, }; @@ -102,7 +102,7 @@ mod tests { required_input_contract(), required_output_contract(), WorkflowUnit::new(WorkflowOp::Aggregate( - crate::workflow::schema::AggregateSpec::new("a"), + crate::schema::AggregateSpec::new("a"), )), )); dag.add_node(Node::opaque( @@ -110,7 +110,7 @@ mod tests { required_input_contract(), required_output_contract(), WorkflowUnit::new(WorkflowOp::Aggregate( - crate::workflow::schema::AggregateSpec::new("b"), + crate::schema::AggregateSpec::new("b"), )), )); dag.add_edge(Edge::control("wf.a", "commit", "wf.b", "after")); @@ -137,7 +137,7 @@ mod tests { inputs, required_output_contract(), WorkflowUnit::new(WorkflowOp::Aggregate( - crate::workflow::schema::AggregateSpec::new("only"), + crate::schema::AggregateSpec::new("only"), )), )); let spec = WorkflowSpec::new(WorkflowId::new("wf"), dag, 1); @@ -163,7 +163,7 @@ mod tests { inputs, required_output_contract(), WorkflowUnit::new(WorkflowOp::Aggregate( - crate::workflow::schema::AggregateSpec::new("only"), + crate::schema::AggregateSpec::new("only"), )), )); let spec = WorkflowSpec::new(WorkflowId::new("wf"), dag, 1); diff --git a/gunbc-dag/src/workflow/errors.rs b/core/workflow/src/errors.rs similarity index 100% rename from gunbc-dag/src/workflow/errors.rs rename to core/workflow/src/errors.rs diff --git a/gunbc-dag/src/workflow/executor.rs b/core/workflow/src/executor.rs similarity index 95% rename from gunbc-dag/src/workflow/executor.rs rename to core/workflow/src/executor.rs index bb28f133deb..8a4d7f0df89 100644 --- a/gunbc-dag/src/workflow/executor.rs +++ b/core/workflow/src/executor.rs @@ -260,10 +260,10 @@ fn emit_unit_status(node_id: &NodeId, status: UnitStatus<'_>) { #[cfg(test)] mod tests { use super::*; - use crate::workflow::coordination::CoordinationStatus; - use crate::workflow::key::{CanonicalKeyPayload, MaterializationKey, WorkIdentity}; - use crate::workflow::planner::WorkflowPlan; - use crate::workflow::process_registry::ProcessId; + use crate::coordination::CoordinationStatus; + use crate::key::{CanonicalKeyPayload, MaterializationKey, WorkIdentity}; + use crate::planner::WorkflowPlan; + use crate::process_registry::ProcessId; fn make_node_plan(name: &str, action: PlanAction) -> super::super::planner::NodePlan { let work_id = WorkIdentity::new(ProcessId::new("test"), NodeId::from(name)); @@ -289,7 +289,7 @@ mod tests { #[test] fn execute_nodes_without_commands_succeed_as_noop() { - let spec = crate::workflow::schema::WorkflowSpec::new("test", gunbc_ir::Dag::new(), 1); + let spec = crate::schema::WorkflowSpec::new("test", gunbc_ir::Dag::new(), 1); let plan = WorkflowPlan { nodes: vec![make_node_plan( "report", @@ -316,7 +316,7 @@ mod tests { #[test] fn dry_run_does_not_execute_commands() { - let spec = crate::workflow::schema::WorkflowSpec::new("test", gunbc_ir::Dag::new(), 1); + let spec = crate::schema::WorkflowSpec::new("test", gunbc_ir::Dag::new(), 1); let plan = WorkflowPlan { nodes: vec![make_node_plan( "build", @@ -345,7 +345,7 @@ mod tests { #[test] fn execution_summary_reports_correct_totals() { - let spec = crate::workflow::schema::WorkflowSpec::new("test", gunbc_ir::Dag::new(), 1); + let spec = crate::schema::WorkflowSpec::new("test", gunbc_ir::Dag::new(), 1); let plan = WorkflowPlan { nodes: vec![ make_node_plan( @@ -381,7 +381,7 @@ mod tests { #[test] fn pending_approval_exit_code_detected() { - let spec = crate::workflow::schema::WorkflowSpec::new("test", gunbc_ir::Dag::new(), 1); + let spec = crate::schema::WorkflowSpec::new("test", gunbc_ir::Dag::new(), 1); let plan = WorkflowPlan { nodes: vec![make_node_plan( "approve", diff --git a/gunbc-dag/src/workflow/global_plan.rs b/core/workflow/src/global_plan.rs similarity index 57% rename from gunbc-dag/src/workflow/global_plan.rs rename to core/workflow/src/global_plan.rs index 4f1336476b6..4f5b6c53236 100644 --- a/gunbc-dag/src/workflow/global_plan.rs +++ b/core/workflow/src/global_plan.rs @@ -89,38 +89,60 @@ fn merge_workflow_plan( } #[cfg(test)] -#[allow(clippy::disallowed_methods)] mod tests { - use std::time::{SystemTime, UNIX_EPOCH}; - use super::*; - use crate::workflow::process_registry::default_process_unit_registry; - use crate::workflow::spec_builders::{ - ci_workflow_spec, gist_workflow_spec, test_all_workflow_spec, - }; - - fn temp_root() -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "gunbc-global-plan-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )) - } + use crate::process_registry::{ProcessUnitRef, ProcessUnitSpec, UnitClaim}; + use crate::schema::{required_input_contract, required_output_contract, WorkflowOp, WorkflowUnit}; + use gunbc_ir::{Dag, Node}; #[test] fn cross_workflow_equivalent_units_are_deduped() { - let root = temp_root(); + let mut ci_dag: Dag = Dag::new(); + ci_dag.add_node(Node::opaque( + "ci.codegen", + required_input_contract(), + required_output_contract(), + WorkflowUnit::new(WorkflowOp::InvokeProcessUnit(ProcessUnitRef::new( + "ci", + "ci.codegen", + ))), + )); + let mut test_all_dag: Dag = Dag::new(); + test_all_dag.add_node(Node::opaque( + "test_all.codegen", + required_input_contract(), + required_output_contract(), + WorkflowUnit::new(WorkflowOp::InvokeProcessUnit(ProcessUnitRef::new( + "test_all", + "test_all.codegen", + ))), + )); + let specs = vec![ - ci_workflow_spec().expect("ci spec"), - test_all_workflow_spec().expect("test-all spec"), + WorkflowSpec::new("ci", ci_dag, 1), + WorkflowSpec::new("test-all", test_all_dag, 1), ]; - let registry = default_process_unit_registry(); - let global = - plan_global_workflows(&specs, ®istry, &PlannerInputsByWorkflow::new(), &root) - .expect("global plan"); + + let mut registry = ProcessUnitRegistry::new(); + registry.register(ProcessUnitSpec::new( + ProcessUnitRef::new("ci", "ci.codegen"), + 1, + vec![UnitClaim::read("tool:cargo")], + )); + registry.register(ProcessUnitSpec::new( + ProcessUnitRef::new("test_all", "test_all.codegen"), + 1, + vec![UnitClaim::read("tool:cargo")], + )); + + let workspace_root = std::path::Path::new("."); + let global = plan_global_workflows( + &specs, + ®istry, + &PlannerInputsByWorkflow::new(), + workspace_root, + ) + .expect("global plan"); let codegen = global .vertices @@ -136,51 +158,5 @@ mod tests { .node_refs .iter() .any(|reference| reference.workflow_id == WorkflowId::new("test-all"))); - let _ = std::fs::remove_dir_all(root); - } - - /// WF14/WF15: compilation and codegen capabilities shared between - /// gist-snapshot and CI workflows via global dedup. - #[test] - fn universal_capabilities_deduped_across_ci_and_gist() { - let root = temp_root(); - let specs = vec![ - ci_workflow_spec().expect("ci spec"), - gist_workflow_spec().expect("gist spec"), - ]; - let registry = default_process_unit_registry(); - let global = - plan_global_workflows(&specs, ®istry, &PlannerInputsByWorkflow::new(), &root) - .expect("global plan"); - - // compilation_ensure should appear as a shared vertex. - let compilation = global - .vertices - .iter() - .find(|vertex| vertex.work_id.unit_id == NodeId::from("compilation_ensure")) - .expect("expected canonical compilation_ensure vertex"); - assert!( - compilation - .node_refs - .iter() - .any(|r| r.workflow_id == WorkflowId::new("gist")), - "compilation_ensure should reference gist workflow" - ); - - // codegen_ensure should appear as a shared vertex. - let codegen_ensure = global - .vertices - .iter() - .find(|vertex| vertex.work_id.unit_id == NodeId::from("codegen_ensure")) - .expect("expected canonical codegen_ensure vertex"); - assert!( - codegen_ensure - .node_refs - .iter() - .any(|r| r.workflow_id == WorkflowId::new("gist")), - "codegen_ensure should reference gist workflow" - ); - - let _ = std::fs::remove_dir_all(root); } } diff --git a/gunbc-dag/src/workflow/key.rs b/core/workflow/src/key.rs similarity index 100% rename from gunbc-dag/src/workflow/key.rs rename to core/workflow/src/key.rs diff --git a/core/workflow/src/lib.rs b/core/workflow/src/lib.rs new file mode 100644 index 00000000000..96507b2b30b --- /dev/null +++ b/core/workflow/src/lib.rs @@ -0,0 +1,51 @@ +//! Generic workflow planner and executor engine. + +pub mod admission; +pub mod coordination; +pub mod errors; +pub mod executor; +pub mod global_plan; +pub mod key; +pub mod planner; +pub mod process_registry; +pub mod projection; +pub mod proof; +pub mod schema; +pub mod slo; + +pub use admission::{ + validate_conflicting_claims, validate_effectful_claim_declarations, validate_required_claims, + validate_workflow_admission, +}; +pub use coordination::{coordination_status, BlockedReason, CoordinationStatus}; +pub use errors::WorkflowAdmissionError; +pub use executor::{execute_workflow_plan, ExecutionSummary, UnitCommand, UnitResult}; +pub use global_plan::{ + plan_global_workflows, GlobalExecutionVertex, GlobalWorkflowPlan, PlannerInputsByWorkflow, + WorkflowNodeRef, +}; +pub use key::{ + CanonicalKeyPayload, MaterializationDigest, MaterializationKey, MissReason, WorkIdentity, +}; +pub use planner::{ + explain_plan, plan_workflow, plan_workflow_with_mode, CapabilityAction, CapabilityStatus, + DryRunMode, NodePlan, PlanAction, PlanExplain, PlannerInputs, WorkflowPlan, + WorkflowPlannerError, +}; +pub use process_registry::{ + claim_handle_type_id, ClaimId, ProcessId, ProcessUnitRef, ProcessUnitRegistry, ProcessUnitSpec, + UnitClaim, +}; +pub use projection::{ + project_execute_set, validate_projection_equivalence, ExecuteProjection, ProjectionDrift, +}; +pub use proof::{prove_non_redundancy, InvariantViolation}; +pub use schema::{ + has_required_unit_contract, required_input_contract, required_output_contract, AggregateSpec, + ReportSpec, WorkflowId, WorkflowOp, WorkflowSpec, WorkflowUnit, PORT_AFTER, PORT_COMMIT, + PORT_RESULT, TYPE_WORKFLOW_RESULT, +}; +pub use slo::{ + check_slo, default_slo_budgets, render_execution_report, top_slow_units, SloBudget, SloResult, + SlowUnit, +}; diff --git a/gunbc-dag/src/workflow/planner.rs b/core/workflow/src/planner.rs similarity index 99% rename from gunbc-dag/src/workflow/planner.rs rename to core/workflow/src/planner.rs index 905992938d2..a03724faad8 100644 --- a/gunbc-dag/src/workflow/planner.rs +++ b/core/workflow/src/planner.rs @@ -436,8 +436,8 @@ mod tests { use gunbc_ir::{Edge, Node, Port}; use super::*; - use crate::workflow::process_registry::{ProcessUnitRef, ProcessUnitSpec, UnitClaim}; - use crate::workflow::schema::{ + use crate::process_registry::{ProcessUnitRef, ProcessUnitSpec, UnitClaim}; + use crate::schema::{ required_input_contract, required_output_contract, WorkflowId, WorkflowSpec, WorkflowUnit, }; diff --git a/core/workflow/src/process_registry.rs b/core/workflow/src/process_registry.rs new file mode 100644 index 00000000000..cb561dfa548 --- /dev/null +++ b/core/workflow/src/process_registry.rs @@ -0,0 +1,226 @@ +//! Typed process-unit registry backing workflow planner units (WF1/WF2). + +use std::collections::BTreeMap; + +use gunbc_ir::{AccessMode, NodeId}; +use serde::{Deserialize, Serialize}; + +/// Canonical process identifier. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ProcessId(pub String); + +impl ProcessId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl From<&str> for ProcessId { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +/// Stable typed process-unit reference. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ProcessUnitRef { + pub process_id: ProcessId, + pub unit_id: NodeId, +} + +impl ProcessUnitRef { + pub fn new(process_id: impl Into, unit_id: impl Into) -> Self { + Self { + process_id: process_id.into(), + unit_id: unit_id.into(), + } + } +} + +/// Canonical claim identity. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ClaimId(pub String); + +impl ClaimId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_resource_name(&self) -> &str { + self.0.as_str() + } +} + +impl From<&str> for ClaimId { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for ClaimId { + fn from(value: String) -> Self { + Self::new(value) + } +} + +/// Declared claim for a workflow unit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnitClaim { + pub claim_id: ClaimId, + pub access_mode: AccessMode, +} + +impl UnitClaim { + pub fn new(claim_id: impl Into, access_mode: AccessMode) -> Self { + Self { + claim_id: claim_id.into(), + access_mode, + } + } + + pub fn read(claim_id: impl Into) -> Self { + Self::new(claim_id, AccessMode::Read) + } + + pub fn write(claim_id: impl Into) -> Self { + Self::new(claim_id, AccessMode::Write) + } +} + +/// Typed process-unit metadata required by workflow planner phases. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessUnitSpec { + pub reference: ProcessUnitRef, + pub op_version: u32, + pub required_claims: Vec, +} + +impl ProcessUnitSpec { + pub fn new( + reference: ProcessUnitRef, + op_version: u32, + required_claims: Vec, + ) -> Self { + Self { + reference, + op_version, + required_claims, + } + } + + /// Context-free work identity projection for cross-workflow dedup. + pub fn canonical_work_identity(&self) -> (ProcessId, NodeId) { + ( + ProcessId::new("process-unit"), + canonicalize_unit_id(&self.reference.unit_id), + ) + } +} + +/// Registry for all workflow process-unit references. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProcessUnitRegistry { + specs: BTreeMap, +} + +impl ProcessUnitRegistry { + pub fn new() -> Self { + Self { + specs: BTreeMap::new(), + } + } + + pub fn register(&mut self, spec: ProcessUnitSpec) { + self.specs.insert(spec.reference.clone(), spec); + } + + pub fn get(&self, reference: &ProcessUnitRef) -> Option<&ProcessUnitSpec> { + self.specs.get(reference) + } + + pub fn contains(&self, reference: &ProcessUnitRef) -> bool { + self.specs.contains_key(reference) + } + + pub fn iter(&self) -> impl Iterator { + self.specs.values() + } +} + +fn canonicalize_unit_id(unit_id: &NodeId) -> NodeId { + if let Some((_, suffix)) = unit_id.0.split_once('.') { + NodeId::from(suffix) + } else { + unit_id.clone() + } +} + +/// Canonical handle type auto-wiring policy for resource claims. +pub fn claim_handle_type_id(claim_id: &ClaimId) -> &'static str { + if claim_id.0.starts_with("file:") { + "FilesystemHandle" + } else if claim_id.0.starts_with("tool:") { + "ToolHandle" + } else if claim_id.0.starts_with("ledger:") { + "WorkflowLedgerHandle" + } else if claim_id.0.starts_with("network:") { + "NetworkHandle" + } else { + "ResourceHandle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_work_identity_is_context_free_across_workflows() { + let ci = ProcessUnitSpec::new( + ProcessUnitRef::new("ci", "ci.codegen"), + 1, + vec![UnitClaim::read("tool:cargo")], + ); + let test_all = ProcessUnitSpec::new( + ProcessUnitRef::new("test_all", "test_all.codegen"), + 1, + vec![UnitClaim::read("tool:cargo")], + ); + assert_eq!( + ci.canonical_work_identity(), + test_all.canonical_work_identity() + ); + } + + #[test] + fn claim_handle_type_policy_maps_common_prefixes() { + assert_eq!( + claim_handle_type_id(&ClaimId::new("file:workspace")), + "FilesystemHandle" + ); + assert_eq!( + claim_handle_type_id(&ClaimId::new("tool:cargo")), + "ToolHandle" + ); + assert_eq!( + claim_handle_type_id(&ClaimId::new("ledger:workflow")), + "WorkflowLedgerHandle" + ); + } + + #[test] + fn network_claim_maps_to_network_handle() { + assert_eq!( + claim_handle_type_id(&ClaimId::new("network:github_gist")), + "NetworkHandle" + ); + } + + #[test] + fn credential_claim_maps_to_resource_handle() { + assert_eq!( + claim_handle_type_id(&ClaimId::new("credential:github")), + "ResourceHandle" + ); + } +} diff --git a/gunbc-dag/src/workflow/projection.rs b/core/workflow/src/projection.rs similarity index 92% rename from gunbc-dag/src/workflow/projection.rs rename to core/workflow/src/projection.rs index c936992b09c..61fcb98a82f 100644 --- a/gunbc-dag/src/workflow/projection.rs +++ b/core/workflow/src/projection.rs @@ -78,12 +78,12 @@ pub fn validate_projection_equivalence( #[cfg(test)] mod tests { use super::*; - use crate::workflow::global_plan::{ + use crate::global_plan::{ GlobalExecutionVertex, GlobalWorkflowPlan, WorkflowNodeRef, }; - use crate::workflow::key::MaterializationDigest; - use crate::workflow::planner::PlanAction; - use crate::workflow::schema::WorkflowId; + use crate::key::MaterializationDigest; + use crate::planner::PlanAction; + use crate::schema::WorkflowId; use gunbc_ir::NodeId; fn sample_plan() -> GlobalWorkflowPlan { @@ -92,7 +92,7 @@ mod tests { work_id: WorkIdentity::new("process-unit", "codegen"), digest: MaterializationDigest("abc".to_string()), action: PlanAction::Execute { - miss_reason: crate::workflow::MissReason::NoPriorRun, + miss_reason: crate::MissReason::NoPriorRun, }, node_refs: vec![WorkflowNodeRef { workflow_id: WorkflowId::new("ci"), diff --git a/gunbc-dag/src/workflow/proof.rs b/core/workflow/src/proof.rs similarity index 87% rename from gunbc-dag/src/workflow/proof.rs rename to core/workflow/src/proof.rs index 18fdf47cf5c..55a0f89e754 100644 --- a/gunbc-dag/src/workflow/proof.rs +++ b/core/workflow/src/proof.rs @@ -49,10 +49,10 @@ pub fn prove_non_redundancy(plan: &GlobalWorkflowPlan) -> Result<(), Vec GlobalExecutionVertex { @@ -60,9 +60,9 @@ mod tests { work_id: WorkIdentity::new("process-unit", "codegen"), digest: MaterializationDigest("abc".to_string()), action: PlanAction::Execute { - miss_reason: crate::workflow::MissReason::NoPriorRun, + miss_reason: crate::MissReason::NoPriorRun, }, - node_refs: vec![crate::workflow::global_plan::WorkflowNodeRef { + node_refs: vec![crate::global_plan::WorkflowNodeRef { workflow_id: WorkflowId::new("ci"), node_id: NodeId::from("ci.codegen"), }], diff --git a/gunbc-dag/src/workflow/schema.rs b/core/workflow/src/schema.rs similarity index 100% rename from gunbc-dag/src/workflow/schema.rs rename to core/workflow/src/schema.rs diff --git a/gunbc-dag/src/workflow/slo.rs b/core/workflow/src/slo.rs similarity index 99% rename from gunbc-dag/src/workflow/slo.rs rename to core/workflow/src/slo.rs index 62c9c7bd9b5..434fd682a42 100644 --- a/gunbc-dag/src/workflow/slo.rs +++ b/core/workflow/src/slo.rs @@ -168,7 +168,7 @@ mod tests { use gunbc_ir::NodeId; use super::*; - use crate::workflow::key::MissReason; + use crate::key::MissReason; fn make_summary( workflow_id: &str, diff --git a/gunbc-dag/Cargo.toml b/gunbc-dag/Cargo.toml index 84a494ddb77..c19505b45ea 100644 --- a/gunbc-dag/Cargo.toml +++ b/gunbc-dag/Cargo.toml @@ -12,6 +12,7 @@ gunbc-ir = { path = "../core/ir" } gunbc-exec = { path = "../core/exec" } gunbc-codegen = { path = "../core/codegen" } gunbc-test = { path = "../core/test" } +gunbc-workflow = { path = "../core/workflow" } # DSL compilation daglang-derive = { path = "../core/daglang/daglang-derive" } diff --git a/gunbc-dag/src/workflow/catalog.rs b/gunbc-dag/src/workflow/catalog.rs index 3de3119339c..14032138d27 100644 --- a/gunbc-dag/src/workflow/catalog.rs +++ b/gunbc-dag/src/workflow/catalog.rs @@ -18,7 +18,7 @@ use super::capabilities::{ use super::process_registry::{ claim_handle_type_id, ProcessUnitRef, ProcessUnitRegistry, ProcessUnitSpec, UnitClaim, }; -use super::schema::{ +use gunbc_workflow::{ required_input_contract, required_output_contract, ReportSpec, WorkflowOp, WorkflowSpec, WorkflowUnit, }; diff --git a/gunbc-dag/src/workflow/commands.rs b/gunbc-dag/src/workflow/commands.rs index 1ac6c3132f4..7ebf16e9d0d 100644 --- a/gunbc-dag/src/workflow/commands.rs +++ b/gunbc-dag/src/workflow/commands.rs @@ -9,10 +9,9 @@ use std::sync::OnceLock; use daglang_driver::{compile_from_context, DriverContext}; use gunbc_ir::NodeId; +use gunbc_workflow::UnitCommand; use serde::Deserialize; -use super::executor::UnitCommand; - #[derive(Debug, Clone, Deserialize)] struct UnitCommandDef { node_id: String, diff --git a/gunbc-dag/src/workflow/mod.rs b/gunbc-dag/src/workflow/mod.rs index 90908901cc8..c050342cced 100644 --- a/gunbc-dag/src/workflow/mod.rs +++ b/gunbc-dag/src/workflow/mod.rs @@ -2,62 +2,37 @@ mod catalog; -pub mod admission; pub mod capabilities; -pub mod coordination; pub mod commands; -pub mod errors; -pub mod executor; -pub mod global_plan; -pub mod key; -pub mod planner; pub mod process_registry; -pub mod projection; -pub mod proof; -pub mod schema; -pub mod slo; pub mod spec_builders; -pub use admission::{ +pub use gunbc_workflow::{ validate_conflicting_claims, validate_effectful_claim_declarations, validate_required_claims, - validate_workflow_admission, -}; -pub use capabilities::{ - codegen_key, compilation_key, CodegenMissReason, CompilationMissReason, CompilationPhase, - CODEGEN_ENSURE_UNIT, CODEGEN_PROCESS_ID, COMPILATION_ENSURE_UNIT, COMPILATION_PROCESS_ID, -}; -pub use coordination::{coordination_status, BlockedReason, CoordinationStatus}; -pub use errors::WorkflowAdmissionError; -pub use executor::{execute_workflow_plan, ExecutionSummary, UnitCommand, UnitResult}; -pub use global_plan::{ + validate_workflow_admission, coordination_status, BlockedReason, CoordinationStatus, + WorkflowAdmissionError, execute_workflow_plan, ExecutionSummary, UnitCommand, UnitResult, plan_global_workflows, GlobalExecutionVertex, GlobalWorkflowPlan, PlannerInputsByWorkflow, WorkflowNodeRef, -}; -pub use key::{ CanonicalKeyPayload, MaterializationDigest, MaterializationKey, MissReason, WorkIdentity, -}; -pub use planner::{ explain_plan, plan_workflow, plan_workflow_with_mode, CapabilityAction, CapabilityStatus, DryRunMode, NodePlan, PlanAction, PlanExplain, PlannerInputs, WorkflowPlan, WorkflowPlannerError, -}; -pub use process_registry::{ - claim_handle_type_id, default_process_unit_registry, ClaimId, ProcessId, ProcessUnitRef, - ProcessUnitRegistry, ProcessUnitSpec, UnitClaim, -}; -pub use projection::{ project_execute_set, validate_projection_equivalence, ExecuteProjection, ProjectionDrift, -}; -pub use proof::{prove_non_redundancy, InvariantViolation}; -pub use schema::{ + prove_non_redundancy, InvariantViolation, has_required_unit_contract, required_input_contract, required_output_contract, AggregateSpec, ReportSpec, WorkflowId, WorkflowOp, WorkflowSpec, WorkflowUnit, PORT_AFTER, PORT_COMMIT, PORT_RESULT, TYPE_WORKFLOW_RESULT, -}; -pub use slo::{ check_slo, default_slo_budgets, render_execution_report, top_slow_units, SloBudget, SloResult, SlowUnit, }; +pub use capabilities::{ + codegen_key, compilation_key, CodegenMissReason, CompilationMissReason, CompilationPhase, + CODEGEN_ENSURE_UNIT, CODEGEN_PROCESS_ID, COMPILATION_ENSURE_UNIT, COMPILATION_PROCESS_ID, +}; +pub use process_registry::{ + claim_handle_type_id, default_process_unit_registry, ClaimId, ProcessId, ProcessUnitRef, + ProcessUnitRegistry, ProcessUnitSpec, UnitClaim, +}; pub use spec_builders::{ all_tool_workflow_names, bootstrap_workflow_spec, ci_workflow_spec, deps_workflow_spec, gist_diff_workflow_spec, gist_recent_workflow_spec, gist_workflow_spec, makegen_workflow_spec, diff --git a/gunbc-dag/src/workflow/process_registry.rs b/gunbc-dag/src/workflow/process_registry.rs index 842b9616c02..73717669a29 100644 --- a/gunbc-dag/src/workflow/process_registry.rs +++ b/gunbc-dag/src/workflow/process_registry.rs @@ -1,184 +1,20 @@ -//! Typed process-unit registry backing workflow planner units (WF1/WF2). +//! gunbc-dag workflow registry adapters. +//! +//! Generic process/unit registry types live in `core/workflow`. +//! This module keeps repo-specific default derivation from DSL workflows. -use std::collections::BTreeMap; +pub use gunbc_workflow::{ + claim_handle_type_id, ClaimId, ProcessId, ProcessUnitRef, ProcessUnitRegistry, ProcessUnitSpec, + UnitClaim, +}; -use gunbc_ir::{AccessMode, NodeId}; -use serde::{Deserialize, Serialize}; - -/// Canonical process identifier. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct ProcessId(pub String); - -impl ProcessId { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } -} - -impl From<&str> for ProcessId { - fn from(value: &str) -> Self { - Self::new(value) - } -} - -/// Stable typed process-unit reference. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct ProcessUnitRef { - pub process_id: ProcessId, - pub unit_id: NodeId, -} - -impl ProcessUnitRef { - pub fn new(process_id: impl Into, unit_id: impl Into) -> Self { - Self { - process_id: process_id.into(), - unit_id: unit_id.into(), - } - } -} - -/// Canonical claim identity. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct ClaimId(pub String); - -impl ClaimId { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } - - pub fn as_resource_name(&self) -> &str { - self.0.as_str() - } -} - -impl From<&str> for ClaimId { - fn from(value: &str) -> Self { - Self::new(value) - } -} - -impl From for ClaimId { - fn from(value: String) -> Self { - Self::new(value) - } -} - -/// Declared claim for a workflow unit. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct UnitClaim { - pub claim_id: ClaimId, - pub access_mode: AccessMode, -} - -impl UnitClaim { - pub fn new(claim_id: impl Into, access_mode: AccessMode) -> Self { - Self { - claim_id: claim_id.into(), - access_mode, - } - } - - pub fn read(claim_id: impl Into) -> Self { - Self::new(claim_id, AccessMode::Read) - } - - pub fn write(claim_id: impl Into) -> Self { - Self::new(claim_id, AccessMode::Write) - } -} - -/// Typed process-unit metadata required by workflow planner phases. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProcessUnitSpec { - pub reference: ProcessUnitRef, - pub op_version: u32, - pub required_claims: Vec, -} - -impl ProcessUnitSpec { - pub fn new( - reference: ProcessUnitRef, - op_version: u32, - required_claims: Vec, - ) -> Self { - Self { - reference, - op_version, - required_claims, - } - } - - /// Context-free work identity projection for cross-workflow dedup. - pub fn canonical_work_identity(&self) -> (ProcessId, NodeId) { - ( - ProcessId::new("process-unit"), - canonicalize_unit_id(&self.reference.unit_id), - ) - } -} - -/// Registry for all workflow process-unit references. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ProcessUnitRegistry { - specs: BTreeMap, -} - -impl ProcessUnitRegistry { - pub fn new() -> Self { - Self { - specs: BTreeMap::new(), - } - } - - pub fn register(&mut self, spec: ProcessUnitSpec) { - self.specs.insert(spec.reference.clone(), spec); - } - - pub fn get(&self, reference: &ProcessUnitRef) -> Option<&ProcessUnitSpec> { - self.specs.get(reference) - } - - pub fn contains(&self, reference: &ProcessUnitRef) -> bool { - self.specs.contains_key(reference) - } - - pub fn iter(&self) -> impl Iterator { - self.specs.values() - } -} - -fn canonicalize_unit_id(unit_id: &NodeId) -> NodeId { - if let Some((_, suffix)) = unit_id.0.split_once('.') { - NodeId::from(suffix) - } else { - unit_id.clone() - } -} - -/// Default registry for WF1/WF2 planner bootstrap. -/// -/// Derived from `dsl/workflows/*.dag` stage claim annotations. +/// Default registry for planner bootstrap, derived from `dsl/workflows/*.dag`. pub fn default_process_unit_registry() -> ProcessUnitRegistry { super::catalog::build_process_unit_registry().unwrap_or_else(|error| { panic!("failed to derive process unit registry from DSL workflows: {error}") }) } -/// Canonical handle type auto-wiring policy for resource claims. -pub fn claim_handle_type_id(claim_id: &ClaimId) -> &'static str { - if claim_id.0.starts_with("file:") { - "FilesystemHandle" - } else if claim_id.0.starts_with("tool:") { - "ToolHandle" - } else if claim_id.0.starts_with("ledger:") { - "WorkflowLedgerHandle" - } else if claim_id.0.starts_with("network:") { - "NetworkHandle" - } else { - "ResourceHandle" - } -} - #[cfg(test)] mod tests { use super::*; @@ -187,52 +23,11 @@ mod tests { }; #[test] - fn default_registry_contains_ci_and_test_all_units() { + fn default_registry_contains_core_and_tool_units() { let registry = default_process_unit_registry(); assert!(registry.contains(&ProcessUnitRef::new("ci", "ci.codegen"))); assert!(registry.contains(&ProcessUnitRef::new("test_all", "test_all.codegen"))); - } - - #[test] - fn registry_exposes_required_claims() { - let registry = default_process_unit_registry(); - let spec = registry - .get(&ProcessUnitRef::new("ci", "ci.build_compile")) - .expect("ci.build_compile should exist"); - assert!(spec.required_claims.iter().any( - |claim| claim.claim_id.0 == "file:target" && claim.access_mode == AccessMode::Write - )); - } - - #[test] - fn canonical_work_identity_is_context_free_across_workflows() { - let registry = default_process_unit_registry(); - let ci = registry - .get(&ProcessUnitRef::new("ci", "ci.codegen")) - .expect("ci.codegen"); - let test_all = registry - .get(&ProcessUnitRef::new("test_all", "test_all.codegen")) - .expect("test_all.codegen"); - assert_eq!( - ci.canonical_work_identity(), - test_all.canonical_work_identity() - ); - } - - #[test] - fn claim_handle_type_policy_maps_common_prefixes() { - assert_eq!( - claim_handle_type_id(&ClaimId::new("file:workspace")), - "FilesystemHandle" - ); - assert_eq!( - claim_handle_type_id(&ClaimId::new("tool:cargo")), - "ToolHandle" - ); - assert_eq!( - claim_handle_type_id(&ClaimId::new("ledger:workflow")), - "WorkflowLedgerHandle" - ); + assert!(registry.contains(&ProcessUnitRef::new("gist", "gist.gist_create"))); } #[test] @@ -249,68 +44,18 @@ mod tests { } #[test] - fn default_registry_contains_tool_workflow_units() { - let registry = default_process_unit_registry(); - assert!(registry.contains(&ProcessUnitRef::new("gist", "gist.branch_resolution"))); - assert!(registry.contains(&ProcessUnitRef::new( - "bootstrap", - "bootstrap.workspace_scan" - ))); - assert!(registry.contains(&ProcessUnitRef::new("makegen", "makegen.load_registry"))); - assert!(registry.contains(&ProcessUnitRef::new("pragma", "pragma.render_clippy"))); - assert!(registry.contains(&ProcessUnitRef::new("deps", "deps.load_manifest"))); - assert!(registry.contains(&ProcessUnitRef::new("build_all", "build_all.build"))); - } - - // gist_create_unit_has_network_write_claim and - // gist_credential_unit_has_credential_read_claim removed: - // M22 deleted @network(WRITE, "github_gist") and @credential(READ, "github") - // from gist.dag. These claims will be re-introduced via mandatory resource - // declarations (M10) once the resource port system replaces annotation-based - // claim inference. - - #[test] - fn universal_capabilities_are_registered_once_without_workflow_duplication() { - let registry = default_process_unit_registry(); - let compilation_specs: Vec<_> = registry - .iter() - .filter(|spec| spec.reference.process_id.0 == COMPILATION_PROCESS_ID) - .collect(); - assert_eq!( - compilation_specs.len(), - 1, - "compilation capability should be registered once" - ); + fn claim_handle_type_policy_maps_common_prefixes() { assert_eq!( - compilation_specs[0].reference.unit_id.0, - COMPILATION_ENSURE_UNIT + claim_handle_type_id(&ClaimId::new("file:workspace")), + "FilesystemHandle" ); - - let codegen_specs: Vec<_> = registry - .iter() - .filter(|spec| spec.reference.process_id.0 == CODEGEN_PROCESS_ID) - .collect(); assert_eq!( - codegen_specs.len(), - 1, - "codegen capability should be registered once" + claim_handle_type_id(&ClaimId::new("tool:cargo")), + "ToolHandle" ); - assert_eq!(codegen_specs[0].reference.unit_id.0, CODEGEN_ENSURE_UNIT); - } - - #[test] - fn network_claim_maps_to_network_handle() { assert_eq!( claim_handle_type_id(&ClaimId::new("network:github_gist")), "NetworkHandle" ); } - - #[test] - fn credential_claim_maps_to_resource_handle() { - assert_eq!( - claim_handle_type_id(&ClaimId::new("credential:github")), - "ResourceHandle" - ); - } } diff --git a/gunbc-dag/src/workflow/spec_builders.rs b/gunbc-dag/src/workflow/spec_builders.rs index 43dc64f1d42..ae31021012c 100644 --- a/gunbc-dag/src/workflow/spec_builders.rs +++ b/gunbc-dag/src/workflow/spec_builders.rs @@ -2,7 +2,7 @@ use super::catalog; use super::process_registry::default_process_unit_registry; -use super::schema::WorkflowSpec; +use gunbc_workflow::WorkflowSpec; /// Build a workflow spec by canonical/alias name. pub fn workflow_spec(name: &str) -> Result { From 33ef486d47df4ce155e7d30de81879758660d65b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 18:01:13 +0000 Subject: [PATCH 4/7] A9 follow-up: add workflow crate hash/json deps Co-authored-by: Brian Searls --- Cargo.lock | 12 ++++++++++++ core/workflow/Cargo.toml | 2 ++ 2 files changed, 14 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a89cadfd9f4..1b79ad8b527 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -394,6 +394,7 @@ dependencies = [ "gunbc-test", "gunbc-testgen-registry", "gunbc-testgen-registry-macros", + "gunbc-workflow", "ron", "serde", "serde_json", @@ -605,6 +606,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "gunbc-workflow" +version = "0.1.0" +dependencies = [ + "gunbc-exec", + "gunbc-infra", + "gunbc-ir", + "serde", + "serde_json", +] + [[package]] name = "hashbrown" version = "0.16.1" diff --git a/core/workflow/Cargo.toml b/core/workflow/Cargo.toml index 769acf4518f..69a7cf9148b 100644 --- a/core/workflow/Cargo.toml +++ b/core/workflow/Cargo.toml @@ -8,4 +8,6 @@ description = "Generic workflow planner/executor engine" [dependencies] gunbc-ir = { path = "../ir" } gunbc-exec = { path = "../exec" } +gunbc-infra = { path = "../infra" } serde = { workspace = true } +serde_json = { workspace = true } From 60f55d9211d7fa9485addffdd18fa8dc94739ba8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 18:03:26 +0000 Subject: [PATCH 5/7] A10: remove BinaryArgs and use schema parsing directly Co-authored-by: Brian Searls --- core/cli/src/binary_args.rs | 438 ------------------------------- core/cli/src/lib.rs | 4 +- core/cli/src/step_mode.rs | 132 ++++++++++ gunbc-dag/src/bin/ci.rs | 30 ++- gunbc-dag/src/bin/codegen_cli.rs | 4 +- gunbc-dag/src/bin/deps_config.rs | 43 ++- gunbc-dag/src/bin/pipeline.rs | 68 +++-- gunbc-dag/src/bin/sdlc.rs | 62 +++-- 8 files changed, 299 insertions(+), 482 deletions(-) delete mode 100644 core/cli/src/binary_args.rs create mode 100644 core/cli/src/step_mode.rs diff --git a/core/cli/src/binary_args.rs b/core/cli/src/binary_args.rs deleted file mode 100644 index 7656fd84b48..00000000000 --- a/core/cli/src/binary_args.rs +++ /dev/null @@ -1,438 +0,0 @@ -//! Shared CLI argument parser for gunbc binary entry points. -//! -//! Replaces the hand-rolled while-loop parsers in each `gunbc-dag/src/bin/*.rs`. -//! All binaries share `-n`/`--dry-run` and `-h`/`--help`; `--mode` and -//! additional string params are opt-in via builder methods. - -use std::collections::HashMap; - -use gunbc_ir::{resource::ExecMode, Value}; - -use crate::{parse, CliParam, ParamType, ParseError}; - -const MODE_PARAM_NAME: &str = "mode"; - -/// Supported subcommands for generated step-mode CLIs. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StepModeSubcommand { - Run, - Step, - ListSteps, - Help, -} - -/// Parsed step-mode CLI command. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParsedStepModeArgs { - /// The resolved subcommand. - pub subcommand: StepModeSubcommand, - /// Remaining argv items to pass to the selected handler. - pub args: Vec, -} - -/// Parse errors for step-mode command dispatch. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum StepModeParseError { - UnknownSubcommand { subcommand: String }, -} - -impl std::fmt::Display for StepModeParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - StepModeParseError::UnknownSubcommand { subcommand } => { - write!(f, "unknown subcommand '{}'", subcommand) - } - } - } -} - -impl std::error::Error for StepModeParseError {} - -/// Parse shared step-mode subcommand dispatch. -/// -/// Behavior: -/// - `run ...` => `Run` with trailing args -/// - `step ...` => `Step` with trailing args -/// - `list-steps` => `ListSteps` -/// - `help` / `-h` / `--help` => `Help` -/// - first arg begins with `-` => `Run` (backwards-compatible flag passthrough) -/// - no subcommand => `Run` -pub fn parse_step_mode(argv: &[String]) -> Result { - let subcommand = argv.get(1).map(String::as_str); - match subcommand { - Some("run") => Ok(ParsedStepModeArgs { - subcommand: StepModeSubcommand::Run, - args: argv.iter().skip(2).cloned().collect(), - }), - Some("step") => Ok(ParsedStepModeArgs { - subcommand: StepModeSubcommand::Step, - args: argv.iter().skip(2).cloned().collect(), - }), - Some("list-steps") => Ok(ParsedStepModeArgs { - subcommand: StepModeSubcommand::ListSteps, - args: Vec::new(), - }), - Some("help") | Some("-h") | Some("--help") => Ok(ParsedStepModeArgs { - subcommand: StepModeSubcommand::Help, - args: Vec::new(), - }), - Some(first) if first.starts_with('-') => Ok(ParsedStepModeArgs { - subcommand: StepModeSubcommand::Run, - args: argv.iter().skip(1).cloned().collect(), - }), - Some(other) => Err(StepModeParseError::UnknownSubcommand { - subcommand: other.to_string(), - }), - None => Ok(ParsedStepModeArgs { - subcommand: StepModeSubcommand::Run, - args: Vec::new(), - }), - } -} - -/// Definition for a string-valued CLI parameter. -struct StringParamDef { - name: String, - short: Option, - default: Option, -} - -/// Builder for binary CLI argument parsing. -/// -/// All binaries get `-n`/`--dry-run` and `-h`/`--help` for free. -/// Optional features are enabled via builder methods: -/// - `with_mode()` — `--mode=VALUE` / `--mode VALUE` -/// - `with_string_param()` — canonical `-- VALUE` / optional short flag -pub struct BinaryArgs { - enable_mode: bool, - string_params: Vec, -} - -/// Result of parsing binary CLI arguments. -pub struct ParsedBinaryArgs { - /// Whether `--dry-run` / `-n` was present. - pub dry_run: bool, - /// Whether `--help` / `-h` was present. - pub help: bool, - /// Parsed resource mode from `--mode`. - pub resource_mode: Option, - /// String parameter values keyed by name. - string_values: HashMap, -} - -impl Default for BinaryArgs { - fn default() -> Self { - Self::new() - } -} - -impl BinaryArgs { - /// Create a new builder with `-n`/`--dry-run` and `-h`/`--help` always enabled. - pub fn new() -> Self { - Self { - enable_mode: false, - string_params: Vec::new(), - } - } - - /// Enable `--mode=VALUE` / `--mode VALUE` parsing. - pub fn with_mode(mut self) -> Self { - self.enable_mode = true; - self - } - - /// Add a string-valued parameter. - /// - /// The long flag is canonicalized from `name` using kebab-case. - /// Example: `output_dir` -> `--output-dir`. - pub fn with_string_param( - mut self, - name: &str, - short: Option, - default: Option<&str>, - ) -> Self { - self.string_params.push(StringParamDef { - name: name.to_string(), - short, - default: default.map(|s| s.to_string()), - }); - self - } - - /// Parse the given argv slice (index 0 = program name, skipped). - pub fn parse(self, argv: &[String]) -> Result { - let mut schema: Vec = Vec::new(); - - if self.enable_mode { - schema.push(CliParam::new(MODE_PARAM_NAME, ParamType::Str)); - } - - for param in &self.string_params { - let mut cli = CliParam::new(¶m.name, ParamType::Str); - if let Some(c) = param.short { - cli = cli.short(c); - } - if let Some(ref default) = param.default { - cli = cli.default(default); - } - schema.push(cli); - } - - let parsed = parse(argv, &schema)?; - - let resource_mode = match parsed.values.get(MODE_PARAM_NAME) { - Some(Value::Str(mode)) => { - Some( - ExecMode::parse_strict(mode).map_err(|_| ParseError::InvalidValue { - flag: "--mode".to_string(), - value: mode.clone(), - })?, - ) - } - _ => None, - }; - - let mut string_values: HashMap = HashMap::new(); - for param in &self.string_params { - if let Some(Value::Str(value)) = parsed.values.get(¶m.name) { - string_values.insert(param.name.clone(), value.clone()); - } - } - - Ok(ParsedBinaryArgs { - dry_run: parsed.dry_run, - help: parsed.help, - resource_mode, - string_values, - }) - } - - /// Parse from `std::env::args()`, printing errors and exiting on failure. - pub fn parse_env(self) -> ParsedBinaryArgs { - let argv: Vec = std::env::args().collect(); - match self.parse(&argv) { - Ok(p) => p, - Err(e) => { - eprintln!("error: {}", e); - std::process::exit(1); - } - } - } -} - -impl ParsedBinaryArgs { - /// Get a string parameter value by name. - pub fn get_string(&self, name: &str) -> Option<&str> { - self.string_values.get(name).map(|s| s.as_str()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn argv(args: &[&str]) -> Vec { - args.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn test_simple_dry_run() { - let parsed = BinaryArgs::new().parse(&argv(&["prog", "-n"])).unwrap(); - assert!(parsed.dry_run); - assert!(!parsed.help); - } - - #[test] - fn test_simple_dry_run_long() { - let parsed = BinaryArgs::new() - .parse(&argv(&["prog", "--dry-run"])) - .unwrap(); - assert!(parsed.dry_run); - } - - #[test] - fn test_simple_help() { - let parsed = BinaryArgs::new().parse(&argv(&["prog", "--help"])).unwrap(); - assert!(parsed.help); - assert!(!parsed.dry_run); - } - - #[test] - fn test_simple_help_short() { - let parsed = BinaryArgs::new().parse(&argv(&["prog", "-h"])).unwrap(); - assert!(parsed.help); - } - - #[test] - fn test_unknown_flag_errors() { - let result = BinaryArgs::new().parse(&argv(&["prog", "--unknown"])); - assert!(matches!(result, Err(ParseError::UnknownFlag { .. }))); - } - - #[test] - fn test_mode_equals() { - let parsed = BinaryArgs::new() - .with_mode() - .parse(&argv(&["prog", "--mode=verify"])) - .unwrap(); - assert_eq!(parsed.resource_mode, Some(ExecMode::Verify)); - } - - #[test] - fn test_mode_space() { - let parsed = BinaryArgs::new() - .with_mode() - .parse(&argv(&["prog", "--mode", "ensure"])) - .unwrap(); - assert_eq!(parsed.resource_mode, Some(ExecMode::Ensure)); - } - - #[test] - fn test_mode_missing_value() { - let result = BinaryArgs::new() - .with_mode() - .parse(&argv(&["prog", "--mode"])); - assert!(matches!(result, Err(ParseError::MissingValue { .. }))); - } - - #[test] - fn test_mode_invalid_value() { - let result = BinaryArgs::new() - .with_mode() - .parse(&argv(&["prog", "--mode=bogus"])); - assert!(matches!(result, Err(ParseError::InvalidValue { .. }))); - } - - #[test] - fn test_deprecated_check_flags_are_unknown() { - let result_short = BinaryArgs::new().with_mode().parse(&argv(&["prog", "-c"])); - let result_long = BinaryArgs::new() - .with_mode() - .parse(&argv(&["prog", "--check"])); - assert!(matches!(result_short, Err(ParseError::UnknownFlag { .. }))); - assert!(matches!(result_long, Err(ParseError::UnknownFlag { .. }))); - } - - #[test] - fn test_string_param_short() { - let parsed = BinaryArgs::new() - .with_string_param("path", Some('o'), Some("Makefile")) - .parse(&argv(&["prog", "-o", "output.mk"])) - .unwrap(); - assert_eq!(parsed.get_string("path"), Some("output.mk")); - } - - #[test] - fn test_string_param_default() { - let parsed = BinaryArgs::new() - .with_string_param("path", Some('o'), Some("Makefile")) - .parse(&argv(&["prog"])) - .unwrap(); - assert_eq!(parsed.get_string("path"), Some("Makefile")); - } - - #[test] - fn test_string_param_long() { - let parsed = BinaryArgs::new() - .with_string_param("path", Some('o'), None) - .parse(&argv(&["prog", "--path", "foo"])) - .unwrap(); - assert_eq!(parsed.get_string("path"), Some("foo")); - } - - #[test] - fn test_string_param_missing_value() { - let result = BinaryArgs::new() - .with_string_param("path", Some('o'), None) - .parse(&argv(&["prog", "--path"])); - assert!(matches!(result, Err(ParseError::MissingValue { .. }))); - } - - #[test] - fn test_combined_flags() { - let parsed = BinaryArgs::new() - .with_mode() - .with_string_param("path", Some('o'), Some("Makefile")) - .parse(&argv(&["prog", "-n", "--mode=verify", "-o", "out.mk"])) - .unwrap(); - assert!(parsed.dry_run); - assert_eq!(parsed.resource_mode, Some(ExecMode::Verify)); - assert_eq!(parsed.get_string("path"), Some("out.mk")); - } - - #[test] - fn test_no_args() { - let parsed = BinaryArgs::new().parse(&argv(&["prog"])).unwrap(); - assert!(!parsed.dry_run); - assert!(!parsed.help); - assert_eq!(parsed.resource_mode, None); - } - - #[test] - fn test_mode_without_enable_is_unknown() { - let result = BinaryArgs::new().parse(&argv(&["prog", "--mode=verify"])); - assert!(matches!(result, Err(ParseError::UnknownFlag { .. }))); - } - - #[test] - fn test_string_param_no_default_absent() { - let parsed = BinaryArgs::new() - .with_string_param("path", Some('o'), None) - .parse(&argv(&["prog"])) - .unwrap(); - assert_eq!(parsed.get_string("path"), None); - } - - #[test] - fn test_string_param_uses_canonical_long_name() { - let parsed = BinaryArgs::new() - .with_string_param("output_dir", None, None) - .parse(&argv(&["prog", "--output-dir", "generated"])) - .unwrap(); - assert_eq!(parsed.get_string("output_dir"), Some("generated")); - } - - #[test] - fn test_step_mode_defaults_to_run() { - let parsed = parse_step_mode(&argv(&["prog"])).unwrap(); - assert_eq!(parsed.subcommand, StepModeSubcommand::Run); - assert!(parsed.args.is_empty()); - } - - #[test] - fn test_step_mode_run_explicit() { - let parsed = parse_step_mode(&argv(&["prog", "run", "-n"])).unwrap(); - assert_eq!(parsed.subcommand, StepModeSubcommand::Run); - assert_eq!(parsed.args, vec!["-n".to_string()]); - } - - #[test] - fn test_step_mode_step_subcommand() { - let parsed = parse_step_mode(&argv(&["prog", "step", "node_a", "-n"])).unwrap(); - assert_eq!(parsed.subcommand, StepModeSubcommand::Step); - assert_eq!(parsed.args, vec!["node_a".to_string(), "-n".to_string()]); - } - - #[test] - fn test_step_mode_list_steps_subcommand() { - let parsed = parse_step_mode(&argv(&["prog", "list-steps"])).unwrap(); - assert_eq!(parsed.subcommand, StepModeSubcommand::ListSteps); - assert!(parsed.args.is_empty()); - } - - #[test] - fn test_step_mode_flags_passthrough_to_run() { - let parsed = parse_step_mode(&argv(&["prog", "--dry-run", "--mode=verify"])).unwrap(); - assert_eq!(parsed.subcommand, StepModeSubcommand::Run); - assert_eq!( - parsed.args, - vec!["--dry-run".to_string(), "--mode=verify".to_string()] - ); - } - - #[test] - fn test_step_mode_unknown_subcommand_errors() { - let err = parse_step_mode(&argv(&["prog", "deploy"])).unwrap_err(); - assert!(matches!(err, StepModeParseError::UnknownSubcommand { .. })); - } -} diff --git a/core/cli/src/lib.rs b/core/cli/src/lib.rs index ffc93035581..8fb83e139ad 100644 --- a/core/cli/src/lib.rs +++ b/core/cli/src/lib.rs @@ -4,8 +4,8 @@ //! generated by `cli_gen.rs`. Contract tests can call `parse()` directly //! instead of spawning subprocesses. -mod binary_args; -pub use binary_args::*; +mod step_mode; +pub use step_mode::*; use std::collections::HashMap; diff --git a/core/cli/src/step_mode.rs b/core/cli/src/step_mode.rs new file mode 100644 index 00000000000..0236d6e81f3 --- /dev/null +++ b/core/cli/src/step_mode.rs @@ -0,0 +1,132 @@ +//! Shared step-mode subcommand dispatch parser. + +/// Supported subcommands for generated step-mode CLIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepModeSubcommand { + Run, + Step, + ListSteps, + Help, +} + +/// Parsed step-mode CLI command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedStepModeArgs { + /// The resolved subcommand. + pub subcommand: StepModeSubcommand, + /// Remaining argv items to pass to the selected handler. + pub args: Vec, +} + +/// Parse errors for step-mode command dispatch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StepModeParseError { + UnknownSubcommand { subcommand: String }, +} + +impl std::fmt::Display for StepModeParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StepModeParseError::UnknownSubcommand { subcommand } => { + write!(f, "unknown subcommand '{}'", subcommand) + } + } + } +} + +impl std::error::Error for StepModeParseError {} + +/// Parse shared step-mode subcommand dispatch. +/// +/// Behavior: +/// - `run ...` => `Run` with trailing args +/// - `step ...` => `Step` with trailing args +/// - `list-steps` => `ListSteps` +/// - `help` / `-h` / `--help` => `Help` +/// - first arg begins with `-` => `Run` (backwards-compatible flag passthrough) +/// - no subcommand => `Run` +pub fn parse_step_mode(argv: &[String]) -> Result { + let subcommand = argv.get(1).map(String::as_str); + match subcommand { + Some("run") => Ok(ParsedStepModeArgs { + subcommand: StepModeSubcommand::Run, + args: argv.iter().skip(2).cloned().collect(), + }), + Some("step") => Ok(ParsedStepModeArgs { + subcommand: StepModeSubcommand::Step, + args: argv.iter().skip(2).cloned().collect(), + }), + Some("list-steps") => Ok(ParsedStepModeArgs { + subcommand: StepModeSubcommand::ListSteps, + args: Vec::new(), + }), + Some("help") | Some("-h") | Some("--help") => Ok(ParsedStepModeArgs { + subcommand: StepModeSubcommand::Help, + args: Vec::new(), + }), + Some(first) if first.starts_with('-') => Ok(ParsedStepModeArgs { + subcommand: StepModeSubcommand::Run, + args: argv.iter().skip(1).cloned().collect(), + }), + Some(other) => Err(StepModeParseError::UnknownSubcommand { + subcommand: other.to_string(), + }), + None => Ok(ParsedStepModeArgs { + subcommand: StepModeSubcommand::Run, + args: Vec::new(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_step_mode_defaults_to_run() { + let parsed = parse_step_mode(&argv(&["prog"])).unwrap(); + assert_eq!(parsed.subcommand, StepModeSubcommand::Run); + assert!(parsed.args.is_empty()); + } + + #[test] + fn test_step_mode_run_explicit() { + let parsed = parse_step_mode(&argv(&["prog", "run", "-n"])).unwrap(); + assert_eq!(parsed.subcommand, StepModeSubcommand::Run); + assert_eq!(parsed.args, vec!["-n".to_string()]); + } + + #[test] + fn test_step_mode_step_subcommand() { + let parsed = parse_step_mode(&argv(&["prog", "step", "node_a", "-n"])).unwrap(); + assert_eq!(parsed.subcommand, StepModeSubcommand::Step); + assert_eq!(parsed.args, vec!["node_a".to_string(), "-n".to_string()]); + } + + #[test] + fn test_step_mode_list_steps_subcommand() { + let parsed = parse_step_mode(&argv(&["prog", "list-steps"])).unwrap(); + assert_eq!(parsed.subcommand, StepModeSubcommand::ListSteps); + assert!(parsed.args.is_empty()); + } + + #[test] + fn test_step_mode_flags_passthrough_to_run() { + let parsed = parse_step_mode(&argv(&["prog", "--dry-run", "--mode=verify"])).unwrap(); + assert_eq!(parsed.subcommand, StepModeSubcommand::Run); + assert_eq!( + parsed.args, + vec!["--dry-run".to_string(), "--mode=verify".to_string()] + ); + } + + #[test] + fn test_step_mode_unknown_subcommand_errors() { + let err = parse_step_mode(&argv(&["prog", "deploy"])).unwrap_err(); + assert!(matches!(err, StepModeParseError::UnknownSubcommand { .. })); + } +} diff --git a/gunbc-dag/src/bin/ci.rs b/gunbc-dag/src/bin/ci.rs index 84210c68e42..b8f8eac003d 100644 --- a/gunbc-dag/src/bin/ci.rs +++ b/gunbc-dag/src/bin/ci.rs @@ -21,7 +21,7 @@ //! without requiring separate workflow steps. #![deny(dead_code)] -use gunbc_cli::BinaryArgs; +use gunbc_cli::{parse, CliParam, ParamType}; use gunbc_dag::build::build_build_graph; use gunbc_dag::resources::MAKEFILE_OUTPUT_PATH; use gunbc_dag::{print_tool_header, run_tool, wire_fs_env_write_mock, RunToolOptions}; @@ -53,14 +53,38 @@ fn ci_path_for_node(node_id: &str) -> Option<&'static str> { } fn main() { - let parsed = BinaryArgs::new().with_mode().parse_env(); + let argv: Vec = std::env::args().collect(); + let parsed = match parse(&argv, &[CliParam::new("mode", ParamType::Str)]) { + Ok(parsed) => parsed, + Err(error) => { + print_attention( + AttentionLevel::Error, + "invalid CLI arguments", + &error.to_string(), + ); + process::exit(1); + } + }; if parsed.help { print_help(); return; } let dry_run = parsed.dry_run; - let resource_mode = parsed.resource_mode.unwrap_or(ExecMode::Ensure); + let resource_mode = match parsed.values.get("mode").and_then(Value::as_str) { + Some(mode) => match ExecMode::parse_strict(mode) { + Ok(mode) => mode, + Err(_) => { + print_attention( + AttentionLevel::Error, + "invalid --mode value", + &format!("expected ensure|verify, got '{mode}'"), + ); + process::exit(1); + } + }, + None => ExecMode::Ensure, + }; // Runtime CI path uses the concrete build/test/lint DAG. let dag = match build_build_graph() { diff --git a/gunbc-dag/src/bin/codegen_cli.rs b/gunbc-dag/src/bin/codegen_cli.rs index 0baa04429b9..608de5be175 100644 --- a/gunbc-dag/src/bin/codegen_cli.rs +++ b/gunbc-dag/src/bin/codegen_cli.rs @@ -22,7 +22,7 @@ #![deny(dead_code)] use cargo_metadata::MetadataCommand; -use gunbc_cli::BinaryArgs; +use gunbc_cli::parse; use gunbc_codegen::{core_outputs, generate_cli_with_import, FileWriter, ToolDef}; use gunbc_exec::{print_attention, run_freshness_steps, AttentionLevel}; use gunbc_ir::resource::{ @@ -39,7 +39,7 @@ use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table}; fn main() { let args: Vec = env::args().collect(); - let parsed = match BinaryArgs::new().parse(&args) { + let parsed = match parse(&args, &[]) { Ok(parsed) => parsed, Err(e) => { print_attention( diff --git a/gunbc-dag/src/bin/deps_config.rs b/gunbc-dag/src/bin/deps_config.rs index 9b93a35080e..d27d292afd6 100644 --- a/gunbc-dag/src/bin/deps_config.rs +++ b/gunbc-dag/src/bin/deps_config.rs @@ -3,7 +3,7 @@ //! Ensures or verifies that `deps.toml` matches the canonical tool registry. #![deny(dead_code)] -use gunbc_cli::BinaryArgs; +use gunbc_cli::{parse, CliParam, ParamType}; use gunbc_codegen::file_writer::{format_diff, FileWriter}; use gunbc_dag::deps_config_resource_def; use gunbc_dag::resources::DEPS_CONFIG_OUTPUT_PATH; @@ -18,19 +18,48 @@ use std::path::{Path, PathBuf}; use std::process; fn main() { - let parsed = BinaryArgs::new() - .with_mode() - .with_string_param("path", Some('p'), Some(DEPS_CONFIG_OUTPUT_PATH)) - .parse_env(); + let argv: Vec = std::env::args().collect(); + let schema = vec![ + CliParam::new("mode", ParamType::Str), + CliParam::new("path", ParamType::Str) + .short('p') + .default(DEPS_CONFIG_OUTPUT_PATH), + ]; + let parsed = match parse(&argv, &schema) { + Ok(parsed) => parsed, + Err(error) => { + print_attention( + AttentionLevel::Error, + "invalid CLI arguments", + &error.to_string(), + ); + process::exit(1); + } + }; if parsed.help { print_help(); return; } let dry_run = parsed.dry_run; - let resource_mode = parsed.resource_mode.unwrap_or(ExecMode::Ensure); + let resource_mode = match parsed.values.get("mode").and_then(|value| value.as_str()) { + Some(mode) => match ExecMode::parse_strict(mode) { + Ok(mode) => mode, + Err(_) => { + print_attention( + AttentionLevel::Error, + "invalid --mode value", + &format!("expected ensure|verify, got '{mode}'"), + ); + process::exit(1); + } + }, + None => ExecMode::Ensure, + }; let path = parsed - .get_string("path") + .values + .get("path") + .and_then(|value| value.as_str()) .unwrap_or(DEPS_CONFIG_OUTPUT_PATH) .to_string(); diff --git a/gunbc-dag/src/bin/pipeline.rs b/gunbc-dag/src/bin/pipeline.rs index eeb738aa6d1..bb73a75c9fd 100644 --- a/gunbc-dag/src/bin/pipeline.rs +++ b/gunbc-dag/src/bin/pipeline.rs @@ -18,7 +18,7 @@ //! ``` #![deny(dead_code)] -use gunbc_cli::BinaryArgs; +use gunbc_cli::{parse, CliParam, ParamType}; use gunbc_dag::{dsl_builder::build_dsl_graph, print_tool_header, run_tool, RunToolOptions}; use gunbc_exec::{print_attention, AttentionLevel, BoundaryMocks, ExecutionMode}; use gunbc_ir::{detect_entrypoints, Value}; @@ -116,14 +116,28 @@ fn query_issue_description(issue_number: &str) -> Option { } fn main() { - let parsed = BinaryArgs::new() - .with_string_param("repo_path", Some('r'), Some(".")) - .with_string_param("base_ref", Some('b'), None) - .with_string_param("provider", Some('p'), Some("anthropic")) - .with_string_param("depth", Some('d'), Some("M")) - .with_string_param("pr", None, None) - .with_string_param("issue", None, None) - .parse_env(); + let argv: Vec = std::env::args().collect(); + let schema = vec![ + CliParam::new("repo_path", ParamType::Str).short('r').default("."), + CliParam::new("base_ref", ParamType::Str).short('b'), + CliParam::new("provider", ParamType::Str) + .short('p') + .default("anthropic"), + CliParam::new("depth", ParamType::Str).short('d').default("M"), + CliParam::new("pr", ParamType::Str), + CliParam::new("issue", ParamType::Str), + ]; + let parsed = match parse(&argv, &schema) { + Ok(parsed) => parsed, + Err(error) => { + print_attention( + AttentionLevel::Error, + "invalid CLI arguments", + &error.to_string(), + ); + process::exit(1); + } + }; if parsed.help { print_help(); @@ -131,15 +145,39 @@ fn main() { } let dry_run = parsed.dry_run; - let repo_path = parsed.get_string("repo_path").unwrap_or(".").to_string(); - let base_ref = parsed.get_string("base_ref").map(|s| s.to_string()); + let repo_path = parsed + .values + .get("repo_path") + .and_then(Value::as_str) + .unwrap_or(".") + .to_string(); + let base_ref = parsed + .values + .get("base_ref") + .and_then(Value::as_str) + .map(str::to_string); let provider = parsed - .get_string("provider") + .values + .get("provider") + .and_then(Value::as_str) .unwrap_or("anthropic") .to_string(); - let depth_str = parsed.get_string("depth").unwrap_or("M").to_string(); - let pr_number = parsed.get_string("pr").map(|s| s.to_string()); - let issue_number = parsed.get_string("issue").map(|s| s.to_string()); + let depth_str = parsed + .values + .get("depth") + .and_then(Value::as_str) + .unwrap_or("M") + .to_string(); + let pr_number = parsed + .values + .get("pr") + .and_then(Value::as_str) + .map(str::to_string); + let issue_number = parsed + .values + .get("issue") + .and_then(Value::as_str) + .map(str::to_string); // Validate provider if !["openai", "anthropic"].contains(&provider.as_str()) { diff --git a/gunbc-dag/src/bin/sdlc.rs b/gunbc-dag/src/bin/sdlc.rs index f6e91bfb370..311bdd2d881 100644 --- a/gunbc-dag/src/bin/sdlc.rs +++ b/gunbc-dag/src/bin/sdlc.rs @@ -15,7 +15,7 @@ //! ``` #![deny(dead_code)] -use gunbc_cli::BinaryArgs; +use gunbc_cli::{parse, CliParam, ParamType}; use gunbc_dag::{ dsl_builder::build_dsl_graph_with_profile, mock_defaults::auto_mock_spec, print_tool_header, run_tool, RunToolOptions, @@ -25,14 +25,30 @@ use gunbc_ir::{detect_entrypoints, Value}; use std::process; fn main() { - let parsed = BinaryArgs::new() - .with_string_param("profile", Some('p'), Some("unit_test")) - .with_string_param("repo", Some('r'), None) - .with_string_param("issue", Some('i'), None) - .with_string_param("worker_id", Some('w'), Some("gunbc-sdlc")) - .with_string_param("llm_provider", None, Some("anthropic")) - .with_string_param("llm_model", None, Some("claude-sonnet-4-20250514")) - .parse_env(); + let argv: Vec = std::env::args().collect(); + let schema = vec![ + CliParam::new("profile", ParamType::Str) + .short('p') + .default("unit_test"), + CliParam::new("repo", ParamType::Str).short('r'), + CliParam::new("issue", ParamType::Str).short('i'), + CliParam::new("worker_id", ParamType::Str) + .short('w') + .default("gunbc-sdlc"), + CliParam::new("llm_provider", ParamType::Str).default("anthropic"), + CliParam::new("llm_model", ParamType::Str).default("claude-sonnet-4-20250514"), + ]; + let parsed = match parse(&argv, &schema) { + Ok(parsed) => parsed, + Err(error) => { + print_attention( + AttentionLevel::Error, + "invalid CLI arguments", + &error.to_string(), + ); + process::exit(1); + } + }; if parsed.help { print_help(); @@ -41,21 +57,37 @@ fn main() { let dry_run = parsed.dry_run; let profile = parsed - .get_string("profile") + .values + .get("profile") + .and_then(Value::as_str) .unwrap_or("unit_test") .to_string(); - let repo = parsed.get_string("repo").map(|s| s.to_string()); - let issue = parsed.get_string("issue").map(|s| s.to_string()); + let repo = parsed + .values + .get("repo") + .and_then(Value::as_str) + .map(str::to_string); + let issue = parsed + .values + .get("issue") + .and_then(Value::as_str) + .map(str::to_string); let worker_id = parsed - .get_string("worker_id") + .values + .get("worker_id") + .and_then(Value::as_str) .unwrap_or("gunbc-sdlc") .to_string(); let llm_provider = parsed - .get_string("llm_provider") + .values + .get("llm_provider") + .and_then(Value::as_str) .unwrap_or("anthropic") .to_string(); let llm_model = parsed - .get_string("llm_model") + .values + .get("llm_model") + .and_then(Value::as_str) .unwrap_or("claude-sonnet-4-20250514") .to_string(); From 56d64b6e71bef25308a4dbd9afe386a257efa6ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 18:04:29 +0000 Subject: [PATCH 6/7] A11: delete compensating workflow and infra CLI tests Co-authored-by: Brian Searls --- gunbc-dag/tests/infra_cli.rs | 212 ------------ .../tests/workflow_admission_contracts.rs | 189 ----------- .../tests/workflow_executor_contracts.rs | 301 ------------------ gunbc-dag/tests/workflow_gist_contracts.rs | 269 ---------------- .../tests/workflow_global_dedup_contracts.rs | 65 ---- .../tests/workflow_key_ledger_contracts.rs | 119 ------- .../tests/workflow_plan_cli_contracts.rs | 89 ------ gunbc-dag/tests/workflow_schema_contracts.rs | 114 ------- .../workflow_tool_capability_contracts.rs | 214 ------------- 9 files changed, 1572 deletions(-) delete mode 100644 gunbc-dag/tests/infra_cli.rs delete mode 100644 gunbc-dag/tests/workflow_admission_contracts.rs delete mode 100644 gunbc-dag/tests/workflow_executor_contracts.rs delete mode 100644 gunbc-dag/tests/workflow_gist_contracts.rs delete mode 100644 gunbc-dag/tests/workflow_global_dedup_contracts.rs delete mode 100644 gunbc-dag/tests/workflow_key_ledger_contracts.rs delete mode 100644 gunbc-dag/tests/workflow_plan_cli_contracts.rs delete mode 100644 gunbc-dag/tests/workflow_schema_contracts.rs delete mode 100644 gunbc-dag/tests/workflow_tool_capability_contracts.rs diff --git a/gunbc-dag/tests/infra_cli.rs b/gunbc-dag/tests/infra_cli.rs deleted file mode 100644 index 88eaba903fb..00000000000 --- a/gunbc-dag/tests/infra_cli.rs +++ /dev/null @@ -1,212 +0,0 @@ -#![allow(clippy::disallowed_methods)] -mod common; -use common::fixture::CliTestContext; - -fn infra_bin() -> &'static str { - env!("CARGO_BIN_EXE_gunbc-infra") -} - -#[test] -fn spec_command_emits_structured_json() { - let ctx = CliTestContext::new("infra", infra_bin()); - let output = ctx - .command() - .arg("spec") - .arg("--env") - .arg("dev") - .output() - .expect("run infra spec command"); - assert!( - output.status.success(), - "infra spec should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let payload: serde_json::Value = - serde_json::from_slice(&output.stdout).expect("infra spec output should be JSON"); - assert_eq!(payload["environment"], "dev"); - assert!( - payload["service_accounts"] - .as_array() - .expect("service_accounts should be array") - .iter() - .any(|entry| entry["name"] == "gunbai-dev-secrets"), - "infra spec should include configured service-account dependencies" - ); - assert_eq!(payload["wif"]["pool_id"], "github-pool"); -} - -#[test] -fn plan_command_reports_runtime_dependency_targets() { - let ctx = CliTestContext::new("infra", infra_bin()); - let output = ctx - .command() - .arg("plan") - .arg("--env") - .arg("dev") - .output() - .expect("run infra plan command"); - assert!( - output.status.success(), - "infra plan should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("secret:github-token"), - "infra plan should include secret runtime dependency target: {stdout}" - ); - assert!( - stdout.contains("service-account:gunbai-dev-secrets"), - "infra plan should include service-account runtime dependency target: {stdout}" - ); - assert!( - stdout.contains("wif:github-pool:github"), - "infra plan should include wif runtime dependency target: {stdout}" - ); -} - -#[test] -fn status_command_fails_closed_without_adc_in_isolated_home() { - let ctx = CliTestContext::new("infra", infra_bin()); - - let output = ctx - .command() - .arg("status") - .arg("--env") - .arg("dev") - .output() - .expect("run infra status command"); - assert!( - !output.status.success(), - "infra status should fail closed without adc in isolated HOME" - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stdout.contains("[FAIL] auth"), - "status output should include fail-closed auth section: {stdout}" - ); - assert!( - stdout.contains("overall: FAIL"), - "status output should include failed overall health state: {stdout}" - ); - assert!( - stderr.contains("one or more health checks failed"), - "status error should explain fail-closed health gate: {stderr}" - ); -} - -#[test] -fn status_command_succeeds_with_adc_refresh_token_in_isolated_home() { - let ctx = CliTestContext::new("infra", infra_bin()); - let adc_path = ctx - .path() - .to_path_buf() - .join(".config") - .join("gcloud") - .join("application_default_credentials.json"); - if let Some(parent) = adc_path.parent() { - std::fs::create_dir_all(parent).expect("create ADC parent directories"); - } - std::fs::write( - &adc_path, - r#"{"type":"authorized_user","client_id":"test","client_secret":"test","refresh_token":"test-refresh-token"}"#, - ) - .expect("write ADC fixture with refresh token"); - - let output = ctx - .command() - .arg("status") - .arg("--env") - .arg("dev") - .output() - .expect("run infra status command"); - assert!( - output.status.success(), - "infra status should succeed with adc fixture: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("[OK] auth"), - "status output should include successful auth section: {stdout}" - ); - assert!( - stdout.contains("overall: OK"), - "status output should include successful overall health state: {stdout}" - ); -} - -#[test] -fn reconcile_command_fails_closed_when_status_unhealthy() { - let ctx = CliTestContext::new("infra", infra_bin()); - - let output = ctx - .command() - .arg("reconcile") - .arg("--env") - .arg("dev") - .output() - .expect("run infra reconcile command"); - assert!( - !output.status.success(), - "infra reconcile should fail closed when health checks are unhealthy" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("cannot reconcile while infra health checks fail"), - "reconcile should explain health-gated fail-closed behavior: {stderr}" - ); -} - -#[test] -fn reconcile_command_preview_succeeds_when_health_is_ready() { - let ctx = CliTestContext::new("infra", infra_bin()); - let adc_path = ctx - .path() - .to_path_buf() - .join(".config") - .join("gcloud") - .join("application_default_credentials.json"); - if let Some(parent) = adc_path.parent() { - std::fs::create_dir_all(parent).expect("create ADC parent directories"); - } - std::fs::write( - &adc_path, - r#"{"type":"authorized_user","client_id":"test","client_secret":"test","refresh_token":"test-refresh-token"}"#, - ) - .expect("write ADC fixture with refresh token"); - - let output = ctx - .command() - .arg("reconcile") - .arg("--env") - .arg("dev") - .output() - .expect("run infra reconcile preview command"); - assert!( - output.status.success(), - "infra reconcile preview should succeed when health checks pass: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("infra reconcile preview"), - "reconcile preview should print mode banner: {stdout}" - ); - assert!( - stdout.contains("service-account:gunbai-dev-secrets"), - "reconcile preview should include runtime dependency targets from plan: {stdout}" - ); -} - -// Tests for reconcile execute entrypoint input validation were removed: -// `run_compiled_infra_orchestration` provides all 7 entrypoint inputs -// (environment, runtime, spec_targets, target, skip, __deps, execute) -// from the InfraSpec, and `--input` CLI args aren't wired into that path. -// The removed tests asserted phantom contracts (missing inputs, invalid -// CloudSecretConfig json) that the current interface never triggers. diff --git a/gunbc-dag/tests/workflow_admission_contracts.rs b/gunbc-dag/tests/workflow_admission_contracts.rs deleted file mode 100644 index 3ad0d70b91f..00000000000 --- a/gunbc-dag/tests/workflow_admission_contracts.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! Workflow admission contract tests (WF2). - -use gunbc_dag::workflow::{WorkflowId, WorkflowSpec}; -use gunbc_dag::{ - ci_workflow_spec, default_process_unit_registry, required_input_contract, - required_output_contract, test_all_workflow_spec, validate_workflow_admission, ClaimId, - ProcessUnitRef, ProcessUnitRegistry, ProcessUnitSpec, UnitClaim, WorkflowAdmissionError, - WorkflowOp, WorkflowUnit, -}; -use gunbc_ir::{AccessMode, Dag, Node, Port}; - -fn invoke_node( - id: &str, - process_id: &str, - unit_id: &str, - claims: &[(&str, AccessMode)], -) -> Node { - let mut inputs = required_input_contract(); - for (claim, mode) in claims { - inputs.push(Port::resource(*claim, "ResourceHandle", *mode)); - } - Node::opaque( - id, - inputs, - required_output_contract(), - WorkflowUnit::new(WorkflowOp::InvokeProcessUnit(ProcessUnitRef::new( - process_id, unit_id, - ))), - ) -} - -fn registry_for_two_nodes( - required_a: Vec, - required_b: Vec, -) -> ProcessUnitRegistry { - let mut registry = ProcessUnitRegistry::new(); - registry.register(ProcessUnitSpec::new( - ProcessUnitRef::new("wf", "a"), - 1, - required_a, - )); - registry.register(ProcessUnitSpec::new( - ProcessUnitRef::new("wf", "b"), - 1, - required_b, - )); - registry -} - -#[test] -fn ci_and_test_all_specs_pass_default_admission_validation() { - let registry = default_process_unit_registry(); - let ci = ci_workflow_spec().expect("ci workflow spec"); - validate_workflow_admission(&ci, ®istry).expect("ci workflow admission should pass"); - - let test_all = test_all_workflow_spec().expect("test-all workflow spec"); - validate_workflow_admission(&test_all, ®istry) - .expect("test-all workflow admission should pass"); -} - -#[test] -fn read_read_claims_allowed() { - let mut dag = Dag::new(); - dag.add_node(invoke_node( - "wf.a", - "wf", - "a", - &[("file:workspace", AccessMode::Read)], - )); - dag.add_node(invoke_node( - "wf.b", - "wf", - "b", - &[("file:workspace", AccessMode::Read)], - )); - let spec = WorkflowSpec::new(WorkflowId::new("wf"), dag, 1); - let registry = registry_for_two_nodes( - vec![UnitClaim::read("file:workspace")], - vec![UnitClaim::read("file:workspace")], - ); - - validate_workflow_admission(&spec, ®istry).expect("read/read must be allowed"); -} - -#[test] -fn write_write_claims_fail_with_unit_and_claim_diagnostics() { - let mut dag = Dag::new(); - dag.add_node(invoke_node( - "wf.a", - "wf", - "a", - &[("file:workspace", AccessMode::Write)], - )); - dag.add_node(invoke_node( - "wf.b", - "wf", - "b", - &[("file:workspace", AccessMode::Write)], - )); - let spec = WorkflowSpec::new(WorkflowId::new("wf"), dag, 1); - let registry = registry_for_two_nodes( - vec![UnitClaim::write("file:workspace")], - vec![UnitClaim::write("file:workspace")], - ); - - let errors = - validate_workflow_admission(&spec, ®istry).expect_err("write/write should fail"); - let conflict = errors - .iter() - .find_map(|error| match error { - WorkflowAdmissionError::ConflictingClaims { - left_node, - right_node, - left_claim, - right_claim, - .. - } => Some((left_node, right_node, left_claim, right_claim)), - _ => None, - }) - .expect("conflicting claim diagnostics must be present"); - - assert!( - (conflict.0 .0 == "wf.a" && conflict.1 .0 == "wf.b") - || (conflict.0 .0 == "wf.b" && conflict.1 .0 == "wf.a") - ); - assert_eq!(conflict.2, &ClaimId::new("file:workspace")); - assert_eq!(conflict.3, &ClaimId::new("file:workspace")); -} - -#[test] -fn missing_required_claims_fail_closed() { - let mut dag = Dag::new(); - dag.add_node(invoke_node("wf.a", "wf", "a", &[])); - dag.add_node(invoke_node( - "wf.b", - "wf", - "b", - &[("file:workspace", AccessMode::Read)], - )); - dag.add_edge(gunbc_ir::Edge::control("wf.a", "commit", "wf.b", "after")); - - let spec = WorkflowSpec::new(WorkflowId::new("wf"), dag, 1); - let registry = registry_for_two_nodes( - vec![UnitClaim::write("file:workspace")], - vec![UnitClaim::read("file:workspace")], - ); - - let errors = - validate_workflow_admission(&spec, ®istry).expect_err("missing claim should fail"); - assert!(errors.iter().any(|error| matches!( - error, - WorkflowAdmissionError::MissingRequiredClaims { node_id, .. } if node_id.0 == "wf.a" - ))); - assert!(errors.iter().any(|error| matches!( - error, - WorkflowAdmissionError::UndeclaredEffectfulIo { node_id, .. } if node_id.0 == "wf.a" - ))); -} - -#[test] -fn coarse_file_claim_conflicts_with_qualified_file_claim() { - let mut dag = Dag::new(); - dag.add_node(invoke_node( - "wf.a", - "wf", - "a", - &[("file", AccessMode::Write)], - )); - dag.add_node(invoke_node( - "wf.b", - "wf", - "b", - &[("file:workspace", AccessMode::Write)], - )); - let spec = WorkflowSpec::new(WorkflowId::new("wf"), dag, 1); - let registry = registry_for_two_nodes( - vec![UnitClaim::write("file")], - vec![UnitClaim::write("file:workspace")], - ); - - let errors = - validate_workflow_admission(&spec, ®istry).expect_err("coarse vs scoped must conflict"); - assert!(errors.iter().any(|error| matches!( - error, - WorkflowAdmissionError::ConflictingClaims { left_claim, right_claim, .. } - if (left_claim == &ClaimId::new("file") && right_claim == &ClaimId::new("file:workspace")) - || (left_claim == &ClaimId::new("file:workspace") && right_claim == &ClaimId::new("file")) - ))); -} diff --git a/gunbc-dag/tests/workflow_executor_contracts.rs b/gunbc-dag/tests/workflow_executor_contracts.rs deleted file mode 100644 index 0b627159f64..00000000000 --- a/gunbc-dag/tests/workflow_executor_contracts.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Contract tests for workflow executor (WF6/WF7) and SLO instrumentation (WF9). -#![allow(clippy::disallowed_methods)] - -use std::time::{SystemTime, UNIX_EPOCH}; - -use gunbc_dag::{ - check_slo, ci_unit_commands, ci_workflow_spec, default_process_unit_registry, - default_slo_budgets, execute_workflow_plan, explain_plan, plan_workflow, - render_execution_report, test_all_unit_commands, test_all_workflow_spec, top_slow_units, - ExecutionSummary, MissReason, PlannerInputs, SloBudget, SloResult, UnitResult, -}; -use gunbc_ir::NodeId; - -fn temp_root() -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "gunbc-executor-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )) -} - -// ============================================================================ -// WF6: CI workflow executor contracts -// ============================================================================ - -#[test] -fn ci_workflow_dry_run_plans_all_units_as_execute() { - let root = temp_root(); - let spec = ci_workflow_spec().expect("ci spec"); - let registry = default_process_unit_registry(); - let plan = plan_workflow(&spec, ®istry, &PlannerInputs::new(), &root).expect("plan"); - - let commands = ci_unit_commands(); - let summary = execute_workflow_plan(&spec, &plan, &commands, &root, true); - - // All units should be executed (no cache hits). - assert!(summary.success(), "dry-run should always succeed"); - assert_eq!(summary.total_units, plan.nodes.len()); - assert_eq!(summary.cache_hits, 0, "should have no cache hits"); - assert!(summary.executed > 0, "should have executed units"); - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn ci_unit_commands_map_covers_all_non_report_nodes() { - let spec = ci_workflow_spec().expect("ci spec"); - let commands = ci_unit_commands(); - for node in &spec.dag.nodes { - if node.id.0.ends_with(".report") { - // Report nodes are no-ops; no command expected. - assert!( - !commands.contains_key(&node.id), - "report node '{}' should not have a command", - node.id.0 - ); - } else { - assert!( - commands.contains_key(&node.id), - "non-report node '{}' should have a command", - node.id.0 - ); - } - } -} - -#[test] -fn ci_executor_preserves_topological_unit_ordering() { - let root = temp_root(); - let spec = ci_workflow_spec().expect("ci spec"); - let registry = default_process_unit_registry(); - let plan = plan_workflow(&spec, ®istry, &PlannerInputs::new(), &root).expect("plan"); - - let commands = ci_unit_commands(); - let summary = execute_workflow_plan(&spec, &plan, &commands, &root, true); - - // Results should be in same order as plan nodes. - assert_eq!(summary.results.len(), plan.nodes.len()); - for (result, node_plan) in summary.results.iter().zip(plan.nodes.iter()) { - assert_eq!(result.node_id, node_plan.node_id); - } - let _ = std::fs::remove_dir_all(root); -} - -// ============================================================================ -// WF7: test-all workflow executor contracts -// ============================================================================ - -#[test] -fn test_all_workflow_dry_run_plans_all_units() { - let root = temp_root(); - let spec = test_all_workflow_spec().expect("test-all spec"); - let registry = default_process_unit_registry(); - let plan = plan_workflow(&spec, ®istry, &PlannerInputs::new(), &root).expect("plan"); - - let commands = test_all_unit_commands(); - let summary = execute_workflow_plan(&spec, &plan, &commands, &root, true); - - assert!(summary.success()); - assert_eq!(summary.total_units, plan.nodes.len()); - assert_eq!(summary.cache_hits, 0); - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn test_all_unit_commands_map_covers_all_non_report_nodes() { - let spec = test_all_workflow_spec().expect("test-all spec"); - let commands = test_all_unit_commands(); - for node in &spec.dag.nodes { - if node.id.0.ends_with(".report") { - assert!( - !commands.contains_key(&node.id), - "report node '{}' should not have a command", - node.id.0 - ); - } else { - assert!( - commands.contains_key(&node.id), - "non-report node '{}' should have a command", - node.id.0 - ); - } - } -} - -// ============================================================================ -// WF9: SLO instrumentation contracts -// ============================================================================ - -#[test] -fn default_slo_budgets_cover_ci_and_test_all() { - let budgets = default_slo_budgets(); - assert!(budgets.iter().any(|b| b.workflow_id == "ci")); - assert!(budgets.iter().any(|b| b.workflow_id == "test-all")); -} - -#[test] -fn slo_warm_noop_within_budget_passes() { - let summary = ExecutionSummary { - workflow_id: "ci".to_string(), - total_units: 11, - cache_hits: 11, - executed: 0, - failed: 0, - pending_approvals: 0, - skipped: 0, - results: vec![], - total_duration_ms: 1_000, - }; - let budget = SloBudget { - workflow_id: "ci".to_string(), - warm_noop_ms: 5_000, - total_max_ms: 600_000, - }; - assert!(check_slo(&summary, &budget).is_pass()); -} - -#[test] -fn slo_warm_noop_exceeding_budget_fails() { - let summary = ExecutionSummary { - workflow_id: "ci".to_string(), - total_units: 11, - cache_hits: 11, - executed: 0, - failed: 0, - pending_approvals: 0, - skipped: 0, - results: vec![], - total_duration_ms: 8_000, - }; - let budget = SloBudget { - workflow_id: "ci".to_string(), - warm_noop_ms: 5_000, - total_max_ms: 600_000, - }; - assert!(matches!( - check_slo(&summary, &budget), - SloResult::WarmNoopExceeded { .. } - )); -} - -#[test] -fn slo_total_exceeding_budget_fails() { - let summary = ExecutionSummary { - workflow_id: "ci".to_string(), - total_units: 11, - cache_hits: 0, - executed: 11, - failed: 0, - pending_approvals: 0, - skipped: 0, - results: vec![], - total_duration_ms: 700_000, - }; - let budget = SloBudget { - workflow_id: "ci".to_string(), - warm_noop_ms: 5_000, - total_max_ms: 600_000, - }; - assert!(matches!( - check_slo(&summary, &budget), - SloResult::TotalExceeded { .. } - )); -} - -#[test] -fn top_slow_units_sorted_by_duration_descending() { - let results = vec![ - UnitResult { - node_id: NodeId::from("fast"), - success: true, - cached: false, - pending_approval: false, - duration_ms: 100, - miss_reason: Some(MissReason::NoPriorRun), - }, - UnitResult { - node_id: NodeId::from("slow"), - success: true, - cached: false, - pending_approval: false, - duration_ms: 5_000, - miss_reason: Some(MissReason::NoPriorRun), - }, - UnitResult { - node_id: NodeId::from("medium"), - success: true, - cached: false, - pending_approval: false, - duration_ms: 2_000, - miss_reason: Some(MissReason::NoPriorRun), - }, - ]; - let slow = top_slow_units(&results, 3); - assert_eq!(slow.len(), 3); - assert_eq!(slow[0].node_id, "slow"); - assert_eq!(slow[1].node_id, "medium"); - assert_eq!(slow[2].node_id, "fast"); -} - -#[test] -fn top_slow_units_excludes_cached_hits() { - let results = vec![ - UnitResult { - node_id: NodeId::from("cached"), - success: true, - cached: true, - pending_approval: false, - duration_ms: 0, - miss_reason: None, - }, - UnitResult { - node_id: NodeId::from("executed"), - success: true, - cached: false, - pending_approval: false, - duration_ms: 1_000, - miss_reason: Some(MissReason::NoPriorRun), - }, - ]; - let slow = top_slow_units(&results, 5); - assert_eq!(slow.len(), 1); - assert_eq!(slow[0].node_id, "executed"); -} - -#[test] -fn render_execution_report_includes_slo_and_summary() { - let root = temp_root(); - let spec = ci_workflow_spec().expect("ci spec"); - let registry = default_process_unit_registry(); - let plan = plan_workflow(&spec, ®istry, &PlannerInputs::new(), &root).expect("plan"); - let commands = ci_unit_commands(); - let summary = execute_workflow_plan(&spec, &plan, &commands, &root, true); - let explain = explain_plan(&spec, &plan); - - let report = render_execution_report(&summary, &explain, &SloResult::Pass); - assert!(report.contains("workflow: ci")); - assert!(report.contains("slo: PASS")); - assert!(report.contains("result: PASS")); - assert!(report.contains("critical-path:")); - let _ = std::fs::remove_dir_all(root); -} - -// ============================================================================ -// WF8: Makefile thinning contract (core target registration) -// ============================================================================ - -#[test] -fn makefile_ci_and_test_all_registered_as_core_workflows() { - let workflows = gunbc_dag::makegen::default_core_workflows(); - assert!( - workflows.iter().any(|w| w.name == "ci"), - "ci should be registered as a core workflow" - ); - assert!( - workflows.iter().any(|w| w.name == "test-all"), - "test-all should be registered as a core workflow" - ); -} diff --git a/gunbc-dag/tests/workflow_gist_contracts.rs b/gunbc-dag/tests/workflow_gist_contracts.rs deleted file mode 100644 index f04685359a3..00000000000 --- a/gunbc-dag/tests/workflow_gist_contracts.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! Gist workflow planner contracts (WF16-WF18). -//! -//! These tests pin: -//! 1. Shared base units across gist modes. -//! 2. Mode-specific node shape for gist/diff/recent. -//! 3. Cross-workflow dedup for shared capability units. - -#![allow(clippy::disallowed_methods)] - -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; - -use gunbc_dag::{ - default_process_unit_registry, explain_plan, gist_diff_workflow_spec, - gist_recent_workflow_spec, gist_workflow_spec, plan_global_workflows, plan_workflow_with_mode, - DryRunMode, PlanAction, PlannerInputs, PlannerInputsByWorkflow, -}; - -fn temp_root() -> PathBuf { - std::env::temp_dir().join(format!( - "gunbc-gist-wf-contracts-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )) -} - -#[test] -fn gist_plan_produces_execute_on_first_run() { - let root = temp_root(); - let spec = gist_workflow_spec().expect("gist spec"); - let registry = default_process_unit_registry(); - let plan = plan_workflow_with_mode( - &spec, - ®istry, - &PlannerInputs::new(), - &root, - DryRunMode::Lenient, - ) - .expect("plan"); - - for node in &plan.nodes { - assert!( - matches!(node.action, PlanAction::Execute { .. }), - "node '{}' should execute on first run", - node.node_id.0 - ); - } - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn gist_recent_critical_path_includes_rev_list() { - let root = temp_root(); - let spec = gist_recent_workflow_spec().expect("gist-recent spec"); - let registry = default_process_unit_registry(); - let plan = plan_workflow_with_mode( - &spec, - ®istry, - &PlannerInputs::new(), - &root, - DryRunMode::Lenient, - ) - .expect("plan"); - let explain = explain_plan(&spec, &plan); - - assert!( - explain.critical_path.iter().any(|n| n.0 == "gist.rev_list"), - "expected gist.rev_list in gist-recent critical path" - ); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn gist_diff_has_fewer_nodes_than_gist() { - let gist = gist_workflow_spec().expect("gist"); - let diff = gist_diff_workflow_spec().expect("diff"); - assert!( - diff.dag.nodes.len() < gist.dag.nodes.len(), - "diff ({}) should have fewer nodes than gist ({})", - diff.dag.nodes.len(), - gist.dag.nodes.len() - ); -} - -#[test] -fn shared_base_units_keep_identical_work_identity_across_modes() { - let root = temp_root(); - let registry = default_process_unit_registry(); - let gist = gist_workflow_spec().expect("gist"); - let diff = gist_diff_workflow_spec().expect("diff"); - let recent = gist_recent_workflow_spec().expect("recent"); - - let gist_plan = plan_workflow_with_mode( - &gist, - ®istry, - &PlannerInputs::new(), - &root, - DryRunMode::Lenient, - ) - .expect("gist plan"); - let diff_plan = plan_workflow_with_mode( - &diff, - ®istry, - &PlannerInputs::new(), - &root, - DryRunMode::Lenient, - ) - .expect("diff plan"); - let recent_plan = plan_workflow_with_mode( - &recent, - ®istry, - &PlannerInputs::new(), - &root, - DryRunMode::Lenient, - ) - .expect("recent plan"); - - for node_name in [ - "gist.compilation_ensure", - "gist.codegen_ensure", - "gist.branch_resolution", - "gist.credential_resolve", - "gist.gist_create", - ] { - let a = gist_plan - .nodes - .iter() - .find(|n| n.node_id.0 == node_name) - .map(|n| &n.work_id) - .unwrap_or_else(|| panic!("gist should include {node_name}")); - let b = diff_plan - .nodes - .iter() - .find(|n| n.node_id.0 == node_name) - .map(|n| &n.work_id) - .unwrap_or_else(|| panic!("diff should include {node_name}")); - let c = recent_plan - .nodes - .iter() - .find(|n| n.node_id.0 == node_name) - .map(|n| &n.work_id) - .unwrap_or_else(|| panic!("recent should include {node_name}")); - - assert_eq!( - a, b, - "work identity drift for {node_name} between gist/diff" - ); - assert_eq!( - b, c, - "work identity drift for {node_name} between diff/recent" - ); - } - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn shared_compilation_key_is_identical_between_gist_and_diff() { - let root = temp_root(); - let registry = default_process_unit_registry(); - let gist = gist_workflow_spec().expect("gist"); - let diff = gist_diff_workflow_spec().expect("diff"); - - let gist_plan = plan_workflow_with_mode( - &gist, - ®istry, - &PlannerInputs::new(), - &root, - DryRunMode::Lenient, - ) - .expect("gist plan"); - let diff_plan = plan_workflow_with_mode( - &diff, - ®istry, - &PlannerInputs::new(), - &root, - DryRunMode::Lenient, - ) - .expect("diff plan"); - - let a = gist_plan - .nodes - .iter() - .find(|n| n.node_id.0 == "gist.compilation_ensure") - .expect("gist compilation"); - let b = diff_plan - .nodes - .iter() - .find(|n| n.node_id.0 == "gist.compilation_ensure") - .expect("diff compilation"); - - assert_eq!( - a.key.digest, b.key.digest, - "compilation key should be stable across gist modes" - ); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn global_plan_deduplicates_shared_gist_base_units() { - let root = temp_root(); - let registry = default_process_unit_registry(); - let gist = gist_workflow_spec().expect("gist"); - let diff = gist_diff_workflow_spec().expect("diff"); - - let mut inputs = PlannerInputsByWorkflow::new(); - inputs.insert(gist.id.clone(), PlannerInputs::new()); - inputs.insert(diff.id.clone(), PlannerInputs::new()); - - let specs = vec![gist, diff]; - let global = plan_global_workflows(&specs, ®istry, &inputs, &root).expect("global plan"); - - let compile_vertices: Vec<_> = global - .vertices - .iter() - .filter(|v| { - v.node_refs - .iter() - .any(|r| r.node_id.0 == "gist.compilation_ensure") - }) - .collect(); - assert_eq!( - compile_vertices.len(), - 1, - "shared gist compilation node should be deduplicated" - ); - assert_eq!( - compile_vertices[0].node_refs.len(), - 2, - "deduplicated gist compilation node should reference both workflows" - ); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn explain_execute_set_matches_node_count_for_snapshot() { - let root = temp_root(); - let spec = gist_workflow_spec().expect("gist"); - let registry = default_process_unit_registry(); - let plan = plan_workflow_with_mode( - &spec, - ®istry, - &PlannerInputs::new(), - &root, - DryRunMode::Lenient, - ) - .expect("plan"); - let explain = explain_plan(&spec, &plan); - - assert_eq!(explain.execute_set.len(), spec.dag.nodes.len()); - assert!(explain.cache_hit_set.is_empty()); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn planner_inputs_by_workflow_alias_is_btreemap() { - let mut map = PlannerInputsByWorkflow::new(); - map.insert("gist-snapshot".into(), PlannerInputs::new()); - let _: BTreeMap<_, _> = map; -} diff --git a/gunbc-dag/tests/workflow_global_dedup_contracts.rs b/gunbc-dag/tests/workflow_global_dedup_contracts.rs deleted file mode 100644 index 1b323c5ce99..00000000000 --- a/gunbc-dag/tests/workflow_global_dedup_contracts.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Global flattening + projection-proof contracts (M17-M19). - -use std::collections::BTreeSet; -use std::time::{SystemTime, UNIX_EPOCH}; - -use gunbc_dag::{ - ci_workflow_spec, default_process_unit_registry, plan_global_workflows, project_execute_set, - prove_non_redundancy, test_all_workflow_spec, validate_projection_equivalence, - PlannerInputsByWorkflow, WorkflowId, -}; -use gunbc_ir::NodeId; - -fn temp_root() -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "gunbc-workflow-global-dedup-contracts-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )) -} - -#[test] -fn global_plan_dedups_equivalent_work_across_ci_and_test_all() { - let root = temp_root(); - let specs = vec![ - ci_workflow_spec().expect("ci spec"), - test_all_workflow_spec().expect("test-all spec"), - ]; - let registry = default_process_unit_registry(); - let global = plan_global_workflows(&specs, ®istry, &PlannerInputsByWorkflow::new(), &root) - .expect("global plan"); - - let codegen = global - .vertices - .iter() - .find(|vertex| vertex.work_id.unit_id == NodeId::from("codegen")) - .expect("canonical codegen vertex"); - let refs = codegen - .node_refs - .iter() - .map(|reference| reference.workflow_id.clone()) - .collect::>(); - assert_eq!(refs.len(), 2); - assert!(refs.contains(&WorkflowId::new("ci"))); - assert!(refs.contains(&WorkflowId::new("test-all"))); -} - -#[test] -fn proof_and_projection_checks_hold_for_global_plan() { - let root = temp_root(); - let specs = vec![ - ci_workflow_spec().expect("ci spec"), - test_all_workflow_spec().expect("test-all spec"), - ]; - let registry = default_process_unit_registry(); - let global = plan_global_workflows(&specs, ®istry, &PlannerInputsByWorkflow::new(), &root) - .expect("global plan"); - - prove_non_redundancy(&global).expect("global plan should satisfy non-redundancy invariants"); - let projection = project_execute_set(&global); - validate_projection_equivalence(&global, &projection) - .expect("canonical execute projection should be drift-free"); -} diff --git a/gunbc-dag/tests/workflow_key_ledger_contracts.rs b/gunbc-dag/tests/workflow_key_ledger_contracts.rs deleted file mode 100644 index 4435a1359cb..00000000000 --- a/gunbc-dag/tests/workflow_key_ledger_contracts.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Workflow key contracts (WF3). -#![allow(clippy::disallowed_methods, clippy::disallowed_types)] - -use std::thread; -use std::time::{SystemTime, UNIX_EPOCH}; - -use gunbc_dag::{ - ci_workflow_spec, default_process_unit_registry, plan_workflow, required_input_contract, - required_output_contract, PlanAction, PlannerInputs, PlannerWorkflowSpec, ProcessUnitRef, - ProcessUnitRegistry, WorkflowId, WorkflowOp, WorkflowPlannerError, WorkflowUnit, -}; -use gunbc_ir::{Dag, Node, NodeId}; - -fn temp_root() -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "gunbc-workflow-key-contracts-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )) -} - -#[test] -fn keys_are_deterministic_for_fixed_inputs() { - let root = temp_root(); - let spec = ci_workflow_spec().expect("ci spec"); - let registry = default_process_unit_registry(); - let inputs = PlannerInputs::new(); - - let first = plan_workflow(&spec, ®istry, &inputs, &root).expect("first plan"); - let second = plan_workflow(&spec, ®istry, &inputs, &root).expect("second plan"); - - let first_digests = first - .nodes - .iter() - .map(|node| (node.node_id.clone(), node.key.digest.clone())) - .collect::>(); - let second_digests = second - .nodes - .iter() - .map(|node| (node.node_id.clone(), node.key.digest.clone())) - .collect::>(); - assert_eq!(first_digests, second_digests); -} - -#[test] -fn all_nodes_always_execute() { - let root = temp_root(); - let spec = ci_workflow_spec().expect("ci spec"); - let registry = default_process_unit_registry(); - let inputs = PlannerInputs::new(); - - let plan = plan_workflow(&spec, ®istry, &inputs, &root).expect("plan"); - for node in &plan.nodes { - assert!( - matches!(node.action, PlanAction::Execute { .. }), - "all nodes should be Execute" - ); - } -} - -#[test] -fn unknown_process_units_fail_planner_admission() { - let root = temp_root(); - let mut dag = Dag::new(); - dag.add_node(Node::opaque( - "wf.unknown", - required_input_contract(), - required_output_contract(), - WorkflowUnit::new(WorkflowOp::InvokeProcessUnit(ProcessUnitRef::new( - "wf", - "wf.unknown", - ))), - )); - let spec = PlannerWorkflowSpec::new(WorkflowId::new("wf"), dag, 1); - let registry = ProcessUnitRegistry::new(); - - let err = plan_workflow(&spec, ®istry, &PlannerInputs::new(), &root) - .expect_err("unknown process unit should fail planner"); - assert!(matches!( - err, - WorkflowPlannerError::UnknownProcessUnit { node_id, .. } if node_id == NodeId::from("wf.unknown") - )); -} - -#[test] -fn concurrent_planning_calls_remain_deterministic() { - let root = temp_root(); - let spec = ci_workflow_spec().expect("ci spec"); - let registry = default_process_unit_registry(); - let baseline = plan_workflow(&spec, ®istry, &PlannerInputs::new(), &root) - .expect("baseline plan should succeed") - .nodes - .into_iter() - .map(|node| (node.node_id, node.key.digest)) - .collect::>(); - - let mut handles = Vec::new(); - for _ in 0..6 { - let spec = spec.clone(); - let registry = registry.clone(); - let root = root.clone(); - handles.push(thread::spawn(move || { - plan_workflow(&spec, ®istry, &PlannerInputs::new(), &root) - .expect("concurrent plan should succeed") - .nodes - .into_iter() - .map(|node| (node.node_id, node.key.digest)) - .collect::>() - })); - } - - for handle in handles { - let digests = handle.join().expect("planning thread should not panic"); - assert_eq!(digests, baseline); - } -} diff --git a/gunbc-dag/tests/workflow_plan_cli_contracts.rs b/gunbc-dag/tests/workflow_plan_cli_contracts.rs deleted file mode 100644 index ea916fb9b05..00000000000 --- a/gunbc-dag/tests/workflow_plan_cli_contracts.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Workflow planner CLI explainability contracts (WF5). -#![allow(clippy::disallowed_methods)] - -use std::path::PathBuf; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; - -use serde_json::Value; - -fn temp_root() -> PathBuf { - std::env::temp_dir().join(format!( - "gunbc-workflow-cli-contracts-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )) -} - -fn run_workflow_plan_json(workflow: &str, workspace_root: &PathBuf) -> Value { - let output = Command::new(env!("CARGO_BIN_EXE_gunbc-workflow")) - .arg("--plan") - .arg(workflow) - .arg("--format") - .arg("json") - .arg("--workspace-root") - .arg(workspace_root) - .output() - .expect("gunbc-workflow should run"); - assert!( - output.status.success(), - "gunbc-workflow failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - serde_json::from_slice(&output.stdout).expect("workflow plan output should be valid json") -} - -#[test] -fn plan_output_is_deterministic_for_ci_fixture_state() { - let root = temp_root(); - std::fs::create_dir_all(&root).expect("temp workspace root"); - - let first = run_workflow_plan_json("ci", &root); - let second = run_workflow_plan_json("ci", &root); - assert_eq!(first, second, "ci plan output should be deterministic"); - assert_eq!(first["workflow"], Value::String("ci".to_string())); - assert!(first["execute_set"].is_array()); - assert!(first["cache_hit_set"].is_array()); - assert!(first["critical_path"].is_array()); - assert!(first["blocked"].is_object()); - assert!(first["ready"].is_array()); - - for entry in first["execute_set"] - .as_array() - .expect("execute_set should be an array") - { - assert!(entry["node_id"].is_string()); - assert!(entry["miss_reason"].is_string()); - } -} - -#[test] -fn plan_output_is_deterministic_for_test_all_fixture_state() { - let root = temp_root(); - std::fs::create_dir_all(&root).expect("temp workspace root"); - - let first = run_workflow_plan_json("test-all", &root); - let second = run_workflow_plan_json("test-all", &root); - assert_eq!( - first, second, - "test-all plan output should be deterministic" - ); - assert_eq!(first["workflow"], Value::String("test-all".to_string())); - assert!(first["execute_set"].is_array()); - assert!(first["critical_path"].is_array()); - assert!( - first["execute_set"] - .as_array() - .expect("execute_set should be an array") - .iter() - .all(|entry| { - entry["miss_reason"] - .as_str() - .is_some_and(|reason| reason.starts_with("miss:no-prior-run")) - }), - "fresh fixture should classify execute set misses as no-prior-run" - ); -} diff --git a/gunbc-dag/tests/workflow_schema_contracts.rs b/gunbc-dag/tests/workflow_schema_contracts.rs deleted file mode 100644 index a0202a22225..00000000000 --- a/gunbc-dag/tests/workflow_schema_contracts.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Workflow schema contract tests (WF1). - -use std::collections::BTreeSet; - -use gunbc_dag::{ - ci_workflow_spec, default_process_unit_registry, has_required_unit_contract, - test_all_workflow_spec, ProcessUnitRegistry, WorkflowOp, -}; - -fn assert_workflow_schema_contracts( - spec: &gunbc_dag::PlannerWorkflowSpec, - expected_node_prefix: &str, - expected_process_namespaces: &[&str], - registry: &ProcessUnitRegistry, -) { - assert!( - !spec.dag.nodes.is_empty(), - "workflow '{}' should contain at least one node", - spec.id.0 - ); - - let ids = spec - .dag - .nodes - .iter() - .map(|node| node.id.0.clone()) - .collect::>(); - assert_eq!( - ids.len(), - spec.dag.nodes.len(), - "workflow '{}' contains duplicate node IDs", - spec.id.0 - ); - - let mut has_report_node = false; - let mut invoke_count = 0usize; - for node in &spec.dag.nodes { - assert!( - node.id.0.starts_with(expected_node_prefix), - "node '{}' drifted outside expected workflow prefix '{}'", - node.id.0, - expected_node_prefix - ); - assert!( - has_required_unit_contract(&node.inputs, &node.outputs), - "node '{}' missing required workflow unit contract", - node.id.0 - ); - - let gunbc_ir::NodeBody::Opaque(unit) = &node.body else { - panic!("workflow node '{}' must use opaque typed unit", node.id.0); - }; - match &unit.op { - WorkflowOp::InvokeProcessUnit(process_ref) => { - invoke_count += 1; - assert!( - expected_process_namespaces.contains(&process_ref.process_id.0.as_str()), - "invoke node '{}' should stay within {:?} process namespaces (found '{}')", - node.id.0, - expected_process_namespaces, - process_ref.process_id.0 - ); - assert!( - registry.contains(process_ref), - "invoke node '{}' references non-registered process unit '{}::{}'", - node.id.0, - process_ref.process_id.0, - process_ref.unit_id.0 - ); - } - WorkflowOp::Aggregate(_) => {} - WorkflowOp::Report(_) => has_report_node = true, - } - } - - assert!( - has_report_node, - "workflow '{}' must include a report node", - spec.id.0 - ); - assert!( - invoke_count > 0, - "workflow '{}' must include at least one invoke node", - spec.id.0 - ); -} - -#[test] -fn ci_workflow_spec_satisfies_schema_contracts() { - let spec = ci_workflow_spec().expect("ci workflow spec should build"); - let registry = default_process_unit_registry(); - assert_workflow_schema_contracts(&spec, "ci.", &["ci"], ®istry); -} - -#[test] -fn test_all_workflow_spec_satisfies_schema_contracts() { - let spec = test_all_workflow_spec().expect("test-all workflow spec should build"); - let registry = default_process_unit_registry(); - assert_workflow_schema_contracts(&spec, "test_all.", &["test_all"], ®istry); -} - -#[test] -fn planner_schema_uses_typed_ops_not_shell_fallback() { - let ci = ci_workflow_spec().expect("ci workflow spec should build"); - for node in &ci.dag.nodes { - let gunbc_ir::NodeBody::Opaque(unit) = &node.body else { - panic!("workflow unit '{}' must be opaque typed unit", node.id.0); - }; - match &unit.op { - WorkflowOp::InvokeProcessUnit(_) | WorkflowOp::Aggregate(_) | WorkflowOp::Report(_) => { - } - } - } -} diff --git a/gunbc-dag/tests/workflow_tool_capability_contracts.rs b/gunbc-dag/tests/workflow_tool_capability_contracts.rs deleted file mode 100644 index 61f3accacbc..00000000000 --- a/gunbc-dag/tests/workflow_tool_capability_contracts.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! Cross-workflow capability sharing contracts (WF19/WF20). -//! -//! These tests verify: -//! 1. Universal capabilities (compilation_ensure, codegen_ensure) share -//! canonical WorkIdentity across all tool workflows via global dedup. -//! 2. All tool workflow specs build deterministically. -//! 3. Global plan flattening and non-redundancy proofs hold when tool -//! workflows are included alongside CI and test-all. -#![allow(clippy::disallowed_methods)] - -use std::collections::BTreeSet; -use std::time::{SystemTime, UNIX_EPOCH}; - -use gunbc_dag::{ - all_tool_workflow_names, bootstrap_workflow_spec, ci_workflow_spec, - default_process_unit_registry, deps_workflow_spec, makegen_workflow_spec, - plan_global_workflows, pragma_workflow_spec, project_execute_set, prove_non_redundancy, - test_all_workflow_spec, tool_workflow_spec, validate_projection_equivalence, - PlannerInputsByWorkflow, -}; -use gunbc_ir::NodeId; - -fn temp_root() -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "gunbc-workflow-tool-cap-contracts-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )) -} - -// ========================================================================= -// WF19/WF20: Spec construction contracts -// ========================================================================= - -#[test] -fn all_tool_workflow_specs_build_without_error() { - for name in all_tool_workflow_names() { - tool_workflow_spec(&name) - .unwrap_or_else(|error| panic!("tool workflow '{name}' failed to build: {error}")); - } -} - -#[test] -fn all_tool_workflow_specs_are_deterministic() { - for name in all_tool_workflow_names() { - let a = tool_workflow_spec(&name).expect(&name); - let b = tool_workflow_spec(&name).expect(&name); - assert_eq!( - a.dag.to_ascii(&name), - b.dag.to_ascii(&name), - "workflow '{name}' is not deterministic" - ); - } -} - -// ========================================================================= -// WF19/WF20: Universal capability dedup contracts -// ========================================================================= - -#[test] -fn compilation_ensure_is_deduped_across_all_tool_workflows() { - let root = temp_root(); - let specs = vec![ - bootstrap_workflow_spec().expect("bootstrap"), - makegen_workflow_spec().expect("makegen"), - pragma_workflow_spec().expect("pragma"), - deps_workflow_spec().expect("deps"), - ]; - let registry = default_process_unit_registry(); - let global = plan_global_workflows(&specs, ®istry, &PlannerInputsByWorkflow::new(), &root) - .expect("global plan"); - - // Find the canonical compilation_ensure vertex - let compilation = global - .vertices - .iter() - .find(|vertex| vertex.work_id.unit_id == NodeId::from("compilation_ensure")) - .expect("expected canonical compilation_ensure vertex in global plan"); - - // All 4 tool workflows should reference this single vertex - let workflows: BTreeSet<_> = compilation - .node_refs - .iter() - .map(|r| r.workflow_id.clone()) - .collect(); - assert_eq!( - workflows.len(), - 4, - "compilation_ensure should be shared across all 4 tool workflows, found: {workflows:?}" - ); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn codegen_ensure_is_deduped_across_all_tool_workflows() { - let root = temp_root(); - let specs = vec![ - bootstrap_workflow_spec().expect("bootstrap"), - makegen_workflow_spec().expect("makegen"), - pragma_workflow_spec().expect("pragma"), - deps_workflow_spec().expect("deps"), - ]; - let registry = default_process_unit_registry(); - let global = plan_global_workflows(&specs, ®istry, &PlannerInputsByWorkflow::new(), &root) - .expect("global plan"); - - let codegen = global - .vertices - .iter() - .find(|vertex| vertex.work_id.unit_id == NodeId::from("codegen_ensure")) - .expect("expected canonical codegen_ensure vertex in global plan"); - - let workflows: BTreeSet<_> = codegen - .node_refs - .iter() - .map(|r| r.workflow_id.clone()) - .collect(); - assert_eq!( - workflows.len(), - 4, - "codegen_ensure should be shared across all 4 tool workflows, found: {workflows:?}" - ); - - let _ = std::fs::remove_dir_all(root); -} - -// ========================================================================= -// Global plan invariants with all workflows combined -// ========================================================================= - -#[test] -fn global_plan_with_all_workflows_satisfies_non_redundancy_proof() { - let root = temp_root(); - let specs = vec![ - ci_workflow_spec().expect("ci"), - test_all_workflow_spec().expect("test-all"), - bootstrap_workflow_spec().expect("bootstrap"), - makegen_workflow_spec().expect("makegen"), - pragma_workflow_spec().expect("pragma"), - deps_workflow_spec().expect("deps"), - ]; - let registry = default_process_unit_registry(); - let global = plan_global_workflows(&specs, ®istry, &PlannerInputsByWorkflow::new(), &root) - .expect("global plan"); - - prove_non_redundancy(&global).expect("global plan with all workflows should be non-redundant"); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn global_plan_with_all_workflows_satisfies_projection_equivalence() { - let root = temp_root(); - let specs = vec![ - ci_workflow_spec().expect("ci"), - test_all_workflow_spec().expect("test-all"), - bootstrap_workflow_spec().expect("bootstrap"), - makegen_workflow_spec().expect("makegen"), - pragma_workflow_spec().expect("pragma"), - deps_workflow_spec().expect("deps"), - ]; - let registry = default_process_unit_registry(); - let global = plan_global_workflows(&specs, ®istry, &PlannerInputsByWorkflow::new(), &root) - .expect("global plan"); - - let projection = project_execute_set(&global); - validate_projection_equivalence(&global, &projection) - .expect("execute projection should be drift-free across all workflows"); - - let _ = std::fs::remove_dir_all(root); -} - -// ========================================================================= -// WF19: Acceptance criteria - warm no-op contracts -// ========================================================================= - -#[test] -fn bootstrap_workflow_contains_compilation_and_codegen_gates() { - let spec = bootstrap_workflow_spec().expect("bootstrap spec"); - let node_ids: Vec = spec.dag.nodes.iter().map(|n| n.id.0.clone()).collect(); - assert!(node_ids.contains(&"bootstrap.compilation_ensure".to_string())); - assert!(node_ids.contains(&"bootstrap.codegen_ensure".to_string())); -} - -#[test] -fn makegen_workflow_is_linear_chain() { - let spec = makegen_workflow_spec().expect("makegen spec"); - // 6 nodes, 5 edges in a linear chain - assert_eq!(spec.dag.nodes.len(), 6); - assert_eq!(spec.dag.edges.len(), 5); -} - -#[test] -fn pragma_workflow_has_three_parallel_upsert_chains() { - let spec = pragma_workflow_spec().expect("pragma spec"); - let node_ids: Vec = spec.dag.nodes.iter().map(|n| n.id.0.clone()).collect(); - assert!(node_ids.contains(&"pragma.upsert_clippy".to_string())); - assert!(node_ids.contains(&"pragma.upsert_allowlist".to_string())); - assert!(node_ids.contains(&"pragma.upsert_policy".to_string())); -} - -#[test] -fn deps_workflow_has_parallel_install_and_generate_chains() { - let spec = deps_workflow_spec().expect("deps spec"); - let node_ids: Vec = spec.dag.nodes.iter().map(|n| n.id.0.clone()).collect(); - // Install chain - assert!(node_ids.contains(&"deps.execute_installs".to_string())); - // Generate chain - assert!(node_ids.contains(&"deps.write_deps_toml".to_string())); -} From 7199f5d76e70af4e23baff58e8d6e932aca3fc9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 18:06:37 +0000 Subject: [PATCH 7/7] Fix Worker A chain typo in tasks Co-authored-by: Brian Searls --- tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.md b/tasks.md index 7cebb2b9258..1109b76e564 100644 --- a/tasks.md +++ b/tasks.md @@ -460,7 +460,7 @@ NOT modify `core/codegen/src/cli_gen.rs` or any compiler crate. | A11 | — | **Delete compensating tests.** 7 `workflow_*.rs` + `infra_cli.rs`. | Files deleted. `cargo test --workspace` passes. | S | **Prerequisite**: C20 (profile/mode/subcommand CLI gen) must land before A1-A5. -**Chain**: A1 → A2 → A3; A4 → A5 → A6; A7 → A8 → A9 → A10 → A11 +**Chain**: A1 → A2 → A3; A4 → A5; A7 → A8 → A9 → A10 → A11 ---