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/Cargo.toml b/Cargo.toml index 19e0899f380..1522848c687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "core/infra", "core/ir", "core/exec", + "core/workflow", "core/cli", "core/codegen", "core/test", diff --git a/core/cli/src/binary_args.rs b/core/cli/src/binary_args.rs index 7656fd84b48..21ae42f0cf9 100644 --- a/core/cli/src/binary_args.rs +++ b/core/cli/src/binary_args.rs @@ -1,16 +1,4 @@ -//! 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"; +//! Shared step-mode subcommand parsing for generated CLIs. /// Supported subcommands for generated step-mode CLIs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -90,143 +78,6 @@ pub fn parse_step_mode(argv: &[String]) -> Result, - 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::*; @@ -235,163 +86,6 @@ mod tests { 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(); diff --git a/core/workflow/Cargo.toml b/core/workflow/Cargo.toml new file mode 100644 index 00000000000..f041dc20131 --- /dev/null +++ b/core/workflow/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "gunbc-workflow" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Generic workflow engine: planner, executor, coordination, SLO" + +[dependencies] +gunbc-infra = { path = "../infra" } +gunbc-ir = { path = "../ir" } +gunbc-exec = { path = "../exec" } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/gunbc-dag/src/workflow/admission.rs b/core/workflow/src/admission.rs similarity index 98% rename from gunbc-dag/src/workflow/admission.rs rename to core/workflow/src/admission.rs index 6a7b8637590..69bd524b93c 100644 --- a/gunbc-dag/src/workflow/admission.rs +++ b/core/workflow/src/admission.rs @@ -4,9 +4,9 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use gunbc_ir::{derive_resource_accesses, Dag, NodeBody, ResourceId}; -use super::errors::WorkflowAdmissionError; -use super::process_registry::{ClaimId, ProcessUnitRegistry, UnitClaim}; -use super::schema::{WorkflowOp, WorkflowSpec, WorkflowUnit}; +use crate::errors::WorkflowAdmissionError; +use crate::process_registry::{ClaimId, ProcessUnitRegistry, UnitClaim}; +use crate::schema::{WorkflowOp, WorkflowSpec, WorkflowUnit}; /// Validate workflow admission with fail-closed semantics. /// @@ -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 87% rename from gunbc-dag/src/workflow/coordination.rs rename to core/workflow/src/coordination.rs index d7f6c02403f..5d8b944dfa2 100644 --- a/gunbc-dag/src/workflow/coordination.rs +++ b/core/workflow/src/coordination.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use gunbc_ir::{NodeId, PortName}; -use super::schema::{WorkflowSpec, PORT_AFTER}; +use crate::schema::{WorkflowSpec, PORT_AFTER}; /// Why a node is blocked from execution readiness. #[derive(Debug, Clone, PartialEq, Eq)] @@ -89,9 +89,9 @@ mod tests { use super::*; use gunbc_ir::{Dag, Edge, Node, Port}; - use crate::workflow::schema::{ - required_input_contract, required_output_contract, WorkflowId, WorkflowOp, WorkflowSpec, - WorkflowUnit, + use crate::schema::{ + required_input_contract, required_output_contract, AggregateSpec, WorkflowId, WorkflowOp, + WorkflowSpec, WorkflowUnit, }; #[test] @@ -101,17 +101,13 @@ mod tests { "wf.a", required_input_contract(), required_output_contract(), - WorkflowUnit::new(WorkflowOp::Aggregate( - crate::workflow::schema::AggregateSpec::new("a"), - )), + WorkflowUnit::new(WorkflowOp::Aggregate(AggregateSpec::new("a"))), )); dag.add_node(Node::opaque( "wf.b", required_input_contract(), required_output_contract(), - WorkflowUnit::new(WorkflowOp::Aggregate( - crate::workflow::schema::AggregateSpec::new("b"), - )), + WorkflowUnit::new(WorkflowOp::Aggregate(AggregateSpec::new("b"))), )); dag.add_edge(Edge::control("wf.a", "commit", "wf.b", "after")); let spec = WorkflowSpec::new(WorkflowId::new("wf"), dag, 1); @@ -136,9 +132,7 @@ mod tests { "wf.only", inputs, required_output_contract(), - WorkflowUnit::new(WorkflowOp::Aggregate( - crate::workflow::schema::AggregateSpec::new("only"), - )), + WorkflowUnit::new(WorkflowOp::Aggregate(AggregateSpec::new("only"))), )); let spec = WorkflowSpec::new(WorkflowId::new("wf"), dag, 1); @@ -162,9 +156,7 @@ mod tests { "wf.only", inputs, required_output_contract(), - WorkflowUnit::new(WorkflowOp::Aggregate( - crate::workflow::schema::AggregateSpec::new("only"), - )), + WorkflowUnit::new(WorkflowOp::Aggregate(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 98% rename from gunbc-dag/src/workflow/errors.rs rename to core/workflow/src/errors.rs index b33baee5516..e65ac635f64 100644 --- a/gunbc-dag/src/workflow/errors.rs +++ b/core/workflow/src/errors.rs @@ -3,7 +3,7 @@ use gunbc_ir::{AccessMode, NodeId}; use std::fmt; -use super::process_registry::{ClaimId, ProcessUnitRef, UnitClaim}; +use crate::process_registry::{ClaimId, ProcessUnitRef, UnitClaim}; /// Fail-closed validation errors for workflow planner admission. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/gunbc-dag/src/workflow/executor.rs b/core/workflow/src/executor.rs similarity index 93% rename from gunbc-dag/src/workflow/executor.rs rename to core/workflow/src/executor.rs index bb28f133deb..7b0a30b96d4 100644 --- a/gunbc-dag/src/workflow/executor.rs +++ b/core/workflow/src/executor.rs @@ -16,9 +16,9 @@ use std::time::Instant; use gunbc_ir::NodeId; -use super::key::MissReason; -use super::planner::{PlanAction, WorkflowPlan}; -use super::schema::WorkflowSpec; +use crate::key::MissReason; +use crate::planner::{PlanAction, WorkflowPlan}; +use crate::schema::WorkflowSpec; /// Shell command to execute for a workflow unit. #[derive(Debug, Clone)] @@ -260,12 +260,12 @@ 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 { + fn make_node_plan(name: &str, action: PlanAction) -> crate::planner::NodePlan { let work_id = WorkIdentity::new(ProcessId::new("test"), NodeId::from(name)); let key = MaterializationKey::new( work_id.clone(), @@ -279,7 +279,7 @@ mod tests { ) .expect("key should build"); - super::super::planner::NodePlan { + crate::planner::NodePlan { node_id: NodeId::from(name), work_id, key, @@ -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/core/workflow/src/global_plan.rs b/core/workflow/src/global_plan.rs new file mode 100644 index 00000000000..bc686e8cf6b --- /dev/null +++ b/core/workflow/src/global_plan.rs @@ -0,0 +1,89 @@ +//! Global flattening + dedup planner contracts (M17). + +use std::collections::BTreeMap; +use std::path::Path; + +use gunbc_ir::NodeId; + +use crate::key::{MaterializationDigest, WorkIdentity}; +use crate::planner::{ + plan_workflow, PlanAction, PlannerInputs, WorkflowPlan, WorkflowPlannerError, +}; +use crate::process_registry::ProcessUnitRegistry; +use crate::schema::{WorkflowId, WorkflowSpec}; + +/// Workflow+node reference attached to a global dedup vertex. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct WorkflowNodeRef { + pub workflow_id: WorkflowId, + pub node_id: NodeId, +} + +/// Global deduplicated execution vertex. +#[derive(Debug, Clone, PartialEq)] +pub struct GlobalExecutionVertex { + pub work_id: WorkIdentity, + pub digest: MaterializationDigest, + pub action: PlanAction, + pub node_refs: Vec, +} + +/// Flattened global workflow plan across multiple entrypoint workflows. +#[derive(Debug, Clone, PartialEq)] +pub struct GlobalWorkflowPlan { + pub vertices: Vec, +} + +/// Per-workflow planner inputs used by global planner. +pub type PlannerInputsByWorkflow = BTreeMap; + +/// Flatten and deduplicate workflow plans by `(WorkIdentity, MaterializationDigest)`. +pub fn plan_global_workflows( + specs: &[WorkflowSpec], + registry: &ProcessUnitRegistry, + planner_inputs: &PlannerInputsByWorkflow, + workspace_root: &Path, +) -> Result { + let mut grouped: BTreeMap<(WorkIdentity, MaterializationDigest), GlobalExecutionVertex> = + BTreeMap::new(); + + for spec in specs { + let inputs = planner_inputs.get(&spec.id).cloned().unwrap_or_default(); + let plan = plan_workflow(spec, registry, &inputs, workspace_root)?; + merge_workflow_plan(spec, &plan, &mut grouped); + } + + let mut vertices = grouped.into_values().collect::>(); + vertices.sort_by(|left, right| { + left.work_id + .cmp(&right.work_id) + .then(left.digest.cmp(&right.digest)) + }); + Ok(GlobalWorkflowPlan { vertices }) +} + +fn merge_workflow_plan( + workflow_spec: &WorkflowSpec, + plan: &WorkflowPlan, + grouped: &mut BTreeMap<(WorkIdentity, MaterializationDigest), GlobalExecutionVertex>, +) { + for node in &plan.nodes { + let key = (node.work_id.clone(), node.key.digest.clone()); + let node_ref = WorkflowNodeRef { + workflow_id: workflow_spec.id.clone(), + node_id: node.node_id.clone(), + }; + grouped + .entry(key.clone()) + .and_modify(|existing| { + existing.node_refs.push(node_ref.clone()); + existing.node_refs.sort(); + }) + .or_insert_with(|| GlobalExecutionVertex { + work_id: key.0, + digest: key.1, + action: node.action.clone(), + node_refs: vec![node_ref], + }); + } +} diff --git a/gunbc-dag/src/workflow/key.rs b/core/workflow/src/key.rs similarity index 99% rename from gunbc-dag/src/workflow/key.rs rename to core/workflow/src/key.rs index e695fe5dd86..61d41ac9081 100644 --- a/gunbc-dag/src/workflow/key.rs +++ b/core/workflow/src/key.rs @@ -6,7 +6,7 @@ use gunbc_infra::hash::ContentHash; use gunbc_ir::{NodeId, PortName}; use serde::{Deserialize, Serialize}; -use super::process_registry::ProcessId; +use crate::process_registry::ProcessId; /// Deterministic materialization digest (sha256 of canonical key payload). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] diff --git a/core/workflow/src/lib.rs b/core/workflow/src/lib.rs new file mode 100644 index 00000000000..325c0bcd886 --- /dev/null +++ b/core/workflow/src/lib.rs @@ -0,0 +1,54 @@ +//! Generic workflow engine: planner, executor, coordination, SLO. +//! +//! This crate provides workflow planning, execution, and coordination logic +//! that is independent of any specific repository's DSL catalog or commands. + +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 97% rename from gunbc-dag/src/workflow/planner.rs rename to core/workflow/src/planner.rs index 905992938d2..e2be77509ca 100644 --- a/gunbc-dag/src/workflow/planner.rs +++ b/core/workflow/src/planner.rs @@ -6,12 +6,12 @@ use std::path::Path; use gunbc_exec::topo_sort; use gunbc_ir::{canonical_edge_order, NodeBody, NodeId, PortName, Value}; -use super::coordination::{coordination_status, BlockedReason, CoordinationStatus}; -use super::key::{ +use crate::coordination::{coordination_status, BlockedReason, CoordinationStatus}; +use crate::key::{ CanonicalKeyPayload, MaterializationDigest, MaterializationKey, MissReason, WorkIdentity, }; -use super::process_registry::{ProcessId, ProcessUnitRegistry}; -use super::schema::{WorkflowOp, WorkflowSpec, WorkflowUnit, PORT_AFTER}; +use crate::process_registry::{ProcessId, ProcessUnitRegistry}; +use crate::schema::{WorkflowOp, WorkflowSpec, WorkflowUnit, PORT_AFTER}; /// Per-node planner action. #[derive(Debug, Clone, PartialEq)] @@ -81,7 +81,7 @@ pub enum WorkflowPlannerError { UnknownNode(NodeId), UnknownProcessUnit { node_id: NodeId, - process_unit: super::process_registry::ProcessUnitRef, + process_unit: crate::process_registry::ProcessUnitRef, }, StrictDryRunMissingInput { node_id: NodeId, @@ -230,7 +230,7 @@ pub fn plan_workflow_with_mode( let provided_inputs = planner_inputs .iter() .map(|(node, ports)| (node.clone(), ports.keys().cloned().collect())) - .collect::>>(); + .collect::>>(); let coordination = coordination_status(spec, &std::collections::HashSet::new(), &provided_inputs); @@ -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/gunbc-dag/src/workflow/process_registry.rs b/core/workflow/src/process_registry.rs similarity index 60% rename from gunbc-dag/src/workflow/process_registry.rs rename to core/workflow/src/process_registry.rs index 842b9616c02..2c57551751d 100644 --- a/gunbc-dag/src/workflow/process_registry.rs +++ b/core/workflow/src/process_registry.rs @@ -155,15 +155,6 @@ fn canonicalize_unit_id(unit_id: &NodeId) -> NodeId { } } -/// Default registry for WF1/WF2 planner bootstrap. -/// -/// Derived from `dsl/workflows/*.dag` stage claim annotations. -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:") { @@ -182,42 +173,6 @@ pub fn claim_handle_type_id(claim_id: &ClaimId) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::workflow::capabilities::{ - CODEGEN_ENSURE_UNIT, CODEGEN_PROCESS_ID, COMPILATION_ENSURE_UNIT, COMPILATION_PROCESS_ID, - }; - - #[test] - fn default_registry_contains_ci_and_test_all_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() { @@ -235,69 +190,6 @@ mod tests { ); } - #[test] - fn default_registry_contains_canonical_capability_units() { - let registry = default_process_unit_registry(); - assert!(registry.contains(&ProcessUnitRef::new( - COMPILATION_PROCESS_ID, - COMPILATION_ENSURE_UNIT - ))); - assert!(registry.contains(&ProcessUnitRef::new( - CODEGEN_PROCESS_ID, - CODEGEN_ENSURE_UNIT - ))); - } - - #[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" - ); - assert_eq!( - compilation_specs[0].reference.unit_id.0, - COMPILATION_ENSURE_UNIT - ); - - 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" - ); - assert_eq!(codegen_specs[0].reference.unit_id.0, CODEGEN_ENSURE_UNIT); - } - #[test] fn network_claim_maps_to_network_handle() { assert_eq!( @@ -313,4 +205,29 @@ mod tests { "ResourceHandle" ); } + + #[test] + fn canonical_work_identity_is_context_free_across_workflows() { + let mut registry = ProcessUnitRegistry::new(); + registry.register(ProcessUnitSpec::new( + ProcessUnitRef::new("ci", "ci.codegen"), + 1, + vec![UnitClaim::read("file:dsl")], + )); + registry.register(ProcessUnitSpec::new( + ProcessUnitRef::new("test_all", "test_all.codegen"), + 1, + vec![UnitClaim::read("file:dsl")], + )); + 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() + ); + } } diff --git a/gunbc-dag/src/workflow/projection.rs b/core/workflow/src/projection.rs similarity index 84% rename from gunbc-dag/src/workflow/projection.rs rename to core/workflow/src/projection.rs index c936992b09c..6503a90639e 100644 --- a/gunbc-dag/src/workflow/projection.rs +++ b/core/workflow/src/projection.rs @@ -2,9 +2,9 @@ use std::collections::BTreeSet; -use super::global_plan::GlobalWorkflowPlan; -use super::key::{MaterializationDigest, WorkIdentity}; -use super::planner::PlanAction; +use crate::global_plan::GlobalWorkflowPlan; +use crate::key::{MaterializationDigest, WorkIdentity}; +use crate::planner::PlanAction; /// Projection of canonical execute semantics into wrapper surfaces. #[derive(Debug, Clone, PartialEq, Eq)] @@ -78,12 +78,7 @@ pub fn validate_projection_equivalence( #[cfg(test)] mod tests { use super::*; - use crate::workflow::global_plan::{ - GlobalExecutionVertex, GlobalWorkflowPlan, WorkflowNodeRef, - }; - use crate::workflow::key::MaterializationDigest; - use crate::workflow::planner::PlanAction; - use crate::workflow::schema::WorkflowId; + use crate::global_plan::{GlobalExecutionVertex, WorkflowNodeRef}; use gunbc_ir::NodeId; fn sample_plan() -> GlobalWorkflowPlan { @@ -92,10 +87,10 @@ 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"), + workflow_id: crate::schema::WorkflowId::new("ci"), node_id: NodeId::from("ci.codegen"), }], }], diff --git a/gunbc-dag/src/workflow/proof.rs b/core/workflow/src/proof.rs similarity index 83% rename from gunbc-dag/src/workflow/proof.rs rename to core/workflow/src/proof.rs index 18fdf47cf5c..13110f08a38 100644 --- a/gunbc-dag/src/workflow/proof.rs +++ b/core/workflow/src/proof.rs @@ -2,8 +2,8 @@ use std::collections::BTreeSet; -use super::global_plan::GlobalWorkflowPlan; -use super::key::{MaterializationDigest, WorkIdentity}; +use crate::global_plan::GlobalWorkflowPlan; +use crate::key::{MaterializationDigest, WorkIdentity}; /// Invariant failure diagnostics for workflow non-redundancy checks. #[derive(Debug, Clone, PartialEq, Eq)] @@ -49,10 +49,9 @@ pub fn prove_non_redundancy(plan: &GlobalWorkflowPlan) -> Result<(), Vec GlobalExecutionVertex { @@ -60,9 +59,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 98% rename from gunbc-dag/src/workflow/schema.rs rename to core/workflow/src/schema.rs index 40bdc1d3fed..7638d5ead78 100644 --- a/gunbc-dag/src/workflow/schema.rs +++ b/core/workflow/src/schema.rs @@ -4,7 +4,7 @@ use gunbc_ir::{Dag, Port, PortName, TypeId}; -use super::process_registry::ProcessUnitRef; +use crate::process_registry::ProcessUnitRef; /// Canonical workflow ID. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/gunbc-dag/src/workflow/slo.rs b/core/workflow/src/slo.rs similarity index 98% rename from gunbc-dag/src/workflow/slo.rs rename to core/workflow/src/slo.rs index 62c9c7bd9b5..610c8324641 100644 --- a/gunbc-dag/src/workflow/slo.rs +++ b/core/workflow/src/slo.rs @@ -3,8 +3,8 @@ //! Provides run-ledger timing metrics, warm-path budget assertions, //! and "top slow units" reporting for CI integration. -use super::executor::{ExecutionSummary, UnitResult}; -use super::planner::PlanExplain; +use crate::executor::{ExecutionSummary, UnitResult}; +use crate::planner::PlanExplain; /// SLO budget thresholds (milliseconds). #[derive(Debug, Clone)] @@ -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/dsl/config/workflow_catalog.dag b/dsl/config/workflow_catalog.dag new file mode 100644 index 00000000000..5f178d41a2b --- /dev/null +++ b/dsl/config/workflow_catalog.dag @@ -0,0 +1,112 @@ +// config/workflow_catalog.dag -- Canonical workflow variant registry. +// +// This is the source of truth for workflow aliases, backing DSL files, +// pipeline names, mode gates, and planner namespaces. + +module config.workflow_catalog + +type WorkflowVariantDef { + canonical_name: String, + aliases: List, + file: String, + pipeline: String, + mode: String?, + namespace: String, + is_tool: Bool +} + +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 + } +] diff --git a/dsl/config/workflow_commands.dag b/dsl/config/workflow_commands.dag new file mode 100644 index 00000000000..23c7ccfb12d --- /dev/null +++ b/dsl/config/workflow_commands.dag @@ -0,0 +1,115 @@ +// config/workflow_commands.dag -- Workflow execution command catalog. +// +// Each entry maps workflow unit node IDs to shell commands used by the +// workflow executor when running plans. + +module config.workflow_commands + +type UnitCommandDef { + node_id: String, + label: String, + program: String, + args: List +} + +type WorkflowCommandSet { + workflow: String, + commands: List +} + +data workflow_commands: List = [ + { + workflow: "ci", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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/dsl/extdeps/api/gcp_ops.dag b/dsl/extdeps/api/gcp_ops.dag new file mode 100644 index 00000000000..5594486e06e --- /dev/null +++ b/dsl/extdeps/api/gcp_ops.dag @@ -0,0 +1,127 @@ +// extdeps/api/gcp_ops.dag -- GCP API operational detail. + +module extdeps.api.gcp_ops + +import std.rate_limit { BackoffStrategy, RetryPolicy } + +type GcpApiPattern { + service: String + version: String + base_url_template: String +} + +type GcpPropagationDelay { + operation: String + typical_seconds: Int + max_seconds: Int +} + +type GcpOrgPolicyConstraint { + constraint_id: String + affects: String +} + +type GcpCommandClassification = Allowed | Deprecated | Forbidden + +type GcpServiceConcurrency { + service: String + max_concurrent: Int +} + +type GcpRetryConfig { + name: String + policy: RetryPolicy +} + +type GcpCommandEntry { + command: String + classification: GcpCommandClassification + rationale: String +} + +data api_patterns: List = [ + { service: "secretmanager", version: "v1", base_url_template: "https://secretmanager.googleapis.com/v1" }, + { service: "iam", version: "v1", base_url_template: "https://iam.googleapis.com/v1" }, + { service: "storage", version: "v1", base_url_template: "https://storage.googleapis.com/storage/v1" }, + { service: "run", version: "v2", base_url_template: "https://run.googleapis.com/v2" }, + { service: "artifactregistry", version: "v1", base_url_template: "https://artifactregistry.googleapis.com/v1" } +] + +data propagation_delays: List = [ + { operation: "iam_policy_change", typical_seconds: 60, max_seconds: 420 }, + { operation: "dns_change", typical_seconds: 300, max_seconds: 900 }, + { operation: "api_enablement", typical_seconds: 30, max_seconds: 180 } +] + +data org_policy_constraints: List = [ + { constraint_id: "constraints/iam.allowedPolicyMemberDomains", affects: "IAM member domain allowlist" }, + { constraint_id: "constraints/storage.publicAccessPrevention", affects: "Bucket/object public exposure" } +] + +data service_concurrency: List = [ + { service: "secret_manager", max_concurrent: 10 }, + { service: "iam", max_concurrent: 5 }, + { service: "storage", max_concurrent: 10 }, + { service: "compute", max_concurrent: 10 }, + { service: "cloud_run", max_concurrent: 5 }, + { service: "cloud_functions", max_concurrent: 3 }, + { service: "artifact_registry", max_concurrent: 10 } +] + +data retry_configs: List = [ + { + name: "default", + policy: { + max_attempts: 5, + backoff: Exponential { base_ms: 30000, max_ms: 120000 }, + retry_on: ["http_429", "http_500", "http_503", "network", "timeout"] + } + }, + { + name: "api_enablement", + policy: { + max_attempts: 4, + backoff: Exponential { base_ms: 30000, max_ms: 120000 }, + retry_on: ["http_429", "http_503", "api_not_enabled_yet"] + } + }, + { + name: "iam_propagation", + policy: { + max_attempts: 5, + backoff: Exponential { base_ms: 60000, max_ms: 180000 }, + retry_on: ["permission_not_yet_propagated", "http_403_transient"] + } + } +] + +data gcp_label_constraints: List = [ + "Label keys and values are limited to 63 characters", + "Resources accept at most 64 labels" +] + +data iam_roles_modeled: List = [ + "roles/secretmanager.secretAccessor", + "roles/storage.objectViewer", + "roles/storage.objectCreator", + "roles/artifactregistry.reader", + "roles/artifactregistry.writer" +] + +data gcloud_command_registry: List = [ + { + command: "gcloud services enable SERVICE", + classification: Allowed, + rationale: "Required to activate APIs before first use" + }, + { + command: "gcloud iam service-accounts keys create", + classification: Deprecated, + rationale: "Prefer workload identity federation over long-lived keys" + }, + { + command: "gcloud projects add-iam-policy-binding allUsers", + classification: Forbidden, + rationale: "Violates least-privilege and public access controls" + } +] diff --git a/dsl/extdeps/api/github_ops.dag b/dsl/extdeps/api/github_ops.dag new file mode 100644 index 00000000000..590fa62b9c5 --- /dev/null +++ b/dsl/extdeps/api/github_ops.dag @@ -0,0 +1,127 @@ +// extdeps/api/github_ops.dag -- GitHub API operational detail. + +module extdeps.api.github_ops + +import std.rate_limit { RateLimit, ResetStrategy } + +type GitHubProductTier = Free | Pro | Team | EnterpriseCloud | EnterpriseServer + +type GitHubApiVersion { + date: String + header: String +} + +type GitHubRateLimitScope = Core | Search | GraphQL | CodeSearch + +type GitHubAppAuth { + jwt_expiry_minutes: Int + clock_skew_seconds: Int + installation_token_ttl: Int +} + +type GitHubPollingConfig { + interval_seconds: Int + max_wait_seconds: Int +} + +type GitHubRateLimitSpec { + tier: GitHubProductTier + scope: GitHubRateLimitScope + limit: RateLimit +} + +data supported_api_version: GitHubApiVersion = { + date: "2022-11-28", + header: "X-GitHub-Api-Version" +} + +data github_app_auth: GitHubAppAuth = { + jwt_expiry_minutes: 9, + clock_skew_seconds: 60, + installation_token_ttl: 3600 +} + +data polling_default: GitHubPollingConfig = { + interval_seconds: 30, + max_wait_seconds: 1800 +} + +data rate_limits: List = [ + { + tier: Free, + scope: Core, + limit: { scope: "github.core", requests_per_window: 5000, window_seconds: 3600, burst: null, reset_strategy: FixedWindow } + }, + { + tier: Pro, + scope: Core, + limit: { scope: "github.core", requests_per_window: 5000, window_seconds: 3600, burst: null, reset_strategy: FixedWindow } + }, + { + tier: Team, + scope: Core, + limit: { scope: "github.core", requests_per_window: 5000, window_seconds: 3600, burst: null, reset_strategy: FixedWindow } + }, + { + tier: EnterpriseCloud, + scope: Core, + limit: { scope: "github.core", requests_per_window: 15000, window_seconds: 3600, burst: null, reset_strategy: FixedWindow } + }, + { + tier: EnterpriseServer, + scope: Core, + limit: { scope: "github.core", requests_per_window: 15000, window_seconds: 3600, burst: null, reset_strategy: FixedWindow } + }, + { + tier: Free, + scope: Search, + limit: { scope: "github.search", requests_per_window: 30, window_seconds: 60, burst: null, reset_strategy: FixedWindow } + }, + { + tier: Pro, + scope: Search, + limit: { scope: "github.search", requests_per_window: 30, window_seconds: 60, burst: null, reset_strategy: FixedWindow } + }, + { + tier: Team, + scope: Search, + limit: { scope: "github.search", requests_per_window: 30, window_seconds: 60, burst: null, reset_strategy: FixedWindow } + }, + { + tier: EnterpriseCloud, + scope: Search, + limit: { scope: "github.search", requests_per_window: 30, window_seconds: 60, burst: null, reset_strategy: FixedWindow } + }, + { + tier: EnterpriseServer, + scope: Search, + limit: { scope: "github.search", requests_per_window: 30, window_seconds: 60, burst: null, reset_strategy: FixedWindow } + }, + { + tier: Free, + scope: GraphQL, + limit: { scope: "github.graphql", requests_per_window: 5000, window_seconds: 3600, burst: null, reset_strategy: FixedWindow } + }, + { + tier: EnterpriseCloud, + scope: GraphQL, + limit: { scope: "github.graphql", requests_per_window: 10000, window_seconds: 3600, burst: null, reset_strategy: FixedWindow } + }, + { + tier: Free, + scope: CodeSearch, + limit: { scope: "github.code_search", requests_per_window: 10, window_seconds: 60, burst: null, reset_strategy: FixedWindow } + } +] + +data versioning_policy: List = [ + "Requests should include X-GitHub-Api-Version header for stable behavior", + "Unsupported API versions may return deprecation warnings before retirement", + "Client code should gate behavior on documented breaking change windows" +] + +data content_limits: List = [ + "REST payloads above 1MB may be truncated for some endpoints", + "100MB is practical threshold for LFS-backed large artifacts", + "Gists with very large file counts should be split to reduce API pressure" +] diff --git a/dsl/extdeps/coordination/core.dag b/dsl/extdeps/coordination/core.dag new file mode 100644 index 00000000000..d8343a7c26b --- /dev/null +++ b/dsl/extdeps/coordination/core.dag @@ -0,0 +1,64 @@ +// extdeps/coordination/core.dag -- "What is a coordination store?" + +module extdeps.coordination.core + +import std.coordination { + CasMechanism, DeadLetterPolicy, NotificationMechanism, DeliveryGuarantee +} + +type CoordinationBackend = ObjectStore | RelationalDb | EmbeddedDb + +type CasPattern { + mechanism: CasMechanism + conflict_resolution: String +} + +type QueuePattern { + claiming: String + visibility: Int? + dead_letter: DeadLetterPolicy? +} + +type LeasePattern { + acquisition: String + heartbeat: String + expiry: String +} + +type NotificationPattern { + mechanism: NotificationMechanism + latency: String + ordering: String +} + +type PatternCompositionRule { + name: String + backend: CoordinationBackend + requires: List + outcome: String + guarantee: DeliveryGuarantee +} + +data pattern_composition_rules: List = [ + { + name: "cas_plus_lease_distributed_lock", + backend: RelationalDb, + requires: ["cas", "lease"], + outcome: "Distributed lock with fencing", + guarantee: AtLeastOnce + }, + { + name: "queue_plus_cas_exactly_once_effective", + backend: ObjectStore, + requires: ["queue_claim", "cas_commit"], + outcome: "Exactly-once effective processing at workflow level", + guarantee: ExactlyOnce + }, + { + name: "queue_only_at_least_once", + backend: EmbeddedDb, + requires: ["queue_claim"], + outcome: "At-least-once delivery without deduplication ledger", + guarantee: AtLeastOnce + } +] diff --git a/dsl/extdeps/coordination/gcs.dag b/dsl/extdeps/coordination/gcs.dag new file mode 100644 index 00000000000..a3352c4e4d5 --- /dev/null +++ b/dsl/extdeps/coordination/gcs.dag @@ -0,0 +1,145 @@ +// extdeps/coordination/gcs.dag -- "GCS as a coordination store." + +module extdeps.coordination.gcs + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.coordination { CasMechanism, NotificationMechanism } +import std.rate_limit { PropagationDelay } + +type GcsGeneration = Int +type GcsMetageneration = Int + +type GcsPrecondition + = IfGenerationMatch { generation: GcsGeneration } + | IfMetagenerationMatch { metageneration: GcsMetageneration } + | IfGenerationNotMatch { generation: GcsGeneration } + +type GcsCoordinationOperationBehavior { + operation: String + behavior: OperationBehavior +} + +type GcsCasPattern { + mechanism: CasMechanism + create_only_generation: Int + conflict_status: Int +} + +type NotificationLatency { + mechanism: NotificationMechanism + delay: PropagationDelay +} + +data operation_behaviors: List = [ + { + operation: "GetObject", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [{ name: "NotFound", condition: "object_absent", http_status: 404, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "generation and metageneration are separate monotonic counters", trigger: null, severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "storage.objects.get permission", kind: Capability }], + assumptions: ["Read path can request media payload via alt=media"], + unknowns: ["Regional latency variance for cold objects"] + } + }, + { + operation: "InsertObject", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["bucket", "object", "ifGenerationMatch"], + determinism: Deterministic, + failure_modes: [{ name: "PreconditionFailed", condition: "generation_mismatch", http_status: 412, recoverable: true, retry_safe: false }], + edge_cases: [{ description: "ifGenerationMatch=0 enforces create-only semantics", trigger: "create_only_write", severity: High }], + confidence: Documented, + prerequisites: [{ description: "storage.objects.create permission", kind: Capability }], + assumptions: ["Returned generation is authoritative for future CAS writes"], + unknowns: ["Exact contention profile under high-frequency concurrent writers"] + } + }, + { + operation: "PatchMetadata", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["bucket", "object", "ifMetagenerationMatch"], + determinism: Deterministic, + failure_modes: [{ name: "PreconditionFailed", condition: "metageneration_mismatch", http_status: 412, recoverable: true, retry_safe: false }], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "storage.objects.update permission", kind: Capability }], + assumptions: ["Metadata writes are serialized by metageneration checks"], + unknowns: [] + } + }, + { + operation: "DeleteObject", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["bucket", "object", "ifGenerationMatch"], + determinism: Deterministic, + failure_modes: [{ name: "PreconditionFailed", condition: "generation_mismatch", http_status: 412, recoverable: true, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "storage.objects.delete permission", kind: Capability }], + assumptions: ["Delete with generation precondition avoids lost updates"], + unknowns: ["Delete visibility lag in eventually consistent listings"] + } + }, + { + operation: "ComposeObjects", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "compose can fail if source generations changed", trigger: "concurrent_source_mutation", severity: High }], + confidence: MediumConfidence, + prerequisites: [{ description: "compose permissions on source and destination", kind: Capability }], + assumptions: ["Source object list is bounded by provider compose limits"], + unknowns: ["Performance and limits for deeply chained compose operations"] + } + }, + { + operation: "WatchChanges", + behavior: { + side_effects: ReadOnly, + idempotent: false, + idempotency_keys: null, + determinism: EventuallyConsistent, + failure_modes: [], + edge_cases: [{ description: "notification stream is best-effort and must be treated as hint channel", trigger: null, severity: High }], + confidence: Documented, + prerequisites: [{ description: "bucket notifications configured to Pub/Sub topic", kind: Infrastructure }], + assumptions: ["Primary correctness remains in store scan + CAS logic"], + unknowns: ["Maximum notification delay under sustained event volume"] + } + } +] + +data cas_patterns: List = [ + { + mechanism: GenerationBased, + create_only_generation: 0, + conflict_status: 412 + } +] + +data notification_latency: List = [ + { + mechanism: PubSub, + delay: { + typical_ms: 5000, + max_ms: 30000, + description: "Bucket notification delivery to Pub/Sub subscribers" + } + } +] diff --git a/dsl/extdeps/coordination/postgres.dag b/dsl/extdeps/coordination/postgres.dag new file mode 100644 index 00000000000..e976d1e4204 --- /dev/null +++ b/dsl/extdeps/coordination/postgres.dag @@ -0,0 +1,145 @@ +// extdeps/coordination/postgres.dag -- "PostgreSQL as a coordination store." + +module extdeps.coordination.postgres + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.rate_limit { PropagationDelay } + +type PgIsolationLevel = ReadCommitted | Serializable + +type PgSkipLocked { + enabled: Bool +} + +type PgFencingToken { + column: String + monotonic: Bool +} + +type PgNotifyChannel { + name: String + payload_limit_bytes: Int +} + +type PgCoordinationOperationBehavior { + operation: String + behavior: OperationBehavior +} + +data operation_behaviors: List = [ + { + operation: "KvGet", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "soft TTL requires client-side expires_at checks", trigger: "expired_row_present", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "read access to kv table", kind: Capability }], + assumptions: ["SELECT path uses indexed key lookup"], + unknowns: [] + } + }, + { + operation: "KvSet", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["key", "expected_version"], + determinism: Deterministic, + failure_modes: [{ name: "Conflict", condition: "rowcount_zero_due_to_version_mismatch", http_status: null, recoverable: true, retry_safe: false }], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "write access to kv table", kind: Capability }], + assumptions: ["Transaction wraps read-modify-write with row-level lock"], + unknowns: ["Optimal lock granularity under mixed read/write workloads"] + } + }, + { + operation: "KvCas", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["key", "expected_version", "value_hash"], + determinism: Deterministic, + failure_modes: [{ name: "Conflict", condition: "version_mismatch", http_status: null, recoverable: true, retry_safe: false }], + edge_cases: [{ description: "conditional create may use insert-on-conflict semantics", trigger: "missing_row", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "transactional SQL execution capability", kind: Tooling }], + assumptions: ["Version increments atomically with update"], + unknowns: [] + } + }, + { + operation: "ClaimFromQueue", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "SKIP LOCKED skips in-flight rows and can reorder fairness under contention", trigger: "hot_queue", severity: High }], + confidence: Documented, + prerequisites: [{ description: "queue table has claim_expires_at + ordering indexes", kind: Infrastructure }], + assumptions: ["Claims include expiry and owner metadata"], + unknowns: ["Starvation risk with persistent hot partitions"] + } + }, + { + operation: "Heartbeat", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["claim_id", "owner"], + determinism: Deterministic, + failure_modes: [{ name: "LeaseLost", condition: "claim_not_owned_or_expired", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "owner identity is stable for claim lifetime", kind: Runtime }], + assumptions: ["Heartbeat updates claim_expires_at only for matching owner"], + unknowns: [] + } + }, + { + operation: "Release", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["claim_id", "owner"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "release caller owns active claim", kind: Runtime }], + assumptions: ["Release either clears owner fields or marks row done"], + unknowns: [] + } + }, + { + operation: "NotifyWatch", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "NOTIFY payload limited to 8000 bytes", trigger: "oversized_payload", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "long-lived listener connections exist", kind: Infrastructure }], + assumptions: ["Notifications are hint channel and not source of truth"], + unknowns: ["Dropped notification behavior during network partitions"] + } + } +] + +data skip_locked_semantics: PgSkipLocked = { enabled: true } + +data notify_latency: PropagationDelay = { + typical_ms: 50, + max_ms: 2000, + description: "NOTIFY delivery latency to active listeners" +} diff --git a/dsl/extdeps/coordination/sqlite.dag b/dsl/extdeps/coordination/sqlite.dag new file mode 100644 index 00000000000..ec24894457d --- /dev/null +++ b/dsl/extdeps/coordination/sqlite.dag @@ -0,0 +1,105 @@ +// extdeps/coordination/sqlite.dag -- "SQLite as a coordination store." + +module extdeps.coordination.sqlite + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} + +type SqliteJournalMode = Wal | Delete | Truncate + +type SqliteBusyTimeout { + millis: Int +} + +type SqliteCoordinationOperationBehavior { + operation: String + behavior: OperationBehavior +} + +data operation_behaviors: List = [ + { + operation: "KvGet", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "client must evaluate expires_at for soft TTL semantics", trigger: "stale_rows", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "database file readable", kind: Infrastructure }], + assumptions: ["Key column indexed for lookup path"], + unknowns: [] + } + }, + { + operation: "KvSet", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["key", "expected_version"], + determinism: Deterministic, + failure_modes: [{ name: "Busy", condition: "writer_lock_contended", http_status: null, recoverable: true, retry_safe: true }], + edge_cases: [{ description: "single-writer lock can serialize all writes", trigger: "high_write_load", severity: High }], + confidence: Documented, + prerequisites: [{ description: "WAL mode enabled for concurrent readers", kind: Infrastructure }], + assumptions: ["SQLite >= 3.35 available for RETURNING support"], + unknowns: ["Performance profile on networked filesystems"] + } + }, + { + operation: "KvCas", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["key", "expected_version", "value_hash"], + determinism: Deterministic, + failure_modes: [{ name: "Conflict", condition: "update_rowcount_zero", http_status: null, recoverable: true, retry_safe: false }], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "caller retries on BUSY with bounded timeout", kind: Runtime }], + assumptions: ["CAS encoded as update with version predicate"], + unknowns: [] + } + }, + { + operation: "Heartbeat", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["lease_id"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: MediumConfidence, + prerequisites: [{ description: "lease row exists and has not expired", kind: Runtime }], + assumptions: ["Heartbeat updates are short transactions"], + unknowns: [] + } + }, + { + operation: "TtlCleanup", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["cleanup_cutoff"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "cleanup cadence drives storage growth and read amplification", trigger: "infrequent_cleanup", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "periodic scheduler invokes cleanup", kind: Tooling }], + assumptions: ["datetime('now') aligns with process wall clock assumptions"], + unknowns: ["Long-term bloat behavior without incremental vacuum tuning"] + } + } +] + +data default_journal_mode: SqliteJournalMode = Wal +data default_busy_timeout: SqliteBusyTimeout = { millis: 5000 } + +data wal_constraints: List = [ + "WAL mode requires write access to .db-wal and .db-shm sidecar files", + "Single-writer semantics still apply even with WAL enabled", + "Avoid NFS/SMB when strict lock semantics are required" +] diff --git a/dsl/extdeps/devenv/devcontainers.dag b/dsl/extdeps/devenv/devcontainers.dag new file mode 100644 index 00000000000..e72ce7c4540 --- /dev/null +++ b/dsl/extdeps/devenv/devcontainers.dag @@ -0,0 +1,73 @@ +// extdeps/devenv/devcontainers.dag -- "What is a devcontainer?" + +module extdeps.devenv.devcontainers + +type LifecycleHook + = InitializeCommand + | OnCreateCommand + | PostCreateCommand + | PostStartCommand + | PostAttachCommand + +type HookExecution { + phase: LifecycleHook + runs_as: String + timeout: Int? +} + +type FeatureOption { + name: String + value: String +} + +type DevcontainerFeature { + id: String + version: String + options: List +} + +type EnvVarSource = ContainerEnv | LocalEnv | SecretStore | FeatureGenerated + +type DevcontainerEnvVar { + name: String + source: EnvVarSource + available_in: List +} + +data lifecycle_timing: List = [ + { phase: InitializeCommand, runs_as: "host-user", timeout: 300 }, + { phase: OnCreateCommand, runs_as: "container-user", timeout: 600 }, + { phase: PostCreateCommand, runs_as: "container-user", timeout: 1200 }, + { phase: PostStartCommand, runs_as: "container-user", timeout: 300 }, + { phase: PostAttachCommand, runs_as: "container-user", timeout: 120 } +] + +data common_features: List = [ + { + id: "ghcr.io/devcontainers/features/common-utils", + version: "2", + options: [{ name: "installZsh", value: "false" }] + }, + { + id: "ghcr.io/devcontainers/features/git", + version: "1", + options: [] + }, + { + id: "ghcr.io/devcontainers/features/github-cli", + version: "1", + options: [] + } +] + +data runtime_env_vars: List = [ + { name: "REMOTE_CONTAINERS", source: ContainerEnv, available_in: ["initialize", "runtime"] }, + { name: "CODESPACES", source: ContainerEnv, available_in: ["runtime"] }, + { name: "GITHUB_TOKEN", source: SecretStore, available_in: ["runtime", "task"] } +] + +data known_quirks: List = [ + "postStartCommand runs on every container start and must be idempotent", + "feature install order can affect PATH and shell initialization side effects", + "user UID/GID mismatch can cause permission drift on bind-mounted workspaces" +] diff --git a/dsl/extdeps/llm/pricing.dag b/dsl/extdeps/llm/pricing.dag new file mode 100644 index 00000000000..ad453502832 --- /dev/null +++ b/dsl/extdeps/llm/pricing.dag @@ -0,0 +1,181 @@ +// extdeps/llm/pricing.dag -- LLM pricing and capability detail. + +module extdeps.llm.pricing + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} + +type ModelPricing { + input_per_million: Float + output_per_million: Float + cached_input_per_million: Float? +} + +type ContextWindow { + max_tokens: Int + max_output_tokens: Int +} + +type ModelCapability = ExtendedThinking | StructuredOutput | Grounding | ToolUse | VisionInput + +type CachingBehavior { + min_cache_tokens: Int + ttl_minutes: Int? + discount_pct: Int +} + +type ModelSpec { + provider: String + model: String + pricing: ModelPricing + context: ContextWindow + capabilities: List + caching: CachingBehavior? +} + +type LlmOperationBehavior { + operation: String + behavior: OperationBehavior +} + +data model_specs: List = [ + { + provider: "anthropic", + model: "claude-haiku-4-5-20251001", + pricing: { input_per_million: 1.00, output_per_million: 5.00, cached_input_per_million: 0.10 }, + context: { max_tokens: 200000, max_output_tokens: 64000 }, + capabilities: [ExtendedThinking, ToolUse], + caching: { min_cache_tokens: 1024, ttl_minutes: 5, discount_pct: 90 } + }, + { + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + pricing: { input_per_million: 3.00, output_per_million: 15.00, cached_input_per_million: 0.30 }, + context: { max_tokens: 200000, max_output_tokens: 64000 }, + capabilities: [ExtendedThinking, ToolUse, VisionInput], + caching: { min_cache_tokens: 1024, ttl_minutes: 5, discount_pct: 90 } + }, + { + provider: "anthropic", + model: "claude-opus-4-5-20251101", + pricing: { input_per_million: 5.00, output_per_million: 25.00, cached_input_per_million: 0.50 }, + context: { max_tokens: 200000, max_output_tokens: 64000 }, + capabilities: [ExtendedThinking, ToolUse, VisionInput], + caching: { min_cache_tokens: 1024, ttl_minutes: 60, discount_pct: 90 } + }, + { + provider: "openai", + model: "gpt-5.2", + pricing: { input_per_million: 1.75, output_per_million: 14.00, cached_input_per_million: 0.175 }, + context: { max_tokens: 128000, max_output_tokens: 16000 }, + capabilities: [StructuredOutput, ToolUse, VisionInput], + caching: { min_cache_tokens: 128, ttl_minutes: 5, discount_pct: 50 } + }, + { + provider: "openai", + model: "gpt-5.1-codex", + pricing: { input_per_million: 1.25, output_per_million: 10.00, cached_input_per_million: 0.125 }, + context: { max_tokens: 128000, max_output_tokens: 16000 }, + capabilities: [StructuredOutput, ToolUse], + caching: { min_cache_tokens: 128, ttl_minutes: 5, discount_pct: 50 } + }, + { + provider: "openai", + model: "o3", + pricing: { input_per_million: 2.00, output_per_million: 8.00, cached_input_per_million: 0.50 }, + context: { max_tokens: 128000, max_output_tokens: 32000 }, + capabilities: [StructuredOutput, ToolUse], + caching: { min_cache_tokens: 128, ttl_minutes: 5, discount_pct: 50 } + }, + { + provider: "gemini", + model: "gemini-2.5-pro", + pricing: { input_per_million: 1.25, output_per_million: 5.00, cached_input_per_million: null }, + context: { max_tokens: 1000000, max_output_tokens: 65000 }, + capabilities: [StructuredOutput, Grounding, ToolUse, VisionInput], + caching: null + }, + { + provider: "gemini", + model: "gemini-2.5-flash", + pricing: { input_per_million: 0.075, output_per_million: 0.30, cached_input_per_million: null }, + context: { max_tokens: 1000000, max_output_tokens: 65000 }, + capabilities: [StructuredOutput, Grounding, ToolUse, VisionInput], + caching: null + }, + { + provider: "gemini", + model: "gemini-2.5-flash-lite", + pricing: { input_per_million: 0.01875, output_per_million: 0.075, cached_input_per_million: null }, + context: { max_tokens: 1000000, max_output_tokens: 65000 }, + capabilities: [StructuredOutput, ToolUse], + caching: null + } +] + +data llm_operation_behaviors: List = [ + { + operation: "ChatCompletion", + behavior: { + side_effects: WritesExternal, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [ + { name: "RateLimited", condition: "provider_quota_exhausted", http_status: 429, recoverable: true, retry_safe: true }, + { name: "ContextTooLarge", condition: "input_tokens_exceed_context_window", http_status: 400, recoverable: false, retry_safe: false } + ], + edge_cases: [{ description: "provider may return a different backing model variant than requested alias", trigger: "alias_resolution", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "provider credential is configured", kind: Credential }], + assumptions: ["temperature normalization handled by caller"], + unknowns: ["Tier-specific burst limits and adaptive throttling thresholds"] + } + }, + { + operation: "CountTokens", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["model", "input_hash"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "counts can vary by tokenizer version updates", trigger: "tokenizer_upgrade", severity: Low }], + confidence: HighConfidence, + prerequisites: [], + assumptions: ["Tokenizer build matches production model family"], + unknowns: [] + } + }, + { + operation: "StreamCompletion", + behavior: { + side_effects: WritesExternal, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [{ name: "StreamInterrupted", condition: "connection_dropped_mid_response", http_status: null, recoverable: true, retry_safe: false }], + edge_cases: [{ description: "partial output may require caller-level checkpointing for resume logic", trigger: "network_instability", severity: High }], + confidence: Documented, + prerequisites: [{ description: "stream-capable client transport", kind: Tooling }], + assumptions: ["Caller handles incremental token events safely"], + unknowns: [] + } + }, + { + operation: "ListModels", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "available models depend on account permissions and region", trigger: "tenant_scope", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "provider key or ADC token is valid", kind: Credential }], + assumptions: ["Model metadata endpoint is eventually consistent"], + unknowns: [] + } + } +] diff --git a/dsl/extdeps/secrets/core.dag b/dsl/extdeps/secrets/core.dag new file mode 100644 index 00000000000..e9df410c841 --- /dev/null +++ b/dsl/extdeps/secrets/core.dag @@ -0,0 +1,94 @@ +// extdeps/secrets/core.dag -- Abstract "what is a secret provider?" model. + +module extdeps.secrets.core + +import std.behavioral { + OperationBehavior, FailureMode, EdgeCase, Confidence, Prerequisite +} + +type SecretLifecycle = Active | Disabled | Destroyed | PendingDeletion + +type SecretVersion { + id: String + state: SecretLifecycle + created_at: String? +} + +type RotationPolicy { + period_seconds: Int + next_rotation: String? +} + +type SecretAccessPolicy { + principal: String + permission: String + condition: String? +} + +type SecretEncryption = ProviderManaged | CustomerManaged { key_id: String } + +type SecretOperationBehavior { + operation: String + behavior: OperationBehavior +} + +data shared_secret_operation_behaviors: List = [ + { + operation: "ReadSecret", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [ + { name: "NotFound", condition: "secret_missing", http_status: 404, recoverable: false, retry_safe: false }, + { name: "PermissionDenied", condition: "access_denied", http_status: 403, recoverable: false, retry_safe: false } + ], + edge_cases: [ + { description: "Version alias may resolve to different concrete version over time", trigger: "latest_alias", severity: Medium } + ], + confidence: Documented, + prerequisites: [{ description: "Caller is authenticated", kind: Credential }], + assumptions: ["Provider enforces resource-level IAM policies"], + unknowns: ["Exact read-after-write propagation characteristics vary by provider"] + } + }, + { + operation: "CreateSecret", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [ + { name: "AlreadyExists", condition: "name_collision", http_status: 409, recoverable: true, retry_safe: false } + ], + edge_cases: [ + { description: "Provider name rules differ across backends", trigger: "invalid_name", severity: Medium } + ], + confidence: HighConfidence, + prerequisites: [{ description: "Caller has create permission on secret namespace", kind: Capability }], + assumptions: ["Create allocates secret metadata and policy context"], + unknowns: ["Cross-region create latency under control-plane load"] + } + }, + { + operation: "AddSecretVersion", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [ + { name: "SecretMissing", condition: "parent_secret_missing", http_status: 404, recoverable: false, retry_safe: false } + ], + edge_cases: [ + { description: "Version state may default to enabled immediately", trigger: null, severity: Low } + ], + confidence: HighConfidence, + prerequisites: [{ description: "Caller can add versions to existing secret", kind: Capability }], + assumptions: ["Version append is monotonic and does not mutate existing versions"], + unknowns: ["Per-provider limits for versions per secret over long retention windows"] + } + } +] diff --git a/dsl/extdeps/secrets/env_file.dag b/dsl/extdeps/secrets/env_file.dag new file mode 100644 index 00000000000..3ff85aecf44 --- /dev/null +++ b/dsl/extdeps/secrets/env_file.dag @@ -0,0 +1,129 @@ +// extdeps/secrets/env_file.dag -- "What is a .env file?" + +module extdeps.secrets.env_file + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} + +type EnvEntry { + key: String + value: String + comment: String? +} + +type EnvFile { + path: String + entries: List +} + +type EnvValidation { + required_keys: List + format_rules: List +} + +type EnvOperationBehavior { + operation: String + behavior: OperationBehavior +} + +data parsing_rules: List = [ + "Ignore blank lines", + "Ignore lines starting with #", + "Split key/value at first = character", + "Preserve quoted values as literal payload", + "Treat leading export prefix as optional syntax sugar" +] + +data operation_behaviors: List = [ + { + operation: "Load", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["path"], + determinism: Deterministic, + failure_modes: [{ name: "ParseError", condition: "invalid_line_format", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "missing file may be silent depending on loader options", trigger: "dotenv_missing_file", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: ".env file path is readable", kind: Infrastructure }], + assumptions: ["Callers intentionally choose whether existing process env may be overridden"], + unknowns: ["Cross-platform newline normalization differences for escaped values"] + } + }, + { + operation: "Get", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [{ name: "MissingVar", condition: "variable_not_set", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [], + assumptions: ["Process environment is authoritative for lookup"], + unknowns: [] + } + }, + { + operation: "Set", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["path", "key"], + determinism: Deterministic, + failure_modes: [{ name: "PermissionDenied", condition: "file_not_writable", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "target file is writable", kind: Infrastructure }], + assumptions: ["Encoding is UTF-8 compatible for key/value text"], + unknowns: ["Concurrent writer conflict handling strategy"] + } + }, + { + operation: "List", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "target file is readable", kind: Infrastructure }], + assumptions: ["Parser consistently orders returned keys by file order"], + unknowns: [] + } + }, + { + operation: "Validate", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "validation returns missing and extra keys rather than mutating files", trigger: null, severity: Low }], + confidence: Documented, + prerequisites: [], + assumptions: ["Template schema is complete for intended environment"], + unknowns: [] + } + }, + { + operation: "GenerateExample", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["path"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "template generation must avoid writing real secret values", trigger: "secret_leakage", severity: Critical }], + confidence: HighConfidence, + prerequisites: [{ description: "schema exists for expected env variables", kind: Tooling }], + assumptions: ["Generated example file is intended for onboarding only"], + unknowns: [] + } + } +] diff --git a/dsl/extdeps/secrets/gcp_secret_manager.dag b/dsl/extdeps/secrets/gcp_secret_manager.dag new file mode 100644 index 00000000000..b291a586e1b --- /dev/null +++ b/dsl/extdeps/secrets/gcp_secret_manager.dag @@ -0,0 +1,192 @@ +// extdeps/secrets/gcp_secret_manager.dag -- "What is GCP Secret Manager?" + +module extdeps.secrets.gcp_secret_manager + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.rate_limit { RateLimit, PropagationDelay, ResetStrategy } +import extdeps.secrets.core { SecretLifecycle, SecretVersion, SecretEncryption, RotationPolicy } + +type GcpReplicationPolicy = Automatic | UserManaged { locations: List } + +type GcpSecret { + project: String + secret_id: String + replication: GcpReplicationPolicy + rotation: RotationPolicy? + encryption: SecretEncryption +} + +type GcpSecretVersion { + name: String + version: SecretVersion +} + +type GcpOperationBehavior { + operation: String + behavior: OperationBehavior +} + +type GcpMethodRateLimit { + operation: String + limit: RateLimit +} + +data operation_behaviors: List = [ + { + operation: "AccessVersion", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [ + { name: "NotFound", condition: "secret_or_version_missing", http_status: 404, recoverable: false, retry_safe: false }, + { name: "PermissionDenied", condition: "missing_secret_accessor_role", http_status: 403, recoverable: false, retry_safe: false }, + { name: "VersionUnavailable", condition: "version_disabled_or_destroyed", http_status: 409, recoverable: false, retry_safe: false } + ], + edge_cases: [{ description: "version can be numeric or latest alias", trigger: "version=latest", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "ADC credential with secretmanager.versions.access", kind: Credential }], + assumptions: ["Project and secret identifiers are valid and routable"], + unknowns: ["Exact IAM policy propagation delay for newly granted access"] + } + }, + { + operation: "CreateSecret", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["project", "secret_id"], + determinism: Deterministic, + failure_modes: [{ name: "AlreadyExists", condition: "duplicate_secret_id", http_status: 409, recoverable: true, retry_safe: false }], + edge_cases: [{ description: "create only provisions metadata; value added in a second call", trigger: "missing_add_version", severity: High }], + confidence: Documented, + prerequisites: [{ description: "secretmanager.secrets.create permission", kind: Capability }], + assumptions: ["Replication policy selection is accepted by org policy"], + unknowns: ["Cross-region metadata convergence under control-plane incidents"] + } + }, + { + operation: "AddVersion", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [{ name: "NotFound", condition: "parent_secret_missing", http_status: 404, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "new version starts in ENABLED state", trigger: null, severity: Low }], + confidence: Documented, + prerequisites: [{ description: "secretmanager.versions.add permission", kind: Capability }], + assumptions: ["Version append never mutates prior version payloads"], + unknowns: ["Per-secret throughput limits for version creation bursts"] + } + }, + { + operation: "ListSecrets", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: EventuallyConsistent, + failure_modes: [], + edge_cases: [{ description: "response is paginated by page_token", trigger: "large_secret_sets", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "secretmanager.secrets.list permission", kind: Capability }], + assumptions: ["Pagination cursors remain valid during short listing windows"], + unknowns: ["Latency variability under very high cardinality projects"] + } + }, + { + operation: "GetSecret", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [{ name: "NotFound", condition: "secret_missing", http_status: 404, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "secretmanager.secrets.get permission", kind: Capability }], + assumptions: ["Metadata reads are strongly consistent enough for policy checks"], + unknowns: ["Behavior during transient control-plane partitions"] + } + }, + { + operation: "DestroyVersion", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "destroy is irreversible", trigger: "operator_mistake", severity: Critical }], + confidence: Documented, + prerequisites: [{ description: "secretmanager.versions.destroy permission", kind: Capability }], + assumptions: ["Destroy tombstones payload permanently"], + unknowns: ["Retention metadata visibility for destroyed versions"] + } + }, + { + operation: "DisableVersion", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "disable blocks access but preserves data", trigger: null, severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "secretmanager.versions.disable permission", kind: Capability }], + assumptions: ["State transitions obey ENABLED->DISABLED semantics"], + unknowns: ["Transient delays before disable is globally enforced"] + } + }, + { + operation: "EnableVersion", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "enable may race with concurrent destroy", trigger: "concurrent_mutation", severity: High }], + confidence: HighConfidence, + prerequisites: [{ description: "secretmanager.versions.enable permission", kind: Capability }], + assumptions: ["Version lifecycle transitions are serialized by provider"], + unknowns: ["Observed behavior under rapid flip-flop enable/disable requests"] + } + } +] + +data method_rate_limits: List = [ + { + operation: "AccessVersion", + limit: { + scope: "gcp.secret_manager.access", + requests_per_window: 60000, + window_seconds: 60, + burst: 6000, + reset_strategy: SlidingWindow + } + }, + { + operation: "CreateSecret", + limit: { + scope: "gcp.secret_manager.write", + requests_per_window: 6000, + window_seconds: 60, + burst: 1000, + reset_strategy: SlidingWindow + } + } +] + +data propagation_delays: List = [ + { + typical_ms: 30000, + max_ms: 120000, + description: "IAM policy grants can take time before access checks pass" + } +] diff --git a/dsl/extdeps/secrets/github_secrets.dag b/dsl/extdeps/secrets/github_secrets.dag new file mode 100644 index 00000000000..6c68bb94496 --- /dev/null +++ b/dsl/extdeps/secrets/github_secrets.dag @@ -0,0 +1,166 @@ +// extdeps/secrets/github_secrets.dag -- "What is GitHub Secrets?" + +module extdeps.secrets.github_secrets + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} + +type GitHubSecretScope = Repository | Environment | Organization + +type GitHubPublicKey { + key_id: String + key: String +} + +type OidcTokenClaims { + sub: String + aud: String + iss: String + ref: String? + sha: String? + repository: String? +} + +type GitHubSecretsOperationBehavior { + operation: String + behavior: OperationBehavior +} + +type EncryptionModel { + algorithm: String + key_source: String + payload_encoding: String +} + +data github_encryption_model: EncryptionModel = { + algorithm: "libsodium sealed box", + key_source: "repo/environment/org public key endpoint", + payload_encoding: "base64" +} + +data operation_behaviors: List = [ + { + operation: "ListRepoSecrets", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "values are write-only and never returned", trigger: null, severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "token has actions:read scope", kind: Credential }], + assumptions: ["Pagination order is stable enough for deterministic scans"], + unknowns: ["Rate-limit behavior under large organization secret inventories"] + } + }, + { + operation: "GetRepoSecret", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [{ name: "NotFound", condition: "secret_missing", http_status: 404, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "metadata only; secret value is never retrievable", trigger: null, severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "token has actions:read scope", kind: Credential }], + assumptions: ["Secret metadata reflects latest update timestamp"], + unknowns: ["Consistency window between create/update and metadata reads"] + } + }, + { + operation: "GetPublicKey", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "public keys rotate periodically; fetch fresh before encryption", trigger: "stale_key_cache", severity: High }], + confidence: Documented, + prerequisites: [{ description: "token can read repository metadata", kind: Credential }], + assumptions: ["Provider key endpoint is globally reachable from caller"], + unknowns: ["Rotation cadence and overlap window for old/new key acceptance"] + } + }, + { + operation: "CreateOrUpdateRepoSecret", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["owner", "repo", "secret_name"], + determinism: Deterministic, + failure_modes: [{ name: "ValidationFailed", condition: "invalid_secret_name", http_status: 422, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "updates overwrite prior value; there is no version history", trigger: "reusing_secret_name", severity: High }], + confidence: Documented, + prerequisites: [ + { description: "token has actions:write scope", kind: Credential }, + { description: "encrypted_value produced with current key_id", kind: Tooling } + ], + assumptions: ["Secret names match provider regex constraints"], + unknowns: ["Propagation delay from update to workflow runtime visibility"] + } + }, + { + operation: "DeleteRepoSecret", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["owner", "repo", "secret_name"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "token has actions:write scope", kind: Credential }], + assumptions: ["Delete is immediate for future workflow runs"], + unknowns: ["How quickly running workflows lose access to deleted secret values"] + } + }, + { + operation: "ListEnvironmentSecrets", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "scope constrained to a deployment environment", trigger: "environment_mismatch", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "token can read environment metadata", kind: Credential }], + assumptions: ["Environment identifiers are resolved to repository IDs"], + unknowns: ["Pagination behavior for very large environment secret sets"] + } + }, + { + operation: "GetOidcToken", + behavior: { + side_effects: ReadOnly, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [{ name: "PermissionDenied", condition: "missing_id_token_write_permission", http_status: 403, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "claims vary by workflow context (ref, sha, repo)", trigger: "different_runner_context", severity: Low }], + confidence: Documented, + prerequisites: [{ description: "workflow grants id-token:write", kind: Policy }], + assumptions: ["JWT signing keys are trusted by downstream verifier"], + unknowns: ["Exact token issuance latency under high Actions load"] + } + }, + { + operation: "AccessSecretInWorkflow", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "missing secret resolves to empty string rather than hard failure", trigger: "absent_secret_reference", severity: High }], + confidence: Documented, + prerequisites: [{ description: "secret is scoped to the workflow context", kind: Infrastructure }], + assumptions: ["Runner masking protects secret values in logs"], + unknowns: ["Provider-side masking edge cases for multiline secret payloads"] + } + } +] diff --git a/dsl/extdeps/secrets/vault.dag b/dsl/extdeps/secrets/vault.dag new file mode 100644 index 00000000000..f30d0dc78e0 --- /dev/null +++ b/dsl/extdeps/secrets/vault.dag @@ -0,0 +1,196 @@ +// extdeps/secrets/vault.dag -- "What is HashiCorp Vault?" + +module extdeps.secrets.vault + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} + +type VaultEngine = Kv2 | Transit | Database | Pki + +type VaultAuth = AppRole | Oidc | Token | Kubernetes + +type VaultLease { + id: String + ttl_seconds: Int + renewable: Bool +} + +type VaultPolicy { + name: String + rules: List +} + +type DynamicCredential { + engine: VaultEngine + role: String + ttl: Int +} + +type VaultOperationBehavior { + operation: String + behavior: OperationBehavior +} + +type LeaseLifecycleState = Issued | Renewable | Expired | Revoked + +data lease_lifecycle: List = [Issued, Renewable, Expired, Revoked] + +data operation_behaviors: List = [ + { + operation: "KvRead", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [ + { name: "NotFound", condition: "path_missing_or_version_deleted", http_status: 404, recoverable: false, retry_safe: false }, + { name: "Denied", condition: "policy_denies_read", http_status: 403, recoverable: false, retry_safe: false } + ], + edge_cases: [{ description: "version=0 resolves to latest", trigger: "implicit_version", severity: Low }], + confidence: Documented, + prerequisites: [{ description: "valid Vault token with read policy", kind: Credential }], + assumptions: ["KV v2 mount semantics are enabled at mount path"], + unknowns: ["Read consistency during HA failover windows"] + } + }, + { + operation: "KvList", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "folder-like entries end with /", trigger: "nested_paths", severity: Low }], + confidence: Documented, + prerequisites: [{ description: "list capability on metadata endpoint", kind: Capability }], + assumptions: ["HTTP LIST method supported by proxy path"], + unknowns: ["Path traversal semantics with deeply nested prefixes"] + } + }, + { + operation: "KvWrite", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["path", "cas"], + determinism: Deterministic, + failure_modes: [{ name: "CasMismatch", condition: "cas_precondition_failed", http_status: 400, recoverable: true, retry_safe: false }], + edge_cases: [ + { description: "cas=0 means create-only", trigger: "first_write_only", severity: Medium }, + { description: "cas=N means update-if-current-version-is-N", trigger: "optimistic_locking", severity: Medium } + ], + confidence: Documented, + prerequisites: [{ description: "token can write KV data path", kind: Credential }], + assumptions: ["KV metadata versioning is enabled"], + unknowns: ["Conflict probability under high contention and retry storms"] + } + }, + { + operation: "KvDelete", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["path"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "delete is soft and can be undeleted", trigger: null, severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "token can delete data path", kind: Credential }], + assumptions: ["Soft-delete tombstones are retained by provider policy"], + unknowns: [] + } + }, + { + operation: "KvDestroy", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["path", "versions"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "destroy permanently removes data versions", trigger: "irreversible_request", severity: Critical }], + confidence: Documented, + prerequisites: [{ description: "token can destroy metadata versions", kind: Credential }], + assumptions: ["Version list is explicit and intentionally chosen"], + unknowns: [] + } + }, + { + operation: "AuthTokenLookup", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "ttl is remaining lifetime at lookup time", trigger: null, severity: Low }], + confidence: Documented, + prerequisites: [{ description: "valid token is already present", kind: Credential }], + assumptions: ["Token lookup endpoint available in current auth mount"], + unknowns: [] + } + }, + { + operation: "AuthAppRole", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [{ name: "InvalidCreds", condition: "role_id_or_secret_id_invalid", http_status: 400, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "role_id and secret_id are provisioned", kind: Credential }], + assumptions: ["AppRole auth mount is enabled"], + unknowns: ["Rate-limiting behavior on repeated failed auth attempts"] + } + }, + { + operation: "AuthOidc", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [{ name: "JwtRejected", condition: "token_expired_or_claim_mismatch", http_status: 400, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "OIDC role + bound claims configured", kind: Policy }], + assumptions: ["Issuer keys are reachable for signature verification"], + unknowns: [] + } + }, + { + operation: "DatabaseCreds", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "generated credentials auto-revoke when TTL expires", trigger: "ttl_elapsed", severity: High }], + confidence: Documented, + prerequisites: [{ description: "database secrets engine configured with role", kind: Capability }], + assumptions: ["Downstream database role exists and grants required privileges"], + unknowns: ["Lease renewal impact on pooled client connections"] + } + }, + { + operation: "LeaseRenew", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["lease_id"], + determinism: Deterministic, + failure_modes: [{ name: "RenewRejected", condition: "lease_expired_or_not_renewable", http_status: 400, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "lease is still active and renewable", kind: Runtime }], + assumptions: ["Renew requests occur before hard max TTL is reached"], + unknowns: ["Renew latency variability under heavy auth backend load"] + } + } +] diff --git a/dsl/extdeps/tools/gh_cli.dag b/dsl/extdeps/tools/gh_cli.dag new file mode 100644 index 00000000000..fe7b87b0981 --- /dev/null +++ b/dsl/extdeps/tools/gh_cli.dag @@ -0,0 +1,127 @@ +// extdeps/tools/gh_cli.dag -- "What is the GitHub CLI?" + +module extdeps.tools.gh_cli + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} + +type GhAuthMethod = OAuthBrowser | Token | GitHubApp + +type GhCommand { + verb: String + resource: String + flags: List +} + +type GhOutputFormat = Json | Table | Template + +type GhCliOperationBehavior { + operation: String + behavior: OperationBehavior +} + +type AuthFlowStep { + step: String + required: Bool +} + +data auth_flow: List = [ + { step: "Select auth method (token/browser/app)", required: true }, + { step: "Exchange or validate credentials with API", required: true }, + { step: "Persist credential in keychain or config", required: true }, + { step: "Verify scopes with gh auth status", required: true } +] + +data operation_behaviors: List = [ + { + operation: "Auth", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["host", "auth_method"], + determinism: NonDeterministic, + failure_modes: [{ name: "AuthFailed", condition: "token_invalid_or_scope_missing", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "network access to GitHub host", kind: Infrastructure }], + assumptions: ["Credential storage backend is available on host platform"], + unknowns: ["Exact scope requirements per subcommand family"] + } + }, + { + operation: "ReleaseList", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: null, + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "read access to repository releases", kind: Credential }], + assumptions: ["CLI pagination handles large release lists safely"], + unknowns: [] + } + }, + { + operation: "ReleaseDownload", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["repo", "tag", "pattern", "dest"], + determinism: Deterministic, + failure_modes: [{ name: "AssetNotFound", condition: "release_or_pattern_missing", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "write access to destination path", kind: Infrastructure }], + assumptions: ["Artifact checksum validation handled by caller when required"], + unknowns: ["Retry behavior for partial downloads over unstable links"] + } + }, + { + operation: "IssueCreate", + behavior: { + side_effects: WritesExternal, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "duplicate issues possible without caller-level dedup keys", trigger: "repeated_submit", severity: Medium }], + confidence: MediumConfidence, + prerequisites: [{ description: "issues:write permission", kind: Credential }], + assumptions: ["Repository issue tracker enabled"], + unknowns: [] + } + }, + { + operation: "PrCreate", + behavior: { + side_effects: WritesExternal, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [{ name: "ValidationFailed", condition: "base_or_head_invalid", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: MediumConfidence, + prerequisites: [{ description: "pull_requests:write permission", kind: Credential }], + assumptions: ["Head branch exists and is push-accessible"], + unknowns: [] + } + }, + { + operation: "RunWatch", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["run_id"], + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "watch loop can exceed terminal timeout for long workflows", trigger: "long_running_run", severity: Medium }], + confidence: HighConfidence, + prerequisites: [{ description: "actions:read permission", kind: Credential }], + assumptions: ["Polling interval is bounded to avoid API exhaustion"], + unknowns: [] + } + } +] diff --git a/dsl/extdeps/tools/package_managers.dag b/dsl/extdeps/tools/package_managers.dag new file mode 100644 index 00000000000..4fc29e55c62 --- /dev/null +++ b/dsl/extdeps/tools/package_managers.dag @@ -0,0 +1,160 @@ +// extdeps/tools/package_managers.dag -- "What are package managers?" + +module extdeps.tools.package_managers + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} + +type PackageManager = Apt | Brew | Cargo | Winget | Choco | GithubRelease + +type PlatformSupport { + os: String + arch: String +} + +type InstallSource + = SystemRepo + | Crate + | Release { url_template: String } + +type PackageManagerOperationBehavior { + manager: PackageManager + operation: String + behavior: OperationBehavior +} + +type ManagerPlatformSupport { + manager: PackageManager + platform: PlatformSupport +} + +data manager_platform_matrix: List = [ + { manager: Apt, platform: { os: "linux", arch: "x86_64" } }, + { manager: Apt, platform: { os: "linux", arch: "aarch64" } }, + { manager: Brew, platform: { os: "macos", arch: "x86_64" } }, + { manager: Brew, platform: { os: "macos", arch: "aarch64" } }, + { manager: Brew, platform: { os: "linux", arch: "x86_64" } }, + { manager: Winget, platform: { os: "windows", arch: "x86_64" } }, + { manager: Choco, platform: { os: "windows", arch: "x86_64" } }, + { manager: Cargo, platform: { os: "linux", arch: "x86_64" } }, + { manager: Cargo, platform: { os: "macos", arch: "aarch64" } }, + { manager: Cargo, platform: { os: "windows", arch: "x86_64" } }, + { manager: GithubRelease, platform: { os: "linux", arch: "x86_64" } } +] + +// Canonical operation vocabulary applied across managers. +data operation_behaviors: List = [ + { + manager: Apt, + operation: "Install", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["package", "version"], + determinism: Deterministic, + failure_modes: [{ name: "PackageNotFound", condition: "repo_missing_package", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "may require root privileges", trigger: "non_privileged_shell", severity: High }], + confidence: Documented, + prerequisites: [{ description: "apt metadata updated and network reachable", kind: Infrastructure }], + assumptions: ["Debian-compatible package database is present"], + unknowns: [] + } + }, + { + manager: Brew, + operation: "Install", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["formula_or_cask"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "cask installs may trigger GUI-specific prompts", trigger: "cask_mode", severity: Medium }], + confidence: HighConfidence, + prerequisites: [{ description: "brew installed and taps configured", kind: Tooling }], + assumptions: ["Formula naming remains stable across taps"], + unknowns: [] + } + }, + { + manager: Choco, + operation: "Install", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["package", "version"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "administrator shell commonly required", trigger: "limited_permissions", severity: High }], + confidence: MediumConfidence, + prerequisites: [{ description: "Windows host with Chocolatey configured", kind: Infrastructure }], + assumptions: ["Package feed metadata is trusted"], + unknowns: [] + } + }, + { + manager: Winget, + operation: "Install", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["package_id", "version"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "source and package agreement flags may be required non-interactively", trigger: "headless_runner", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "winget sources are initialized", kind: Infrastructure }], + assumptions: ["Package IDs are canonicalized by source"], + unknowns: [] + } + }, + { + manager: Cargo, + operation: "Install", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["crate", "version"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "requires toolchain and compatible target libc", trigger: "platform_mismatch", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "rust toolchain installed", kind: Tooling }], + assumptions: ["Crate index reachable and lockable"], + unknowns: [] + } + }, + { + manager: GithubRelease, + operation: "Download", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["repo", "version", "asset"], + determinism: Deterministic, + failure_modes: [{ name: "ReleaseMissing", condition: "tag_or_asset_not_found", http_status: 404, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: MediumConfidence, + prerequisites: [{ description: "network access to GitHub release assets", kind: Infrastructure }], + assumptions: ["Asset naming follows predictable platform suffix conventions"], + unknowns: [] + } + }, + { + manager: GithubRelease, + operation: "RunScript", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "arbitrary script execution may have side effects beyond package installation", trigger: "installer_script", severity: Critical }], + confidence: MediumConfidence, + prerequisites: [{ description: "script source is authenticated and trusted", kind: Policy }], + assumptions: ["Caller sandboxing policy determines blast radius"], + unknowns: [] + } + } +] diff --git a/dsl/extdeps/tools/rust_toolchain.dag b/dsl/extdeps/tools/rust_toolchain.dag new file mode 100644 index 00000000000..e2cb82c3ea1 --- /dev/null +++ b/dsl/extdeps/tools/rust_toolchain.dag @@ -0,0 +1,131 @@ +// extdeps/tools/rust_toolchain.dag -- "What is the Rust toolchain?" + +module extdeps.tools.rust_toolchain + +import std.behavioral { + OperationBehavior, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} + +type RustChannel = Stable | Beta | Nightly { date: String? } + +type RustupComponent = Rustfmt | Clippy | RustSrc | Miri | LlvmTools + +type CargoCommand = Build | Test | ClippyLint | Doc | Run | Install | Bench | PublishCrate + +type RustTarget { + triple: String + tier: Int +} + +type RustEdition = E2015 | E2018 | E2021 | E2024 + +type ToolchainOperationBehavior { + operation: String + behavior: OperationBehavior +} + +type PlatformSupport { + os: String + arch: String +} + +data supported_platforms: List = [ + { os: "linux", arch: "x86_64" }, + { os: "linux", arch: "aarch64" }, + { os: "macos", arch: "x86_64" }, + { os: "macos", arch: "aarch64" }, + { os: "windows", arch: "x86_64" } +] + +data operation_behaviors: List = [ + { + operation: "Install", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["channel"], + determinism: NonDeterministic, + failure_modes: [{ name: "NetworkUnavailable", condition: "rustup_download_failed", http_status: null, recoverable: true, retry_safe: true }], + edge_cases: [{ description: "shell restart or cargo env source may be required after first install", trigger: "fresh_install", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "network access and curl or package manager availability", kind: Infrastructure }], + assumptions: ["Rustup is the canonical install manager"], + unknowns: ["Mirror fallback behavior on partial CDN outages"] + } + }, + { + operation: "AddComponent", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["component", "channel"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "component availability differs by target/channel combination", trigger: "unsupported_component", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "toolchain installed", kind: Tooling }], + assumptions: ["rustup component registry metadata is up to date"], + unknowns: [] + } + }, + { + operation: "Build", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["workspace_hash", "profile", "target"], + determinism: Deterministic, + failure_modes: [{ name: "CompileError", condition: "source_or_dependency_error", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "Cargo.toml and lockfile resolve dependency graph", kind: Infrastructure }], + assumptions: ["Build cache contents are not part of logical output semantics"], + unknowns: ["Toolchain minor-version drift impact on warning surface"] + } + }, + { + operation: "Test", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["workspace_hash", "test_filter"], + determinism: NonDeterministic, + failure_modes: [{ name: "TestFailure", condition: "assertion_failed", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "ignored tests require explicit include flag", trigger: "missing_include_ignored", severity: Low }], + confidence: Documented, + prerequisites: [{ description: "binary artifacts are buildable for selected targets", kind: Tooling }], + assumptions: ["Tests avoid hidden global state when run in CI"], + unknowns: ["Flake rate under concurrent test execution on constrained runners"] + } + }, + { + operation: "ClippyLint", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["workspace_hash", "lint_profile"], + determinism: Deterministic, + failure_modes: [{ name: "LintFailure", condition: "warning_promoted_to_error", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "clippy component installed", kind: Tooling }], + assumptions: ["Lint version tracks selected rust toolchain"], + unknowns: [] + } + }, + { + operation: "PublishCrate", + behavior: { + side_effects: WritesExternal, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [{ name: "VersionAlreadyPublished", condition: "crate_version_exists", http_status: null, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "publish cannot overwrite an existing semver version", trigger: "duplicate_version", severity: High }], + confidence: HighConfidence, + prerequisites: [{ description: "registry token with publish permission", kind: Credential }], + assumptions: ["Crate metadata satisfies registry policy checks"], + unknowns: ["Registry-side indexing delay before just-published crate is queryable"] + } + } +] diff --git a/dsl/interfaces/agent_provider.dag b/dsl/interfaces/agent_provider.dag index 3c8b63a4019..ec6be4cf4ae 100644 --- a/dsl/interfaces/agent_provider.dag +++ b/dsl/interfaces/agent_provider.dag @@ -12,6 +12,113 @@ module interfaces.agent_provider import std.types { NonEmptyStr, AgentStatus, ApprovalMode } +import std.behavioral { + OperationBehavior, FailureMode, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.coordination { DeliveryGuarantee, QueueSemantics, QueueOrdering } +import std.capability { CapabilityRequirement, InfraScope, AccessLevel } + +type CapabilityBehaviorContract { + capability: String + behavior: OperationBehavior +} + +type CapabilityFailureContract { + capability: String + failures: List +} + +data execution_delivery: DeliveryGuarantee = AtLeastOnce + +data execution_queue_semantics: QueueSemantics = { + ordering: Fifo, + visibility_timeout_seconds: 900, + dead_letter: { max_attempts: 5, destination: "agent_provider.dlq" } +} + +data capability_behaviors: List = [ + { + capability: "spawn", + behavior: { + side_effects: WritesExternal, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [{ name: "AdmissionDenied", condition: "provider_refused_new_run", http_status: 429, recoverable: true, retry_safe: true }], + edge_cases: [{ description: "spawn may return started=false with queued status under capacity pressure", trigger: "saturated_executor", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "agent execution backend available", kind: Infrastructure }], + assumptions: ["run_id is globally unique within provider namespace"], + unknowns: ["Long-tail startup latency under burst load"] + } + }, + { + capability: "poll", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["run_id"], + determinism: NonDeterministic, + failure_modes: [{ name: "NotFound", condition: "run_id_unknown", http_status: 404, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "run_id was previously issued by spawn", kind: Runtime }], + assumptions: ["status transitions are monotonic toward terminal states"], + unknowns: [] + } + }, + { + capability: "cancel", + behavior: { + side_effects: WritesExternal, + idempotent: true, + idempotency_keys: ["run_id"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "cancel may race with natural completion", trigger: "late_cancel", severity: Medium }], + confidence: HighConfidence, + prerequisites: [{ description: "caller has authority to cancel run", kind: Capability }], + assumptions: ["final_status reflects terminal state after cancellation attempt"], + unknowns: [] + } + }, + { + capability: "get_result", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["run_id"], + determinism: Deterministic, + failure_modes: [{ name: "NotReady", condition: "run_not_terminal", http_status: 409, recoverable: true, retry_safe: true }], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "run has reached terminal status", kind: Runtime }], + assumptions: ["stdout/stderr capture may be truncated by provider limits"], + unknowns: [] + } + } +] + +data capability_requirements: List = [ + { scope: Compute, level: Write, resource: "agent_provider.exec" }, + { scope: Storage, level: Read, resource: "agent_provider.logs" }, + { scope: Api, level: Write, resource: "agent_provider.control" } +] + +data capability_failure_contracts: List = [ + { + capability: "spawn", + failures: [{ name: "AdmissionDenied", condition: "provider_refused_new_run", http_status: 429, recoverable: true, retry_safe: true }] + }, + { + capability: "poll", + failures: [{ name: "NotFound", condition: "run_id_unknown", http_status: 404, recoverable: false, retry_safe: false }] + }, + { + capability: "get_result", + failures: [{ name: "NotReady", condition: "run_not_terminal", http_status: 409, recoverable: true, retry_safe: true }] + } +] interface AgentProvider { diff --git a/dsl/interfaces/artifact_store.dag b/dsl/interfaces/artifact_store.dag index 494a1b0be3b..59e31695d00 100644 --- a/dsl/interfaces/artifact_store.dag +++ b/dsl/interfaces/artifact_store.dag @@ -13,6 +13,104 @@ module interfaces.artifact_store import std.types { NonEmptyStr, ArtifactPayload, ArtifactType, ArtifactMarker } +import std.behavioral { + OperationBehavior, FailureMode, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.coordination { CasMechanism, DeliveryGuarantee } +import std.capability { CapabilityRequirement, InfraScope, AccessLevel } + +type CapabilityBehaviorContract { + capability: String + behavior: OperationBehavior +} + +type CapabilityFailureContract { + capability: String + failures: List +} + +data cas_mechanism: CasMechanism = GenerationBased +data delivery_guarantee: DeliveryGuarantee = AtLeastOnce + +data capability_behaviors: List = [ + { + capability: "store", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["artifact_id", "content_hash"], + determinism: Deterministic, + failure_modes: [{ name: "Conflict", condition: "same_artifact_id_different_hash", http_status: 409, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "artifact storage write access", kind: Capability }], + assumptions: ["content_hash computed over canonical artifact bytes"], + unknowns: [] + } + }, + { + capability: "retrieve", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["artifact_id"], + determinism: Deterministic, + failure_modes: [{ name: "NotFound", condition: "artifact_missing", http_status: 404, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "artifact storage read access", kind: Capability }], + assumptions: [], + unknowns: [] + } + }, + { + capability: "store_marker", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["marker.run_key", "marker.artifact_type", "marker.phase", "marker.content_hash"], + determinism: Deterministic, + failure_modes: [{ name: "MarkerConflict", condition: "same_marker_key_different_hash", http_status: 409, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "marker namespace write access", kind: Capability }], + assumptions: ["Canonical markers are immutable once committed"], + unknowns: [] + } + }, + { + capability: "get_canonical_marker", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["run_key", "artifact_type"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "marker namespace read access", kind: Capability }], + assumptions: ["Canonical marker is authoritative commit pointer"], + unknowns: [] + } + } +] + +data capability_requirements: List = [ + { scope: Storage, level: Write, resource: "artifact_store.payload" }, + { scope: Storage, level: Write, resource: "artifact_store.markers" }, + { scope: Storage, level: Read, resource: "artifact_store.read" } +] + +data capability_failure_contracts: List = [ + { + capability: "store", + failures: [{ name: "Conflict", condition: "same_artifact_id_different_hash", http_status: 409, recoverable: false, retry_safe: false }] + }, + { + capability: "store_marker", + failures: [{ name: "MarkerConflict", condition: "same_marker_key_different_hash", http_status: 409, recoverable: false, retry_safe: false }] + } +] interface ArtifactStore { diff --git a/dsl/interfaces/claim_store.dag b/dsl/interfaces/claim_store.dag index ad2c4f4cd35..b13aedefecf 100644 --- a/dsl/interfaces/claim_store.dag +++ b/dsl/interfaces/claim_store.dag @@ -14,6 +14,94 @@ module interfaces.claim_store import std.types { NonEmptyStr, IssueLifecycleStage } +import std.behavioral { + OperationBehavior, FailureMode, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.coordination { CasMechanism, LeaseConfig, DeliveryGuarantee } +import std.capability { CapabilityRequirement, InfraScope, AccessLevel } + +type CapabilityBehaviorContract { + capability: String + behavior: OperationBehavior +} + +type CapabilityFailureContract { + capability: String + failures: List +} + +data cas_mechanism: CasMechanism = GenerationBased + +data default_lease: LeaseConfig = { + ttl_seconds: 300, + heartbeat_interval_seconds: 60 +} + +data delivery_guarantee: DeliveryGuarantee = AtMostOnce + +data capability_behaviors: List = [ + { + capability: "acquire", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["issue_id", "stage", "owner"], + determinism: NonDeterministic, + failure_modes: [{ name: "Conflict", condition: "active_lease_exists", http_status: 409, recoverable: true, retry_safe: true }], + edge_cases: [{ description: "expired leases are reclaimable", trigger: "claim_expired", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "coordination storage write access", kind: Capability }], + assumptions: ["generation monotonically increments for each successful acquire"], + unknowns: [] + } + }, + { + capability: "heartbeat", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["issue_id", "stage", "owner", "generation"], + determinism: Deterministic, + failure_modes: [{ name: "LeaseLost", condition: "owner_or_generation_mismatch", http_status: 409, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "caller holds active lease", kind: Runtime }], + assumptions: ["heartbeat extends lease expiry only for current owner+generation"], + unknowns: [] + } + }, + { + capability: "release", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["issue_id", "stage", "owner", "generation"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "caller holds active lease", kind: Runtime }], + assumptions: ["release clears claim ownership for subsequent acquirers"], + unknowns: [] + } + } +] + +data capability_requirements: List = [ + { scope: Storage, level: Write, resource: "claim_store.records" }, + { scope: Queue, level: Write, resource: "claim_store.leases" } +] + +data capability_failure_contracts: List = [ + { + capability: "acquire", + failures: [{ name: "Conflict", condition: "active_lease_exists", http_status: 409, recoverable: true, retry_safe: true }] + }, + { + capability: "heartbeat", + failures: [{ name: "LeaseLost", condition: "owner_or_generation_mismatch", http_status: 409, recoverable: false, retry_safe: false }] + } +] interface ClaimStore { diff --git a/dsl/interfaces/credential_provider.dag b/dsl/interfaces/credential_provider.dag index f763001362d..f631fd4d463 100644 --- a/dsl/interfaces/credential_provider.dag +++ b/dsl/interfaces/credential_provider.dag @@ -19,6 +19,82 @@ module interfaces.credential_provider import std.types { AccessToken, AuthScheme, CredentialFlow, NonEmptyStr, Secret } +import std.behavioral { + OperationBehavior, FailureMode, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.coordination { DeliveryGuarantee } +import std.capability { CapabilityRequirement, InfraScope, AccessLevel } + +type CapabilityBehaviorContract { + capability: String + behavior: OperationBehavior +} + +type CapabilityFailureContract { + capability: String + failures: List +} + +data token_delivery_guarantee: DeliveryGuarantee = AtLeastOnce + +data capability_behaviors: List = [ + { + capability: "acquire", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["secret_name", "flow", "scheme", "source_id", "required_scopes_hash"], + determinism: NonDeterministic, + failure_modes: [ + { name: "MissingCredential", condition: "secret_or_source_missing", http_status: 404, recoverable: false, retry_safe: false }, + { name: "InsufficientScope", condition: "token_missing_required_scope", http_status: 403, recoverable: false, retry_safe: false } + ], + edge_cases: [{ description: "token TTL may be shorter than downstream workflow runtime", trigger: "short_lived_token", severity: High }], + confidence: Documented, + prerequisites: [ + { description: "access to configured credential source", kind: Capability }, + { description: "provider-specific auth handshake configured", kind: Infrastructure } + ], + assumptions: ["acquire returns a usable token for selected auth scheme"], + unknowns: ["Cross-provider clock skew tolerance for exp/nbf checks"] + } + }, + { + capability: "validate", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["token_hash"], + determinism: Deterministic, + failure_modes: [{ name: "Expired", condition: "token_expired", http_status: 401, recoverable: true, retry_safe: false }], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "provider introspection endpoint reachable when needed", kind: Infrastructure }], + assumptions: ["validate can return expires_in when provider exposes TTL"], + unknowns: [] + } + } +] + +data capability_requirements: List = [ + { scope: Secret, level: Read, resource: "credential_provider.sources" }, + { scope: Identity, level: Read, resource: "credential_provider.identity" }, + { scope: Federation, level: Read, resource: "credential_provider.wif" } +] + +data capability_failure_contracts: List = [ + { + capability: "acquire", + failures: [ + { name: "MissingCredential", condition: "secret_or_source_missing", http_status: 404, recoverable: false, retry_safe: false }, + { name: "InsufficientScope", condition: "token_missing_required_scope", http_status: 403, recoverable: false, retry_safe: false } + ] + }, + { + capability: "validate", + failures: [{ name: "Expired", condition: "token_expired", http_status: 401, recoverable: true, retry_safe: false }] + } +] interface CredentialProvider { diff --git a/dsl/interfaces/issue_provider.dag b/dsl/interfaces/issue_provider.dag index f751824a6db..fd048c17ea5 100644 --- a/dsl/interfaces/issue_provider.dag +++ b/dsl/interfaces/issue_provider.dag @@ -15,6 +15,11 @@ module interfaces.issue_provider import std.types { TrackedIssue, IssueState, IssueLifecycleStage, NonEmptyStr, Url } +import std.behavioral { + OperationBehavior, FailureMode, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.coordination { DeliveryGuarantee } +import std.capability { CapabilityRequirement, InfraScope, AccessLevel } // IssueEvent represents a single event in an issue's history. type IssueEvent { @@ -24,6 +29,146 @@ type IssueEvent { payload: Json? // Event-specific data } +type CapabilityBehaviorContract { + capability: String + behavior: OperationBehavior +} + +type CapabilityFailureContract { + capability: String + failures: List +} + +data delivery_guarantee: DeliveryGuarantee = AtLeastOnce + +data capability_behaviors: List = [ + { + capability: "discover", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["labels", "state"], + determinism: Deterministic, + failure_modes: [{ name: "RateLimited", condition: "provider_quota_exhausted", http_status: 429, recoverable: true, retry_safe: true }], + edge_cases: [{ description: "empty label list returns all managed issues", trigger: "labels=[]", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "issue read access", kind: Capability }], + assumptions: ["Label filtering follows provider search semantics"], + unknowns: [] + } + }, + { + capability: "get", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["id"], + determinism: Deterministic, + failure_modes: [{ name: "NotFound", condition: "issue_absent", http_status: 404, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "issue read access", kind: Capability }], + assumptions: ["found=false is returned instead of throwing for missing issue"], + unknowns: [] + } + }, + { + capability: "create", + behavior: { + side_effects: WritesExternal, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [{ name: "ValidationFailed", condition: "title_or_body_invalid", http_status: 422, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "duplicate create can happen without caller dedup key", trigger: "retry_without_marker", severity: High }], + confidence: HighConfidence, + prerequisites: [{ description: "issue write access", kind: Capability }], + assumptions: ["Provider creates canonical issue ID on success"], + unknowns: [] + } + }, + { + capability: "comment", + behavior: { + side_effects: WritesExternal, + idempotent: true, + idempotency_keys: ["id", "marker"], + determinism: Deterministic, + failure_modes: [{ name: "PermissionDenied", condition: "missing_comment_permission", http_status: 403, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "marker-based upsert updates existing comment when marker matches", trigger: "same_marker", severity: Low }], + confidence: Documented, + prerequisites: [{ description: "comment write access", kind: Capability }], + assumptions: ["Marker text is unique within managed automation comments"], + unknowns: [] + } + }, + { + capability: "set_labels", + behavior: { + side_effects: WritesExternal, + idempotent: true, + idempotency_keys: ["id", "labels_hash"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "operation replaces full label set rather than merging", trigger: "partial_update_expectation", severity: Medium }], + confidence: Documented, + prerequisites: [{ description: "label write access", kind: Capability }], + assumptions: ["Caller provides complete desired label set"], + unknowns: [] + } + }, + { + capability: "close", + behavior: { + side_effects: WritesExternal, + idempotent: true, + idempotency_keys: ["id"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "closing an already closed issue is no-op success", trigger: "already_closed", severity: Low }], + confidence: Documented, + prerequisites: [{ description: "issue close permission", kind: Capability }], + assumptions: [], + unknowns: [] + } + }, + { + capability: "list_events", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["id", "since"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: HighConfidence, + prerequisites: [{ description: "event read access", kind: Capability }], + assumptions: ["Event timeline ordering is stable by timestamp"], + unknowns: [] + } + } +] + +data capability_requirements: List = [ + { scope: Api, level: Read, resource: "github.issues.read" }, + { scope: Api, level: Write, resource: "github.issues.write" } +] + +data capability_failure_contracts: List = [ + { + capability: "discover", + failures: [{ name: "RateLimited", condition: "provider_quota_exhausted", http_status: 429, recoverable: true, retry_safe: true }] + }, + { + capability: "get", + failures: [{ name: "NotFound", condition: "issue_absent", http_status: 404, recoverable: false, retry_safe: false }] + }, + { + capability: "create", + failures: [{ name: "ValidationFailed", condition: "title_or_body_invalid", http_status: 422, recoverable: false, retry_safe: false }] + } +] + interface IssueProvider { // --- Discovery ----------------------------------------------------------- diff --git a/dsl/interfaces/outcome_ledger.dag b/dsl/interfaces/outcome_ledger.dag index 0747030edaa..1d78279d947 100644 --- a/dsl/interfaces/outcome_ledger.dag +++ b/dsl/interfaces/outcome_ledger.dag @@ -14,6 +14,84 @@ module interfaces.outcome_ledger import std.types { NonEmptyStr, StageOutcome } +import std.behavioral { + OperationBehavior, FailureMode, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.coordination { CasMechanism, DeliveryGuarantee } +import std.capability { CapabilityRequirement, InfraScope, AccessLevel } + +type CapabilityBehaviorContract { + capability: String + behavior: OperationBehavior +} + +type CapabilityFailureContract { + capability: String + failures: List +} + +data cas_mechanism: CasMechanism = GenerationBased +data delivery_guarantee: DeliveryGuarantee = AtLeastOnce + +data capability_behaviors: List = [ + { + capability: "upsert", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["run_key", "stage", "outcome.content_hash"], + determinism: Deterministic, + failure_modes: [{ name: "Conflict", condition: "same_run_stage_different_content_hash", http_status: 409, recoverable: false, retry_safe: false }], + edge_cases: [{ description: "same content hash is no-op success", trigger: "duplicate_write", severity: Low }], + confidence: Documented, + prerequisites: [{ description: "ledger write access", kind: Capability }], + assumptions: ["content_hash is stable canonical digest of outcome payload"], + unknowns: [] + } + }, + { + capability: "get", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["run_key", "stage"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "ledger read access", kind: Capability }], + assumptions: [], + unknowns: [] + } + }, + { + capability: "list_by_issue", + behavior: { + side_effects: ReadOnly, + idempotent: true, + idempotency_keys: ["issue_id"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [{ description: "result spans multiple runs and stages and may require paging", trigger: "large_history", severity: Medium }], + confidence: HighConfidence, + prerequisites: [{ description: "ledger read access", kind: Capability }], + assumptions: ["Outcome records preserve causal ordering metadata"], + unknowns: [] + } + } +] + +data capability_requirements: List = [ + { scope: Storage, level: Write, resource: "outcome_ledger.upsert" }, + { scope: Storage, level: Read, resource: "outcome_ledger.read" } +] + +data capability_failure_contracts: List = [ + { + capability: "upsert", + failures: [{ name: "Conflict", condition: "same_run_stage_different_content_hash", http_status: 409, recoverable: false, retry_safe: false }] + } +] interface OutcomeLedger { diff --git a/dsl/interfaces/signal_store.dag b/dsl/interfaces/signal_store.dag index 76e1be23935..3119419758f 100644 --- a/dsl/interfaces/signal_store.dag +++ b/dsl/interfaces/signal_store.dag @@ -13,6 +13,89 @@ module interfaces.signal_store import std.types { NonEmptyStr, Signal, SignalType } +import std.behavioral { + OperationBehavior, FailureMode, SideEffects, Determinism, Confidence, PrerequisiteKind, Severity +} +import std.coordination { DeliveryGuarantee, QueueSemantics, QueueOrdering } +import std.capability { CapabilityRequirement, InfraScope, AccessLevel } + +type CapabilityBehaviorContract { + capability: String + behavior: OperationBehavior +} + +type CapabilityFailureContract { + capability: String + failures: List +} + +data delivery_guarantee: DeliveryGuarantee = AtLeastOnce + +data queue_semantics: QueueSemantics = { + ordering: Unordered, + visibility_timeout_seconds: 300, + dead_letter: { max_attempts: 10, destination: "signals.dlq" } +} + +data capability_behaviors: List = [ + { + capability: "emit", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["signal.idempotency_key"], + determinism: Deterministic, + failure_modes: [{ name: "Conflict", condition: "duplicate_idempotency_key_with_different_payload", http_status: 409, recoverable: false, retry_safe: false }], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "signal backend write access", kind: Capability }], + assumptions: ["idempotency key deduplicates equivalent logical signal"], + unknowns: [] + } + }, + { + capability: "consume", + behavior: { + side_effects: WritesState, + idempotent: false, + idempotency_keys: null, + determinism: NonDeterministic, + failure_modes: [], + edge_cases: [{ description: "duplicate, delayed, and reordered signals are expected", trigger: "distributed_delivery", severity: High }], + confidence: Documented, + prerequisites: [{ description: "consumer has claim/read access to queue", kind: Capability }], + assumptions: ["authoritative state checks happen against store/ledger, not only signal stream"], + unknowns: [] + } + }, + { + capability: "ack", + behavior: { + side_effects: WritesState, + idempotent: true, + idempotency_keys: ["idempotency_key"], + determinism: Deterministic, + failure_modes: [], + edge_cases: [], + confidence: Documented, + prerequisites: [{ description: "consumer identity can ack consumed signals", kind: Runtime }], + assumptions: ["acking an already-acked signal is no-op success"], + unknowns: [] + } + } +] + +data capability_requirements: List = [ + { scope: Queue, level: Write, resource: "signal_store.emit" }, + { scope: Queue, level: Read, resource: "signal_store.consume" } +] + +data capability_failure_contracts: List = [ + { + capability: "emit", + failures: [{ name: "Conflict", condition: "duplicate_idempotency_key_with_different_payload", http_status: 409, recoverable: false, retry_safe: false }] + } +] interface SignalStore { diff --git a/dsl/std/behavioral.dag b/dsl/std/behavioral.dag new file mode 100644 index 00000000000..2fc4dae591f --- /dev/null +++ b/dsl/std/behavioral.dag @@ -0,0 +1,54 @@ +// std/behavioral.dag -- Behavioral vocabulary for external-system modeling. +// +// Canonical operation behavior schema ported from the-gunbai Understanding +// model and used by Lane 4 extdeps + interface contracts. + +module std.behavioral + +// How an operation affects the world. +type SideEffects = ReadOnly | WritesState | WritesExternal + +// Whether repeated invocations with equal inputs converge to one outcome. +type Determinism = Deterministic | NonDeterministic | EventuallyConsistent + +// Severity used by modeled edge cases. +type Severity = Low | Medium | High | Critical + +// Confidence assigned to a behavior declaration. +type Confidence = Documented | HighConfidence | MediumConfidence | LowConfidence | Assumed + +// Coarse-grained prerequisite kind. +type PrerequisiteKind = Capability | Credential | Infrastructure | Policy | Tooling | Runtime | Other + +type FailureMode { + name: String + condition: String? + http_status: Int? + recoverable: Bool + retry_safe: Bool +} + +type EdgeCase { + description: String + trigger: String? + severity: Severity +} + +type Prerequisite { + description: String + kind: PrerequisiteKind +} + +// Canonical behavior payload attached to operations and capabilities. +type OperationBehavior { + side_effects: SideEffects + idempotent: Bool + idempotency_keys: List? + determinism: Determinism + failure_modes: List + edge_cases: List + confidence: Confidence + prerequisites: List + assumptions: List + unknowns: List +} diff --git a/dsl/std/capability.dag b/dsl/std/capability.dag new file mode 100644 index 00000000000..cef82272e3a --- /dev/null +++ b/dsl/std/capability.dag @@ -0,0 +1,40 @@ +// std/capability.dag -- Capability contract vocabulary. + +module std.capability + +type InfraScope = Secret | Identity | Api | Storage | Compute | Network | Database | Queue | Federation + +type AccessLevel = Read | Write | Admin + +type CapabilityRequirement { + scope: InfraScope + level: AccessLevel + resource: String +} + +type CapabilityGrant { + provider: String + scope: InfraScope + concrete_resource: String +} + +type ServiceAccessManifest { + service_name: String + requirements: List +} + +data github_issue_provider_manifest: ServiceAccessManifest = { + service_name: "IssueProvider", + requirements: [ + { scope: Api, level: Read, resource: "github/issues.read" }, + { scope: Api, level: Write, resource: "github/issues.write" } + ] +} + +data credential_provider_manifest: ServiceAccessManifest = { + service_name: "CredentialProvider", + requirements: [ + { scope: Secret, level: Read, resource: "credential.source" }, + { scope: Identity, level: Read, resource: "workload.identity" } + ] +} diff --git a/dsl/std/coordination.dag b/dsl/std/coordination.dag new file mode 100644 index 00000000000..35d916c8a4a --- /dev/null +++ b/dsl/std/coordination.dag @@ -0,0 +1,42 @@ +// std/coordination.dag -- Coordination primitive vocabulary. + +module std.coordination + +type CasMechanism = GenerationBased | ETagBased | VersionId | RowVersion + +type FencingToken { + scope: String + value: Int +} + +type LeaseConfig { + ttl_seconds: Int + heartbeat_interval_seconds: Int +} + +type DeliveryGuarantee = AtLeastOnce | AtMostOnce | ExactlyOnce + +type QueueOrdering = Unordered | Fifo | PriorityFifo + +type DeadLetterPolicy { + max_attempts: Int + destination: String +} + +type QueueSemantics { + ordering: QueueOrdering + visibility_timeout_seconds: Int? + dead_letter: DeadLetterPolicy? +} + +type NotificationMechanism = Polling | PubSub | Listen | Webhook + +data default_lease: LeaseConfig = { + ttl_seconds: 300, + heartbeat_interval_seconds: 60 +} + +data default_dead_letter: DeadLetterPolicy = { + max_attempts: 10, + destination: "dead-letter" +} diff --git a/dsl/std/errors.dag b/dsl/std/errors.dag new file mode 100644 index 00000000000..2ca4dd62475 --- /dev/null +++ b/dsl/std/errors.dag @@ -0,0 +1,76 @@ +// std/errors.dag -- Canonical provider error-shape vocabulary. + +module std.errors + +type HttpErrorShape { + status: Int + error_type: String + message: String + detail: String? +} + +type AuthError = Expired | InvalidToken | MissingCredential | InsufficientScope | Forbidden + +type RateLimitError { + retry_after_seconds: Int? + scope: String +} + +type ConflictError { + resource: String + expected_version: String? + actual_version: String? +} + +type ProviderError { + provider: String + operation: String + http_status: Int? + message: String +} + +type GitHubErrorShape { + message: String + documentation_url: String? +} + +type GcpErrorDetails { + code: Int + message: String + status: String? +} + +type GcpErrorShape { + error: GcpErrorDetails +} + +type AnthropicErrorDetails { + type: String + message: String +} + +type AnthropicErrorShape { + type: String + error: AnthropicErrorDetails +} + +data github_error_example: GitHubErrorShape = { + message: "Not Found", + documentation_url: "https://docs.github.com/rest" +} + +data gcp_error_example: GcpErrorShape = { + error: { + code: 403, + message: "Permission denied", + status: "PERMISSION_DENIED" + } +} + +data anthropic_error_example: AnthropicErrorShape = { + type: "error", + error: { + type: "authentication_error", + message: "invalid x-api-key" + } +} diff --git a/dsl/std/rate_limit.dag b/dsl/std/rate_limit.dag new file mode 100644 index 00000000000..3132950bada --- /dev/null +++ b/dsl/std/rate_limit.dag @@ -0,0 +1,59 @@ +// std/rate_limit.dag -- Rate limiting, retries, and propagation delay vocabulary. + +module std.rate_limit + +type ResetStrategy = FixedWindow | SlidingWindow | TokenBucket + +type RateLimit { + scope: String + requests_per_window: Int + window_seconds: Int + burst: Int? + reset_strategy: ResetStrategy +} + +type BackoffStrategy + = Exponential { base_ms: Int, max_ms: Int } + | Linear { step_ms: Int } + | JitteredExponential { base_ms: Int, max_ms: Int } + +type RetryPolicy { + max_attempts: Int + backoff: BackoffStrategy + retry_on: List +} + +type PropagationDelay { + typical_ms: Int + max_ms: Int + description: String +} + +// Common presets reused across extdeps and service metadata. +data github_core_limit: RateLimit = { + scope: "github.core", + requests_per_window: 5000, + window_seconds: 3600, + burst: null, + reset_strategy: FixedWindow +} + +data github_search_limit: RateLimit = { + scope: "github.search", + requests_per_window: 30, + window_seconds: 60, + burst: null, + reset_strategy: FixedWindow +} + +data default_jittered_retry: RetryPolicy = { + max_attempts: 5, + backoff: JitteredExponential { base_ms: 200, max_ms: 8000 }, + retry_on: ["rate_limit", "timeout", "http_429", "http_5xx", "network"] +} + +data conservative_retry: RetryPolicy = { + max_attempts: 3, + backoff: Exponential { base_ms: 500, max_ms: 5000 }, + retry_on: ["http_429", "http_503", "network"] +} diff --git a/gunbc-dag/Cargo.toml b/gunbc-dag/Cargo.toml index 84a494ddb77..6710a55c689 100644 --- a/gunbc-dag/Cargo.toml +++ b/gunbc-dag/Cargo.toml @@ -10,6 +10,7 @@ description = "gunbc repo-specific DAG configuration, CI, and generators" gunbc-infra = { path = "../core/infra" } gunbc-ir = { path = "../core/ir" } gunbc-exec = { path = "../core/exec" } +gunbc-workflow = { path = "../core/workflow" } gunbc-codegen = { path = "../core/codegen" } gunbc-test = { path = "../core/test" } diff --git a/gunbc-dag/src/bin/ci.rs b/gunbc-dag/src/bin/ci.rs index 84210c68e42..743ff3467bb 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,40 @@ 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).default("ensure")], + ) { + Ok(parsed) => parsed, + Err(error) => { + print_attention( + AttentionLevel::Error, + "CI argument parsing failed", + &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 mode = parsed + .values + .get("mode") + .and_then(Value::as_str) + .unwrap_or("ensure"); + let resource_mode = ExecMode::parse_strict(mode).unwrap_or_else(|_| { + print_attention( + AttentionLevel::Error, + "Invalid --mode value", + "expected one of: ensure, verify", + ); + process::exit(1); + }); // 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..baf302cea7f 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,47 @@ 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).default("ensure"), + 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, + "deps-config argument parsing failed", + &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 mode_raw = parsed + .values + .get("mode") + .and_then(|v| v.as_str()) + .unwrap_or("ensure"); + let resource_mode = ExecMode::parse_strict(mode_raw).unwrap_or_else(|_| { + print_attention( + AttentionLevel::Error, + "deps-config --mode is invalid", + "expected one of: ensure, verify", + ); + process::exit(1); + }); let path = parsed - .get_string("path") + .values + .get("path") + .and_then(|v| v.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..f7c1766fd2a 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,32 @@ 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, + "Pipeline argument parsing failed", + &error.to_string(), + ); + process::exit(1); + } + }; if parsed.help { print_help(); @@ -131,15 +149,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(|v| v.as_str()) + .unwrap_or(".") + .to_string(); + let base_ref = parsed + .values + .get("base_ref") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let provider = parsed - .get_string("provider") + .values + .get("provider") + .and_then(|v| v.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(|v| v.as_str()) + .unwrap_or("M") + .to_string(); + let pr_number = parsed + .values + .get("pr") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let issue_number = parsed + .values + .get("issue") + .and_then(|v| v.as_str()) + .map(|s| s.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..4bf274f961c 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, + "SDLC argument parsing failed", + &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(|v| v.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(|v| v.as_str()) + .map(|s| s.to_string()); + let issue = parsed + .values + .get("issue") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let worker_id = parsed - .get_string("worker_id") + .values + .get("worker_id") + .and_then(|v| v.as_str()) .unwrap_or("gunbc-sdlc") .to_string(); let llm_provider = parsed - .get_string("llm_provider") + .values + .get("llm_provider") + .and_then(|v| v.as_str()) .unwrap_or("anthropic") .to_string(); let llm_model = parsed - .get_string("llm_model") + .values + .get("llm_model") + .and_then(|v| v.as_str()) .unwrap_or("claude-sonnet-4-20250514") .to_string(); @@ -153,10 +185,7 @@ fn main() { // Seed entrypoint inputs from auto-mock spec let boundary = spec.to_boundary_mocks(); for (node_id, port_name, _) in &entrypoints.entrypoint_ports { - if input_mocks - .get_input(&node_id.0, &port_name.0) - .is_none() - { + if input_mocks.get_input(&node_id.0, &port_name.0).is_none() { if let Some(val) = boundary.get_input(&node_id.0, &port_name.0) { input_mocks.set_input(node_id.0.clone(), port_name.0.clone(), val.clone()); } @@ -173,10 +202,7 @@ fn main() { // ======================================================================== let mut metadata = vec![ - ( - "exec", - if dry_run { "dry-run" } else { "real" }.to_string(), - ), + ("exec", if dry_run { "dry-run" } else { "real" }.to_string()), ("profile", profile), ("repo", format!("{}/{}", owner, repo_name)), ("worker", worker_id), diff --git a/gunbc-dag/src/workflow/catalog.rs b/gunbc-dag/src/workflow/catalog.rs index 092e86e8a57..dbb6a037684 100644 --- a/gunbc-dag/src/workflow/catalog.rs +++ b/gunbc-dag/src/workflow/catalog.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use daglang_syntax::{ ast::{Expr, Item, Literal, Stmt}, @@ -12,119 +13,165 @@ use gunbc_ir::{Dag, Edge, Node, Port}; use super::capabilities::{ CODEGEN_ENSURE_UNIT, CODEGEN_PROCESS_ID, COMPILATION_ENSURE_UNIT, COMPILATION_PROCESS_ID, }; -use super::process_registry::{ +use gunbc_workflow::{ claim_handle_type_id, ProcessUnitRef, ProcessUnitRegistry, ProcessUnitSpec, UnitClaim, }; -use super::schema::{ +use gunbc_workflow::{ required_input_contract, required_output_contract, ReportSpec, WorkflowOp, WorkflowSpec, WorkflowUnit, }; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] 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, - }, -]; +static WORKFLOW_VARIANTS: OnceLock> = OnceLock::new(); + +fn workflow_variants() -> &'static [WorkflowVariantDef] { + WORKFLOW_VARIANTS.get_or_init(|| { + load_workflow_variants_from_dsl() + .unwrap_or_else(|error| panic!("failed to load workflow catalog DSL data: {error}")) + }) +} + +#[allow(clippy::disallowed_methods)] +fn load_workflow_variants_from_dsl() -> Result, String> { + let path = workflow_catalog_file_path(); + let source = std::fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let parsed = parser::parse_with_file_diagnostics(&path, &source).map_err(|diagnostics| { + diagnostics + .into_iter() + .map(|diagnostic| diagnostic.render()) + .collect::>() + .join("\n") + })?; + + let mut raw = None; + for item in parsed.items { + if let Item::DataDef(def) = item.node { + if def.name == "workflow_variants" { + raw = Some(def.value); + break; + } + } + } + let raw = raw.ok_or_else(|| { + format!( + "workflow catalog '{}' missing `data workflow_variants` declaration", + path.display() + ) + })?; + parse_workflow_variants_expr(&raw) +} + +fn parse_workflow_variants_expr(expr: &Expr) -> Result, String> { + let Expr::List(items) = expr else { + return Err("workflow_variants must be a list of records".to_string()); + }; + let mut variants = Vec::with_capacity(items.len()); + for (idx, item) in items.iter().enumerate() { + let Expr::Record(_, fields) = item else { + return Err(format!( + "workflow_variants[{idx}] must be a record, found {:?}", + item + )); + }; + variants.push(parse_workflow_variant_record(fields, idx)?); + } + Ok(variants) +} + +fn parse_workflow_variant_record( + fields: &[(String, Expr)], + idx: usize, +) -> Result { + Ok(WorkflowVariantDef { + canonical_name: expect_string_field(fields, "canonical_name", idx)?, + aliases: expect_string_list_field(fields, "aliases", idx)?, + file: expect_string_field(fields, "file", idx)?, + pipeline: expect_string_field(fields, "pipeline", idx)?, + mode: expect_optional_string_field(fields, "mode", idx)?, + namespace: expect_string_field(fields, "namespace", idx)?, + is_tool: expect_bool_field(fields, "is_tool", idx)?, + }) +} + +fn expect_field<'a>(fields: &'a [(String, Expr)], name: &str, idx: usize) -> Result<&'a Expr, String> { + fields + .iter() + .find(|(field, _)| field == name) + .map(|(_, value)| value) + .ok_or_else(|| format!("workflow_variants[{idx}] missing required field '{name}'")) +} + +fn expect_string_field(fields: &[(String, Expr)], name: &str, idx: usize) -> Result { + match expect_field(fields, name, idx)? { + Expr::Literal(Literal::String(value)) => Ok(value.clone()), + other => Err(format!( + "workflow_variants[{idx}].{name} must be String, found {:?}", + other + )), + } +} + +fn expect_optional_string_field( + fields: &[(String, Expr)], + name: &str, + idx: usize, +) -> Result, String> { + match expect_field(fields, name, idx)? { + Expr::Literal(Literal::String(value)) => Ok(Some(value.clone())), + Expr::Literal(Literal::None) => Ok(None), + Expr::Ident(ref id) if id == "None" || id == "none" => Ok(None), + other => Err(format!( + "workflow_variants[{idx}].{name} must be String or None, found {:?}", + other + )), + } +} + +fn expect_string_list_field( + fields: &[(String, Expr)], + name: &str, + idx: usize, +) -> Result, String> { + let Expr::List(items) = expect_field(fields, name, idx)? else { + return Err(format!( + "workflow_variants[{idx}].{name} must be List" + )); + }; + let mut values = Vec::with_capacity(items.len()); + for (alias_idx, item) in items.iter().enumerate() { + match item { + Expr::Literal(Literal::String(value)) => values.push(value.clone()), + other => { + return Err(format!( + "workflow_variants[{idx}].{name}[{alias_idx}] must be String, found {:?}", + other + )) + } + } + } + Ok(values) +} + +fn expect_bool_field(fields: &[(String, Expr)], name: &str, idx: usize) -> Result { + match expect_field(fields, name, idx)? { + Expr::Literal(Literal::Bool(value)) => Ok(*value), + other => Err(format!( + "workflow_variants[{idx}].{name} must be Bool, found {:?}", + other + )), + } +} #[derive(Debug, Clone)] struct StageTemplate { @@ -141,24 +188,27 @@ struct WorkflowTemplate { } pub(super) fn all_tool_workflow_names() -> Vec<&'static str> { - WORKFLOW_VARIANTS + workflow_variants() .iter() .filter(|variant| variant.is_tool) - .map(|variant| variant.canonical_name) + .map(|variant| variant.canonical_name.as_str()) .collect() } pub(super) fn all_known_workflow_names() -> Vec<&'static str> { - WORKFLOW_VARIANTS + workflow_variants() .iter() - .map(|variant| variant.canonical_name) + .map(|variant| variant.canonical_name.as_str()) .collect() } pub(super) fn resolve_workflow_variant(name: &str) -> Option<&'static WorkflowVariantDef> { - WORKFLOW_VARIANTS - .iter() - .find(|variant| variant.canonical_name == name || variant.aliases.contains(&name)) + let normalized = name.replace('_', "-"); + workflow_variants().iter().find(|variant| { + variant.canonical_name == name + || variant.canonical_name == normalized + || variant.aliases.iter().any(|alias| alias == name || alias == &normalized) + }) } pub(super) fn build_workflow_spec( @@ -175,7 +225,7 @@ pub(super) fn build_workflow_spec( let templates = load_workflow_templates()?; let template = templates - .get(variant.file) + .get(&variant.file) .ok_or_else(|| format!("missing workflow template for file '{}'", variant.file))?; if template.pipeline_name != variant.pipeline { @@ -188,7 +238,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 +307,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 +317,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_variants() { let template = templates - .get(variant.file) + .get(&variant.file) .ok_or_else(|| format!("missing workflow template for file '{}'", variant.file))?; if template.pipeline_name != variant.pipeline { @@ -282,7 +332,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 +430,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_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) @@ -517,10 +567,18 @@ fn workflow_file_path(file: &str) -> PathBuf { workflows_root().join(file) } +fn workflow_catalog_file_path() -> PathBuf { + configs_root().join("workflow_catalog.dag") +} + fn workflows_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../dsl/workflows") } +fn configs_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../dsl/config") +} + fn compilation_ref() -> ProcessUnitRef { ProcessUnitRef::new(COMPILATION_PROCESS_ID, COMPILATION_ENSURE_UNIT) } @@ -540,6 +598,15 @@ fn default_codegen_claims() -> Vec { vec![UnitClaim::write("file:generated:cli")] } +/// Default registry for WF1/WF2 planner bootstrap. +/// +/// Derived from DSL workflow catalog. Panics on derivation failure. +pub fn default_process_unit_registry() -> ProcessUnitRegistry { + build_process_unit_registry().unwrap_or_else(|error| { + panic!("failed to derive process unit registry from DSL workflows: {error}") + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/gunbc-dag/src/workflow/commands.rs b/gunbc-dag/src/workflow/commands.rs new file mode 100644 index 00000000000..551e5aff01f --- /dev/null +++ b/gunbc-dag/src/workflow/commands.rs @@ -0,0 +1,223 @@ +//! DSL-backed workflow unit command catalog. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use daglang_syntax::{ + ast::{Expr, Item, Literal}, + parser, +}; +use gunbc_ir::NodeId; +use gunbc_workflow::UnitCommand; + +static WORKFLOW_COMMANDS: OnceLock>> = OnceLock::new(); + +fn commands_by_workflow() -> &'static HashMap> { + WORKFLOW_COMMANDS.get_or_init(|| { + load_workflow_commands_from_dsl() + .unwrap_or_else(|error| panic!("failed to load workflow command DSL data: {error}")) + }) +} + +/// Build command map for CI workflow units. +pub fn ci_unit_commands() -> BTreeMap { + workflow_unit_commands("ci").unwrap_or_else(|error| panic!("failed to load ci commands: {error}")) +} + +/// Build command map for test-all workflow units. +pub fn test_all_unit_commands() -> BTreeMap { + workflow_unit_commands("test-all") + .unwrap_or_else(|error| panic!("failed to load test-all commands: {error}")) +} + +/// Build command map for a supported workflow name. +pub fn workflow_unit_commands( + workflow_name: &str, +) -> Result, String> { + let Some(variant) = super::catalog::resolve_workflow_variant(workflow_name) else { + return Err(format!( + "workflow '{}' does not support execution mode; use --plan", + workflow_name + )); + }; + commands_by_workflow() + .get(&variant.canonical_name) + .cloned() + .ok_or_else(|| { + format!( + "workflow '{}' does not support execution mode; use --plan", + workflow_name + ) + }) +} + +#[allow(clippy::disallowed_methods)] +fn load_workflow_commands_from_dsl() -> Result>, String> { + let path = workflow_commands_file_path(); + let source = std::fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let parsed = parser::parse_with_file_diagnostics(&path, &source).map_err(|diagnostics| { + diagnostics + .into_iter() + .map(|diagnostic| diagnostic.render()) + .collect::>() + .join("\n") + })?; + + let mut raw = None; + for item in parsed.items { + if let Item::DataDef(def) = item.node { + if def.name == "workflow_commands" { + raw = Some(def.value); + break; + } + } + } + let raw = raw.ok_or_else(|| { + format!( + "workflow command catalog '{}' missing `data workflow_commands` declaration", + path.display() + ) + })?; + parse_workflow_commands_expr(&raw) +} + +fn parse_workflow_commands_expr( + expr: &Expr, +) -> Result>, String> { + let Expr::List(workflows) = expr else { + return Err("workflow_commands must be a list of records".to_string()); + }; + let mut out = HashMap::new(); + for (idx, workflow_item) in workflows.iter().enumerate() { + let Expr::Record(_, workflow_fields) = workflow_item else { + return Err(format!( + "workflow_commands[{idx}] must be a record, found {:?}", + workflow_item + )); + }; + let workflow = expect_string_field(workflow_fields, "workflow", idx)?; + let commands_expr = expect_field(workflow_fields, "commands", idx)?; + let commands = parse_command_map(commands_expr, idx)?; + if out.insert(workflow.clone(), commands).is_some() { + return Err(format!( + "workflow_commands contains duplicate workflow entry '{workflow}'" + )); + } + } + Ok(out) +} + +fn parse_command_map(expr: &Expr, workflow_idx: usize) -> Result, String> { + let Expr::List(command_items) = expr else { + return Err(format!( + "workflow_commands[{workflow_idx}].commands must be a list" + )); + }; + let mut commands = BTreeMap::new(); + for (cmd_idx, item) in command_items.iter().enumerate() { + let Expr::Record(_, fields) = item else { + return Err(format!( + "workflow_commands[{workflow_idx}].commands[{cmd_idx}] must be a record" + )); + }; + let node_id = expect_string_field(fields, "node_id", cmd_idx)?; + let label = expect_string_field(fields, "label", cmd_idx)?; + let program = expect_string_field(fields, "program", cmd_idx)?; + let args = expect_string_list_field(fields, "args", cmd_idx)?; + commands.insert(NodeId::from(node_id), UnitCommand::new(label, program, args)); + } + Ok(commands) +} + +fn expect_field<'a>(fields: &'a [(String, Expr)], name: &str, idx: usize) -> Result<&'a Expr, String> { + fields + .iter() + .find(|(field, _)| field == name) + .map(|(_, value)| value) + .ok_or_else(|| format!("record[{idx}] missing required field '{name}'")) +} + +fn expect_string_field(fields: &[(String, Expr)], name: &str, idx: usize) -> Result { + match expect_field(fields, name, idx)? { + Expr::Literal(Literal::String(value)) => Ok(value.clone()), + other => Err(format!("record[{idx}].{name} must be String, found {:?}", other)), + } +} + +fn expect_string_list_field( + fields: &[(String, Expr)], + name: &str, + idx: usize, +) -> Result, String> { + let Expr::List(items) = expect_field(fields, name, idx)? else { + return Err(format!("record[{idx}].{name} must be List")); + }; + let mut values = Vec::with_capacity(items.len()); + for (item_idx, item) in items.iter().enumerate() { + match item { + Expr::Literal(Literal::String(value)) => values.push(value.clone()), + other => { + return Err(format!( + "record[{idx}].{name}[{item_idx}] must be String, found {:?}", + other + )) + } + } + } + Ok(values) +} + +fn workflow_commands_file_path() -> PathBuf { + configs_root().join("workflow_commands.dag") +} + +fn configs_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../dsl/config") +} + +#[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"))); + 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 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_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/global_plan.rs b/gunbc-dag/src/workflow/global_plan.rs deleted file mode 100644 index 4f1336476b6..00000000000 --- a/gunbc-dag/src/workflow/global_plan.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Global flattening + dedup planner contracts (M17). - -use std::collections::BTreeMap; -use std::path::Path; - -use gunbc_ir::NodeId; - -use super::key::{MaterializationDigest, WorkIdentity}; -use super::planner::{ - plan_workflow, PlanAction, PlannerInputs, WorkflowPlan, WorkflowPlannerError, -}; -use super::process_registry::ProcessUnitRegistry; -use super::schema::{WorkflowId, WorkflowSpec}; - -/// Workflow+node reference attached to a global dedup vertex. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct WorkflowNodeRef { - pub workflow_id: WorkflowId, - pub node_id: NodeId, -} - -/// Global deduplicated execution vertex. -#[derive(Debug, Clone, PartialEq)] -pub struct GlobalExecutionVertex { - pub work_id: WorkIdentity, - pub digest: MaterializationDigest, - pub action: PlanAction, - pub node_refs: Vec, -} - -/// Flattened global workflow plan across multiple entrypoint workflows. -#[derive(Debug, Clone, PartialEq)] -pub struct GlobalWorkflowPlan { - pub vertices: Vec, -} - -/// Per-workflow planner inputs used by global planner. -pub type PlannerInputsByWorkflow = BTreeMap; - -/// Flatten and deduplicate workflow plans by `(WorkIdentity, MaterializationDigest)`. -pub fn plan_global_workflows( - specs: &[WorkflowSpec], - registry: &ProcessUnitRegistry, - planner_inputs: &PlannerInputsByWorkflow, - workspace_root: &Path, -) -> Result { - let mut grouped: BTreeMap<(WorkIdentity, MaterializationDigest), GlobalExecutionVertex> = - BTreeMap::new(); - - for spec in specs { - let inputs = planner_inputs.get(&spec.id).cloned().unwrap_or_default(); - let plan = plan_workflow(spec, registry, &inputs, workspace_root)?; - merge_workflow_plan(spec, &plan, &mut grouped); - } - - let mut vertices = grouped.into_values().collect::>(); - vertices.sort_by(|left, right| { - left.work_id - .cmp(&right.work_id) - .then(left.digest.cmp(&right.digest)) - }); - Ok(GlobalWorkflowPlan { vertices }) -} - -fn merge_workflow_plan( - workflow_spec: &WorkflowSpec, - plan: &WorkflowPlan, - grouped: &mut BTreeMap<(WorkIdentity, MaterializationDigest), GlobalExecutionVertex>, -) { - for node in &plan.nodes { - let key = (node.work_id.clone(), node.key.digest.clone()); - let node_ref = WorkflowNodeRef { - workflow_id: workflow_spec.id.clone(), - node_id: node.node_id.clone(), - }; - grouped - .entry(key.clone()) - .and_modify(|existing| { - existing.node_refs.push(node_ref.clone()); - existing.node_refs.sort(); - }) - .or_insert_with(|| GlobalExecutionVertex { - work_id: key.0, - digest: key.1, - action: node.action.clone(), - node_refs: vec![node_ref], - }); - } -} - -#[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() - )) - } - - #[test] - fn cross_workflow_equivalent_units_are_deduped() { - 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("expected canonical codegen vertex"); - assert_eq!(codegen.node_refs.len(), 2); - assert!(codegen - .node_refs - .iter() - .any(|reference| reference.workflow_id == WorkflowId::new("ci"))); - assert!(codegen - .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/mod.rs b/gunbc-dag/src/workflow/mod.rs index 8386165b928..8e433ad51a9 100644 --- a/gunbc-dag/src/workflow/mod.rs +++ b/gunbc-dag/src/workflow/mod.rs @@ -1,66 +1,58 @@ //! Workflow planner core modules (WF1+). +//! +//! Re-exports generic workflow engine from gunbc-workflow and exposes +//! repo-specific catalog, commands, spec builders, and capabilities. mod catalog; +mod capabilities; +mod commands; +mod spec_builders; -pub mod admission; -pub mod capabilities; -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 mod spec_builders; -pub mod unit_commands; - -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::{ +pub use gunbc_workflow::{coordination_status, BlockedReason, CoordinationStatus}; +pub use gunbc_workflow::WorkflowAdmissionError; +pub use gunbc_workflow::{execute_workflow_plan, ExecutionSummary, UnitCommand, UnitResult}; +pub use gunbc_workflow::{ plan_global_workflows, GlobalExecutionVertex, GlobalWorkflowPlan, PlannerInputsByWorkflow, WorkflowNodeRef, }; -pub use key::{ +pub use gunbc_workflow::{ CanonicalKeyPayload, MaterializationDigest, MaterializationKey, MissReason, WorkIdentity, }; -pub use planner::{ +pub use gunbc_workflow::{ 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 gunbc_workflow::{ + claim_handle_type_id, ClaimId, ProcessId, ProcessUnitRef, ProcessUnitRegistry, ProcessUnitSpec, + UnitClaim, }; -pub use projection::{ +pub use gunbc_workflow::{ project_execute_set, validate_projection_equivalence, ExecuteProjection, ProjectionDrift, }; -pub use proof::{prove_non_redundancy, InvariantViolation}; -pub use schema::{ +pub use gunbc_workflow::{prove_non_redundancy, InvariantViolation}; +pub use gunbc_workflow::{ 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::{ +pub use gunbc_workflow::{ 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 catalog::default_process_unit_registry; 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, 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..5077ea750dd 100644 --- a/gunbc-dag/src/workflow/spec_builders.rs +++ b/gunbc-dag/src/workflow/spec_builders.rs @@ -1,8 +1,8 @@ //! DSL-backed workflow spec builders. use super::catalog; -use super::process_registry::default_process_unit_registry; -use super::schema::WorkflowSpec; +use super::catalog::default_process_unit_registry; +use gunbc_workflow::WorkflowSpec; /// Build a workflow spec by canonical/alias name. pub fn workflow_spec(name: &str) -> Result { 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/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 99202c931ad..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())); -} diff --git a/tasks.md b/tasks.md index b02a7776437..e4103061b2c 100644 --- a/tasks.md +++ b/tasks.md @@ -460,7 +460,8 @@ 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 +**Execution order**: Start with A7 → A8 → A9 → A10 → A11 while waiting on C20. +Then run A1 → A2 → A3 and A4 → A5 once C20 is available. --- @@ -894,53 +895,53 @@ the sibling repos useful (failure modes, edge cases, rate limits, prerequisites) | # | ID | Task | Size | Status | Deps | |---|-----|------|------|--------|------| -| 1 | DM-1 | **`std/behavioral.dag`** — behavioral property vocabulary. Types: `SideEffects` (ReadOnly \| WritesState \| WritesExternal), `Determinism` (Deterministic \| NonDeterministic \| EventuallyConsistent), `FailureMode` { name, http_status?, recoverable, retry_safe }, `EdgeCase` { description, trigger, severity }, `Confidence` (Documented \| HighConfidence \| MediumConfidence \| LowConfidence \| Assumed), `Prerequisite` { description, kind }, `OperationBehavior` { side_effects, idempotent, determinism, failure_modes, edge_cases, confidence, prerequisites, assumptions, unknowns }. Ref: `docs/design/domain-model-porting.md` §1. | M | Pending | — | -| 2 | DM-2 | **`std/rate_limit.dag`** — rate limiting & retry vocabulary. Types: `RateLimit` { scope, requests_per_window, window_seconds, burst? }, `ResetStrategy` (FixedWindow \| SlidingWindow \| TokenBucket), `RetryPolicy` { max_attempts, backoff, retry_on }, `BackoffStrategy` (Exponential { base_ms, max_ms } \| Linear { step_ms } \| JitteredExponential { base_ms, max_ms }), `PropagationDelay` { typical_ms, max_ms, description }. Data: common presets. Ref: `docs/design/domain-model-porting.md` §7. | S | Pending | — | -| 3 | DM-3 | **`std/coordination.dag`** — coordination primitive vocabulary. Types: `CasMechanism` (GenerationBased \| ETagBased \| VersionId \| RowVersion), `FencingToken`, `LeaseConfig` { ttl_seconds, heartbeat_interval_seconds }, `DeliveryGuarantee` (AtLeastOnce \| AtMostOnce \| ExactlyOnce), `QueueSemantics` { ordering, visibility_timeout_seconds, dead_letter? }, `DeadLetterPolicy` { max_attempts, destination }, `NotificationMechanism` (Polling \| PubSub \| Listen \| Webhook). Ref: `docs/design/domain-model-porting.md` §3. | M | Pending | — | -| 4 | DM-4 | **`std/errors.dag`** — standard error type vocabulary. Types: `HttpErrorShape` { status, error_type, message, detail? }, `AuthError` (Expired \| InvalidToken \| MissingCredential \| InsufficientScope \| Forbidden), `RateLimitError` { retry_after_seconds, scope }, `ConflictError` { resource, expected_version?, actual_version? }, `ProviderError` { provider, operation, http_status?, message }. Data: canonical error shapes — GitHub `{ message, documentation_url }`, GCP `{ error: { code, message, status } }`, Anthropic `{ type, error: { type, message } }`. Ref: `docs/design/domain-model-porting.md` §2, §7. | M | Pending | — | -| 5 | DM-5 | **`std/capability.dag`** — capability contract vocabulary. Types: `InfraScope` (Secret \| Identity \| Api \| Storage \| Compute \| Network \| Database \| Queue \| Federation), `AccessLevel` (Read \| Write \| Admin), `CapabilityRequirement` { scope, level, resource }, `CapabilityGrant` { provider, scope, concrete_resource }, `ServiceAccessManifest` { service_name, requirements }. Ref: `docs/design/domain-model-porting.md` §6. | M | Pending | — | +| 1 | DM-1 | **`std/behavioral.dag`** — behavioral property vocabulary. Types: `SideEffects` (ReadOnly \| WritesState \| WritesExternal), `Determinism` (Deterministic \| NonDeterministic \| EventuallyConsistent), `FailureMode` { name, http_status?, recoverable, retry_safe }, `EdgeCase` { description, trigger, severity }, `Confidence` (Documented \| HighConfidence \| MediumConfidence \| LowConfidence \| Assumed), `Prerequisite` { description, kind }, `OperationBehavior` { side_effects, idempotent, determinism, failure_modes, edge_cases, confidence, prerequisites, assumptions, unknowns }. Ref: `docs/design/domain-model-porting.md` §1. | M | Done | — | +| 2 | DM-2 | **`std/rate_limit.dag`** — rate limiting & retry vocabulary. Types: `RateLimit` { scope, requests_per_window, window_seconds, burst? }, `ResetStrategy` (FixedWindow \| SlidingWindow \| TokenBucket), `RetryPolicy` { max_attempts, backoff, retry_on }, `BackoffStrategy` (Exponential { base_ms, max_ms } \| Linear { step_ms } \| JitteredExponential { base_ms, max_ms }), `PropagationDelay` { typical_ms, max_ms, description }. Data: common presets. Ref: `docs/design/domain-model-porting.md` §7. | S | Done | — | +| 3 | DM-3 | **`std/coordination.dag`** — coordination primitive vocabulary. Types: `CasMechanism` (GenerationBased \| ETagBased \| VersionId \| RowVersion), `FencingToken`, `LeaseConfig` { ttl_seconds, heartbeat_interval_seconds }, `DeliveryGuarantee` (AtLeastOnce \| AtMostOnce \| ExactlyOnce), `QueueSemantics` { ordering, visibility_timeout_seconds, dead_letter? }, `DeadLetterPolicy` { max_attempts, destination }, `NotificationMechanism` (Polling \| PubSub \| Listen \| Webhook). Ref: `docs/design/domain-model-porting.md` §3. | M | Done | — | +| 4 | DM-4 | **`std/errors.dag`** — standard error type vocabulary. Types: `HttpErrorShape` { status, error_type, message, detail? }, `AuthError` (Expired \| InvalidToken \| MissingCredential \| InsufficientScope \| Forbidden), `RateLimitError` { retry_after_seconds, scope }, `ConflictError` { resource, expected_version?, actual_version? }, `ProviderError` { provider, operation, http_status?, message }. Data: canonical error shapes — GitHub `{ message, documentation_url }`, GCP `{ error: { code, message, status } }`, Anthropic `{ type, error: { type, message } }`. Ref: `docs/design/domain-model-porting.md` §2, §7. | M | Done | — | +| 5 | DM-5 | **`std/capability.dag`** — capability contract vocabulary. Types: `InfraScope` (Secret \| Identity \| Api \| Storage \| Compute \| Network \| Database \| Queue \| Federation), `AccessLevel` (Read \| Write \| Admin), `CapabilityRequirement` { scope, level, resource }, `CapabilityGrant` { provider, scope, concrete_resource }, `ServiceAccessManifest` { service_name, requirements }. Ref: `docs/design/domain-model-porting.md` §6. | M | Done | — | **Part B: Secret Provider Models** (deps on DM-1) | # | ID | Task | Size | Status | Deps | |---|-----|------|------|--------|------| -| 6 | DM-6 | **`extdeps/secrets/core.dag`** — "What is a secret provider?" Abstract model. Types: `SecretLifecycle` (Active \| Disabled \| Destroyed \| PendingDeletion), `SecretVersion` { id, state, created_at? }, `RotationPolicy` { period_seconds, next_rotation? }, `SecretAccessPolicy`, `SecretEncryption` (ProviderManaged \| CustomerManaged { key_id }). Data: shared behavioral properties — all reads readonly+idempotent, creates non-idempotent, version-add non-idempotent. | M | Pending | DM-1 | -| 7 | DM-7 | **`extdeps/secrets/gcp_secret_manager.dag`** — "What is GCP Secret Manager?" Types: `GcpSecret`, `GcpSecretVersion`, `GcpReplicationPolicy` (Automatic \| UserManaged). Data: 8 `OperationBehavior` records (AccessVersion, CreateSecret, AddVersion, ListSecrets, GetSecret, DestroyVersion, DisableVersion, EnableVersion), rate limits per method, propagation delays. Ref: `docs/design/domain-model-porting.md` §2.1. | M | Pending | DM-6 | -| 8 | DM-8 | **`extdeps/secrets/github_secrets.dag`** — "What is GitHub Secrets?" Types: `GitHubSecretScope` (Repository \| Environment \| Organization), `GitHubPublicKey` { key_id, key }, `OidcTokenClaims`. Data: 8 `OperationBehavior` records (Get, Create, Update, Delete, ListRepo, ListEnv, GetPublicKey, GetOidcToken), encryption model (libsodium sealed box). Ref: `docs/design/domain-model-porting.md` §2.2. | M | Pending | DM-6 | -| 9 | DM-9 | **`extdeps/secrets/env_file.dag`** — "What is a .env file?" Types: `EnvEntry` { key, value, comment? }, `EnvFile`, `EnvValidation` { required_keys, format_rules }. Data: 6 `OperationBehavior` records (Load, Get, Set, List, Validate, GenerateExample), parsing rules. Ref: `docs/design/domain-model-porting.md` §2.3. | S | Pending | DM-6 | -| 10 | DM-10 | **`extdeps/secrets/vault.dag`** — "What is HashiCorp Vault?" Types: `VaultEngine` (Kv2 \| Transit \| Database \| Pki), `VaultAuth` (AppRole \| Oidc \| Token \| Kubernetes), `VaultLease` { id, ttl_seconds, renewable }, `VaultPolicy`, `DynamicCredential` { engine, role, ttl }. Data: 10 `OperationBehavior` records (KvGet, KvPut, KvDelete, KvList, Authenticate, RenewLease, RevokeLease, GenerateDynamicCred, RotateRoot, WrapToken), lease lifecycle. Ref: `docs/design/domain-model-porting.md` §2.4. | L | Pending | DM-6 | +| 6 | DM-6 | **`extdeps/secrets/core.dag`** — "What is a secret provider?" Abstract model. Types: `SecretLifecycle` (Active \| Disabled \| Destroyed \| PendingDeletion), `SecretVersion` { id, state, created_at? }, `RotationPolicy` { period_seconds, next_rotation? }, `SecretAccessPolicy`, `SecretEncryption` (ProviderManaged \| CustomerManaged { key_id }). Data: shared behavioral properties — all reads readonly+idempotent, creates non-idempotent, version-add non-idempotent. | M | Done | DM-1 | +| 7 | DM-7 | **`extdeps/secrets/gcp_secret_manager.dag`** — "What is GCP Secret Manager?" Types: `GcpSecret`, `GcpSecretVersion`, `GcpReplicationPolicy` (Automatic \| UserManaged). Data: 8 `OperationBehavior` records (AccessVersion, CreateSecret, AddVersion, ListSecrets, GetSecret, DestroyVersion, DisableVersion, EnableVersion), rate limits per method, propagation delays. Ref: `docs/design/domain-model-porting.md` §2.1. | M | Done | DM-6 | +| 8 | DM-8 | **`extdeps/secrets/github_secrets.dag`** — "What is GitHub Secrets?" Types: `GitHubSecretScope` (Repository \| Environment \| Organization), `GitHubPublicKey` { key_id, key }, `OidcTokenClaims`. Data: 8 `OperationBehavior` records (Get, Create, Update, Delete, ListRepo, ListEnv, GetPublicKey, GetOidcToken), encryption model (libsodium sealed box). Ref: `docs/design/domain-model-porting.md` §2.2. | M | Done | DM-6 | +| 9 | DM-9 | **`extdeps/secrets/env_file.dag`** — "What is a .env file?" Types: `EnvEntry` { key, value, comment? }, `EnvFile`, `EnvValidation` { required_keys, format_rules }. Data: 6 `OperationBehavior` records (Load, Get, Set, List, Validate, GenerateExample), parsing rules. Ref: `docs/design/domain-model-porting.md` §2.3. | S | Done | DM-6 | +| 10 | DM-10 | **`extdeps/secrets/vault.dag`** — "What is HashiCorp Vault?" Types: `VaultEngine` (Kv2 \| Transit \| Database \| Pki), `VaultAuth` (AppRole \| Oidc \| Token \| Kubernetes), `VaultLease` { id, ttl_seconds, renewable }, `VaultPolicy`, `DynamicCredential` { engine, role, ttl }. Data: 10 `OperationBehavior` records (KvGet, KvPut, KvDelete, KvList, Authenticate, RenewLease, RevokeLease, GenerateDynamicCred, RotateRoot, WrapToken), lease lifecycle. Ref: `docs/design/domain-model-porting.md` §2.4. | L | Done | DM-6 | **Part C: Coordination Store Models** (deps on DM-3) | # | ID | Task | Size | Status | Deps | |---|-----|------|------|--------|------| -| 11 | DM-11 | **`extdeps/coordination/core.dag`** — "What is a coordination store?" Abstract model. Types: `CoordinationBackend` (ObjectStore \| RelationalDb \| EmbeddedDb), `CasPattern` { mechanism, conflict_resolution }, `QueuePattern` { claiming, visibility, dead_letter }, `LeasePattern` { acquisition, heartbeat, expiry }, `NotificationPattern` { mechanism, latency, ordering }. Data: pattern composition rules (CAS+lease = distributed lock, queue+CAS = exactly-once processing). | M | Pending | DM-3 | -| 12 | DM-12 | **`extdeps/coordination/gcs.dag`** — "GCS as coordination store." Types: `GcsGeneration`, `GcsMetageneration`, `GcsPrecondition` (IfGenerationMatch \| IfMetagenerationMatch \| IfGenerationNotMatch). Data: 6 `OperationBehavior` records (GetObject, InsertObject, PatchMetadata, DeleteObject, ComposeObjects, WatchChanges), CAS patterns (if-generation-match:0 = create-only), PubSub notification latency. Ref: `docs/design/domain-model-porting.md` §3.1. | M | Pending | DM-11 | -| 13 | DM-13 | **`extdeps/coordination/postgres.dag`** — "PostgreSQL as coordination store." Types: `PgIsolationLevel` (ReadCommitted \| Serializable), `PgSkipLocked`, `PgFencingToken` { column, monotonic }, `PgNotifyChannel`. Data: 7 `OperationBehavior` records (KvGet, KvSet, KvCas, ClaimFromQueue, Heartbeat, Release, NotifyWatch), SKIP LOCKED semantics, NOTIFY/LISTEN latency. Ref: `docs/design/domain-model-porting.md` §3.2. | M | Pending | DM-11 | -| 14 | DM-14 | **`extdeps/coordination/sqlite.dag`** — "SQLite as coordination store." Types: `SqliteJournalMode` (Wal \| Delete \| Truncate), `SqliteBusyTimeout`. Data: 5 `OperationBehavior` records (KvGet, KvSet, KvCas, Heartbeat, TtlCleanup), WAL mode constraints, single-writer limitation, CAS via `UPDATE WHERE version = ?`. Ref: `docs/design/domain-model-porting.md` §3.3. | S | Pending | DM-11 | +| 11 | DM-11 | **`extdeps/coordination/core.dag`** — "What is a coordination store?" Abstract model. Types: `CoordinationBackend` (ObjectStore \| RelationalDb \| EmbeddedDb), `CasPattern` { mechanism, conflict_resolution }, `QueuePattern` { claiming, visibility, dead_letter }, `LeasePattern` { acquisition, heartbeat, expiry }, `NotificationPattern` { mechanism, latency, ordering }. Data: pattern composition rules (CAS+lease = distributed lock, queue+CAS = exactly-once processing). | M | Done | DM-3 | +| 12 | DM-12 | **`extdeps/coordination/gcs.dag`** — "GCS as coordination store." Types: `GcsGeneration`, `GcsMetageneration`, `GcsPrecondition` (IfGenerationMatch \| IfMetagenerationMatch \| IfGenerationNotMatch). Data: 6 `OperationBehavior` records (GetObject, InsertObject, PatchMetadata, DeleteObject, ComposeObjects, WatchChanges), CAS patterns (if-generation-match:0 = create-only), PubSub notification latency. Ref: `docs/design/domain-model-porting.md` §3.1. | M | Done | DM-11 | +| 13 | DM-13 | **`extdeps/coordination/postgres.dag`** — "PostgreSQL as coordination store." Types: `PgIsolationLevel` (ReadCommitted \| Serializable), `PgSkipLocked`, `PgFencingToken` { column, monotonic }, `PgNotifyChannel`. Data: 7 `OperationBehavior` records (KvGet, KvSet, KvCas, ClaimFromQueue, Heartbeat, Release, NotifyWatch), SKIP LOCKED semantics, NOTIFY/LISTEN latency. Ref: `docs/design/domain-model-porting.md` §3.2. | M | Done | DM-11 | +| 14 | DM-14 | **`extdeps/coordination/sqlite.dag`** — "SQLite as coordination store." Types: `SqliteJournalMode` (Wal \| Delete \| Truncate), `SqliteBusyTimeout`. Data: 5 `OperationBehavior` records (KvGet, KvSet, KvCas, Heartbeat, TtlCleanup), WAL mode constraints, single-writer limitation, CAS via `UPDATE WHERE version = ?`. Ref: `docs/design/domain-model-porting.md` §3.3. | S | Done | DM-11 | **Part D: Tool Lifecycle Models** (deps on DM-1) | # | ID | Task | Size | Status | Deps | |---|-----|------|------|--------|------| -| 15 | DM-15 | **`extdeps/tools/rust_toolchain.dag`** — "What is the Rust toolchain?" Types: `RustChannel` (Stable \| Beta \| Nightly { date? }), `RustupComponent` (Rustfmt \| Clippy \| RustSrc \| Miri \| LlvmTools), `CargoCommand` (Build \| Test \| Clippy \| Doc \| Run \| Install \| Bench), `RustTarget`, `RustEdition` (E2015 \| E2018 \| E2021 \| E2024). Data: 6 `OperationBehavior` records (Install, AddComponent, Build, Test, ClippyLint, PublishCrate), platform matrix. Ref: `docs/design/domain-model-porting.md` §4.1. | M | Pending | DM-1 | -| 16 | DM-16 | **`extdeps/tools/gh_cli.dag`** — "What is the GitHub CLI?" Types: `GhAuthMethod` (OAuthBrowser \| Token \| GitHubApp), `GhCommand` { verb, resource, flags }, `GhOutputFormat` (Json \| Table \| Template). Data: 6 `OperationBehavior` records (Auth, ReleaseList, ReleaseDownload, IssueCreate, PrCreate, RunWatch), auth flow. Ref: `docs/design/domain-model-porting.md` §4.2. | S | Pending | DM-1 | -| 17 | DM-17 | **`extdeps/tools/package_managers.dag`** — "What are package managers?" Types: `PackageManager` (Apt \| Brew \| Cargo \| Winget \| Choco \| GithubRelease), `PlatformSupport` { os, arch }, `InstallSource` (SystemRepo \| Crate \| Release { url_template }). Data: 7 `OperationBehavior` records per manager (Verify, Install, Update, Remove, Search, ListInstalled, AddSource), cross-platform matrix. Ref: `docs/design/domain-model-porting.md` §4.3. | M | Pending | DM-1 | +| 15 | DM-15 | **`extdeps/tools/rust_toolchain.dag`** — "What is the Rust toolchain?" Types: `RustChannel` (Stable \| Beta \| Nightly { date? }), `RustupComponent` (Rustfmt \| Clippy \| RustSrc \| Miri \| LlvmTools), `CargoCommand` (Build \| Test \| Clippy \| Doc \| Run \| Install \| Bench), `RustTarget`, `RustEdition` (E2015 \| E2018 \| E2021 \| E2024). Data: 6 `OperationBehavior` records (Install, AddComponent, Build, Test, ClippyLint, PublishCrate), platform matrix. Ref: `docs/design/domain-model-porting.md` §4.1. | M | Done | DM-1 | +| 16 | DM-16 | **`extdeps/tools/gh_cli.dag`** — "What is the GitHub CLI?" Types: `GhAuthMethod` (OAuthBrowser \| Token \| GitHubApp), `GhCommand` { verb, resource, flags }, `GhOutputFormat` (Json \| Table \| Template). Data: 6 `OperationBehavior` records (Auth, ReleaseList, ReleaseDownload, IssueCreate, PrCreate, RunWatch), auth flow. Ref: `docs/design/domain-model-porting.md` §4.2. | S | Done | DM-1 | +| 17 | DM-17 | **`extdeps/tools/package_managers.dag`** — "What are package managers?" Types: `PackageManager` (Apt \| Brew \| Cargo \| Winget \| Choco \| GithubRelease), `PlatformSupport` { os, arch }, `InstallSource` (SystemRepo \| Crate \| Release { url_template }). Data: 7 `OperationBehavior` records per manager (Verify, Install, Update, Remove, Search, ListInstalled, AddSource), cross-platform matrix. Ref: `docs/design/domain-model-porting.md` §4.3. | M | Done | DM-1 | **Part E: Complementary Models** (deps on DM-1, DM-2) | # | ID | Task | Size | Status | Deps | |---|-----|------|------|--------|------| -| 18 | DM-18 | **`extdeps/devenv/devcontainers.dag`** — "What is a devcontainer?" Types: `LifecycleHook` (InitializeCommand \| OnCreateCommand \| PostCreateCommand \| PostStartCommand \| PostAttachCommand), `HookExecution` { phase, runs_as, timeout? }, `DevcontainerFeature` { id, version, options }, `DevcontainerEnvVar` { name, source, available_in }. Data: lifecycle timing, common features, quirks. Ref: `docs/design/domain-model-porting.md` §9. | S | Pending | DM-1 | -| 19 | DM-19 | **`extdeps/llm/pricing.dag`** — LLM pricing and capability detail. Types: `ModelPricing` { input_per_million, output_per_million, cached_input_per_million? }, `ContextWindow` { max_tokens, max_output_tokens }, `ModelCapability` (ExtendedThinking \| StructuredOutput \| Grounding \| ToolUse \| VisionInput), `CachingBehavior` { min_cache_tokens, ttl_minutes?, discount_pct }. Data: model specs for Anthropic (Claude 4 family), OpenAI (GPT-4o, o1, o3), Gemini (2.5 family). Ref: `docs/design/domain-model-porting.md` §5. Complements ED-6:8 without overlap. | M | Pending | DM-1, DM-2 | -| 20 | DM-20 | **`extdeps/api/github_ops.dag`** — GitHub API operational detail. Types: `GitHubProductTier` (Free \| Pro \| Team \| EnterpriseCloud \| EnterpriseServer), `GitHubApiVersion` { date, header }, `GitHubRateLimitScope` (Core \| Search \| GraphQL \| CodeSearch), `GitHubAppAuth` { jwt_expiry_minutes, clock_skew_seconds, installation_token_ttl }, `GitHubPollingConfig` { interval_seconds, max_wait_seconds }. Data: rate limits per scope+tier, versioning policy, polling configs. Ref: `docs/design/domain-model-porting.md` §7.1, §7.3. Complements ED-2:5 without overlap. | M | Pending | DM-1, DM-2, DM-5 | -| 21 | DM-21 | **`extdeps/api/gcp_ops.dag`** — GCP API operational detail. Types: `GcpApiPattern` { service, version, base_url_template }, `GcpPropagationDelay` { operation, typical_seconds, max_seconds }, `GcpOrgPolicyConstraint` { constraint_id, affects }, `GcpCommandClassification` (Allowed \| Deprecated \| Forbidden). Data: propagation delays per service (IAM ~60s, DNS ~300s), retry configs, org policy constraints, gcloud command registry. Ref: `docs/design/domain-model-porting.md` §7.2. Complements ED-9:15 without overlap. | M | Pending | DM-1, DM-2, DM-5 | +| 18 | DM-18 | **`extdeps/devenv/devcontainers.dag`** — "What is a devcontainer?" Types: `LifecycleHook` (InitializeCommand \| OnCreateCommand \| PostCreateCommand \| PostStartCommand \| PostAttachCommand), `HookExecution` { phase, runs_as, timeout? }, `DevcontainerFeature` { id, version, options }, `DevcontainerEnvVar` { name, source, available_in }. Data: lifecycle timing, common features, quirks. Ref: `docs/design/domain-model-porting.md` §9. | S | Done | DM-1 | +| 19 | DM-19 | **`extdeps/llm/pricing.dag`** — LLM pricing and capability detail. Types: `ModelPricing` { input_per_million, output_per_million, cached_input_per_million? }, `ContextWindow` { max_tokens, max_output_tokens }, `ModelCapability` (ExtendedThinking \| StructuredOutput \| Grounding \| ToolUse \| VisionInput), `CachingBehavior` { min_cache_tokens, ttl_minutes?, discount_pct }. Data: model specs for Anthropic (Claude 4 family), OpenAI (GPT-4o, o1, o3), Gemini (2.5 family). Ref: `docs/design/domain-model-porting.md` §5. Complements ED-6:8 without overlap. | M | Done | DM-1, DM-2 | +| 20 | DM-20 | **`extdeps/api/github_ops.dag`** — GitHub API operational detail. Types: `GitHubProductTier` (Free \| Pro \| Team \| EnterpriseCloud \| EnterpriseServer), `GitHubApiVersion` { date, header }, `GitHubRateLimitScope` (Core \| Search \| GraphQL \| CodeSearch), `GitHubAppAuth` { jwt_expiry_minutes, clock_skew_seconds, installation_token_ttl }, `GitHubPollingConfig` { interval_seconds, max_wait_seconds }. Data: rate limits per scope+tier, versioning policy, polling configs. Ref: `docs/design/domain-model-porting.md` §7.1, §7.3. Complements ED-2:5 without overlap. | M | Done | DM-1, DM-2, DM-5 | +| 21 | DM-21 | **`extdeps/api/gcp_ops.dag`** — GCP API operational detail. Types: `GcpApiPattern` { service, version, base_url_template }, `GcpPropagationDelay` { operation, typical_seconds, max_seconds }, `GcpOrgPolicyConstraint` { constraint_id, affects }, `GcpCommandClassification` (Allowed \| Deprecated \| Forbidden). Data: propagation delays per service (IAM ~60s, DNS ~300s), retry configs, org policy constraints, gcloud command registry. Ref: `docs/design/domain-model-porting.md` §7.2. Complements ED-9:15 without overlap. | M | Done | DM-1, DM-2, DM-5 | **Part F: Interface Enrichment** (deps on DM-1, DM-3, DM-5) | # | ID | Task | Size | Status | Deps | |---|-----|------|------|--------|------| -| 22 | DM-22 | **Enrich all 7 interfaces with behavioral contracts.** Import `std.behavioral`, `std.coordination`, `std.capability`. Add `OperationBehavior` data declarations for each capability. Add `CapabilityRequirement` declarations. Add failure mode contracts. Files: `interfaces/{artifact_store, outcome_ledger, signal_store, claim_store, credential_provider, issue_provider, agent_provider}.dag`. | L | Pending | DM-1, DM-3, DM-5 | +| 22 | DM-22 | **Enrich all 7 interfaces with behavioral contracts.** Import `std.behavioral`, `std.coordination`, `std.capability`. Add `OperationBehavior` data declarations for each capability. Add `CapabilityRequirement` declarations. Add failure mode contracts. Files: `interfaces/{artifact_store, outcome_ledger, signal_store, claim_store, credential_provider, issue_provider, agent_provider}.dag`. | L | Done | DM-1, DM-3, DM-5 | **Chain**: DM-1:5 (parallel) → DM-6:10, DM-11:14, DM-15:17, DM-18:21 (parallel within group) → DM-22