diff --git a/Cargo.lock b/Cargo.lock index f7a068528..d6c69461f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,72 +58,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "agent-client-protocol" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c" -dependencies = [ - "agent-client-protocol-derive", - "agent-client-protocol-schema", - "anyhow", - "futures", - "futures-concurrency", - "jsonrpcmsg", - "rmcp", - "rustc-hash", - "schemars 1.2.1", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "uuid", -] - -[[package]] -name = "agent-client-protocol-derive" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce42c2d3c048c12897eef2e577dfff1e3355c632c9f1625cc953b9df48b44631" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "agent-client-protocol-schema" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c" -dependencies = [ - "anyhow", - "derive_more", - "schemars 1.2.1", - "serde", - "serde_json", - "serde_with", - "strum 0.28.0", - "tracing", -] - -[[package]] -name = "agent-client-protocol-tokio" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1572b219f22c4b3be0f20f934c8b6f1d1457126ce72923c4f6608f96153b65" -dependencies = [ - "agent-client-protocol", - "futures", - "serde", - "serde_json", - "shell-words", - "tokio", - "tokio-util", -] - [[package]] name = "ahash" version = "0.8.12" @@ -2006,7 +1940,6 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", - "unicode-xid", ] [[package]] @@ -2266,26 +2199,6 @@ dependencies = [ "libc", ] -[[package]] -name = "fabro-acp" -version = "0.357.0-nightly.0" -dependencies = [ - "agent-client-protocol", - "agent-client-protocol-tokio", - "fabro-sandbox", - "fabro-types", - "fabro-util", - "futures", - "pebble-coding-agent", - "serde_json", - "shlex", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "fabro-api" version = "0.357.0-nightly.0" @@ -2389,7 +2302,6 @@ dependencies = [ "dialoguer", "dirs", "dotenvy", - "fabro-acp", "fabro-api", "fabro-auth", "fabro-build-support", @@ -2422,7 +2334,6 @@ dependencies = [ "fabro-tool", "fabro-types", "fabro-util", - "fabro-validate", "fabro-vault", "fabro-workflow", "fs2", @@ -2637,7 +2548,6 @@ dependencies = [ "regex", "serde", "serde_json", - "strum 0.28.0", "thiserror 2.0.18", ] @@ -2986,7 +2896,6 @@ dependencies = [ "fabro-tool", "fabro-types", "fabro-util", - "fabro-validate", "fabro-variable", "fabro-vault", "fabro-workflow", @@ -3256,19 +3165,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "fabro-validate" -version = "0.357.0-nightly.0" -dependencies = [ - "fabro-acp", - "fabro-graphviz", - "fabro-llm", - "fabro-types", - "serde", - "thiserror 2.0.18", - "toml 0.8.23", -] - [[package]] name = "fabro-variable" version = "0.357.0-nightly.0" @@ -3335,7 +3231,6 @@ dependencies = [ "fabro-tool", "fabro-types", "fabro-util", - "fabro-validate", "fabro-vault", "fabro-workflow", "fabro-workflow-version", @@ -3447,12 +3342,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flatbuffers" version = "25.12.19" @@ -3734,19 +3623,6 @@ dependencies = [ "futures-sink", ] -[[package]] -name = "futures-concurrency" -version = "7.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" -dependencies = [ - "fixedbitset", - "futures-core", - "futures-lite", - "pin-project", - "smallvec", -] - [[package]] name = "futures-core" version = "0.3.32" @@ -4719,16 +4595,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonrpcmsg" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "jsonschema" version = "0.42.2" @@ -6931,7 +6797,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" dependencies = [ "async-trait", - "base64", "chrono", "futures", "http 1.4.0", diff --git a/lib/apps/fabro-cli/Cargo.toml b/lib/apps/fabro-cli/Cargo.toml index b7ec71fd2..c4551fde0 100644 --- a/lib/apps/fabro-cli/Cargo.toml +++ b/lib/apps/fabro-cli/Cargo.toml @@ -38,7 +38,6 @@ fabro-proc = { path = "../../foundation/fabro-proc" } fabro-sandbox = { path = "../../components/fabro-sandbox" } fabro-checkpoint = { path = "../../components/fabro-checkpoint" } fabro-graphviz = { path = "../../components/fabro-graphviz" } -fabro-validate = { path = "../../components/fabro-validate" } fabro-workflow = { path = "../../components/fabro-workflow" } fabro-server = { path = "../fabro-server" } fabro-client = { path = "../../foundation/fabro-client" } @@ -118,7 +117,6 @@ chrono = { workspace = true } assert_cmd = "2" fabro-db = { path = "../../foundation/fabro-db" } walkdir.workspace = true -fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] } rmcp = { workspace = true, features = ["client", "transport-child-process"] } fabro-build-support = { path = "../../foundation/build-support" } fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] } diff --git a/lib/apps/fabro-cli/src/commands/graph.rs b/lib/apps/fabro-cli/src/commands/graph.rs index 9af9863dd..285c4add3 100644 --- a/lib/apps/fabro-cli/src/commands/graph.rs +++ b/lib/apps/fabro-cli/src/commands/graph.rs @@ -14,6 +14,7 @@ use fabro_api::types; use fabro_config::user::active_settings_path; use fabro_graphviz::render; use fabro_manifest::{ManifestBuildInput, build_run_manifest}; +use fabro_types::diagnostic::Severity; use fabro_util::terminal::Styles; use tracing::debug; @@ -47,7 +48,7 @@ pub(crate) async fn run( print_diagnostics(&diagnostics, styles, printer); let has_errors = diagnostics .iter() - .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error); + .any(|diagnostic| diagnostic.severity == Severity::Error); if has_errors && !args.allow_invalid { bail!("Validation failed"); } diff --git a/lib/apps/fabro-cli/src/commands/preflight.rs b/lib/apps/fabro-cli/src/commands/preflight.rs index bfcc62549..2bf94cb08 100644 --- a/lib/apps/fabro-cli/src/commands/preflight.rs +++ b/lib/apps/fabro-cli/src/commands/preflight.rs @@ -1,6 +1,7 @@ use anyhow::bail; use fabro_config::user::active_settings_path; use fabro_manifest::{ManifestBuildInput, build_run_manifest}; +use fabro_types::diagnostic::Severity; use fabro_util::terminal::Styles; use crate::args::PreflightArgs; @@ -57,7 +58,7 @@ pub(crate) async fn execute( ); if diagnostics .iter() - .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + .any(|diagnostic| diagnostic.severity == Severity::Error) { bail!("Validation failed"); } @@ -75,7 +76,7 @@ pub(crate) async fn execute( if diagnostics .iter() - .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + .any(|diagnostic| diagnostic.severity == Severity::Error) { bail!("Validation failed"); } diff --git a/lib/apps/fabro-cli/src/commands/run/output.rs b/lib/apps/fabro-cli/src/commands/run/output.rs index c05d7c537..e07a0e6b3 100644 --- a/lib/apps/fabro-cli/src/commands/run/output.rs +++ b/lib/apps/fabro-cli/src/commands/run/output.rs @@ -5,6 +5,7 @@ use anyhow::{Context as _, Result}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; use fabro_api::types; +use fabro_types::diagnostic::{Diagnostic, RelatedDiagnostic, Severity}; use fabro_types::{PullRequestLink, RunId, StageId, parse_blob_ref}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_util::error::render_with_causes; @@ -68,13 +69,13 @@ pub(crate) fn print_workflow_summary( print_diagnostics(&diagnostics, styles, printer); } -fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_validate::Diagnostic { - fabro_validate::Diagnostic { +fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> Diagnostic { + Diagnostic { rule: diagnostic.rule.clone(), severity: match diagnostic.severity { - types::WorkflowDiagnosticSeverity::Error => fabro_validate::Severity::Error, - types::WorkflowDiagnosticSeverity::Warning => fabro_validate::Severity::Warning, - types::WorkflowDiagnosticSeverity::Info => fabro_validate::Severity::Info, + types::WorkflowDiagnosticSeverity::Error => Severity::Error, + types::WorkflowDiagnosticSeverity::Warning => Severity::Warning, + types::WorkflowDiagnosticSeverity::Info => Severity::Info, }, message: diagnostic.message.clone(), node_id: diagnostic.node_id.clone(), @@ -97,7 +98,7 @@ fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_vali related: diagnostic .related .iter() - .map(|related| fabro_validate::RelatedDiagnostic { + .map(|related| RelatedDiagnostic { message: related.message.clone(), source_path: related.source_path.clone(), line: related.line.and_then(|value| u32::try_from(value).ok()), @@ -109,7 +110,7 @@ fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_vali pub(crate) fn api_diagnostics_to_local( diagnostics: &[types::WorkflowDiagnostic], -) -> Vec { +) -> Vec { diagnostics.iter().map(api_diagnostic_to_local).collect() } diff --git a/lib/apps/fabro-cli/src/commands/validate.rs b/lib/apps/fabro-cli/src/commands/validate.rs index e157b50b3..cec9f7672 100644 --- a/lib/apps/fabro-cli/src/commands/validate.rs +++ b/lib/apps/fabro-cli/src/commands/validate.rs @@ -3,6 +3,7 @@ use fabro_config::RunLayer; use fabro_config::user::active_settings_path; use fabro_manifest::{ManifestBuildInput, build_run_manifest}; use fabro_server::manifest_validation; +use fabro_types::diagnostic::Severity; use fabro_util::terminal::Styles; use crate::args::ValidateArgs; @@ -31,13 +32,13 @@ pub(crate) fn run( "workflow_name": response.workflow.name, "nodes": response.workflow.nodes, "edges": response.workflow.edges, - "valid": !diagnostics.iter().any(|d| d.severity == fabro_validate::Severity::Error), + "valid": !diagnostics.iter().any(|d| d.severity == Severity::Error), "diagnostics": diagnostics, }))?; if diagnostics .iter() - .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + .any(|diagnostic| diagnostic.severity == Severity::Error) { bail!("Validation failed"); } @@ -64,7 +65,7 @@ pub(crate) fn run( if diagnostics .iter() - .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + .any(|diagnostic| diagnostic.severity == Severity::Error) { bail!("Validation failed"); } diff --git a/lib/apps/fabro-cli/src/shared/utilities.rs b/lib/apps/fabro-cli/src/shared/utilities.rs index 606483fba..77a5de01b 100644 --- a/lib/apps/fabro-cli/src/shared/utilities.rs +++ b/lib/apps/fabro-cli/src/shared/utilities.rs @@ -14,9 +14,9 @@ use std::time::Duration; use anyhow::Context as _; use cli_table::Color; use fabro_types::RunStatus; +use fabro_types::diagnostic::{Diagnostic, Severity}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use fabro_validate::{Diagnostic, Severity}; use indicatif::{ProgressBar, ProgressStyle}; use serde::Serialize; diff --git a/lib/apps/fabro-cli/tests/it/cmd/graph.rs b/lib/apps/fabro-cli/tests/it/cmd/graph.rs index fbf48599f..c32312edf 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/graph.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/graph.rs @@ -94,10 +94,7 @@ fn graph_allow_invalid_renders_after_diagnostics() { exit_code: 0 ----- stdout ----- ----- stderr ----- - error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node) - fix: Add a node with shape=Mdiamond or id 'start' - error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing) - fix: Remove outgoing edges from the exit node + error: [FIXTURES]/invalid.fabro:1:9: the workflow has no start node (`shape=Mdiamond`, `type=start`, or an id of `start`) (attractor.no_start) "); let svg = read_text(&output_path); @@ -120,10 +117,7 @@ fn graph_invalid_workflow_fails_after_diagnostics() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node) - fix: Add a node with shape=Mdiamond or id 'start' - error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing) - fix: Remove outgoing edges from the exit node + error: [FIXTURES]/invalid.fabro:1:9: the workflow has no start node (`shape=Mdiamond`, `type=start`, or an id of `start`) (attractor.no_start) × Validation failed "); } diff --git a/lib/apps/fabro-cli/tests/it/cmd/preflight.rs b/lib/apps/fabro-cli/tests/it/cmd/preflight.rs index b76246318..1bf970ff0 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/preflight.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/preflight.rs @@ -51,10 +51,7 @@ fn preflight_invalid_workflow_fails_with_validation_output() { ----- stderr ----- Workflow: Invalid (2 nodes, 1 edges) Graph: [FIXTURES]/invalid.fabro - error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node) - fix: Add a node with shape=Mdiamond or id 'start' - error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing) - fix: Remove outgoing edges from the exit node + error: [FIXTURES]/invalid.fabro:1:9: the workflow has no start node (`shape=Mdiamond`, `type=start`, or an id of `start`) (attractor.no_start) × Validation failed "); } @@ -79,6 +76,10 @@ fn preflight_rejects_unbound_template_inputs() { fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=` error: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable) fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=` + error: [FIXTURES]/templated_unbound.fabro:2:12: the graph `goal` reads `{{ inputs.app_dir }}`, which no input binds (unsupported.template.unbound_input) + fix: pass `--input app_dir=VALUE`, or add a default under `[run.inputs]` in workflow.toml + error: [FIXTURES]/templated_unbound.fabro:7:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds (unsupported.template.unbound_input) + fix: pass `--input app_dir=VALUE`, or add a default under `[run.inputs]` in workflow.toml × Validation failed "); } diff --git a/lib/apps/fabro-cli/tests/it/cmd/validate.rs b/lib/apps/fabro-cli/tests/it/cmd/validate.rs index 0af6cf8fa..f99191b5f 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/validate.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/validate.rs @@ -116,8 +116,8 @@ fn branching() { ----- stderr ----- Workflow: Branch (6 nodes, 6 edges) Graph: [FIXTURES]/branching.fabro - warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry) - fix: Add retry_target or fallback_retry_target attribute + warning: [FIXTURES]/branching.fabro:9:5: goal gate `implement` has no retry target that exists; when it fails the run ends failed (attractor.goal_gate_without_target) + warning: [FIXTURES]/branching.fabro:7:5: `exit` is in a loop with no `max_visits`; Fabro's unlimited visits lower to the hard maximum of 500 firings (info.budget.default) Validation: OK "); } @@ -134,6 +134,8 @@ fn conditions() { ----- stderr ----- Workflow: Conditions (5 nodes, 5 edges) Graph: [FIXTURES]/conditions.fabro + warning: [FIXTURES]/conditions.fabro:8:5: agent node `path_a` has no `prompt`; its label is the prompt (attractor.prompt_missing) + warning: [FIXTURES]/conditions.fabro:9:5: agent node `path_b` has no `prompt`; its label is the prompt (attractor.prompt_missing) Validation: OK "); } @@ -150,6 +152,9 @@ fn parallel() { ----- stderr ----- Workflow: Parallel (7 nodes, 7 edges) Graph: [FIXTURES]/parallel.fabro + warning: [FIXTURES]/parallel.fabro:8:5: agent node `branch1` has no `prompt`; its label is the prompt (attractor.prompt_missing) + warning: [FIXTURES]/parallel.fabro:9:5: agent node `branch2` has no `prompt`; its label is the prompt (attractor.prompt_missing) + warning: [FIXTURES]/parallel.fabro:11:5: agent node `review` has no `prompt`; its label is the prompt (attractor.prompt_missing) Validation: OK "); } @@ -166,6 +171,9 @@ fn styled() { ----- stderr ----- Workflow: Styled (5 nodes, 4 edges) Graph: [FIXTURES]/styled.fabro + warning: [FIXTURES]/styled.fabro:14:5: agent node `plan` has no `prompt`; its label is the prompt (attractor.prompt_missing) + warning: [FIXTURES]/styled.fabro:15:5: agent node `implement` has no `prompt`; its label is the prompt (attractor.prompt_missing) + warning: [FIXTURES]/styled.fabro:16:5: agent node `critical_review` has no `prompt`; its label is the prompt (attractor.prompt_missing) Validation: OK "); } @@ -202,6 +210,8 @@ fn bare_fabro_with_unbound_inputs_validates_structurally_with_warning() { fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=` warning: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable) fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=` + warning: [FIXTURES]/templated_unbound.fabro:2:12: the graph `goal` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input) + warning: [FIXTURES]/templated_unbound.fabro:7:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input) Validation: OK "); } @@ -220,6 +230,7 @@ fn unbound_model_stylesheet_input_warns_without_css_error() { Graph: [FIXTURES]/model_stylesheet_unbound.fabro warning: [FIXTURES]/model_stylesheet_unbound.fabro:4:38: undefined template variable `inputs.effort` in graph attribute `model_stylesheet` (template_undefined_variable) fix: bind `effort` via `[run.inputs]` in workflow.toml, or pass `--input effort=` + warning: [FIXTURES]/model_stylesheet_unbound.fabro:3:9: the `model_stylesheet` reads `{{ inputs.effort }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input effort=VALUE` to render it (attractor.unbound_input) Validation: OK "); } @@ -243,6 +254,7 @@ fn bare_fabro_with_unbound_inputs_in_imported_prompt_validates_structurally_with Graph: [FIXTURES]/templated_unbound_imported/workflow.fabro warning: [FIXTURES]/templated_unbound_imported/work.md:1:12: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable) fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=` + warning: [FIXTURES]/templated_unbound_imported/workflow.fabro:5:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input) Validation: OK "); } @@ -256,17 +268,18 @@ fn bare_fabro_with_unbound_inputs_in_template_partial_validates_structurally_wit let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("templated_unbound_partial/workflow.fabro")); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 + fabro_snapshot!(context.filters(), cmd, @r#" + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- Workflow: TemplatedUnboundPartial (3 nodes, 2 edges) Graph: [FIXTURES]/templated_unbound_partial/workflow.fabro warning: [FIXTURES]/templated_unbound_partial/test-include.partial.md:1:4: undefined template variable `inputs.hello` in node `test_imported_include` attribute `prompt` [node: test_imported_include] (template_undefined_variable) fix: bind `hello` via `[run.inputs]` in workflow.toml, or pass `--input hello=` - Validation: OK - "); + error: [FIXTURES]/templated_unbound_partial/workflow.fabro:3:42: node `test_imported_include` `prompt`: template render: could not render include: error in "../../../../../../../..[FIXTURES]/templated_unbound_partial/test-include.partial.md" (in ../../../../../../../..[FIXTURES]/templated_unbound_partial/__petri_root__:1) (attractor.template) + × Validation failed + "#); } #[test] @@ -351,8 +364,7 @@ fn edge_only_node() { ----- stderr ----- Workflow: EdgeOnlyNode (2 nodes, 2 edges) Graph: [FIXTURES]/edge_only_node.fabro - error [node: misspelled_node]: Node 'misspelled_node' is referenced by edge 'start -> misspelled_node' but has no node declaration (edge_target_exists) - fix: Declare node 'misspelled_node' or correct the edge endpoint + error: [FIXTURES]/edge_only_node.fabro:8:14: `misspelled_node` is named by an edge but never declared (attractor.undeclared_node) × Validation failed "); } @@ -369,10 +381,7 @@ fn invalid() { ----- stderr ----- Workflow: Invalid (2 nodes, 1 edges) Graph: [FIXTURES]/invalid.fabro - error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node) - fix: Add a node with shape=Mdiamond or id 'start' - error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing) - fix: Remove outgoing edges from the exit node + error: [FIXTURES]/invalid.fabro:1:9: the workflow has no start node (`shape=Mdiamond`, `type=start`, or an id of `start`) (attractor.no_start) × Validation failed "); } @@ -389,8 +398,7 @@ fn invalid_node_on_failure_is_a_validation_failure() { ----- stderr ----- Workflow: InvalidNodeOnFailure (3 nodes, 2 edges) Graph: [FIXTURES]/on_failure_node_invalid.fabro - error [node: work]: Node 'work' has invalid on_failure value 'stop' (on_failure_valid) - fix: Use one of: route, exit, succeed + error: [FIXTURES]/on_failure_node_invalid.fabro:4:32: `on_failure` must be `route`, `exit`, `succeed` or `partially_succeed`, not `stop` (attractor.bad_on_failure) × Validation failed "); } @@ -400,17 +408,16 @@ fn deprecated_auto_status_warns_with_succeed_policy_replacement() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("auto_status_deprecated.fabro")); - fabro_snapshot!(context.filters(), cmd, @" + fabro_snapshot!(context.filters(), cmd, @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: DeprecatedAutoStatus (3 nodes, 2 edges) Graph: [FIXTURES]/auto_status_deprecated.fabro - warning [node: scan]: Node 'scan' sets deprecated 'auto_status=true' (auto_status_deprecated) - fix: Use on_failure=\"succeed\" instead + warning: [FIXTURES]/auto_status_deprecated.fabro:4:34: `auto_status=true` on node `scan` is the deprecated spelling of `on_failure="succeed"`; use `on_failure="succeed"` (deprecated.auto_status) Validation: OK - "); + "#); } #[test] @@ -425,8 +432,7 @@ fn invalid_on_failure_is_a_validation_failure() { ----- stderr ----- Workflow: InvalidOnFailure (2 nodes, 1 edges) Graph: [FIXTURES]/on_failure_invalid.fabro - error: Graph has invalid on_failure value 'stop' (on_failure_valid) - fix: Use one of: route, exit, succeed + error: [FIXTURES]/on_failure_invalid.fabro:2:12: `on_failure` must be `route`, `exit`, `succeed` or `partially_succeed`, not `stop` (attractor.bad_on_failure) × Validation failed "); } diff --git a/lib/apps/fabro-cli/tests/it/workflow/acp.rs b/lib/apps/fabro-cli/tests/it/workflow/acp.rs deleted file mode 100644 index f01472e03..000000000 --- a/lib/apps/fabro-cli/tests/it/workflow/acp.rs +++ /dev/null @@ -1,325 +0,0 @@ -#![expect( - clippy::disallowed_methods, - reason = "integration test initializes an isolated git repository with the system git binary" -)] - -use fabro_acp::test_support::fake_acp_agent_script; -use fabro_config::Storage; -use fabro_test::test_context; -use fabro_types::EventBody; -use fabro_vault::{SecretType, Vault}; - -use super::{find_run_dir, has_event, read_conclusion, run_events, run_id_for, run_state}; -use crate::cmd::support::output_stdout; - -#[test] -fn acp_backend_workflow() { - let mut context = test_context!(); - context.write_home( - ".fabro/settings.toml", - "[server.auth]\nmethods = [\"dev-token\"]\n", - ); - context.isolated_server(); - let fake_agent = write_fake_acp_agent(&context); - let acp_config = fake_acp_config_attr(&fake_agent); - let workflow = context.temp_dir.join("acp_backend.fabro"); - context.write_temp( - "acp_backend.fabro", - format!( - r#"digraph ACP {{ - graph [goal="Exercise ACP backend"] - start [shape=Mdiamond] - work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}] - exit [shape=Msquare] - start -> work - work -> exit -}}"# - ), - ); - init_git_repo(&context.temp_dir); - - context - .run_cmd() - .args(["--auto-approve", "--environment", "local"]) - .arg(&workflow) - .assert() - .success(); - - let run_dir = find_run_dir(&context); - let conclusion = read_conclusion(&run_dir); - assert_eq!(conclusion["status"].as_str(), Some("succeeded")); - - let events = run_events(&run_dir); - assert!(has_event(&run_dir, "agent.acp.started")); - assert!(has_event(&run_dir, "agent.acp.completed")); - let completed = events - .iter() - .find_map(|event| match &event.event.body { - EventBody::StageCompleted(props) if event.event.node_id.as_deref() == Some("work") => { - Some(props) - } - _ => None, - }) - .expect("work stage should complete"); - assert_eq!(completed.response.as_deref(), Some("hello from acp")); - assert!( - completed - .files_touched - .iter() - .any(|file| file == "hello.txt"), - "files_touched should include hello.txt: {:?}", - completed.files_touched - ); - - let state = serde_json::to_value(run_state(&run_dir)).expect("run state should serialize"); - let stages = state["stages"] - .as_object() - .expect("run state should contain stages"); - assert!( - stages.values().any(|stage| { - stage["provider_used"]["mode"] == "acp" - && stage["provider_used"]["provider"] == "acp" - && stage["provider_used"]["model"] == "fake" - && stage["provider_used"].get("reasoning_effort").is_none() - && stage["provider_used"].get("speed").is_none() - }), - "run projection should include ACP model usage metadata: {stages:?}" - ); -} - -#[test] -fn acp_backend_does_not_inject_registered_provider_credentials() { - let mut context = test_context!(); - context.write_home( - ".fabro/settings.toml", - "[server.auth]\nmethods = [\"dev-token\"]\n", - ); - context.isolated_server(); - seed_anthropic_vault(&context.storage_dir); - - let fake_agent = write_fake_acp_agent(&context); - let env_record = context.temp_dir.join("acp-env.json"); - let acp_config = fake_acp_config_attr_recording_env(&fake_agent, &env_record); - let workflow = context.temp_dir.join("acp_provider_env.fabro"); - context.write_temp( - "acp_provider_env.fabro", - format!( - r#"digraph ACP {{ - graph [goal="Exercise ACP backend with stored provider credentials"] - start [shape=Mdiamond] - work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}] - exit [shape=Msquare] - start -> work - work -> exit -}}"# - ), - ); - init_git_repo(&context.temp_dir); - - context - .run_cmd() - .env_remove("ANTHROPIC_API_KEY") - .env_remove("OPENAI_API_KEY") - .env_remove("GEMINI_API_KEY") - .args(["--auto-approve", "--environment", "local"]) - .arg(&workflow) - .assert() - .success(); - - let run_dir = find_run_dir(&context); - let conclusion = read_conclusion(&run_dir); - assert_eq!(conclusion["status"].as_str(), Some("succeeded")); - - let recorded_env: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(&env_record) - .expect("fake ACP agent should record its environment"), - ) - .expect("fake ACP environment record should be valid JSON"); - assert_eq!(recorded_env, serde_json::json!({})); -} - -#[test] -fn acp_artifacts_are_listed_when_touched_file_mtime_precedes_attempt_start() { - let mut context = test_context!(); - context.write_home( - ".fabro/settings.toml", - "[server.auth]\nmethods = [\"dev-token\"]\n", - ); - context.isolated_server(); - let fake_agent = write_fake_acp_agent(&context); - let acp_config = fake_acp_config_attr_with_env(&fake_agent, vec![ - serde_json::json!({ - "name": "ACP_WRITE_PATH", - "value": "verification-artifacts/report.md", - }), - serde_json::json!({ - "name": "ACP_WRITE_CONTENT", - "value": "verified\n", - }), - serde_json::json!({ - "name": "ACP_WRITE_MTIME_EPOCH", - "value": "946684800", - }), - ]); - context.write_temp( - "acp_artifact_collection.fabro", - format!( - r#"digraph ACPArtifacts {{ - graph [goal="Capture verification artifacts"] - start [shape=Mdiamond] - verify [type="agent", backend="acp", prompt="write verification artifact", acp.config={acp_config}] - exit [shape=Msquare] - start -> verify - verify -> exit -}}"# - ), - ); - context.write_temp( - "workflow.toml", - r#"_version = 1 - -[workflow] -graph = "acp_artifact_collection.fabro" - -[run] -goal = "Capture verification artifacts" - -[run.checkpoint] -exclude_globs = ["verification-artifacts/**"] - -[run.artifacts] -include = ["verification-artifacts/**"] -"#, - ); - init_git_repo(&context.temp_dir); - - context - .run_cmd() - .args(["--auto-approve", "--environment", "local"]) - .arg(context.temp_dir.join("workflow.toml")) - .assert() - .success(); - - let run_dir = find_run_dir(&context); - let run_id = run_id_for(&run_dir); - let events = run_events(&run_dir); - let completed = events - .iter() - .find_map(|event| match &event.event.body { - EventBody::StageCompleted(props) - if event.event.node_id.as_deref() == Some("verify") => - { - Some(props) - } - _ => None, - }) - .expect("verify stage should complete"); - assert!( - completed - .files_touched - .iter() - .any(|file| file == "verification-artifacts/report.md"), - "files_touched should include the artifact path: {:?}", - completed.files_touched - ); - - let output = context - .command() - .args(["--json", "artifact", "list", &run_id]) - .output() - .expect("artifact list should execute"); - assert!( - output.status.success(), - "artifact list failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let artifacts: Vec = - serde_json::from_str(&output_stdout(&output)).expect("artifact list JSON should parse"); - assert!( - artifacts.iter().any(|artifact| { - artifact["node_slug"] == "verify" - && artifact["relative_path"] == "verification-artifacts/report.md" - }), - "artifact list should include the touched verification artifact: {artifacts:?}" - ); - - let completed_run = events - .iter() - .find_map(|event| match &event.event.body { - EventBody::RunCompleted(props) => Some(props), - _ => None, - }) - .expect("run.completed should be emitted"); - assert!( - completed_run.artifact_count > 0, - "run.completed should report captured artifacts" - ); -} - -fn write_fake_acp_agent(context: &fabro_test::TestContext) -> std::path::PathBuf { - context.write_temp("fake_acp_agent.py", fake_acp_agent_script()); - context.temp_dir.join("fake_acp_agent.py") -} - -fn fake_acp_config_attr(script_path: &std::path::Path) -> String { - fake_acp_config_attr_with_env(script_path, Vec::new()) -} - -fn fake_acp_config_attr_recording_env( - script_path: &std::path::Path, - env_record: &std::path::Path, -) -> String { - fake_acp_config_attr_with_env(script_path, vec![ - serde_json::json!({"name": "ACP_ENV_RECORD", "value": env_record.to_string_lossy()}), - serde_json::json!({ - "name": "ACP_ENV_RECORD_KEYS", - "value": "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY", - }), - ]) -} - -fn fake_acp_config_attr_with_env( - script_path: &std::path::Path, - extra_env: Vec, -) -> String { - let mut env = vec![serde_json::json!({"name": "ACP_MODE", "value": "write_file"})]; - env.extend(extra_env); - - let config = serde_json::json!({ - "type": "stdio", - "name": "fake", - "command": "python3", - "args": [script_path.to_string_lossy()], - "env": env, - }) - .to_string(); - format!("{config:?}") -} - -fn seed_anthropic_vault(storage_dir: &std::path::Path) { - let mut vault = - Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load"); - vault - .set( - "ANTHROPIC_API_KEY", - "vault-anthropic-key", - SecretType::Token, - None, - ) - .expect("Anthropic credential should store in test vault"); -} - -fn init_git_repo(dir: &std::path::Path) { - let output = std::process::Command::new("git") - .args(["init", "-q"]) - .current_dir(dir) - .output() - .expect("git init should run"); - assert!( - output.status.success(), - "git init failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} diff --git a/lib/apps/fabro-cli/tests/it/workflow/git_identity.rs b/lib/apps/fabro-cli/tests/it/workflow/git_identity.rs deleted file mode 100644 index 716a13672..000000000 --- a/lib/apps/fabro-cli/tests/it/workflow/git_identity.rs +++ /dev/null @@ -1,324 +0,0 @@ -#![expect( - clippy::disallowed_methods, - reason = "integration test initializes an isolated git repository with the system git binary" -)] - -use std::path::Path; -use std::process::Command; - -use fabro_acp::test_support::fake_acp_agent_script; -use fabro_test::{TestContext, test_context}; -use fabro_types::{EventBody, GitIdentitySource}; - -use super::{ - dump_export, find_run_dir, read_conclusion, run_events, run_id_for, run_state, sandbox_tests, - stage_dump_dir, timeout_for, -}; - -fn git(dir: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .unwrap_or_else(|err| panic!("git {args:?} should run: {err}")); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8_lossy(&output.stdout).trim().to_string() -} - -fn init_repo_with_local_identity(dir: &Path) { - git(dir, &["init", "-q"]); - git(dir, &["config", "user.name", "Local Config"]); - git(dir, &["config", "user.email", "local@example.com"]); - std::fs::write(dir.join("README.md"), "seed\n").expect("seed file should write"); - git(dir, &["add", "README.md"]); - git(dir, &["commit", "-q", "-m", "seed"]); -} - -fn identity_of(dir: &Path, rev: &str) -> Vec { - git(dir, &["show", "-s", "--format=%an%n%ae%n%cn%n%ce", rev]) - .lines() - .map(str::to_string) - .collect() -} - -fn setup(context: &mut TestContext) { - context.write_home( - ".fabro/settings.toml", - "[server.auth]\nmethods = [\"dev-token\"]\n", - ); - context.isolated_server(); -} - -/// A host run with no GitHub credential and no explicit author commits as -/// the generic Fabro identity: a commit a script stage creates in the -/// checkout, and a commit it creates in a repository it initializes itself. The -/// checkout's own Git configuration and the host's inherited `GIT_*` variables -/// do not leak in, and the run does not touch the isolated HOME's Git -/// configuration. -#[test] -fn script_stage_commits_carry_the_run_identity() { - let mut context = test_context!(); - setup(&mut context); - init_repo_with_local_identity(&context.temp_dir); - let other_repo = context.temp_dir.join("other-repo"); - let home_gitconfig = context.home_dir.join(".gitconfig"); - let script = format!( - "set -e; printf work > work.txt && git add work.txt && git commit -q -m 'workflow commit' && \ - git init -q {other} && cd {other} && printf x > x.txt && git add x.txt && git commit -q -m 'other commit'", - other = other_repo.display() - ); - context.write_temp( - "identity.fabro", - format!( - r#"digraph Identity {{ - graph [goal="Commit as the run identity"] - start [shape=Mdiamond] - work [shape=parallelogram, script="{script}"] - exit [shape=Msquare] - start -> work -> exit -}}"# - ), - ); - - context - .run_cmd() - .env("GIT_AUTHOR_NAME", "Inherited Host") - .env("GIT_AUTHOR_EMAIL", "host@example.com") - .env("GIT_COMMITTER_NAME", "Inherited Host") - .env("GIT_COMMITTER_EMAIL", "host@example.com") - .args(["--auto-approve", "--environment", "local"]) - .arg(context.temp_dir.join("identity.fabro")) - .assert() - .success(); - - let run_dir = find_run_dir(&context); - assert_eq!(read_conclusion(&run_dir)["status"], "succeeded"); - - let expected = vec![ - "Fabro".to_string(), - "noreply@fabro.sh".to_string(), - "Fabro".to_string(), - "noreply@fabro.sh".to_string(), - ]; - // A host run without a clone has no managed run branch, so the checkout - // HEAD is the workflow's own commit. The engine checkpoint path is - // covered by the fabro-workflow git integration tests. - assert_eq!( - git(&context.temp_dir, &["log", "-1", "--format=%s"]), - "workflow commit" - ); - assert_eq!( - identity_of(&context.temp_dir, "HEAD"), - expected, - "workflow commit in the checkout" - ); - assert_eq!( - identity_of(&other_repo, "HEAD"), - expected, - "commit in a workflow-created repository" - ); - - // No configuration was written: the checkout keeps its local identity and - // the isolated HOME gained no Git configuration. - assert_eq!( - git(&context.temp_dir, &["config", "user.name"]), - "Local Config" - ); - assert_eq!( - git(&context.temp_dir, &["config", "user.email"]), - "local@example.com" - ); - assert!( - !home_gitconfig.exists(), - "the run must not write {}", - home_gitconfig.display() - ); - - // The resolved identity is recorded durably and in the event stream. - let state = run_state(&run_dir); - let identity = state - .git_identity - .expect("run state should record the identity"); - assert_eq!(identity.name, "Fabro"); - assert_eq!(identity.email, "noreply@fabro.sh"); - assert_eq!(identity.source, GitIdentitySource::Default); - assert!( - run_events(&run_dir) - .iter() - .any(|event| matches!(&event.event.body, EventBody::GitIdentityResolved(props) if props.identity == identity)), - "git.identity.resolved should be emitted" - ); -} - -/// `run.git.author` overrides the identity for every path, and a run whose -/// working directory has no Git origin still receives it. -#[test] -fn explicit_author_reaches_script_stages_without_a_git_origin() { - let mut context = test_context!(); - setup(&mut context); - let repo = context.temp_dir.join("fresh"); - let script = format!( - "set -e; git init -q {repo} && cd {repo} && printf x > x.txt && git add x.txt && git commit -q -m 'fresh commit'", - repo = repo.display() - ); - context.write_temp( - "explicit.fabro", - format!( - r#"digraph Explicit {{ - graph [goal="Commit as the explicit author"] - start [shape=Mdiamond] - work [shape=parallelogram, script="{script}"] - exit [shape=Msquare] - start -> work -> exit -}}"# - ), - ); - context.write_temp( - "workflow.toml", - r#"_version = 1 - -[workflow] -graph = "explicit.fabro" - -[run] -goal = "Commit as the explicit author" - -[run.git.author] -name = "Release Bot" -email = "release@example.com" - -[run.environment.env] -GIT_AUTHOR_NAME = "Run Env" -"#, - ); - - context - .run_cmd() - .args(["--auto-approve", "--environment", "local"]) - .arg(context.temp_dir.join("workflow.toml")) - .assert() - .success(); - - let run_dir = find_run_dir(&context); - assert_eq!(read_conclusion(&run_dir)["status"], "succeeded"); - assert_eq!(identity_of(&repo, "HEAD"), vec![ - "Release Bot".to_string(), - "release@example.com".to_string(), - "Release Bot".to_string(), - "release@example.com".to_string(), - ]); - let identity = run_state(&run_dir) - .git_identity - .expect("run state should record the identity"); - assert_eq!(identity.source, GitIdentitySource::Explicit); -} - -/// An ACP agent process is launched with the run identity in its environment. -#[test] -fn acp_agent_launch_env_carries_the_run_identity() { - let mut context = test_context!(); - setup(&mut context); - context.write_temp("fake_acp_agent.py", fake_acp_agent_script()); - let fake_agent = context.temp_dir.join("fake_acp_agent.py"); - let env_record = context.temp_dir.join("acp-env.json"); - let config = serde_json::json!({ - "type": "stdio", - "name": "fake", - "command": "python3", - "args": [fake_agent.to_string_lossy()], - "env": [ - {"name": "ACP_MODE", "value": "write_file"}, - {"name": "ACP_ENV_RECORD", "value": env_record.to_string_lossy()}, - { - "name": "ACP_ENV_RECORD_KEYS", - "value": "GIT_AUTHOR_NAME,GIT_AUTHOR_EMAIL,GIT_COMMITTER_NAME,GIT_COMMITTER_EMAIL", - }, - ], - }) - .to_string(); - let acp_config = format!("{config:?}"); - context.write_temp( - "acp_identity.fabro", - format!( - r#"digraph ACP {{ - graph [goal="Exercise ACP launch env"] - start [shape=Mdiamond] - work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}] - exit [shape=Msquare] - start -> work -> exit -}}"# - ), - ); - git(&context.temp_dir, &["init", "-q"]); - - context - .run_cmd() - .env("GIT_AUTHOR_NAME", "Inherited Host") - .args(["--auto-approve", "--environment", "local"]) - .arg(context.temp_dir.join("acp_identity.fabro")) - .assert() - .success(); - - let run_dir = find_run_dir(&context); - assert_eq!(read_conclusion(&run_dir)["status"], "succeeded"); - let recorded: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(&env_record).expect("fake ACP agent should record its env"), - ) - .expect("record should be JSON"); - assert_eq!( - recorded, - serde_json::json!({ - "GIT_AUTHOR_NAME": "Fabro", - "GIT_AUTHOR_EMAIL": "noreply@fabro.sh", - "GIT_COMMITTER_NAME": "Fabro", - "GIT_COMMITTER_EMAIL": "noreply@fabro.sh", - }) - ); -} - -// --------------------------------------------------------------------------- -// The same contract across sandbox providers: a script stage commits in a -// repository it initializes inside the sandbox and reports the identity the -// commit object carries. Runs on the local host, and on the host and Docker -// sandbox plugins when their executables are on PATH. -// --------------------------------------------------------------------------- - -sandbox_tests!(git_identity_in_sandbox); - -fn scenario_git_identity_in_sandbox(context: &TestContext, sandbox: &str) { - context.write_temp( - "sandbox_identity.fabro", - r#"digraph SandboxIdentity { - graph [goal="Commit inside the sandbox as the run identity"] - start [shape=Mdiamond] - work [shape=parallelogram, script="set -e; git init -q identity-probe && cd identity-probe && printf x > x.txt && git add x.txt && git commit -q -m probe && printf 'identity=%s|%s|%s|%s\n' \"$(git log -1 --format=%an)\" \"$(git log -1 --format=%ae)\" \"$(git log -1 --format=%cn)\" \"$(git log -1 --format=%ce)\""] - exit [shape=Msquare] - start -> work -> exit -} -"#, - ); - - context - .run_cmd() - .env("GIT_AUTHOR_NAME", "Inherited Host") - .env("GIT_COMMITTER_EMAIL", "host@example.com") - .args(["--auto-approve", "--environment", sandbox]) - .arg(context.temp_dir.join("sandbox_identity.fabro")) - .timeout(timeout_for(sandbox)) - .assert() - .success(); - - let run_dir = find_run_dir(context); - assert_eq!(read_conclusion(&run_dir)["status"], "succeeded"); - let export_dir = dump_export(context, &run_id_for(&run_dir)); - let output = std::fs::read_to_string(stage_dump_dir(&export_dir, "work@1").join("output.log")) - .expect("work output.log should exist"); - assert!( - output.contains("identity=Fabro|noreply@fabro.sh|Fabro|noreply@fabro.sh"), - "{sandbox}: the sandbox commit should carry the run identity, got: {output}" - ); -} diff --git a/lib/apps/fabro-cli/tests/it/workflow/mod.rs b/lib/apps/fabro-cli/tests/it/workflow/mod.rs index 155df6d23..c494defbd 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/mod.rs @@ -3,7 +3,6 @@ reason = "This test module prefers explicit type paths over extra imports." )] -mod acp; mod agent_linear; mod artifacts; mod command_agent_mixed; @@ -12,7 +11,6 @@ mod command_routing; mod conditional_branching; mod dry_run_examples; mod full_stack; -mod git_identity; mod hooks; mod human_gate; pub(super) mod plugin; diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index 0a51955ab..f3b1d5a90 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -32,7 +32,6 @@ fabro-interview = { path = "../../components/fabro-interview" } fabro-slack = { path = "../../components/fabro-slack" } fabro-workflow = { path = "../../components/fabro-workflow" } fabro-workflow-version = { path = "../../components/fabro-workflow-version" } -fabro-validate = { path = "../../components/fabro-validate" } fabro-sandbox = { path = "../../components/fabro-sandbox" } sandbox-driver.workspace = true fabro-github = { path = "../../components/fabro-github" } diff --git a/lib/apps/fabro-server/src/lib.rs b/lib/apps/fabro-server/src/lib.rs index 4d1be5fc3..4dd63b123 100644 --- a/lib/apps/fabro-server/src/lib.rs +++ b/lib/apps/fabro-server/src/lib.rs @@ -32,6 +32,7 @@ mod interp; pub mod jwt_auth; pub mod manifest_validation; mod migrations; +mod petri_check; mod petri_runs; mod principal_middleware; mod request_id; diff --git a/lib/apps/fabro-server/src/manifest_validation.rs b/lib/apps/fabro-server/src/manifest_validation.rs index 648180db4..43c93571c 100644 --- a/lib/apps/fabro-server/src/manifest_validation.rs +++ b/lib/apps/fabro-server/src/manifest_validation.rs @@ -5,17 +5,19 @@ use anyhow::{Result, anyhow}; use fabro_api::types; use fabro_config::{RunLayer, WorkflowSettingsBuilder}; use fabro_manifest::CollectedWorkflowClosure; +use fabro_petri::runtime::RuntimeSpec; use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use fabro_workflow::pipeline::TEMPLATE_UNDEFINED_VARIABLE_RULE; -use crate::{run_intent, run_manifest}; +use crate::{petri_check, run_intent, run_manifest}; /// Validate a manifest without a model catalog. /// /// Every caller is a client — the CLI, an MCP server, a run worker — and a /// client's catalog is its own, not the server's. Judging model and provider -/// availability here would reject workflows the server can run, so that is -/// left to the server on create. +/// availability here would reject workflows the server can run, so Petri +/// checks the bundle with no model client: the structure, the settings and +/// the templates, with every model node left for the server on create. pub fn validate_manifest( manifest_run_defaults: &RunLayer, manifest: &types::RunManifest, @@ -26,11 +28,30 @@ pub fn validate_manifest( &HashMap::new(), manifest, )?; - let validated = run_manifest::validate_prepared_manifest_structural(&prepared) - .map_err(anyhow::Error::new)?; + let validated = run_manifest::validate_prepared_manifest( + &prepared, + &HashMap::new(), + petri_check::launch_without_catalog(&prepared.settings), + offline_runtime(), + true, + true, + ) + .map_err(anyhow::Error::new)?; Ok(run_manifest::validate_response(&prepared, &validated)) } +/// Petri's runtime for a check away from the server: no operator settings, +/// no model client, no Fabro home, no run tools. +fn offline_runtime() -> RuntimeSpec { + RuntimeSpec { + settings_toml: None, + model_client: None, + dry_run: false, + fabro_home: None, + run_tools: None, + } +} + /// Validate an already collected local workflow before any version upload. /// /// The supplied run layer is complete, including any already resolved inline @@ -46,8 +67,8 @@ pub fn validate_collected_workflow( let lowered = run_intent::lower_collected_workflow_closure(closure)?; let workflow = lowered .workflow_bundle - .into_workflows() - .remove(&lowered.entrypoint) + .workflow(&lowered.entrypoint) + .cloned() .ok_or_else(|| anyhow!("lowered root workflow is missing from its bundle"))?; let mut builder = WorkflowSettingsBuilder::new() .server_manifest_defaults( @@ -63,14 +84,26 @@ pub fn validate_collected_workflow( } let mut settings = builder.build().map_err(anyhow::Error::new)?; settings.run.inputs.extend(input_overrides.clone()); - let validated = validate(ValidateInput { - workflow: WorkflowInput::Bundled(workflow), - settings, - vars: HashMap::new(), - cwd: PathBuf::from("/workspace"), + let mut validated = validate(ValidateInput { + workflow: WorkflowInput::Bundled(workflow), + settings: settings.clone(), + vars: HashMap::new(), + cwd: PathBuf::from("/workspace"), custom_transforms: Vec::new(), }) .map_err(anyhow::Error::new)?; + let request = petri_check::check_request( + &lowered.workflow_bundle, + &lowered.entrypoint, + &settings, + &HashMap::new(), + petri_check::launch_without_catalog(&settings), + offline_runtime(), + false, + ) + .map_err(anyhow::Error::new)?; + let checked = petri_check::check(&request, true).map_err(anyhow::Error::new)?; + validated.extend_diagnostics(checked.diagnostics); let mut response = types::ValidateResponse { ok: !validated.has_errors(), workflow: run_manifest::workflow_summary(&validated, lowered.entrypoint.as_path()), @@ -120,6 +153,8 @@ mod tests { r#"_version = 1 [workflow] graph = "workflow.fabro" +[environments.default] +provider = "local" [run.goal] file = "goal.md" [run.environment.image] @@ -240,7 +275,7 @@ dockerfile = { path = "Dockerfile" } .iter() .all(|diagnostic| { diagnostic.rule != TEMPLATE_UNDEFINED_VARIABLE_RULE }) ); - assert!(present.ok); + assert!(present.ok, "{:?}", present.workflow.diagnostics); assert_eq!(present.workflow.goal, "resolved inline goal"); assert!(present.workflow.diagnostics.iter().all(|diagnostic| { !diagnostic.message.contains("future-provider") diff --git a/lib/apps/fabro-server/src/petri_check.rs b/lib/apps/fabro-server/src/petri_check.rs new file mode 100644 index 000000000..543c43bc3 --- /dev/null +++ b/lib/apps/fabro-server/src/petri_check.rs @@ -0,0 +1,194 @@ +//! Petri's check as Fabro's judge of a workflow. +//! +//! The create handler, the validate and preflight endpoints and the offline +//! `fabro validate` all ask Petri the same question: does this bundle, under +//! these settings, lower to a graph Petri admits? This module builds the +//! request from a workflow bundle and the run's settings, runs the check, +//! and hands back the admitted graphs with Petri's diagnostics in Fabro's +//! shape. Fabro adds one rule of its own: a workflow with a node that runs a +//! model is refused when no LLM provider is ready, since Petri admits the +//! model nodes unchecked without a model client. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::PathBuf; + +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::selection; +use fabro_petri::check::{self, Admitted, Bundle, CheckError, CheckRequest, Diagnostic, Launch}; +use fabro_petri::runtime::RuntimeSpec; +use fabro_types::diagnostic::{Diagnostic as FabroDiagnostic, Severity}; +use fabro_types::{ManifestPath, WorkflowSettings}; +use fabro_workflow::Error as WorkflowError; +use fabro_workflow::workflow_bundle::WorkflowBundle; +use lithos_llm::catalog::ProviderId; + +/// Fabro's rule for a model node with no provider ready to run it. +pub(crate) const NO_READY_PROVIDER_RULE: &str = "fabro.model.no_ready_provider"; + +/// The launch Fabro binds below the settings: the run's model and provider. +/// When the settings name neither, the default offering of the eligible +/// providers is bound as the launch model alone: a node that names no model +/// runs on it, and a node that names a model the catalog lacks stays +/// unqualified, so Petri's admission refuses it. +pub(crate) fn launch( + catalog: &Catalog, + settings: &WorkflowSettings, + eligible: &[ProviderId], + repository: Option, +) -> Launch { + let model = settings.run.model.name.clone().or_else(|| { + if settings.run.model.provider.is_some() { + return None; + } + let eligible = eligible.iter().cloned().collect::>(); + selection::select_default(catalog, &eligible) + .ok() + .map(|offering| offering.model.id().to_string()) + }); + Launch { + model, + provider: settings.run.model.provider.clone(), + repository, + } +} + +/// The launch with no catalog to pick a default from: what the settings +/// name, for a check away from the server. +pub(crate) fn launch_without_catalog(settings: &WorkflowSettings) -> Launch { + Launch { + model: settings.run.model.name.clone(), + provider: settings.run.model.provider.clone(), + repository: None, + } +} + +/// The check request for `bundle`'s `entrypoint`: every file of every +/// workflow in the bundle at its bundle-relative path, the run's inputs and +/// variables, the launch and the runtime. `unbound_is_warning` makes a +/// template that reads an input nothing binds a warning, for a validation +/// before the run's inputs exist; a run's admission never sets it. +pub(crate) fn check_request( + bundle: &WorkflowBundle, + entrypoint: &ManifestPath, + settings: &WorkflowSettings, + vars: &HashMap, + launch: Launch, + runtime: RuntimeSpec, + unbound_is_warning: bool, +) -> Result { + let mut files = BTreeMap::new(); + for workflow in bundle.workflows().values() { + for (path, text) in &workflow.files { + files.insert(path.to_string(), text.clone()); + } + files.insert(workflow.path.to_string(), workflow.source.clone()); + if let Some(config) = &workflow.config { + files.insert(config.path.to_string(), config.source.clone()); + } + } + let mut inputs = BTreeMap::new(); + for (name, value) in &settings.run.inputs { + let value = serde_json::to_value(value).map_err(|err| { + WorkflowError::engine_with_source( + format!("run input `{name}` does not encode as JSON"), + err, + ) + })?; + inputs.insert(name.clone(), value); + } + Ok(CheckRequest { + bundle: Bundle { + files, + entrypoint: entrypoint.to_string(), + project_toml: None, + }, + inputs, + vars: vars + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(), + launch, + runtime, + unbound_is_warning, + }) +} + +/// What a check said: the admitted graphs when Petri admitted the workflow, +/// and every diagnostic, Petri's and Fabro's, in Fabro's shape. +pub(crate) struct Checked { + pub(crate) admitted: Option, + pub(crate) diagnostics: Vec, +} + +impl Checked { + pub(crate) fn has_errors(&self) -> bool { + self.diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error) + } +} + +/// Run Petri's check. A refusal comes back as its diagnostics with no +/// graphs; an admission as the graphs with Petri's warnings, plus Fabro's +/// refusal of a model node when `has_ready_provider` is false. Blocking: it +/// lowers the graph and runs the admission passes synchronously. +pub(crate) fn check( + request: &CheckRequest, + has_ready_provider: bool, +) -> Result { + match check::check(request) { + Ok(admitted) => { + let mut diagnostics = admitted + .warnings + .iter() + .map(fabro_diagnostic) + .collect::>(); + if !has_ready_provider && admitted.needs_model() { + diagnostics.push(FabroDiagnostic { + rule: NO_READY_PROVIDER_RULE.to_string(), + severity: Severity::Error, + message: "no default model is available: no LLM provider is ready, and the \ + workflow has a node that runs a model" + .to_string(), + fix: Some( + "configure a provider credential (for example `OPENAI_API_KEY`) or a \ + `[run.model]`" + .to_string(), + ), + ..FabroDiagnostic::default() + }); + } + Ok(Checked { + admitted: Some(admitted), + diagnostics, + }) + } + Err(CheckError::Rejected(diagnostics)) => Ok(Checked { + admitted: None, + diagnostics: diagnostics.iter().map(fabro_diagnostic).collect(), + }), + Err(other) => Err(WorkflowError::engine_with_source( + "Petri could not check the workflow", + other, + )), + } +} + +/// Petri's diagnostic in Fabro's shape: the code is the rule, the hint is +/// the fix, the bundle-relative file and position are the source location. +fn fabro_diagnostic(diagnostic: &Diagnostic) -> FabroDiagnostic { + FabroDiagnostic { + rule: diagnostic.code.clone(), + severity: if diagnostic.is_error() { + Severity::Error + } else { + Severity::Warning + }, + message: diagnostic.message.clone(), + fix: diagnostic.hint.clone(), + source_path: Some(diagnostic.file.clone()), + line: diagnostic.line, + column: diagnostic.column, + ..FabroDiagnostic::default() + } +} diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs index bae1e2c80..1abbd42f2 100644 --- a/lib/apps/fabro-server/src/run_compiler.rs +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -407,7 +407,6 @@ pub(crate) async fn compile_admitted( cwd, workflow_path: Some(entrypoint), workflow_bundle: Some(workflow_bundle), - configured_providers: Vec::new(), })?; Ok(PinnedRun { materialized: operations::materialize_admitted_run(compiled), diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index ef19d22a5..032662fc9 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::future::Future; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -14,14 +14,17 @@ use fabro_config::{ use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; -use fabro_llm::FabroClient; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::probe::{self, ModelTestStatus}; +use fabro_llm::{FabroClient, selection}; +use fabro_petri::check::Launch; +use fabro_petri::runtime::RuntimeSpec; use fabro_proc::ProcessError; use fabro_sandbox::{ CloneRequest, ProviderAccess, RunSandbox, SandboxSpec, sandbox_spec_for_environment, }; use fabro_static::EnvVars; +use fabro_types::diagnostic::{Diagnostic, Severity}; use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{McpServerSettings, RunGoal, RunNamespace}; @@ -29,13 +32,9 @@ use fabro_types::{ BundledProvider, ManifestPath, RunId, SandboxProviderKind, ServerSettings, WorkflowSettings, }; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; -use fabro_validate::Severity; use fabro_workflow::Error as WorkflowError; -use fabro_workflow::operations::{ - ValidateInput, WorkflowInput, validate, validate_with_catalog, validate_with_ready_providers, -}; +use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use fabro_workflow::pipeline::Validated; -use fabro_workflow::run_materialization::materialize_run_with_ready_providers; use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle}; use futures_util::stream::{self, StreamExt}; use lithos_llm::catalog::ProviderId; @@ -44,8 +43,8 @@ use tokio::process::Command; use tokio::time; use tokio_util::sync::CancellationToken; -use crate::run_compiler; use crate::server::AppState; +use crate::{petri_check, run_compiler}; #[derive(Clone)] pub(crate) struct PreparedManifest { @@ -54,6 +53,7 @@ pub(crate) struct PreparedManifest { pub root_source: String, pub settings: WorkflowSettings, pub target_path: ManifestPath, + pub workflow_bundle: WorkflowBundle, pub workflow_input: BundledWorkflow, pub source_directory: PathBuf, } @@ -168,43 +168,87 @@ pub(crate) fn prepare_manifest_with_environment_defaults( root_source, settings, target_path, + workflow_bundle, workflow_input, source_directory, }) } -pub(crate) fn validate_prepared_manifest( - prepared: &PreparedManifest, - catalog: Arc, -) -> Result { - validate_prepared_manifest_with_vars(prepared, catalog, HashMap::new()) -} - +/// Fabro's own structural pass: parse and transform the root workflow, with +/// the transforms' diagnostics. Enough to render a graph. pub(crate) fn validate_prepared_manifest_structural( prepared: &PreparedManifest, ) -> Result { validate(manifest_validate_input(prepared, HashMap::new())) } -pub(crate) fn validate_prepared_manifest_with_vars( +/// The structural pass, then Petri's check of the whole bundle under +/// `launch` and `runtime`, its diagnostics after the transforms' own. +/// `has_ready_provider` false adds Fabro's refusal of a model node no +/// provider can run; `unbound_is_warning` keeps an input nothing binds a +/// warning, for a validation before the run's inputs exist. Blocking: +/// Petri lowers the graph synchronously. +pub(crate) fn validate_prepared_manifest( prepared: &PreparedManifest, - catalog: Arc, - vars: HashMap, + vars: &HashMap, + launch: Launch, + runtime: RuntimeSpec, + has_ready_provider: bool, + unbound_is_warning: bool, ) -> Result { - validate_with_catalog(manifest_validate_input(prepared, vars), catalog) + let mut validated = validate(manifest_validate_input(prepared, vars.clone()))?; + let request = petri_check::check_request( + &prepared.workflow_bundle, + &prepared.target_path, + &prepared.settings, + vars, + launch, + runtime, + unbound_is_warning, + )?; + let checked = petri_check::check(&request, has_ready_provider)?; + validated.extend_diagnostics(checked.diagnostics); + Ok(validated) } -pub(crate) fn validate_prepared_manifest_for_preflight( - prepared: &PreparedManifest, - catalog: Arc, - vars: HashMap, +/// The model and provider the run's LLM nodes default to: what the settings +/// or the graph name, resolved against the ready providers first and the +/// whole catalog after, as the run's launch binds them. +fn preflight_model( + catalog: &Catalog, + graph: &Graph, + settings: &WorkflowSettings, ready_providers: &[ProviderId], -) -> Result { - validate_with_ready_providers( - manifest_validate_input(prepared, vars), +) -> Result<(String, ProviderId)> { + let graph_attr = |name: &str| { + graph + .attrs + .get(name) + .and_then(|value| value.as_str()) + .map(str::to_string) + }; + let model = settings + .run + .model + .name + .clone() + .or_else(|| graph_attr("default_model")); + let provider = settings + .run + .model + .provider + .clone() + .or_else(|| graph_attr("default_provider")) + .filter(|provider| !provider.is_empty()) + .map(ProviderId::new); + let eligible = ready_providers.iter().cloned().collect(); + let selected = selection::resolve_selection_with_catalog_fallback( catalog, - ready_providers, - ) + model.as_deref(), + provider.as_ref(), + &eligible, + )?; + Ok((selected.model, selected.provider)) } fn manifest_validate_input( @@ -420,19 +464,15 @@ async fn build_preflight_report( .as_ref() .map(FabroClient::provider_ids) .unwrap_or_default(); - let materialized = materialize_run_with_ready_providers( - prepared.settings.clone(), - graph, + let (run_model, run_provider) = preflight_model( catalog.as_ref(), + graph, + &prepared.settings, &ready_providers, )?; - let resolved_run = materialized.run; - let (Some(run_model), Some(run_provider)) = ( - resolved_run.model.name.as_deref(), - resolved_run.model.provider.as_deref(), - ) else { - bail!("materialized run is missing a resolved model or provider"); - }; + let run_provider = run_provider.into_string(); + let (run_model, run_provider) = (run_model.as_str(), run_provider.as_str()); + let resolved_run = prepared.settings.run.clone(); let server_settings = state.server_settings(); let github_integration = &server_settings.server.integrations.github; let sandbox_provider = effective_sandbox_provider(&resolved_run); @@ -999,6 +1039,16 @@ async fn run_llm_check( ) -> bool { let mut model_providers = std::collections::BTreeSet::new(); let mut has_llm_nodes = false; + // Each node's selector resolves against the ready providers first and + // the whole catalog after, as the run's launch binds it: an alias + // becomes the catalog model on the provider that offers it. A selector + // the catalog cannot place stays as written; the checks below say why. + let eligible = llm_result + .as_ref() + .map(FabroClient::provider_ids) + .unwrap_or_default() + .into_iter() + .collect::>(); for node in graph.nodes.values() { if !is_llm_handler_type(node.handler_type()) { @@ -1007,7 +1057,23 @@ async fn run_llm_check( has_llm_nodes = true; let node_model = node.model().unwrap_or(model); let node_provider = node.provider().unwrap_or(default_provider); - model_providers.insert((node_model.to_string(), node_provider.to_string())); + // Only a provider the node names itself constrains the selection: + // an unqualified alias goes to whichever eligible provider offers it. + let provider = node + .provider() + .filter(|provider| !provider.is_empty()) + .map(ProviderId::new); + let resolved = selection::resolve_selection_with_catalog_fallback( + catalog, + Some(node_model), + provider.as_ref(), + &eligible, + ) + .map_or_else( + |_| (node_model.to_string(), node_provider.to_string()), + |selected| (selected.model, selected.provider.into_string()), + ); + model_providers.insert(resolved); } if !has_llm_nodes { @@ -1508,9 +1574,7 @@ pub(crate) fn workflow_summary( } } -fn diagnostics_to_api( - diagnostics: &[fabro_validate::Diagnostic], -) -> Vec { +fn diagnostics_to_api(diagnostics: &[Diagnostic]) -> Vec { diagnostics .iter() .map(|diagnostic| types::WorkflowDiagnostic { @@ -1589,11 +1653,44 @@ fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport { #[cfg(test)] mod tests { - use fabro_workflow::run_materialization::materialize_run; use lithos_llm::catalog::ProviderId; use super::*; + /// Validate as the endpoints do: the structural pass, then Petri's check + /// with the state's model client over `ready_providers`. + fn validate_for_test( + state: &AppState, + prepared: &PreparedManifest, + ready_providers: &[ProviderId], + ) -> Result { + let launch = + petri_check::launch(&state.catalog(), &prepared.settings, ready_providers, None); + let runtime = crate::server::petri_runs::runtime_spec(state, ready_providers, false); + validate_prepared_manifest( + prepared, + &HashMap::new(), + launch, + runtime, + !ready_providers.is_empty(), + false, + ) + } + + /// Validate with every provider of the state's catalog eligible, as the + /// legacy catalog validation judged a manifest. + fn validate_with_catalog( + state: &AppState, + prepared: &PreparedManifest, + ) -> Result { + let providers = state + .catalog() + .enabled_provider_ids() + .into_iter() + .collect::>(); + validate_for_test(state, prepared, &providers) + } + #[tokio::test] async fn ls_remote_capture_keeps_diagnostic_precedence_and_unlimited_output() { for (script, expected) in [ @@ -1706,10 +1803,6 @@ mod tests { ) } - fn test_catalog() -> Arc { - Arc::new(fabro_llm::test_support::test_catalog()) - } - fn openai_compatible_completion(model: &str) -> serde_json::Value { serde_json::json!({ "id": "chatcmpl_preflight", @@ -1780,13 +1873,42 @@ digraph Demo {{ &manifest, ) .unwrap(); - let validated = validate_prepared_manifest_for_preflight( - &prepared, - state.catalog(), - HashMap::new(), - &ready_providers, + let validated = validate_for_test(state, &prepared, &ready_providers).unwrap(); + assert!(!validated.has_errors(), "{:?}", validated.diagnostics()); + + run_preflight(state.as_ref(), &prepared, &validated, llm_result) + .await + .unwrap() + } + + /// Preflight for a workflow naming `model`, which Petri refuses. + async fn preflight_for_refused_model( + state: &Arc, + model: &str, + ) -> (types::PreflightResponse, bool) { + let llm_result = state.resolve_llm_client().await; + let ready_providers = llm_result + .as_ref() + .map(FabroClient::provider_ids) + .unwrap_or_default(); + let mut manifest = minimal_manifest(); + manifest.workflows.get_mut("workflow.fabro").unwrap().source = format!( + r#" +digraph Demo {{ + start [shape=Mdiamond] + exit [shape=Msquare] + work [prompt="Do work", model="{model}"] + start -> work -> exit +}} +"# + ); + let prepared = prepare_manifest( + &manifest_run_defaults(Some(&default_settings_fixture())), + &manifest, ) .unwrap(); + let validated = validate_for_test(state, &prepared, &ready_providers).unwrap(); + assert!(validated.has_errors(), "{:?}", validated.diagnostics()); run_preflight(state.as_ref(), &prepared, &validated, llm_result) .await @@ -1866,15 +1988,7 @@ enabled = {clone_enabled} &manifest, ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); - let resolved = materialize_run( - prepared.settings.clone(), - validated.graph(), - test_catalog().as_ref(), - &[lithos_llm::catalog::builtin::anthropic()], - ) - .unwrap() - .run; + let resolved = prepared.settings.run.clone(); (prepared, resolved) } @@ -2415,7 +2529,7 @@ name = "Control Plane" &invalid_manifest(), ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); + let validated = validate_with_catalog(&state, &prepared).unwrap(); assert!(validated.has_errors()); @@ -2446,6 +2560,9 @@ name = "Control Plane" path: "workflow.toml".to_string(), source: r#"_version = 1 +[environments.local] +provider = "local" + [run.environment] id = "local" @@ -2460,8 +2577,8 @@ issues = "read" &manifest, ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); - assert!(!validated.has_errors()); + let validated = validate_with_catalog(&state, &prepared).unwrap(); + assert!(!validated.has_errors(), "{:?}", validated.diagnostics()); let (response, _ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated) .await @@ -2491,6 +2608,9 @@ issues = "read" path: "workflow.toml".to_string(), source: r#"_version = 1 +[environments.local] +provider = "local" + [run.environment] id = "local" @@ -2505,8 +2625,8 @@ issues = "{{ env.GITHUB_ISSUES_PERMISSION }}" &manifest, ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); - assert!(!validated.has_errors()); + let validated = validate_with_catalog(&state, &prepared).unwrap(); + assert!(!validated.has_errors(), "{:?}", validated.diagnostics()); let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated) .await @@ -2559,9 +2679,9 @@ id = "local" &manifest, ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); + let validated = validate_with_catalog(&state, &prepared).unwrap(); - assert!(!validated.has_errors()); + assert!(!validated.has_errors(), "{:?}", validated.diagnostics()); let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated) .await @@ -2668,7 +2788,7 @@ id = "daytona" &manifest, ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); + let validated = validate_with_catalog(&state, &prepared).unwrap(); let (response, _ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated) .await @@ -2736,7 +2856,7 @@ digraph Demo { &manifest, ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); + let validated = validate_with_catalog(&state, &prepared).unwrap(); let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated) .await @@ -2781,7 +2901,12 @@ digraph Demo { .checks .iter() .find(|check| check.name == "LLM" && check.summary == "claude-fable-5") - .expect("preflight should include Claude Fable"); + .unwrap_or_else(|| { + panic!( + "preflight should include Claude Fable: {:?}", + response.checks.sections + ) + }); assert_eq!( llm_check .details @@ -2810,31 +2935,28 @@ digraph Demo { .await; let state = ready_moonshot_and_openrouter_state(&server); - let (response, _ok) = preflight_for_model(&state, "provider-private-preview").await; + // Petri admits no model its catalog lacks, so the workflow is refused + // before any probe runs: no passthrough of an unknown model. + let (response, ok) = preflight_for_refused_model(&state, "provider-private-preview").await; + assert!(!ok); assert!(response.workflow.diagnostics.iter().any(|diagnostic| { - diagnostic.rule == "node_model_known" + diagnostic.rule == "attractor.model.unknown" && diagnostic.message.contains("provider-private-preview") })); - let llm_check = response.checks.sections[0] - .checks - .iter() - .find(|check| check.name == "LLM" && check.summary == "provider-private-preview") - .expect("preflight should include the unknown passthrough model"); - assert_eq!( - llm_check - .details + assert!( + response.checks.sections[0] + .checks .iter() - .map(|detail| detail.text.as_str()) - .find(|detail| detail.starts_with("Provider: ")), - Some("Provider: moonshot") + .all(|check| check.name != "LLM"), + "no LLM check runs on a refused workflow" ); - assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Pass); - moonshot_probe.assert_async().await; + assert_eq!(moonshot_probe.calls_async().await, 0); } #[test] fn static_validation_rejects_unknown_llm_provider() { + let state = crate::test_support::test_app_state(); let mut manifest = minimal_manifest(); manifest.workflows.get_mut("workflow.fabro").unwrap().source = r#" digraph Demo { @@ -2850,16 +2972,16 @@ digraph Demo { &manifest, ) .unwrap(); - let Err(error) = validate_prepared_manifest(&prepared, test_catalog()) else { - panic!("unknown provider should fail static validation"); - }; + let validated = validate_with_catalog(&state, &prepared).unwrap(); - assert!(matches!( - error, - WorkflowError::ModelSelection(fabro_llm::ModelSelectionError::UnknownProvider { - provider - }) if provider.as_str() == "missing-provider" - )); + // Petri refuses the model node: the provider is not in the catalog. + let refusal = validated + .diagnostics() + .iter() + .find(|diagnostic| diagnostic.severity == Severity::Error) + .expect("unknown provider should fail static validation"); + assert_eq!(refusal.rule, "attractor.model.unknown", "{refusal:?}"); + assert!(refusal.message.contains("missing-provider"), "{refusal:?}"); } #[tokio::test] @@ -2908,34 +3030,33 @@ digraph Demo { .map(FabroClient::provider_ids) .unwrap_or_default(); assert!(ready_providers.is_empty()); - let validated = validate_prepared_manifest_for_preflight( - &prepared, - state.catalog(), - HashMap::new(), - &ready_providers, - ) - .unwrap(); + + // With no provider ready there is no model client to resolve the + // alias against, so Fabro refuses the model node before any check + // runs: the run could not pick a model at create either. + let validated = validate_for_test(&state, &prepared, &ready_providers).unwrap(); + assert!(validated.has_errors()); + assert!( + validated + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.rule == petri_check::NO_READY_PROVIDER_RULE), + "{:?}", + validated.diagnostics() + ); let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated, llm_result) .await .unwrap(); - assert!(!ok); - let llm_check = response.checks.sections[0] - .checks - .iter() - .find(|check| check.name == "LLM" && check.summary == "acme-large") - .expect("preflight should resolve the catalog alias"); - assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Warning); - assert_eq!( - llm_check.remediation.as_deref(), - Some("Provider \"acme\" is not configured") - ); assert!( - llm_check - .details + response + .checks + .sections .iter() - .any(|detail| detail.text == "Provider: acme") + .flat_map(|section| section.checks.iter()) + .all(|check| check.name != "LLM"), + "no LLM check runs on a refused workflow" ); } diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index d39a05af3..5ea62daba 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -160,7 +160,7 @@ use crate::{ mod automation_scheduler; mod handler; -mod petri_runs; +pub(crate) mod petri_runs; mod pull_request_supervisor; pub(crate) mod resource_sampler; mod session_runtime; diff --git a/lib/apps/fabro-server/src/server/handler/graph.rs b/lib/apps/fabro-server/src/server/handler/graph.rs index 17e8b903b..f645f2ce0 100644 --- a/lib/apps/fabro-server/src/server/handler/graph.rs +++ b/lib/apps/fabro-server/src/server/handler/graph.rs @@ -52,7 +52,7 @@ async fn render_graph_from_manifest( Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; - let validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) { + let validated = match run_manifest::validate_prepared_manifest_structural(&prepared) { Ok(validated) => validated, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 85818d4c8..763c87cf9 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -26,6 +26,8 @@ use fabro_static::EnvVars; use fabro_store::{ RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryVisibility, }; +use fabro_types::diagnostic::Severity; +use fabro_types::settings::run::RunMode; use fabro_types::{ AutomationRef, ContextWindowStaleness, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, RunStatusKind, RunTarget, SandboxProviderKind, @@ -35,12 +37,13 @@ use fabro_types::{ }; use fabro_util::error as error_util; use fabro_util::version::FABRO_VERSION; +use fabro_workflow::pipeline::Validated; use fabro_workflow::run_status::RunStatus; use fabro_workflow::{Error as WorkflowError, operations}; use lithos_llm::catalog::ProviderId; use serde::de::IgnoredAny; use strum::VariantArray as _; -use tokio::fs; +use tokio::{fs, task}; use tracing::info; use super::super::{ @@ -61,11 +64,11 @@ use crate::run_intent::{ EnvironmentSelectionError, PreparedIntentTarget, RunIntentAdmissionError, lower_workflow_closure, pin_workflow_environment_authority, prepare_intent_target, }; -use crate::run_manifest; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary}; #[cfg(any(test, feature = "test-support"))] use crate::test_support as server_test_support; +use crate::{petri_check, run_manifest}; pub(super) fn manifest_routes() -> Router> { Router::new() @@ -989,7 +992,7 @@ fn compiler_error_detail(error: &run_compiler::RunCompilerError) -> String { }; let listed = diagnostics .iter() - .filter(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + .filter(|diagnostic| diagnostic.severity == Severity::Error) .map(|diagnostic| format!("{}: {}", diagnostic.rule, diagnostic.message)) .collect::>(); if listed.is_empty() { @@ -1228,18 +1231,14 @@ async fn run_preflight( .into_response(); } let (llm_result, ready_providers) = state.resolve_llm_client_with_ready_ids().await; - let mut validated = match run_manifest::validate_prepared_manifest_for_preflight( - &prepared, - state.catalog(), - vars, - &ready_providers, - ) { - Ok(validated) => validated, - Err(WorkflowError::Parse(_)) => { - return ApiError::bad_request("Validation failed").into_response(); - } - Err(err) => return ApiError::bad_request(err.to_string()).into_response(), - }; + let mut validated = + match validate_manifest_on_petri(&state, &prepared, vars, &ready_providers).await { + Ok(validated) => validated, + Err(WorkflowError::Parse(_)) => { + return ApiError::bad_request("Validation failed").into_response(); + } + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; validated.promote_template_undefined_variables_to_errors(); let response = match run_manifest::run_preflight(&state, &prepared, &validated, llm_result).await { @@ -1280,17 +1279,15 @@ async fn validate_run_manifest( return ApiError::bad_request(format!("Run config variable interpolation failed: {err}")) .into_response(); } - let validated = match run_manifest::validate_prepared_manifest_with_vars( - &prepared, - state.catalog(), - vars, - ) { - Ok(validated) => validated, - Err(WorkflowError::Parse(_)) => { - return ApiError::bad_request("Validation failed").into_response(); - } - Err(err) => return ApiError::bad_request(err.to_string()).into_response(), - }; + let (_, ready_providers) = state.resolve_llm_client_with_ready_ids().await; + let validated = + match validate_manifest_on_petri(&state, &prepared, vars, &ready_providers).await { + Ok(validated) => validated, + Err(WorkflowError::Parse(_)) => { + return ApiError::bad_request("Validation failed").into_response(); + } + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; ( StatusCode::OK, Json(run_manifest::validate_response(&prepared, &validated)), @@ -1298,6 +1295,34 @@ async fn validate_run_manifest( .into_response() } +/// Validate a prepared manifest as a run would be admitted: Fabro's +/// structural pass, then Petri's check with the model client over the ready +/// providers, on the blocking pool. +async fn validate_manifest_on_petri( + state: &Arc, + prepared: &run_manifest::PreparedManifest, + vars: HashMap, + ready_providers: &[ProviderId], +) -> Result { + let launch = petri_check::launch(&state.catalog(), &prepared.settings, ready_providers, None); + let dry_run = prepared.settings.run.execution.mode == RunMode::DryRun; + let runtime = petri_runs::runtime_spec(state, ready_providers, dry_run); + let has_ready_provider = !ready_providers.is_empty(); + let prepared = prepared.clone(); + task::spawn_blocking(move || { + run_manifest::validate_prepared_manifest( + &prepared, + &vars, + launch, + runtime, + has_ready_provider, + false, + ) + }) + .await + .map_err(|source| WorkflowError::engine_with_source("manifest check task failed", source))? +} + async fn snapshot_run_variables( state: &AppState, ) -> Result, VariableError> { diff --git a/lib/apps/fabro-server/src/server/petri_runs.rs b/lib/apps/fabro-server/src/server/petri_runs.rs index a52a0651e..a9364e7ea 100644 --- a/lib/apps/fabro-server/src/server/petri_runs.rs +++ b/lib/apps/fabro-server/src/server/petri_runs.rs @@ -28,14 +28,11 @@ //! workspace to the snapshot its durable state names, or reports the run //! failed when it cannot. -use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; use std::time::Instant; use fabro_config::{Home, SettingsLayer, Storage}; use fabro_interview::ControlInterviewer; -use fabro_llm::selection; -use fabro_petri::check::{self, Bundle, CheckError, CheckRequest, Diagnostic, Launch}; use fabro_petri::controls::RunControls; use fabro_petri::engine::{self, Conclusion, Execution, RunRequest}; use fabro_petri::hooks::HooksSpec; @@ -49,7 +46,6 @@ use fabro_petri::{SqliteRunStore, admission}; use fabro_types::settings::run::{ApprovalMode, RunMode}; use fabro_types::{PetriAdmission, RunId, RunRunnableSource, RunTarget, RunTiming, StageOutcome}; use fabro_util::error as error_util; -use fabro_validate::{Diagnostic as FabroDiagnostic, Severity}; use fabro_workflow::Error as WorkflowError; use fabro_workflow::run_status::{FailureReason, RunStatus, SuccessReason}; use lithos_llm::catalog::ProviderId; @@ -58,6 +54,7 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use super::{AppState, RunAnswerTransport, RunExecutionMode, clear_live_run_state, workflow_event}; +use crate::petri_check; use crate::petri_runs::PetriRuns; use crate::run_compiler::{PreparedRun, RunCompilerError}; @@ -114,74 +111,31 @@ fn settings_layer_toml(state: &AppState) -> Option { /// Petri compiles the run: check the bundle, map the diagnostics, and /// persist the admitted graphs. A refusal is the same validation error the -/// legacy compiler raises, carrying Petri's diagnostics. +/// legacy compiler raised, carrying Petri's diagnostics. pub(crate) async fn admit( state: &AppState, prepared: &PreparedRun, eligible: &[ProviderId], ) -> Result { let settings = prepared.settings(); - let mut files = BTreeMap::new(); - for workflow in prepared.workflow_bundle().workflows().values() { - for (path, text) in &workflow.files { - files.insert(path.to_string(), text.clone()); - } - files.insert(workflow.path.to_string(), workflow.source.clone()); - if let Some(config) = &workflow.config { - files.insert(config.path.to_string(), config.source.clone()); - } - } - let mut inputs = BTreeMap::new(); - for (name, value) in &settings.run.inputs { - let value = serde_json::to_value(value).map_err(|err| { - RunCompilerError::Workflow(WorkflowError::engine_with_source( - format!("run input `{name}` does not encode as JSON"), - err, - )) - })?; - inputs.insert(name.clone(), value); - } - // The launch: Fabro's resolved model and provider. When the settings - // name neither, the default offering of the eligible providers, as the - // legacy compiler picked it, is bound as the launch model alone: a node - // that names no model runs on it, and a node that names a model the - // catalog lacks stays unqualified, so Petri's admission refuses it. - let catalog = state.catalog(); - let model = settings.run.model.name.clone().or_else(|| { - if settings.run.model.provider.is_some() { - return None; - } - let eligible = eligible.iter().cloned().collect::>(); - selection::select_default(&catalog, &eligible) - .ok() - .map(|offering| offering.model.id().to_string()) - }); - let provider = settings.run.model.provider.clone(); let repository = match prepared.target() { Some(RunTarget::Folder { path }) => Some(path.into()), Some(RunTarget::Git(_) | RunTarget::None {}) | None => None, }; + let launch = petri_check::launch(&state.catalog(), settings, eligible, repository); let dry_run = settings.run.execution.mode == RunMode::DryRun; - let request = CheckRequest { - bundle: Bundle { - files, - entrypoint: prepared.entrypoint().to_string(), - project_toml: None, - }, - inputs, - vars: prepared - .vars() - .iter() - .map(|(name, value)| (name.clone(), value.clone())) - .collect(), - launch: Launch { - model, - provider, - repository, - }, - runtime: runtime_spec(state, eligible, dry_run), - }; - let admitted = task::spawn_blocking(move || check::check(&request)) + let request = petri_check::check_request( + prepared.workflow_bundle(), + prepared.entrypoint(), + settings, + prepared.vars(), + launch, + runtime_spec(state, eligible, dry_run), + false, + ) + .map_err(RunCompilerError::Workflow)?; + let has_ready_provider = !eligible.is_empty(); + let checked = task::spawn_blocking(move || petri_check::check(&request, has_ready_provider)) .await .map_err(|source| { RunCompilerError::Workflow(WorkflowError::engine_with_source( @@ -189,42 +143,22 @@ pub(crate) async fn admit( source, )) })? - .map_err(|err| match err { - CheckError::Rejected(diagnostics) => { - RunCompilerError::Workflow(WorkflowError::ValidationFailed { - diagnostics: diagnostics.iter().map(fabro_diagnostic).collect(), - }) - } - other => RunCompilerError::Workflow(WorkflowError::engine_with_source( - "Petri could not check the workflow", - other, - )), - })?; - for warning in &admitted.warnings { - info!(code = %warning.code, message = %warning.message, "Petri warned at admission"); - } - // Without a ready provider there is no model client, so Petri admitted - // the model nodes unchecked: refuse a run they would fail at once, as - // the legacy compiler refused every run without a default model. - if eligible.is_empty() && admitted.needs_model() { + .map_err(RunCompilerError::Workflow)?; + if checked.has_errors() { return Err(RunCompilerError::Workflow( WorkflowError::ValidationFailed { - diagnostics: vec![FabroDiagnostic { - rule: "fabro.model.no_ready_provider".to_string(), - severity: Severity::Error, - message: "no default model is available: no LLM provider is ready, and the \ - workflow has a node that runs a model" - .to_string(), - fix: Some( - "configure a provider credential (for example `OPENAI_API_KEY`) or a \ - `[run.model]`" - .to_string(), - ), - ..FabroDiagnostic::default() - }], + diagnostics: checked.diagnostics, }, )); } + for warning in &checked.diagnostics { + info!(code = %warning.rule, message = %warning.message, "Petri warned at admission"); + } + let admitted = checked.admitted.ok_or_else(|| { + RunCompilerError::Workflow(WorkflowError::engine( + "Petri's check admitted no graph and raised no error", + )) + })?; admission::persist(&state.store_ref().blobs(), &admitted) .await .map_err(|err| { @@ -235,25 +169,6 @@ pub(crate) async fn admit( }) } -/// Petri's diagnostic in Fabro's shape: the code is the rule, the hint is -/// the fix, the bundle-relative file and position are the source location. -fn fabro_diagnostic(diagnostic: &Diagnostic) -> FabroDiagnostic { - FabroDiagnostic { - rule: diagnostic.code.clone(), - severity: if diagnostic.is_error() { - Severity::Error - } else { - Severity::Warning - }, - message: diagnostic.message.clone(), - fix: diagnostic.hint.clone(), - source_path: Some(diagnostic.file.clone()), - line: diagnostic.line, - column: diagnostic.column, - ..FabroDiagnostic::default() - } -} - /// Execute a Petri run in the server process, under the test override: /// runnable → starting → running → succeeded or failed, with the lifecycle /// events Fabro's read side needs. Outside tests a Petri run executes in diff --git a/lib/components/fabro-acp/Cargo.toml b/lib/components/fabro-acp/Cargo.toml deleted file mode 100644 index 490b87004..000000000 --- a/lib/components/fabro-acp/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -[package] -name = "fabro-acp" -edition.workspace = true -version.workspace = true -publish = false -license.workspace = true -description = "Agent Client Protocol backend support for Fabro" - -[features] -default = ["runtime"] -runtime = [ - "dep:fabro-sandbox", - "dep:fabro-types", - "dep:futures", - "dep:pebble-coding-agent", - "dep:tokio", - "dep:tokio-util", - "dep:tracing", -] -test-support = [] - -[lib] -doctest = false - -[lints] -workspace = true - -[dependencies] -agent-client-protocol.workspace = true -agent-client-protocol-tokio.workspace = true -fabro-sandbox = { path = "../fabro-sandbox", optional = true } -fabro-types = { path = "../../foundation/fabro-types", optional = true } -fabro-util = { path = "../../foundation/fabro-util" } -pebble-coding-agent = { workspace = true, optional = true } -serde_json.workspace = true -shlex = "1" -thiserror.workspace = true -tokio = { workspace = true, optional = true } -tokio-util = { workspace = true, features = ["compat", "io"], optional = true } -futures = { workspace = true, optional = true } -tracing = { workspace = true, optional = true } - -[dev-dependencies] -fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } -tempfile = "3" diff --git a/lib/components/fabro-acp/src/command.rs b/lib/components/fabro-acp/src/command.rs deleted file mode 100644 index 8e9d335dd..000000000 --- a/lib/components/fabro-acp/src/command.rs +++ /dev/null @@ -1,272 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use agent_client_protocol::schema::{McpServer, McpServerStdio}; -use agent_client_protocol_tokio::AcpAgent; -use fabro_util::shell::shell_join; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AcpProcessSpec { - name: Option, - program: PathBuf, - args: Vec, - env: HashMap, -} - -impl AcpProcessSpec { - pub fn from_attrs( - legacy_command: Option<&str>, - command: Option<&str>, - config: Option<&str>, - ) -> Result { - if legacy_command.is_some() { - return Err(AcpCommandError::LegacyCommandAttribute); - } - - match (command, config) { - (Some(command), None) => Self::from_command_attr(command), - (None, Some(config)) => Self::from_config_attr(config), - (None, None) | (Some(_), Some(_)) => Err(AcpCommandError::MissingOverride), - } - } - - pub fn from_command_attr(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(AcpCommandError::EmptyOverride); - } - - let parts = shlex::split(trimmed).ok_or(AcpCommandError::InvalidCommandString)?; - let agent = - AcpAgent::from_args(parts).map_err(|_| AcpCommandError::InvalidCommandString)?; - let mut spec = Self::from_server(agent.into_server())?; - spec.name = None; - Ok(spec) - } - - pub fn from_config_attr(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(AcpCommandError::EmptyOverride); - } - - let server = parse_config_server(trimmed)?; - Self::from_server(server) - } - - fn from_server(server: McpServer) -> Result { - match server { - McpServer::Stdio(stdio) => Self::from_stdio_config(stdio), - _ => Err(AcpCommandError::UnsupportedTransport), - } - } - - fn from_stdio_config(stdio: McpServerStdio) -> Result { - if stdio.command.as_os_str().is_empty() { - return Err(AcpCommandError::InvalidConfigShape("missing command")); - } - - let env = stdio - .env - .into_iter() - .map(|env| (env.name, env.value)) - .collect(); - Ok(Self::from_stdio_parts( - Some(stdio.name), - stdio.command, - stdio.args, - env, - )) - } - - fn from_stdio_parts( - name: Option, - program: PathBuf, - args: Vec, - env: HashMap, - ) -> Self { - Self { - name, - program, - args, - env, - } - } - - #[must_use] - pub fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - #[must_use] - pub fn program(&self) -> &Path { - &self.program - } - - #[must_use] - pub fn args(&self) -> &[String] { - &self.args - } - - #[must_use] - pub fn env(&self) -> &HashMap { - &self.env - } - - #[must_use] - pub fn to_shell_command(&self) -> String { - render_command(&self.program, &self.args) - } -} - -impl std::fmt::Display for AcpProcessSpec { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.to_shell_command()) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum AcpCommandError { - #[error("acp_command is no longer supported; use acp.command or acp.config")] - LegacyCommandAttribute, - #[error("ACP process attribute must not be empty")] - EmptyOverride, - #[error("backend=\"acp\" requires exactly one of acp.command or acp.config")] - MissingOverride, - #[error("only stdio ACP commands are supported")] - UnsupportedTransport, - #[error("failed to parse acp.command as a shell command")] - InvalidCommandString, - #[error("failed to parse acp.config as JSON")] - InvalidConfigJson(#[source] serde_json::Error), - #[error("invalid acp.config shape: {0}")] - InvalidConfigShape(&'static str), -} - -fn render_command(program: &Path, args: &[String]) -> String { - shell_join(std::iter::once(program.to_string_lossy().into_owned()).chain(args.iter().cloned())) -} - -fn parse_config_server(raw: &str) -> Result { - let mut value: serde_json::Value = - serde_json::from_str(raw).map_err(AcpCommandError::InvalidConfigJson)?; - - match value.get("type").and_then(serde_json::Value::as_str) { - Some("stdio") | None => {} - Some(_) => return Err(AcpCommandError::UnsupportedTransport), - } - - if let Some(object) = value.as_object_mut() { - object - .entry("args".to_string()) - .or_insert_with(|| serde_json::Value::Array(Vec::new())); - object - .entry("env".to_string()) - .or_insert_with(|| serde_json::Value::Array(Vec::new())); - } - - serde_json::from_value(value).map_err(AcpCommandError::InvalidConfigJson) -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::*; - - #[test] - fn command_attr_parses_shell_command() { - let command = AcpProcessSpec::from_command_attr("python fake_agent.py").unwrap(); - assert_eq!(command.to_string(), "python fake_agent.py"); - assert_eq!(command.name(), None); - assert_eq!(command.program(), Path::new("python")); - assert_eq!(command.args(), &["fake_agent.py".to_string()]); - } - - #[test] - fn command_attr_parses_leading_env_assignments() { - let command = AcpProcessSpec::from_command_attr( - "RUST_LOG=debug TOKEN='secret value' python fake_agent.py", - ) - .unwrap(); - - assert_eq!(command.to_string(), "python fake_agent.py"); - assert_eq!(command.program(), Path::new("python")); - assert_eq!( - command.env().get("RUST_LOG").map(String::as_str), - Some("debug") - ); - assert_eq!( - command.env().get("TOKEN").map(String::as_str), - Some("secret value") - ); - } - - #[test] - fn blank_acp_process_attr_is_rejected() { - let err = AcpProcessSpec::from_command_attr(" ").unwrap_err(); - assert!(err.to_string().contains("must not be empty")); - } - - #[test] - fn json_stdio_acp_config_is_supported() { - let raw = r#"{"type":"stdio","name":"fake","command":"python","args":["fake agent.py"],"env":[{"name":"MODE","value":"test"}]}"#; - let command = AcpProcessSpec::from_config_attr(raw).unwrap(); - assert_eq!(command.name(), Some("fake")); - assert_eq!(command.program(), Path::new("python")); - assert_eq!(command.args(), &["fake agent.py".to_string()]); - assert_eq!(command.env().get("MODE").map(String::as_str), Some("test")); - } - - #[test] - fn json_stdio_acp_config_display_omits_env_contents() { - let raw = r#"{"type":"stdio","name":"fake","command":"agent","args":["--flag","two words"],"env":[{"name":"OPENAI_API_KEY","value":"secret-key"}]}"#; - let command = AcpProcessSpec::from_config_attr(raw).unwrap(); - - assert_eq!( - command.env().get("OPENAI_API_KEY").map(String::as_str), - Some("secret-key") - ); - assert_eq!(command.to_string(), "agent --flag 'two words'"); - assert!(!command.to_string().contains("secret-key")); - assert!(!command.to_string().contains("OPENAI_API_KEY")); - } - - #[test] - fn non_stdio_acp_config_is_rejected() { - let raw = r#"{"type":"http","name":"remote","url":"https://example.test/acp"}"#; - let err = AcpProcessSpec::from_config_attr(raw).unwrap_err(); - assert!( - err.to_string() - .contains("only stdio ACP commands are supported") - ); - } - - #[test] - fn command_attr_is_always_shell_command_even_when_json_shaped() { - let command = AcpProcessSpec::from_command_attr(r#"{"type":"stdio"}"#).unwrap(); - - assert_ne!(command.program(), Path::new("stdio")); - assert!(command.args().is_empty()); - } - - #[test] - fn config_attr_requires_json_stdio_config() { - let command = AcpProcessSpec::from_config_attr( - r#"{"type":"stdio","name":"fake","command":"python3","args":["agent.py"]}"#, - ) - .unwrap(); - - assert_eq!(command.name(), Some("fake")); - assert_eq!(command.program(), Path::new("python3")); - assert_eq!(command.args(), &["agent.py".to_string()]); - - assert!(AcpProcessSpec::from_config_attr("python3 agent.py").is_err()); - assert!( - AcpProcessSpec::from_config_attr( - r#"{"type":"http","name":"remote","url":"https://example.test/acp"}"# - ) - .is_err() - ); - } -} diff --git a/lib/components/fabro-acp/src/error.rs b/lib/components/fabro-acp/src/error.rs deleted file mode 100644 index 9e84978a2..000000000 --- a/lib/components/fabro-acp/src/error.rs +++ /dev/null @@ -1,70 +0,0 @@ -use fabro_types::{CommandTermination, ExecOutputTail}; - -use crate::command::AcpCommandError; - -#[derive(Debug)] -pub struct AcpProcessExit { - pub termination: CommandTermination, - pub exit_code: Option, - pub exec_output_tail: Option, -} - -impl std::fmt::Display for AcpProcessExit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let exit_code = self - .exit_code - .map_or_else(|| "unknown".to_string(), |code| code.to_string()); - write!( - f, - "ACP process exited before protocol completed: termination={}, exit_code={exit_code}", - self.termination - ) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum AcpError { - #[error(transparent)] - Command(#[from] AcpCommandError), - - #[error(transparent)] - Sandbox(#[from] fabro_sandbox::Error), - - #[error("ACP protocol error")] - Protocol(#[source] agent_client_protocol::Error), - - #[error("ACP turn was cancelled")] - Cancelled, - - #[error("ACP turn timed out")] - TimedOut { - exec_output_tail: Option, - }, - - #[error("{0}")] - ProcessExited(AcpProcessExit), - - #[error("ACP prompt stopped with {stop_reason}: {text}")] - StopReason { - stop_reason: String, - text: String, - }, -} - -impl AcpError { - #[must_use] - pub fn exec_output_tail(&self) -> Option { - match self { - Self::TimedOut { exec_output_tail } => exec_output_tail.clone(), - Self::ProcessExited(exit) => exit.exec_output_tail.clone(), - Self::Sandbox(source) => source.default_redacted_output_tail(), - _ => None, - } - } -} - -impl From for AcpError { - fn from(error: agent_client_protocol::Error) -> Self { - Self::Protocol(error) - } -} diff --git a/lib/components/fabro-acp/src/lib.rs b/lib/components/fabro-acp/src/lib.rs deleted file mode 100644 index 604dcfc3a..000000000 --- a/lib/components/fabro-acp/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -pub mod command; - -#[cfg(feature = "runtime")] -pub mod error; -#[cfg(feature = "runtime")] -pub mod session; - -#[cfg(any(test, feature = "test-support"))] -pub mod test_support; - -#[cfg(feature = "runtime")] -mod transport; - -pub use command::{AcpCommandError, AcpProcessSpec}; -#[cfg(feature = "runtime")] -pub use error::{AcpError, AcpProcessExit}; -#[cfg(feature = "runtime")] -pub use session::{ - AcpControlHandle, AcpLiveControl, AcpRunRequest, AcpRunResult, render_stop_reason, run_acp_turn, -}; diff --git a/lib/components/fabro-acp/src/session.rs b/lib/components/fabro-acp/src/session.rs deleted file mode 100644 index 91570ff9f..000000000 --- a/lib/components/fabro-acp/src/session.rs +++ /dev/null @@ -1,480 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use agent_client_protocol::schema::{ - CancelNotification, ContentBlock, ContentChunk, InitializeRequest, PermissionOptionKind, - ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, - SelectedPermissionOutcome, SessionNotification, SessionUpdate, StopReason, -}; -use agent_client_protocol::util::MatchDispatch; -use agent_client_protocol::{ActiveSession, Agent, Client, Error as ProtocolError, SessionMessage}; -use fabro_sandbox::RunSandbox; -use fabro_util::time::elapsed_ms; -use pebble_coding_agent::SteeringMessage; -use pebble_coding_agent::events::Actor; -use tokio::sync::Notify; -use tokio::sync::futures::Notified; -use tokio::time::{sleep, timeout}; -use tokio_util::sync::CancellationToken; - -use crate::command::AcpProcessSpec; -use crate::error::AcpError; -use crate::transport::{SandboxAcpTransport, TransportState}; - -pub type AcpNaturalCompletionCallback = Arc bool + Send + Sync>; -pub type AcpSteerPromptCallback = Arc) + Send + Sync>; - -const CANCEL_GRACE_PERIOD: Duration = Duration::from_millis(500); - -#[derive(Default)] -struct AcpControlState { - queue: VecDeque, - waiting_for_steer: bool, - interrupt_requested: bool, -} - -#[derive(Clone, Default)] -pub struct AcpControlHandle { - state: Arc>, - notify: Arc, -} - -impl AcpControlHandle { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - pub fn enqueue_bounded(&self, item: SteeringMessage, cap: usize) -> Option { - self.push_bounded(item, cap, false) - } - - pub fn interrupt(&self) { - { - let mut state = self.state.lock().expect("ACP control lock poisoned"); - if state.queue.is_empty() { - state.waiting_for_steer = true; - } - state.interrupt_requested = true; - } - self.notify.notify_one(); - } - - pub fn interrupt_then_enqueue_bounded( - &self, - item: SteeringMessage, - cap: usize, - ) -> Option { - self.push_bounded(item, cap, true) - } - - fn push_bounded( - &self, - item: SteeringMessage, - cap: usize, - request_interrupt: bool, - ) -> Option { - let evicted = { - let mut state = self.state.lock().expect("ACP control lock poisoned"); - let evicted = if state.queue.len() >= cap { - state.queue.pop_front() - } else { - None - }; - state.waiting_for_steer = false; - if request_interrupt { - state.interrupt_requested = true; - } - state.queue.push_back(item); - evicted - }; - self.notify.notify_one(); - evicted - } - - #[must_use] - pub fn has_pending_control_work(&self) -> bool { - let state = self.state.lock().expect("ACP control lock poisoned"); - !state.queue.is_empty() || state.waiting_for_steer || state.interrupt_requested - } - - #[cfg(test)] - #[must_use] - pub fn queue_len(&self) -> usize { - self.state - .lock() - .expect("ACP control lock poisoned") - .queue - .len() - } - - fn pop_steer(&self) -> Option { - let item = { - let mut state = self.state.lock().expect("ACP control lock poisoned"); - let item = state.queue.pop_front(); - if item.is_some() { - state.waiting_for_steer = false; - } - item - }; - if item.is_some() { - self.notify.notify_one(); - } - item - } - - fn take_interrupt_requested(&self) -> bool { - let mut state = self.state.lock().expect("ACP control lock poisoned"); - let requested = state.interrupt_requested; - state.interrupt_requested = false; - requested - } - - fn should_wait_for_steer(&self) -> bool { - let state = self.state.lock().expect("ACP control lock poisoned"); - state.waiting_for_steer && state.queue.is_empty() - } - - fn notified(&self) -> Notified<'_> { - self.notify.notified() - } -} - -#[derive(Default)] -pub struct AcpLiveControl { - pub handle: AcpControlHandle, - pub on_natural_completion: Option, - pub on_steer_prompt: Option, -} - -impl AcpLiveControl { - #[must_use] - pub fn new(handle: AcpControlHandle) -> Self { - Self { - handle, - on_natural_completion: None, - on_steer_prompt: None, - } - } -} - -pub struct AcpRunRequest { - pub command: AcpProcessSpec, - pub prompt: String, - pub cwd: String, - pub timeout_ms: Option, - pub env: HashMap, - pub sandbox: Arc, - pub cancel_token: CancellationToken, - pub on_activity: Option>, - pub live_control: Option, -} - -#[derive(Debug)] -pub struct AcpRunResult { - pub text: String, - pub stop_reason: StopReason, - pub stderr: String, - pub duration_ms: u64, -} - -pub async fn run_acp_turn(request: AcpRunRequest) -> Result { - let AcpRunRequest { - command, - prompt, - cwd, - timeout_ms, - env, - sandbox, - cancel_token, - on_activity, - live_control, - } = request; - let live_control = live_control.unwrap_or_default(); - let start = std::time::Instant::now(); - let state = TransportState::new(); - let read_cancel_token = cancel_token.clone(); - let run_cancel_token = cancel_token.clone(); - let permission_cancel_token = cancel_token.clone(); - let transport = SandboxAcpTransport::new(command, cwd.clone(), env, sandbox, state.clone()); - - let run = Client - .builder() - .name("fabro") - .on_receive_request( - async move |request: RequestPermissionRequest, responder, _connection| { - let outcome = if permission_cancel_token.is_cancelled() { - RequestPermissionOutcome::Cancelled - } else { - select_permission_outcome(&request) - }; - responder.respond(RequestPermissionResponse::new(outcome)) - }, - agent_client_protocol::on_receive_request!(), - ) - .connect_with(transport, async move |cx| { - cx.send_request(InitializeRequest::new(ProtocolVersion::V1)) - .block_task() - .await?; - - cx.build_session(&cwd) - .block_task() - .run_until(async |mut session| { - session.send_prompt(prompt)?; - read_live_session( - &mut session, - &read_cancel_token, - &live_control.handle, - live_control.on_natural_completion.as_ref(), - live_control.on_steer_prompt.as_ref(), - on_activity.as_ref(), - ) - .await - }) - .await - }); - - let cancel_deadline_token = cancel_token.clone(); - let run_outcome = async { - match timeout_ms { - Some(timeout_ms) => { - if let Ok(result) = timeout(Duration::from_millis(timeout_ms), run).await { - Ok(result) - } else { - state.terminate().await?; - if run_cancel_token.is_cancelled() { - return Err(AcpError::Cancelled); - } - Err(AcpError::TimedOut { - exec_output_tail: state.exec_output_tail().await, - }) - } - } - None => Ok(run.await), - } - }; - let outcome = tokio::select! { - result = run_outcome => result?, - () = async { - cancel_deadline_token.cancelled().await; - sleep(Duration::from_millis(500)).await; - } => { - state.terminate().await?; - return Err(AcpError::Cancelled); - } - }; - let (text, stop_reason) = match outcome { - Ok(result) => result, - Err(_) if run_cancel_token.is_cancelled() => { - state.terminate().await?; - return Err(AcpError::Cancelled); - } - Err(error) => { - state.terminate().await?; - if let Some(startup_error) = state.take_startup_error().await { - return Err(AcpError::Sandbox(startup_error)); - } - if let Some(process_exit) = state.take_process_exit().await { - return Err(AcpError::ProcessExited(process_exit)); - } - return Err(map_protocol_error(error)); - } - }; - - match stop_reason { - StopReason::EndTurn | StopReason::Refusal => {} - StopReason::Cancelled => { - state.terminate().await?; - return Err(AcpError::Cancelled); - } - _ => { - state.terminate().await?; - return Err(AcpError::StopReason { - stop_reason: render_stop_reason(&stop_reason), - text, - }); - } - } - - state.terminate().await?; - let stderr = state.stderr_tail().await; - Ok(AcpRunResult { - text, - stop_reason, - stderr, - duration_ms: elapsed_ms(start), - }) -} - -fn map_protocol_error(error: ProtocolError) -> AcpError { - AcpError::Protocol(error) -} - -fn select_permission_outcome(request: &RequestPermissionRequest) -> RequestPermissionOutcome { - let selected = request - .options - .iter() - .find(|option| option.kind == PermissionOptionKind::AllowAlways) - .or_else(|| { - request - .options - .iter() - .find(|option| option.kind == PermissionOptionKind::AllowOnce) - }) - .or_else(|| { - request.options.iter().find(|option| { - !matches!( - option.kind, - PermissionOptionKind::RejectOnce | PermissionOptionKind::RejectAlways - ) - }) - }); - - selected.map_or(RequestPermissionOutcome::Cancelled, |option| { - RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option.option_id.clone())) - }) -} - -async fn read_live_session( - session: &mut ActiveSession<'_, Agent>, - cancel_token: &CancellationToken, - control_handle: &AcpControlHandle, - on_natural_completion: Option<&AcpNaturalCompletionCallback>, - on_steer_prompt: Option<&AcpSteerPromptCallback>, - on_activity: Option<&Arc>, -) -> Result<(String, StopReason), ProtocolError> { - let mut text = String::new(); - let mut prompt_active = true; - let mut cancel_sent = false; - let mut last_stop_reason: Option = None; - - loop { - if !prompt_active { - if let Some(message) = control_handle.pop_steer() { - if let Some(on_steer_prompt) = on_steer_prompt { - on_steer_prompt(message.text().to_string(), message.actor().cloned()); - } - session.send_prompt(message.text().to_string())?; - prompt_active = true; - cancel_sent = false; - continue; - } - - if control_handle.take_interrupt_requested() { - continue; - } - - if control_handle.should_wait_for_steer() { - let notified = control_handle.notified(); - tokio::select! { - () = cancel_token.cancelled() => { - return Ok((text, StopReason::Cancelled)); - } - () = notified => {} - } - continue; - } - - let stop_reason = last_stop_reason.unwrap_or(StopReason::EndTurn); - if matches!(stop_reason, StopReason::EndTurn | StopReason::Refusal) - && on_natural_completion.is_some_and(|callback| !callback()) - { - // The lease reports pending control work but our flags didn't - // observe it yet. Wait on a notify so we don't spin. - let notified = control_handle.notified(); - tokio::select! { - () = cancel_token.cancelled() => { - return Ok((text, StopReason::Cancelled)); - } - () = notified => {} - } - continue; - } - return Ok((text, stop_reason)); - } - - if control_handle.take_interrupt_requested() && !cancel_sent { - cancel_sent = true; - send_cancel_notification(session)?; - } - - let control_notified = control_handle.notified(); - tokio::select! { - update = session.read_update() => { - if let Some(on_activity) = on_activity { - on_activity(); - } - match update? { - SessionMessage::SessionMessage(dispatch) => { - MatchDispatch::new(dispatch) - .if_notification(async |notification: SessionNotification| { - if let SessionUpdate::AgentMessageChunk(ContentChunk { - content: ContentBlock::Text(text_chunk), - .. - }) = notification.update { - text.push_str(&text_chunk.text); - } - Ok(()) - }) - .await - .otherwise_ignore()?; - } - SessionMessage::StopReason(stop_reason) => { - prompt_active = false; - cancel_sent = false; - last_stop_reason = Some(stop_reason); - } - _ => {} - } - } - () = control_notified => { - if control_handle.take_interrupt_requested() && !cancel_sent { - cancel_sent = true; - send_cancel_notification(session)?; - } - } - () = cancel_token.cancelled(), if !cancel_sent => { - cancel_sent = true; - send_cancel_notification(session)?; - } - () = sleep(CANCEL_GRACE_PERIOD), if cancel_sent => { - return Ok((text, StopReason::Cancelled)); - } - } - } -} - -fn send_cancel_notification(session: &ActiveSession<'_, Agent>) -> Result<(), ProtocolError> { - session - .connection() - .send_notification_to(Agent, CancelNotification::new(session.session_id().clone())) -} - -#[must_use] -pub fn render_stop_reason(stop_reason: &StopReason) -> String { - serde_json::to_value(stop_reason) - .ok() - .and_then(|value| value.as_str().map(str::to_string)) - .unwrap_or_else(|| format!("{stop_reason:?}")) -} - -#[cfg(test)] -mod tests { - use agent_client_protocol::schema::SessionNotification; - - #[test] - fn codex_usage_update_session_notification_deserializes() { - let notification = serde_json::json!({ - "sessionId": "session-1", - "update": { - "sessionUpdate": "usage_update", - "used": 26128, - "size": 258_400 - } - }); - - serde_json::from_value::(notification) - .expect("Codex ACP usage_update notifications should be ignored, not fatal"); - } -} diff --git a/lib/components/fabro-acp/src/test_support.rs b/lib/components/fabro-acp/src/test_support.rs deleted file mode 100644 index 217c3723d..000000000 --- a/lib/components/fabro-acp/src/test_support.rs +++ /dev/null @@ -1,274 +0,0 @@ -use agent_client_protocol::schema::{ - ContentBlock, ContentChunk, SessionNotification, SessionUpdate, -}; -use serde_json::json; - -pub const SESSION_ID: &str = "sess-1"; - -pub fn agent_message_chunk(session_id: &str, text: &str) -> SessionNotification { - SessionNotification::new( - session_id.to_string(), - SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.to_string()))), - ) -} - -pub fn agent_message_chunk_json(session_id: &str, text: &str) -> serde_json::Value { - json!({ - "jsonrpc": "2.0", - "method": "session/update", - "params": agent_message_chunk(session_id, text), - }) -} - -pub fn fake_acp_agent_script() -> &'static str { - r#" -import json -import os -import signal -import sys -import time - -methods = [] -session_id = "sess-1" -prompt_count = 0 - -if os.environ.get("ACP_PID_RECORD"): - with open(os.environ["ACP_PID_RECORD"], "w", encoding="utf-8") as record: - record.write(str(os.getpid())) - -if os.environ.get("ACP_ENV_RECORD"): - keys = [ - key.strip() - for key in os.environ.get( - "ACP_ENV_RECORD_KEYS", - "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY", - ).split(",") - if key.strip() - ] - snapshot = {key: os.environ[key] for key in keys if key in os.environ} - with open(os.environ["ACP_ENV_RECORD"], "w", encoding="utf-8") as record: - record.write(json.dumps(snapshot, sort_keys=True)) - -def handle_sigterm(signum, frame): - if os.environ.get("ACP_LINGER_TERMINATED"): - with open(os.environ["ACP_LINGER_TERMINATED"], "w", encoding="utf-8") as record: - record.write("terminated\n") - sys.exit(0) - -signal.signal(signal.SIGTERM, handle_sigterm) - -def send(message): - print(json.dumps(message), flush=True) - -def respond(message, result): - send({"jsonrpc": "2.0", "id": message["id"], "result": result}) - -def record_methods(): - if os.environ.get("ACP_RECORD"): - with open(os.environ["ACP_RECORD"], "w", encoding="utf-8") as record: - record.write("\n".join(methods) + "\n") - -def first_prompt_text(message): - prompt = message.get("params", {}).get("prompt", []) - if not prompt: - return "" - first = prompt[0] - if isinstance(first, str): - return first - return first.get("text", "") - -for line in sys.stdin: - message = json.loads(line) - method = message.get("method") - methods.append(method) - - if method == "initialize": - if os.environ.get("ACP_MODE") == "slow_initialize": - time.sleep(60) - respond(message, {"protocolVersion": 1, "agentCapabilities": {}}) - elif method == "session/new": - if os.environ.get("ACP_SESSION_NEW_PARAMS"): - with open(os.environ["ACP_SESSION_NEW_PARAMS"], "w", encoding="utf-8") as record: - record.write(json.dumps(message.get("params", {}), separators=(",", ":"))) - respond(message, {"sessionId": session_id}) - elif method == "session/prompt": - prompt_count += 1 - if os.environ.get("ACP_PROMPT_RECORD"): - with open(os.environ["ACP_PROMPT_RECORD"], "w", encoding="utf-8") as record: - record.write(json.dumps(message.get("params", {}))) - mode = os.environ.get("ACP_MODE", "normal") - if mode == "timeout": - time.sleep(60) - if mode == "malformed": - print("malformed json", file=sys.stderr, flush=True) - print("{not-json", flush=True) - break - if mode == "early_exit": - print("early boom", file=sys.stderr, flush=True) - sys.exit(2) - if mode == "write_file": - path = os.environ.get("ACP_WRITE_PATH", "hello.txt") - parent = os.path.dirname(path) - if parent: - os.makedirs(parent, exist_ok=True) - with open(path, "w", encoding="utf-8") as file: - file.write(os.environ.get("ACP_WRITE_CONTENT", "hello from sandbox\n")) - if os.environ.get("ACP_WRITE_MTIME_EPOCH"): - mtime = float(os.environ["ACP_WRITE_MTIME_EPOCH"]) - os.utime(path, (mtime, mtime)) - if mode == "cancel": - send({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": session_id, - "update": { - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "waiting for cancellation"} - } - } - }) - for cancel_line in sys.stdin: - cancel_message = json.loads(cancel_line) - if cancel_message.get("method") == "session/cancel": - with open(os.environ["ACP_CANCEL_RECORD"], "w", encoding="utf-8") as record: - record.write("session/cancel\n") - respond(message, {"stopReason": "cancelled"}) - sys.exit(0) - if mode == "ignore_cancel": - send({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": session_id, - "update": { - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "waiting for ignored cancellation"} - } - } - }) - for control_line in sys.stdin: - control_message = json.loads(control_line) - methods.append(control_message.get("method")) - if control_message.get("method") == "session/cancel": - if os.environ.get("ACP_CANCEL_RECORD"): - with open(os.environ["ACP_CANCEL_RECORD"], "w", encoding="utf-8") as record: - record.write("session/cancel\n") - record_methods() - time.sleep(60) - if mode == "permission": - send({ - "jsonrpc": "2.0", - "id": "permission-1", - "method": "session/request_permission", - "params": { - "sessionId": session_id, - "toolCall": {"toolCallId": "tool-1"}, - "options": [ - {"optionId": "reject", "name": "Reject", "kind": "reject_once"}, - {"optionId": "once", "name": "Allow once", "kind": "allow_once"}, - {"optionId": "always", "name": "Allow always", "kind": "allow_always"} - ] - } - }) - permission_response = json.loads(sys.stdin.readline()) - with open(os.environ["ACP_PERMISSION"], "w", encoding="utf-8") as permission: - permission.write(json.dumps(permission_response.get("result", {}), separators=(",", ":"))) - if mode == "interrupt_steer": - if prompt_count == 1: - send({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": session_id, - "update": { - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "interrupted "} - } - } - }) - for control_line in sys.stdin: - control_message = json.loads(control_line) - methods.append(control_message.get("method")) - if control_message.get("method") == "session/cancel": - if os.environ.get("ACP_CANCEL_RECORD"): - with open(os.environ["ACP_CANCEL_RECORD"], "w", encoding="utf-8") as record: - record.write("session/cancel\n") - respond(message, {"stopReason": "cancelled"}) - break - continue - if os.environ.get("ACP_STEER_PROMPT_RECORD"): - with open(os.environ["ACP_STEER_PROMPT_RECORD"], "w", encoding="utf-8") as record: - record.write(json.dumps(message.get("params", {}), separators=(",", ":"))) - send({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": session_id, - "update": { - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "steered:" + first_prompt_text(message)} - } - } - }) - record_methods() - respond(message, {"stopReason": "end_turn"}) - break - if mode == "steer": - if prompt_count == 1: - send({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": session_id, - "update": { - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "initial "} - } - } - }) - respond(message, {"stopReason": "end_turn"}) - continue - if os.environ.get("ACP_STEER_PROMPT_RECORD"): - with open(os.environ["ACP_STEER_PROMPT_RECORD"], "w", encoding="utf-8") as record: - record.write(json.dumps(message.get("params", {}), separators=(",", ":"))) - send({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": session_id, - "update": { - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "steered:" + first_prompt_text(message)} - } - } - }) - record_methods() - respond(message, {"stopReason": "end_turn"}) - break - for text in ["hello ", "from acp"]: - send({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": session_id, - "update": { - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": text} - } - } - }) - record_methods() - respond(message, {"stopReason": os.environ.get("ACP_STOP_REASON", "end_turn")}) - if mode == "linger_after_response": - while True: - time.sleep(1) - break - else: - send({ - "jsonrpc": "2.0", - "id": message.get("id"), - "error": {"code": -32601, "message": "method not found"} - }) -"# -} diff --git a/lib/components/fabro-acp/src/transport.rs b/lib/components/fabro-acp/src/transport.rs deleted file mode 100644 index 39636fb90..000000000 --- a/lib/components/fabro-acp/src/transport.rs +++ /dev/null @@ -1,193 +0,0 @@ -use std::collections::HashMap; -use std::io::Result as IoResult; -use std::pin::Pin; -use std::sync::Arc; -use std::time::Duration; - -use agent_client_protocol::util::internal_error; -use agent_client_protocol::{ - Agent, Client, ConnectTo, Error as ProtocolError, Lines, Result as AcpProtocolResult, -}; -use fabro_sandbox::{ - DEFAULT_EXEC_OUTPUT_TAIL_BYTES, Error as SandboxError, Result as SandboxResult, RunSandbox, - StderrTail, StdioProcessHandle, Termination, command_termination, program_exit_code, -}; -use fabro_types::ExecOutputTail; -use futures::io::BufReader; -use futures::sink::unfold; -use futures::{AsyncBufReadExt, AsyncWriteExt, Stream}; -use tokio::sync::Mutex as TokioMutex; -use tokio::time::timeout; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; - -use crate::command::AcpProcessSpec; -use crate::error::AcpProcessExit; - -const CLEAN_EXIT_PROTOCOL_GRACE: Duration = Duration::from_millis(500); - -#[derive(Clone)] -pub(crate) struct TransportState { - handle: Arc>>>, - stderr: Arc>>, - startup_error: Arc>>, - process_exit: Arc>>, -} - -impl TransportState { - pub(crate) fn new() -> Self { - Self { - handle: Arc::new(TokioMutex::new(None)), - stderr: Arc::new(TokioMutex::new(None)), - startup_error: Arc::new(TokioMutex::new(None)), - process_exit: Arc::new(TokioMutex::new(None)), - } - } - - async fn set_process(&self, handle: Arc, stderr: StderrTail) { - *self.handle.lock().await = Some(handle); - *self.stderr.lock().await = Some(stderr); - } - - async fn set_startup_error(&self, error: SandboxError) { - *self.startup_error.lock().await = Some(error); - } - - async fn set_process_exit( - &self, - termination: Termination, - exit_code: Option, - stderr: &str, - ) { - *self.process_exit.lock().await = Some(AcpProcessExit { - termination: command_termination(termination), - exit_code: program_exit_code(termination, exit_code), - exec_output_tail: redacted_stderr_tail(stderr), - }); - } - - pub(crate) async fn take_startup_error(&self) -> Option { - self.startup_error.lock().await.take() - } - - pub(crate) async fn take_process_exit(&self) -> Option { - self.process_exit.lock().await.take() - } - - pub(crate) async fn terminate(&self) -> SandboxResult<()> { - if let Some(handle) = self.handle.lock().await.as_ref().cloned() { - handle.terminate().await; - } - Ok(()) - } - - pub(crate) async fn stderr_tail(&self) -> String { - if let Some(stderr) = self.stderr.lock().await.as_ref().cloned() { - return stderr.to_string_lossy(); - } - String::new() - } - - pub(crate) async fn exec_output_tail(&self) -> Option { - let stderr = self.stderr_tail().await; - redacted_stderr_tail(&stderr) - } -} - -pub(crate) struct SandboxAcpTransport { - command: AcpProcessSpec, - cwd: String, - env: HashMap, - sandbox: Arc, - state: TransportState, -} - -impl SandboxAcpTransport { - pub(crate) fn new( - command: AcpProcessSpec, - cwd: String, - env: HashMap, - sandbox: Arc, - state: TransportState, - ) -> Self { - Self { - command, - cwd, - env, - sandbox, - state, - } - } -} - -impl ConnectTo for SandboxAcpTransport { - async fn connect_to(self, client: impl ConnectTo) -> AcpProtocolResult<()> { - let mut env = self.command.env().clone(); - env.extend(self.env); - - let process = match self - .sandbox - .spawn_stdio_process( - &self.command.to_shell_command(), - Some(&self.cwd), - Some(&env), - ) - .await - { - Ok(process) => process, - Err(error) => { - self.state.set_startup_error(error).await; - return Err(internal_error("ACP process failed to start")); - } - }; - - let handle: Arc = Arc::from(process.handle); - let stderr = process.stderr_tail.clone(); - self.state - .set_process(Arc::clone(&handle), stderr.clone()) - .await; - - let incoming_lines = Box::pin(BufReader::new(process.stdout.compat()).lines()) - as Pin> + Send>>; - let outgoing_sink = Box::pin(unfold( - process.stdin.compat_write(), - async move |mut writer, line: String| { - let mut bytes = line.into_bytes(); - bytes.push(b'\n'); - writer.write_all(&bytes).await?; - Ok::<_, std::io::Error>(writer) - }, - )); - - let mut protocol = Box::pin(agent_client_protocol::ConnectTo::::connect_to( - Lines::new(outgoing_sink, incoming_lines), - client, - )); - tokio::select! { - result = &mut protocol => { - handle.terminate().await; - let _ = timeout(Duration::from_millis(500), handle.wait()).await; - result - } - (termination, exit_code) = handle.wait() => { - let stderr = stderr.to_string_lossy(); - if termination == Termination::Exited && exit_code == Some(0) { - // Stdio agents commonly exit immediately after writing their final response. - // Process wait can observe that exit before the line reader drains stdout. - if let Ok(result) = timeout(CLEAN_EXIT_PROTOCOL_GRACE, &mut protocol).await { - return result; - } - } - self.state.set_process_exit(termination, exit_code, &stderr).await; - Err(process_exited_before_protocol_completed()) - } - } - } -} - -fn redacted_stderr_tail(stderr: &str) -> Option { - fabro_sandbox::redacted_output_tail("", stderr, DEFAULT_EXEC_OUTPUT_TAIL_BYTES) -} - -fn process_exited_before_protocol_completed() -> ProtocolError { - internal_error("ACP process exited before protocol completed") -} diff --git a/lib/components/fabro-acp/tests/session.rs b/lib/components/fabro-acp/tests/session.rs deleted file mode 100644 index 1fcdb4f51..000000000 --- a/lib/components/fabro-acp/tests/session.rs +++ /dev/null @@ -1,841 +0,0 @@ -use std::collections::HashMap; -use std::path::Path; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use agent_client_protocol::schema::StopReason; -use fabro_acp::{ - AcpControlHandle, AcpError, AcpLiveControl, AcpProcessSpec, AcpRunRequest, AcpRunResult, - run_acp_turn, -}; -use fabro_sandbox::test_support::{MockSandbox, MockStdioProcess}; -use fabro_sandbox::{RunSandbox, local_sandbox}; -use fabro_util::error::collect_chain; -use fabro_util::shell; -use pebble_coding_agent::SteeringMessage; -use tokio::fs::{read_to_string, write}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream}; -use tokio::process::Command; -use tokio::sync::Notify; -use tokio::time::{sleep, timeout}; -use tokio_util::sync::CancellationToken; - -const ACP_TEST_TIMEOUT_MS: u64 = 30_000; - -#[allow( - unused, - unreachable_pub, - reason = "integration test imports the shared test fixture source as a private module" -)] -#[path = "../src/test_support.rs"] -mod test_support; - -use test_support::fake_acp_agent_script; - -#[tokio::test] -async fn stdio_spawn_failure_returns_sandbox_error() { - const SANDBOX_FAILURE: &str = "ACP backend requires bidirectional stdio; the Daytona sandbox provider does not support it yet"; - - let command = AcpProcessSpec::from_command_attr("fake-acp-agent").expect("parse ACP command"); - let mut sandbox = MockSandbox::linux(); - sandbox.stdio_process_error = Some(SANDBOX_FAILURE.to_string()); - let sandbox = sandbox.sandbox(); - - let result = run_acp_turn(AcpRunRequest { - command, - prompt: "hello".to_string(), - cwd: "/workspace".to_string(), - timeout_ms: Some(ACP_TEST_TIMEOUT_MS), - env: HashMap::new(), - sandbox, - cancel_token: CancellationToken::new(), - on_activity: None, - live_control: None, - }) - .await; - let Err(error) = result else { - panic!("stdio spawn failure should fail"); - }; - - assert!( - matches!(error, AcpError::Sandbox(_)), - "expected sandbox error, got {error:?}" - ); - let chain = collect_chain(&error); - assert!( - chain.iter().any(|cause| cause == SANDBOX_FAILURE), - "cause chain should contain sandbox failure, got: {chain:?}" - ); -} - -#[tokio::test] -async fn clean_stdio_exit_after_final_response_completes_turn() { - let sandbox = MockSandbox { - stdio_process: Some(mock_acp_stdio_process("end_turn")), - ..MockSandbox::linux() - } - .sandbox(); - let command = AcpProcessSpec::from_command_attr("mock-acp-agent").expect("parse ACP command"); - - let result = run_acp_turn(AcpRunRequest { - command, - prompt: "hello".to_string(), - cwd: "/workspace".to_string(), - timeout_ms: Some(ACP_TEST_TIMEOUT_MS), - env: HashMap::new(), - sandbox, - cancel_token: CancellationToken::new(), - on_activity: None, - live_control: None, - }) - .await - .expect("clean ACP process exit should not preempt final protocol response"); - - assert_eq!(result.text, "hello from acp"); - assert_eq!(result.stop_reason, StopReason::EndTurn); -} - -#[tokio::test] -async fn session_lifecycle_initializes_sends_prompt_and_aggregates_text() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let script_path = tempdir.path().join("fake_acp_agent.py"); - let record_path = tempdir.path().join("methods.txt"); - write(&script_path, fake_acp_agent_script()) - .await - .expect("write fake ACP agent"); - - let raw_command = format!( - "python3 {}", - shell::shell_quote(&script_path.to_string_lossy()) - ); - let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); - let sandbox: Arc = Arc::new( - local_sandbox(tempdir.path().to_path_buf()) - .await - .expect("local sandbox should be created"), - ); - - let result = run_acp_turn(AcpRunRequest { - command, - prompt: "hello".to_string(), - cwd: tempdir.path().to_string_lossy().into_owned(), - timeout_ms: Some(ACP_TEST_TIMEOUT_MS), - env: HashMap::from([( - "ACP_RECORD".to_string(), - record_path.to_string_lossy().into_owned(), - )]), - sandbox, - cancel_token: CancellationToken::new(), - on_activity: None, - live_control: None, - }) - .await - .expect("run ACP turn"); - - assert_eq!(result.text, "hello from acp"); - assert_eq!(result.stop_reason, StopReason::EndTurn); - assert_eq!( - read_to_string(record_path) - .await - .expect("read method record"), - "initialize\nsession/new\nsession/prompt\n" - ); -} - -#[tokio::test] -async fn steering_sends_followup_session_prompt_over_acp() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let script_path = tempdir.path().join("fake_acp_agent.py"); - let record_path = tempdir.path().join("methods.txt"); - let steer_prompt_path = tempdir.path().join("steer_prompt.json"); - write(&script_path, fake_acp_agent_script()) - .await - .expect("write fake ACP agent"); - - let raw_command = format!( - "python3 {}", - shell::shell_quote(&script_path.to_string_lossy()) - ); - let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); - let sandbox: Arc = Arc::new( - local_sandbox(tempdir.path().to_path_buf()) - .await - .expect("local sandbox should be created"), - ); - let control_handle = AcpControlHandle::new(); - let handle_for_activity = control_handle.clone(); - let queued = Arc::new(AtomicBool::new(false)); - let queued_for_activity = Arc::clone(&queued); - - let result = run_acp_turn(AcpRunRequest { - command, - prompt: "hello".to_string(), - cwd: tempdir.path().to_string_lossy().into_owned(), - timeout_ms: Some(ACP_TEST_TIMEOUT_MS), - env: HashMap::from([ - ("ACP_MODE".to_string(), "steer".to_string()), - ( - "ACP_RECORD".to_string(), - record_path.to_string_lossy().into_owned(), - ), - ( - "ACP_STEER_PROMPT_RECORD".to_string(), - steer_prompt_path.to_string_lossy().into_owned(), - ), - ]), - sandbox, - cancel_token: CancellationToken::new(), - on_activity: Some(Arc::new(move || { - if !queued_for_activity.swap(true, Ordering::AcqRel) { - handle_for_activity.enqueue_bounded(SteeringMessage::new("please revise"), 32); - } - })), - live_control: Some(AcpLiveControl::new(control_handle)), - }) - .await - .expect("run ACP turn with steering"); - - assert_eq!(result.text, "initial steered:please revise"); - assert_eq!( - read_to_string(record_path) - .await - .expect("read method record"), - "initialize\nsession/new\nsession/prompt\nsession/prompt\n" - ); - assert!( - read_to_string(steer_prompt_path) - .await - .expect("read steer prompt") - .contains("please revise") - ); -} - -#[tokio::test] -async fn interrupt_then_steer_sends_cancel_then_followup_session_prompt_over_acp() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let script_path = tempdir.path().join("fake_acp_agent.py"); - let record_path = tempdir.path().join("methods.txt"); - let cancel_path = tempdir.path().join("cancel.txt"); - let steer_prompt_path = tempdir.path().join("steer_prompt.json"); - write(&script_path, fake_acp_agent_script()) - .await - .expect("write fake ACP agent"); - - let raw_command = format!( - "python3 {}", - shell::shell_quote(&script_path.to_string_lossy()) - ); - let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); - let sandbox: Arc = Arc::new( - local_sandbox(tempdir.path().to_path_buf()) - .await - .expect("local sandbox should be created"), - ); - let control_handle = AcpControlHandle::new(); - let handle_for_activity = control_handle.clone(); - let queued = Arc::new(AtomicBool::new(false)); - let queued_for_activity = Arc::clone(&queued); - - let result = run_acp_turn(AcpRunRequest { - command, - prompt: "hello".to_string(), - cwd: tempdir.path().to_string_lossy().into_owned(), - timeout_ms: Some(ACP_TEST_TIMEOUT_MS), - env: HashMap::from([ - ("ACP_MODE".to_string(), "interrupt_steer".to_string()), - ("LC_ALL".to_string(), "C".to_string()), - ( - "ACP_RECORD".to_string(), - record_path.to_string_lossy().into_owned(), - ), - ( - "ACP_CANCEL_RECORD".to_string(), - cancel_path.to_string_lossy().into_owned(), - ), - ( - "ACP_STEER_PROMPT_RECORD".to_string(), - steer_prompt_path.to_string_lossy().into_owned(), - ), - ]), - sandbox, - cancel_token: CancellationToken::new(), - on_activity: Some(Arc::new(move || { - if !queued_for_activity.swap(true, Ordering::AcqRel) { - handle_for_activity - .interrupt_then_enqueue_bounded(SteeringMessage::new("please revise"), 32); - } - })), - live_control: Some(AcpLiveControl::new(control_handle)), - }) - .await - .expect("run ACP turn with interrupt and steering"); - - assert_eq!(result.text, "interrupted steered:please revise"); - assert_eq!( - read_to_string(record_path) - .await - .expect("read method record"), - "initialize\nsession/new\nsession/prompt\nsession/cancel\nsession/prompt\n" - ); - assert_eq!( - read_to_string(cancel_path) - .await - .expect("read cancel record"), - "session/cancel\n" - ); - assert!( - read_to_string(steer_prompt_path) - .await - .expect("read steer prompt") - .contains("please revise") - ); -} - -#[tokio::test] -async fn inline_interrupt_terminates_agent_that_ignores_cancel() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let script_path = tempdir.path().join("fake_acp_agent.py"); - let record_path = tempdir.path().join("methods.txt"); - let cancel_path = tempdir.path().join("cancel.txt"); - write(&script_path, fake_acp_agent_script()) - .await - .expect("write fake ACP agent"); - - let raw_command = format!( - "python3 {}", - shell::shell_quote(&script_path.to_string_lossy()) - ); - let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); - let sandbox: Arc = Arc::new( - local_sandbox(tempdir.path().to_path_buf()) - .await - .expect("local sandbox should be created"), - ); - let control_handle = AcpControlHandle::new(); - let handle_for_activity = control_handle.clone(); - let interrupted = Arc::new(AtomicBool::new(false)); - let interrupted_for_activity = Arc::clone(&interrupted); - - let err = run_acp_turn(AcpRunRequest { - command, - prompt: "hello".to_string(), - cwd: tempdir.path().to_string_lossy().into_owned(), - timeout_ms: Some(ACP_TEST_TIMEOUT_MS), - env: HashMap::from([ - ("ACP_MODE".to_string(), "ignore_cancel".to_string()), - ("LC_ALL".to_string(), "C".to_string()), - ( - "ACP_RECORD".to_string(), - record_path.to_string_lossy().into_owned(), - ), - ( - "ACP_CANCEL_RECORD".to_string(), - cancel_path.to_string_lossy().into_owned(), - ), - ]), - sandbox, - cancel_token: CancellationToken::new(), - on_activity: Some(Arc::new(move || { - if !interrupted_for_activity.swap(true, Ordering::AcqRel) { - handle_for_activity.interrupt(); - } - })), - live_control: Some(AcpLiveControl::new(control_handle)), - }) - .await - .expect_err("ignored inline interrupt should terminate as cancelled"); - - assert!(matches!(err, AcpError::Cancelled), "{err:?}"); - assert_eq!( - read_to_string(record_path) - .await - .expect("read method record"), - "initialize\nsession/new\nsession/prompt\nsession/cancel\n" - ); - assert_eq!( - read_to_string(cancel_path) - .await - .expect("read cancel record"), - "session/cancel\n" - ); -} - -#[tokio::test] -async fn permission_request_selects_allow_always() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let permission_path = tempdir.path().join("permission.json"); - - let result = run_fake_agent( - tempdir.path(), - HashMap::from([ - ("ACP_MODE".to_string(), "permission".to_string()), - ( - "ACP_PERMISSION".to_string(), - permission_path.to_string_lossy().into_owned(), - ), - ]), - Some(ACP_TEST_TIMEOUT_MS), - CancellationToken::new(), - ) - .await - .expect("run ACP turn"); - - assert_eq!(result.text, "hello from acp"); - let permission = read_to_string(permission_path) - .await - .expect("read permission record"); - assert!(permission.contains(r#""outcome":"selected""#)); - assert!(permission.contains(r#""optionId":"always""#)); -} - -#[tokio::test] -async fn runs_inside_sandbox_and_uses_requested_cwd() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let cwd_path = tempdir.path().join("session_new.json"); - - let result = run_fake_agent( - tempdir.path(), - HashMap::from([ - ("ACP_MODE".to_string(), "write_file".to_string()), - ( - "ACP_SESSION_NEW_PARAMS".to_string(), - cwd_path.to_string_lossy().into_owned(), - ), - ]), - Some(ACP_TEST_TIMEOUT_MS), - CancellationToken::new(), - ) - .await - .expect("run ACP turn"); - - assert_eq!(result.text, "hello from acp"); - assert_eq!( - read_to_string(tempdir.path().join("hello.txt")) - .await - .expect("read sandbox output file"), - "hello from sandbox\n" - ); - assert!( - read_to_string(cwd_path) - .await - .expect("read session/new params") - .contains(&tempdir.path().to_string_lossy().into_owned()) - ); -} - -#[tokio::test] -async fn cancellation_sends_session_cancel_and_returns_cancelled() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let cancel_path = tempdir.path().join("cancel.txt"); - let tempdir_path = tempdir.path().to_path_buf(); - let cancel_path_for_task = cancel_path.clone(); - let cancel_token = CancellationToken::new(); - let cancel_for_task = cancel_token.clone(); - let prompt_started = Arc::new(Notify::new()); - let prompt_started_for_task = prompt_started.clone(); - - let task = tokio::spawn(async move { - run_fake_agent_with_activity( - &tempdir_path, - HashMap::from([ - ("ACP_MODE".to_string(), "cancel".to_string()), - ( - "ACP_CANCEL_RECORD".to_string(), - cancel_path_for_task.to_string_lossy().into_owned(), - ), - ]), - Some(ACP_TEST_TIMEOUT_MS), - cancel_for_task, - Some(Arc::new(move || prompt_started_for_task.notify_one())), - ) - .await - }); - - timeout( - Duration::from_millis(ACP_TEST_TIMEOUT_MS), - prompt_started.notified(), - ) - .await - .expect("fake ACP agent should acknowledge session/prompt before cancellation"); - cancel_token.cancel(); - let err = task - .await - .expect("join cancellation task") - .expect_err("cancelled turn should error"); - - assert!(matches!(err, AcpError::Cancelled)); - assert_eq!( - read_to_string(cancel_path) - .await - .expect("read cancel record"), - "session/cancel\n" - ); -} - -#[tokio::test] -async fn pre_session_cancellation_returns_cancelled() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let cancel_token = CancellationToken::new(); - cancel_token.cancel(); - - let err = run_fake_agent( - tempdir.path(), - HashMap::from([("ACP_MODE".to_string(), "slow_initialize".to_string())]), - Some(1_000), - cancel_token, - ) - .await - .expect_err("pre-session cancellation should error"); - - assert!(matches!(err, AcpError::Cancelled)); -} - -#[tokio::test] -async fn successful_turn_terminates_lingering_agent_process() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - let pid_path = tempdir.path().join("agent.pid"); - - let result = run_fake_agent( - tempdir.path(), - HashMap::from([ - ("ACP_MODE".to_string(), "linger_after_response".to_string()), - ( - "ACP_PID_RECORD".to_string(), - pid_path.to_string_lossy().into_owned(), - ), - ]), - Some(ACP_TEST_TIMEOUT_MS), - CancellationToken::new(), - ) - .await - .expect("run ACP turn"); - - sleep(Duration::from_millis(100)).await; - let pid = read_to_string(&pid_path).await.expect("read agent pid"); - let still_running = process_is_running(pid.trim()).await; - if still_running { - let _ = Command::new("kill") - .arg("-TERM") - .arg(pid.trim()) - .status() - .await; - } - - assert_eq!(result.text, "hello from acp"); - assert!( - !still_running, - "successful ACP turn should not leave lingering agent process" - ); -} - -#[tokio::test] -async fn refusal_stop_reason_returns_text() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - - let result = run_fake_agent( - tempdir.path(), - HashMap::from([("ACP_STOP_REASON".to_string(), "refusal".to_string())]), - Some(ACP_TEST_TIMEOUT_MS), - CancellationToken::new(), - ) - .await - .expect("run ACP turn"); - - assert_eq!(result.text, "hello from acp"); - assert_eq!(result.stop_reason, StopReason::Refusal); -} - -#[tokio::test] -async fn max_tokens_stop_reason_returns_partial_text_error() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - - let err = run_fake_agent( - tempdir.path(), - HashMap::from([("ACP_STOP_REASON".to_string(), "max_tokens".to_string())]), - Some(ACP_TEST_TIMEOUT_MS), - CancellationToken::new(), - ) - .await - .expect_err("max_tokens should return stop reason error"); - - let AcpError::StopReason { stop_reason, text } = err else { - panic!("expected stop reason error"); - }; - assert_eq!(stop_reason, "max_tokens"); - assert_eq!(text, "hello from acp"); -} - -#[tokio::test] -async fn max_turn_requests_stop_reason_returns_partial_text_error() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - - let err = run_fake_agent( - tempdir.path(), - HashMap::from([( - "ACP_STOP_REASON".to_string(), - "max_turn_requests".to_string(), - )]), - Some(ACP_TEST_TIMEOUT_MS), - CancellationToken::new(), - ) - .await - .expect_err("max_turn_requests should return stop reason error"); - - let AcpError::StopReason { stop_reason, text } = err else { - panic!("expected stop reason error"); - }; - assert_eq!(stop_reason, "max_turn_requests"); - assert_eq!(text, "hello from acp"); -} - -#[tokio::test] -async fn timeout_terminates_process_and_returns_timeout() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - - let err = run_fake_agent( - tempdir.path(), - HashMap::from([("ACP_MODE".to_string(), "timeout".to_string())]), - Some(100), - CancellationToken::new(), - ) - .await - .expect_err("timeout should error"); - - assert!(matches!(err, AcpError::TimedOut { .. })); -} - -#[tokio::test] -async fn malformed_json_returns_diagnostic_without_raw_stderr_in_message() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - - let err = run_fake_agent( - tempdir.path(), - HashMap::from([("ACP_MODE".to_string(), "malformed".to_string())]), - Some(ACP_TEST_TIMEOUT_MS), - CancellationToken::new(), - ) - .await - .expect_err("malformed JSON should error"); - - match err { - AcpError::Protocol(error) => { - let message = error.to_string(); - assert!( - !message.contains("malformed json"), - "protocol error display should not include raw stderr: {message}" - ); - } - AcpError::ProcessExited(exit) => { - let message = exit.to_string(); - assert!( - !message.contains("malformed json"), - "process exit display should not include raw stderr: {message}" - ); - assert_eq!( - exit.exec_output_tail - .as_ref() - .and_then(|tail| tail.stderr.as_deref()), - Some("malformed json\n") - ); - } - other => panic!("expected protocol or process exit error, got {other:?}"), - } -} - -#[tokio::test] -async fn early_exit_returns_process_exit_with_redacted_stderr_tail() { - let tempdir = tempfile::tempdir().expect("create tempdir"); - - let err = run_fake_agent( - tempdir.path(), - HashMap::from([("ACP_MODE".to_string(), "early_exit".to_string())]), - Some(ACP_TEST_TIMEOUT_MS), - CancellationToken::new(), - ) - .await - .expect_err("early exit should error"); - - let AcpError::ProcessExited(exit) = err else { - panic!("expected process exit error"); - }; - let message = exit.to_string(); - assert!( - message.contains("exit_code=2"), - "early exit should include exit code in diagnostic: {message}" - ); - assert!( - !message.contains("early boom"), - "early exit display should not include raw stderr: {message}" - ); - assert_eq!( - exit.exec_output_tail - .as_ref() - .and_then(|tail| tail.stderr.as_deref()), - Some("early boom\n") - ); -} - -async fn run_fake_agent( - tempdir: &Path, - env: HashMap, - timeout_ms: Option, - cancel_token: CancellationToken, -) -> Result { - run_fake_agent_with_activity(tempdir, env, timeout_ms, cancel_token, None).await -} - -async fn run_fake_agent_with_activity( - tempdir: &Path, - mut env: HashMap, - timeout_ms: Option, - cancel_token: CancellationToken, - on_activity: Option>, -) -> Result { - let script_path = tempdir.join("fake_acp_agent.py"); - write(&script_path, fake_acp_agent_script()) - .await - .expect("write fake ACP agent"); - let raw_command = format!( - "python3 {}", - shell::shell_quote(&script_path.to_string_lossy()) - ); - let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); - let sandbox: Arc = Arc::new( - local_sandbox(tempdir.to_path_buf()) - .await - .expect("local sandbox should be created"), - ); - env.entry("LC_ALL".to_string()) - .or_insert_with(|| "C".to_string()); - - run_acp_turn(AcpRunRequest { - command, - prompt: "hello".to_string(), - cwd: tempdir.to_string_lossy().into_owned(), - timeout_ms, - env, - sandbox, - cancel_token, - on_activity, - live_control: None, - }) - .await -} - -async fn process_is_running(pid: &str) -> bool { - let Ok(status) = Command::new("kill").arg("-0").arg(pid).status().await else { - return false; - }; - if !status.success() { - return false; - } - - let Ok(output) = Command::new("ps") - .args(["-ww", "-o", "stat=", "-p", pid]) - .output() - .await - else { - return true; - }; - if !output.status.success() { - return false; - } - String::from_utf8_lossy(&output.stdout) - .chars() - .find(|ch| !ch.is_whitespace()) - .is_none_or(|state| !matches!(state, 'Z' | 'z')) -} - -fn mock_acp_stdio_process(stop_reason: &'static str) -> MockStdioProcess { - MockStdioProcess::new(move |stdin, mut stdout, _stderr| { - tokio::spawn(async move { - let mut lines = BufReader::new(stdin).lines(); - while let Some(line) = lines.next_line().await.expect("read mock ACP stdin") { - let message: serde_json::Value = - serde_json::from_str(&line).expect("parse mock ACP request"); - let method = message - .get("method") - .and_then(serde_json::Value::as_str) - .expect("mock ACP request method"); - let id = message - .get("id") - .cloned() - .unwrap_or(serde_json::Value::Null); - - match method { - "initialize" => { - write_acp_response( - &mut stdout, - id, - serde_json::json!({ - "protocolVersion": 1, - "agentCapabilities": {}, - }), - ) - .await; - } - "session/new" => { - write_acp_response( - &mut stdout, - id, - serde_json::json!({ "sessionId": "sess-1" }), - ) - .await; - } - "session/prompt" => { - write_acp_message( - &mut stdout, - serde_json::json!({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": "sess-1", - "update": { - "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": "hello from acp" } - } - } - }), - ) - .await; - write_acp_response( - &mut stdout, - id, - serde_json::json!({ "stopReason": stop_reason }), - ) - .await; - return; - } - _ => {} - } - } - }); - }) -} - -async fn write_acp_response( - stdout: &mut DuplexStream, - id: serde_json::Value, - result: serde_json::Value, -) { - write_acp_message( - stdout, - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": result, - }), - ) - .await; -} - -async fn write_acp_message(stdout: &mut DuplexStream, message: serde_json::Value) { - let mut line = serde_json::to_vec(&message).expect("serialize mock ACP message"); - line.push(b'\n'); - stdout - .write_all(&line) - .await - .expect("write mock ACP stdout"); - stdout.flush().await.expect("flush mock ACP stdout"); -} diff --git a/lib/components/fabro-graphviz/Cargo.toml b/lib/components/fabro-graphviz/Cargo.toml index f78b3b9c2..83faa3c05 100644 --- a/lib/components/fabro-graphviz/Cargo.toml +++ b/lib/components/fabro-graphviz/Cargo.toml @@ -19,7 +19,6 @@ fabro-types = { path = "../../foundation/fabro-types" } nom = "7" regex = { workspace = true } serde = { workspace = true } -strum.workspace = true thiserror = { workspace = true } [dev-dependencies] diff --git a/lib/components/fabro-graphviz/src/condition.rs b/lib/components/fabro-graphviz/src/condition.rs deleted file mode 100644 index 741b16d9a..000000000 --- a/lib/components/fabro-graphviz/src/condition.rs +++ /dev/null @@ -1,571 +0,0 @@ -/// Condition expression parser for edge guards (spec Section 10). -/// -/// Grammar: -/// ```text -/// Expr ::= OrExpr -/// OrExpr ::= AndExpr ('||' AndExpr)* -/// AndExpr ::= UnaryExpr ('&&' UnaryExpr)* -/// UnaryExpr ::= '!' UnaryExpr | Clause -/// Clause ::= Key Op Literal | Key (bare key = truthy) -/// Op ::= '=' | '!=' | '>' | '<' | '>=' | '<=' -/// | 'contains' | 'matches' -/// Literal ::= String | Integer | Boolean | BareLiteral -/// BareLiteral ::= [A-Za-z_][A-Za-z0-9_.:-]* -/// ``` -use crate::error::Error; - -// --------------------------------------------------------------------------- -// AST -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq)] -pub enum ConditionExpr { - Clause(Clause), - Not(Box), - And(Vec), - Or(Vec), -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Clause { - pub key: String, - pub op: Op, - pub value: String, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum Op { - Eq, - NotEq, - Gt, - Lt, - Gte, - Lte, - Contains, - Matches, - Truthy, -} - -// --------------------------------------------------------------------------- -// Tokenizer -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq)] -enum Token { - Word(String), - OpEq, // = - OpNotEq, // != - OpGt, // > - OpLt, // < - OpGte, // >= - OpLte, // <= - And, // && - Or, // || - Not, // ! - Contains, // contains - Matches, // matches -} - -fn tokenize(input: &str) -> Result, Error> { - let input = input.trim(); - if input.is_empty() { - return Ok(Vec::new()); - } - - let chars: Vec = input.chars().collect(); - let len = chars.len(); - let mut i = 0; - let mut tokens = Vec::new(); - - while i < len { - // Skip whitespace - if chars[i].is_whitespace() { - i += 1; - continue; - } - - // Two-char operators (longest match first) - if i + 1 < len { - let two = format!("{}{}", chars[i], chars[i + 1]); - match two.as_str() { - "&&" => { - tokens.push(Token::And); - i += 2; - continue; - } - "||" => { - tokens.push(Token::Or); - i += 2; - continue; - } - "!=" => { - tokens.push(Token::OpNotEq); - i += 2; - continue; - } - ">=" => { - tokens.push(Token::OpGte); - i += 2; - continue; - } - "<=" => { - tokens.push(Token::OpLte); - i += 2; - continue; - } - _ => {} - } - } - - // Single-char operators - match chars[i] { - '=' => { - tokens.push(Token::OpEq); - i += 1; - continue; - } - '>' => { - tokens.push(Token::OpGt); - i += 1; - continue; - } - '<' => { - tokens.push(Token::OpLt); - i += 1; - continue; - } - '!' => { - tokens.push(Token::Not); - i += 1; - continue; - } - _ => {} - } - - // Quoted string: consume `"..."` as a single word token - if chars[i] == '"' { - let start = i; - i += 1; // skip opening quote - while i < len && chars[i] != '"' { - if chars[i] == '\\' && i + 1 < len { - i += 1; // skip escaped char - } - i += 1; - } - if i < len { - i += 1; // skip closing quote - } - let word: String = chars[start..i].iter().collect(); - tokens.push(Token::Word(word)); - continue; - } - - // Word: everything up to whitespace or operator char - let start = i; - while i < len && !chars[i].is_whitespace() && !is_op_char(chars[i]) && chars[i] != '"' { - i += 1; - } - if i == start { - return Err(Error::Parse(format!( - "unexpected character '{}' in condition expression", - chars[i] - ))); - } - let word: String = chars[start..i].iter().collect(); - - // Recognize keyword operators only when they appear between words - // (not as the first or last token, and not adjacent to another operator) - match word.as_str() { - "contains" if is_word_operator_context(&tokens) => { - tokens.push(Token::Contains); - } - "matches" if is_word_operator_context(&tokens) => { - tokens.push(Token::Matches); - } - _ => { - tokens.push(Token::Word(word)); - } - } - } - - Ok(tokens) -} - -fn is_op_char(c: char) -> bool { - matches!(c, '=' | '!' | '>' | '<' | '&' | '|') -} - -/// Word operators (`contains`, `matches`) are recognized when preceded by a -/// Word token. -fn is_word_operator_context(tokens: &[Token]) -> bool { - matches!(tokens.last(), Some(Token::Word(_))) -} - -// --------------------------------------------------------------------------- -// Parser (recursive descent) -// --------------------------------------------------------------------------- - -struct Parser { - tokens: Vec, - pos: usize, -} - -impl Parser { - fn new(tokens: Vec) -> Self { - Self { tokens, pos: 0 } - } - - fn peek(&self) -> Option<&Token> { - self.tokens.get(self.pos) - } - - fn advance(&mut self) -> Option { - let tok = self.tokens.get(self.pos).cloned(); - if tok.is_some() { - self.pos += 1; - } - tok - } - - fn parse_expr(&mut self) -> Result { - self.parse_or() - } - - fn parse_or(&mut self) -> Result { - let mut children = vec![self.parse_and()?]; - while self.peek() == Some(&Token::Or) { - self.advance(); - children.push(self.parse_and()?); - } - if children.len() == 1 { - Ok(children.pop().expect("just checked length")) - } else { - Ok(ConditionExpr::Or(children)) - } - } - - fn parse_and(&mut self) -> Result { - let mut children = vec![self.parse_unary()?]; - while self.peek() == Some(&Token::And) { - self.advance(); - children.push(self.parse_unary()?); - } - if children.len() == 1 { - Ok(children.pop().expect("just checked length")) - } else { - Ok(ConditionExpr::And(children)) - } - } - - fn parse_unary(&mut self) -> Result { - if self.peek() == Some(&Token::Not) { - self.advance(); - let inner = self.parse_unary()?; - return Ok(ConditionExpr::Not(Box::new(inner))); - } - self.parse_clause() - } - - fn parse_clause(&mut self) -> Result { - let key = match self.advance() { - Some(Token::Word(w)) => w, - Some(other) => { - return Err(Error::Parse(format!( - "expected key, got {other:?} in condition expression" - ))); - } - None => { - return Err(Error::Parse( - "unexpected end of condition expression".to_string(), - )); - } - }; - - // Check for operator - let op = match self.peek() { - Some(Token::OpEq) => Some(Op::Eq), - Some(Token::OpNotEq) => Some(Op::NotEq), - Some(Token::OpGt) => Some(Op::Gt), - Some(Token::OpLt) => Some(Op::Lt), - Some(Token::OpGte) => Some(Op::Gte), - Some(Token::OpLte) => Some(Op::Lte), - Some(Token::Contains) => Some(Op::Contains), - Some(Token::Matches) => Some(Op::Matches), - _ => None, - }; - - let Some(op) = op else { - // Bare key -> truthy - return Ok(ConditionExpr::Clause(Clause { - key, - op: Op::Truthy, - value: String::new(), - })); - }; - - self.advance(); // consume the operator - - // Value: must be a Word - let value = match self.advance() { - Some(Token::Word(w)) => parse_literal(&w), - Some(other) => { - return Err(Error::Parse(format!( - "expected value after operator, got {other:?}" - ))); - } - None => { - // Allow empty value for `=` and `!=` (backward compat: `missing_key=`) - if op == Op::Eq || op == Op::NotEq { - String::new() - } else { - return Err(Error::Parse("expected value after operator".to_string())); - } - } - }; - - // Validate regex at parse time - if op == Op::Matches { - regex::Regex::new(&value) - .map_err(|e| Error::Parse(format!("invalid regex pattern '{value}': {e}")))?; - } - - Ok(ConditionExpr::Clause(Clause { key, op, value })) - } -} - -/// Strip surrounding double-quotes from a literal value. -/// Bare values pass through unchanged. -fn parse_literal(raw: &str) -> String { - let trimmed = raw.trim(); - if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { - trimmed[1..trimmed.len() - 1] - .replace("\\\"", "\"") - .replace("\\\\", "\\") - } else { - trimmed.to_string() - } -} - -fn parse_expression(expr: &str) -> Result { - let tokens = tokenize(expr)?; - if tokens.is_empty() { - return Ok(ConditionExpr::And(Vec::new())); - } - let mut parser = Parser::new(tokens); - let result = parser.parse_expr()?; - if parser.pos < parser.tokens.len() { - return Err(Error::Parse(format!( - "unexpected token {:?} in condition expression", - parser.tokens[parser.pos] - ))); - } - Ok(result) -} - -/// Parse and validate a condition expression. -/// -/// # Errors -/// -/// Returns an error if the expression contains invalid syntax. -pub fn parse_condition(expr: &str) -> Result<(), Error> { - parse_expression(expr)?; - Ok(()) -} - -/// Parse a condition expression and return the AST. -/// -/// # Errors -/// -/// Returns an error if the expression contains invalid syntax. -pub fn parse_condition_expr(expr: &str) -> Result { - parse_expression(expr) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_condition_validates() { - assert!(parse_condition("outcome=succeeded").is_ok()); - assert!(parse_condition("outcome=succeeded && context.x=y").is_ok()); - assert!(parse_condition("").is_ok()); - } - - #[test] - fn parse_condition_accepts_bare_key() { - assert!(parse_condition("some_flag").is_ok()); - } - - #[test] - fn parse_eq_into_clause() { - let expr = parse_expression("outcome=succeeded").unwrap(); - assert_eq!( - expr, - ConditionExpr::Clause(Clause { - key: "outcome".to_string(), - op: Op::Eq, - value: "succeeded".to_string(), - }) - ); - } - - #[test] - fn parse_and_into_and_node() { - let expr = parse_expression("a=1 && b=2").unwrap(); - assert_eq!( - expr, - ConditionExpr::And(vec![ - ConditionExpr::Clause(Clause { - key: "a".to_string(), - op: Op::Eq, - value: "1".to_string(), - }), - ConditionExpr::Clause(Clause { - key: "b".to_string(), - op: Op::Eq, - value: "2".to_string(), - }), - ]) - ); - } - - #[test] - fn parse_bare_key_into_truthy() { - let expr = parse_expression("some_flag").unwrap(); - assert_eq!( - expr, - ConditionExpr::Clause(Clause { - key: "some_flag".to_string(), - op: Op::Truthy, - value: String::new(), - }) - ); - } - - #[test] - fn parse_not_eq_into_clause() { - let expr = parse_expression("outcome!=failed").unwrap(); - assert_eq!( - expr, - ConditionExpr::Clause(Clause { - key: "outcome".to_string(), - op: Op::NotEq, - value: "failed".to_string(), - }) - ); - } - - #[test] - fn parse_numeric_comparisons() { - assert!(parse_condition("x > 5").is_ok()); - assert!(parse_condition("x >= 5").is_ok()); - assert!(parse_condition("x < 5").is_ok()); - assert!(parse_condition("x <= 5").is_ok()); - } - - #[test] - fn parse_contains() { - assert!(parse_condition("x contains y").is_ok()); - } - - #[test] - fn parse_matches() { - assert!(parse_condition("x matches ^ok$").is_ok()); - } - - #[test] - fn matches_invalid_regex_fails_parse() { - assert!(parse_condition("x matches [bad").is_err()); - } - - #[test] - fn parse_or() { - assert!(parse_condition("a=1 || b=2").is_ok()); - } - - #[test] - fn parse_not() { - assert!(parse_condition("!x=y").is_ok()); - } - - // ----------------------------------------------------------------------- - // parse_literal: quote stripping - // ----------------------------------------------------------------------- - - #[test] - fn parse_literal_bare_value() { - assert_eq!(parse_literal("success"), "success"); - } - - #[test] - fn parse_literal_strips_quotes() { - assert_eq!(parse_literal("\"success\""), "success"); - } - - #[test] - fn parse_literal_unescapes_inner_quotes() { - assert_eq!(parse_literal(r#""say \"hello\"""#), r#"say "hello""#); - } - - #[test] - fn parse_literal_single_quote_not_stripped() { - // Only double-quotes are stripped - assert_eq!(parse_literal("\"partial"), "\"partial"); - } - - #[test] - fn parse_literal_empty_quoted_string() { - assert_eq!(parse_literal("\"\""), ""); - } - - // ----------------------------------------------------------------------- - // Quoted string values in conditions - // ----------------------------------------------------------------------- - - #[test] - fn quoted_value_stripped_in_clause() { - let expr = parse_expression(r#"outcome="succeeded""#).unwrap(); - assert_eq!( - expr, - ConditionExpr::Clause(Clause { - key: "outcome".to_string(), - op: Op::Eq, - value: "succeeded".to_string(), - }) - ); - } - - #[test] - fn bare_and_quoted_values_produce_same_clause() { - let bare = parse_expression("outcome=succeeded").unwrap(); - let quoted = parse_expression(r#"outcome="succeeded""#).unwrap(); - assert_eq!(bare, quoted); - } - - #[test] - fn quoted_value_with_not_eq() { - let expr = parse_expression(r#"outcome!="failed""#).unwrap(); - assert_eq!( - expr, - ConditionExpr::Clause(Clause { - key: "outcome".to_string(), - op: Op::NotEq, - value: "failed".to_string(), - }) - ); - } - - #[test] - fn quoted_value_with_spaces() { - let expr = parse_expression(r#"context.msg="hello world""#).unwrap(); - assert_eq!( - expr, - ConditionExpr::Clause(Clause { - key: "context.msg".to_string(), - op: Op::Eq, - value: "hello world".to_string(), - }) - ); - } -} diff --git a/lib/components/fabro-graphviz/src/fidelity.rs b/lib/components/fabro-graphviz/src/fidelity.rs deleted file mode 100644 index 98605c135..000000000 --- a/lib/components/fabro-graphviz/src/fidelity.rs +++ /dev/null @@ -1,104 +0,0 @@ -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString, VariantArray}; - -/// Fidelity mode controlling how much prior context is provided to LLM -/// sessions. -#[derive( - Debug, - Clone, - Copy, - Default, - PartialEq, - Eq, - Hash, - Display, - EnumString, - VariantArray, - Serialize, - Deserialize, -)] -#[strum(serialize_all = "lowercase")] -#[serde(rename_all = "lowercase")] -pub enum Fidelity { - /// Complete context, no summarization — sessions share a thread. - Full, - /// Minimal: only graph goal and run ID. - Truncate, - /// Structured nested-bullet summary (default). - #[default] - Compact, - /// Brief textual summary (~600 token target). - #[strum(serialize = "summary:low")] - #[serde(rename = "summary:low")] - SummaryLow, - /// Moderate textual summary (~1500 token target). - #[strum(serialize = "summary:medium")] - #[serde(rename = "summary:medium")] - SummaryMedium, - /// Detailed per-stage Markdown report. - #[strum(serialize = "summary:high")] - #[serde(rename = "summary:high")] - SummaryHigh, -} - -impl Fidelity { - /// All supported fidelity modes in display order. - #[must_use] - pub fn variants() -> &'static [Self] { - Self::VARIANTS - } - - /// Degrade full fidelity to summary:high (used on checkpoint resume). - #[must_use] - pub fn degraded(self) -> Self { - match self { - Self::Full => Self::SummaryHigh, - other => other, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fidelity_display_roundtrips() { - for mode in Fidelity::variants() { - let s = mode.to_string(); - let parsed: Fidelity = s.parse().unwrap(); - assert_eq!(parsed, *mode); - } - } - - #[test] - fn fidelity_default_is_compact() { - assert_eq!(Fidelity::default(), Fidelity::Compact); - } - - #[test] - fn fidelity_degraded_full_becomes_summary_high() { - assert_eq!(Fidelity::Full.degraded(), Fidelity::SummaryHigh); - } - - #[test] - fn fidelity_degraded_non_full_unchanged() { - assert_eq!(Fidelity::Compact.degraded(), Fidelity::Compact); - assert_eq!(Fidelity::SummaryHigh.degraded(), Fidelity::SummaryHigh); - } - - #[test] - fn fidelity_unknown_mode_errors() { - assert!("bogus".parse::().is_err()); - } - - #[test] - fn fidelity_serde_matches_strum_display() { - for mode in Fidelity::variants() { - let json = serde_json::to_value(mode).unwrap(); - assert_eq!(json, serde_json::Value::String(mode.to_string())); - let parsed: Fidelity = serde_json::from_value(json).unwrap(); - assert_eq!(parsed, *mode); - } - } -} diff --git a/lib/components/fabro-graphviz/src/lib.rs b/lib/components/fabro-graphviz/src/lib.rs index ac4a2562f..b9482f650 100644 --- a/lib/components/fabro-graphviz/src/lib.rs +++ b/lib/components/fabro-graphviz/src/lib.rs @@ -1,10 +1,7 @@ -pub mod condition; pub mod error; -pub mod fidelity; pub mod graph; pub mod parser; pub mod render; pub mod stylesheet; pub use error::{Error, Result}; -pub use fidelity::Fidelity; diff --git a/lib/components/fabro-manifest/src/workflow_bundler.rs b/lib/components/fabro-manifest/src/workflow_bundler.rs index da1588bcd..ad6f420f1 100644 --- a/lib/components/fabro-manifest/src/workflow_bundler.rs +++ b/lib/components/fabro-manifest/src/workflow_bundler.rs @@ -434,9 +434,9 @@ impl<'a> WorkflowBundler<'a> { for image in layer.environment_images() { self.collect_environment_dockerfile(files, base_dir, config_path, image)?; } - if self.workflow_version_projection { - self.collect_config_goal_files(files, base_dir, config_path, entrypoint, &layer)?; - } + // The goal file rides with the manifest: Petri reads `[run.goal] + // file` from the bundle at check, as it does for a version. + self.collect_config_goal_files(files, base_dir, config_path, entrypoint, &layer)?; Ok(()) } diff --git a/lib/components/fabro-petri/src/check.rs b/lib/components/fabro-petri/src/check.rs index 60258ccb5..010952b20 100644 --- a/lib/components/fabro-petri/src/check.rs +++ b/lib/components/fabro-petri/src/check.rs @@ -75,13 +75,17 @@ pub struct Launch { /// the runtime. #[derive(Clone, Default)] pub struct CheckRequest { - pub bundle: Bundle, + pub bundle: Bundle, /// The intent's inputs, under which `[run.inputs]` defaults fill in. - pub inputs: BTreeMap, + pub inputs: BTreeMap, /// The server's run variables, read by `{{ vars.NAME }}`. - pub vars: BTreeMap, - pub launch: Launch, - pub runtime: RuntimeSpec, + pub vars: BTreeMap, + pub launch: Launch, + pub runtime: RuntimeSpec, + /// Whether a template that reads an input nothing binds is a warning + /// that leaves the text unrendered, instead of an error: a validation + /// before the run's inputs exist sets it; a run never does. + pub unbound_is_warning: bool, } /// Petri's diagnostic, in the shape Fabro's create handler maps onto its @@ -158,7 +162,8 @@ pub fn check(request: &CheckRequest) -> Result { entrypoint: bundle.entrypoint.clone(), })?; let runtime = request.runtime.runtime(false); - let inputs = compile_inputs(&request.inputs, &request.vars, &request.launch); + let mut inputs = compile_inputs(&request.inputs, &request.vars, &request.launch); + inputs.unbound_is_warning = request.unbound_is_warning; let lowered = runtime .check_source(&bundle.entrypoint, text, &bundle.files(), None, &inputs) .map_err(CheckError::Load)?; diff --git a/lib/components/fabro-petri/tests/check.rs b/lib/components/fabro-petri/tests/check.rs index c07741663..2796f724d 100644 --- a/lib/components/fabro-petri/tests/check.rs +++ b/lib/components/fabro-petri/tests/check.rs @@ -71,6 +71,7 @@ fn request(bundle: Bundle, runtime: RuntimeSpec) -> CheckRequest { vars: BTreeMap::new(), launch: Launch::default(), runtime, + unbound_is_warning: false, } } @@ -131,18 +132,19 @@ async fn the_hello_bundle_is_admitted_and_round_trips_through_the_blob_store() { async fn a_launch_binds_the_repository_and_the_model_default() { let repository = tempfile::tempdir().expect("a temp dir"); let request = CheckRequest { - bundle: bundle(&[ + bundle: bundle(&[ ("workflow.fabro", COMMAND_WORKFLOW), ("workflow.toml", SETTINGS), ]), - inputs: BTreeMap::new(), - vars: BTreeMap::new(), - launch: Launch { + inputs: BTreeMap::new(), + vars: BTreeMap::new(), + launch: Launch { model: Some("gpt-5.4".to_string()), provider: None, repository: Some(repository.path().to_path_buf()), }, - runtime: RuntimeSpec::default(), + runtime: RuntimeSpec::default(), + unbound_is_warning: false, }; let admitted = check::check(&request).expect("the command bundle is admitted"); diff --git a/lib/components/fabro-petri/tests/hooks.rs b/lib/components/fabro-petri/tests/hooks.rs index cd90195ff..e22e4f19f 100644 --- a/lib/components/fabro-petri/tests/hooks.rs +++ b/lib/components/fabro-petri/tests/hooks.rs @@ -83,7 +83,7 @@ const SETTINGS: &str = "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n /// The bundle admitted the way the create handler admits it. fn admit(workflow: &str, settings: &str) -> AdmittedGraphs { let request = CheckRequest { - bundle: Bundle { + bundle: Bundle { files: BTreeMap::from([ ("workflow.fabro".to_string(), workflow.to_string()), ("workflow.toml".to_string(), settings.to_string()), @@ -91,10 +91,11 @@ fn admit(workflow: &str, settings: &str) -> AdmittedGraphs { entrypoint: "workflow.fabro".to_string(), project_toml: None, }, - inputs: BTreeMap::new(), - vars: BTreeMap::new(), - launch: Launch::default(), - runtime: RuntimeSpec::default(), + inputs: BTreeMap::new(), + vars: BTreeMap::new(), + launch: Launch::default(), + runtime: RuntimeSpec::default(), + unbound_is_warning: false, }; let admitted = check::check(&request).expect("the bundle is admitted"); AdmittedGraphs { diff --git a/lib/components/fabro-petri/tests/support/mod.rs b/lib/components/fabro-petri/tests/support/mod.rs index 0458427e7..3c8d00cef 100644 --- a/lib/components/fabro-petri/tests/support/mod.rs +++ b/lib/components/fabro-petri/tests/support/mod.rs @@ -88,6 +88,7 @@ pub(crate) fn admit( vars: BTreeMap::new(), launch, runtime: runtime.clone(), + unbound_is_warning: false, }; let admitted = check::check(&request) .unwrap_or_else(|error| panic!("the workflow is admitted: {error:?}")); diff --git a/lib/components/fabro-validate/Cargo.toml b/lib/components/fabro-validate/Cargo.toml deleted file mode 100644 index 4ffd06a2a..000000000 --- a/lib/components/fabro-validate/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "fabro-validate" -edition.workspace = true -version.workspace = true -publish = false -license.workspace = true -description = "Validation and lint rules for Fabro workflow graphs" - -[lib] -doctest = false - -[lints] -workspace = true - -[dependencies] -fabro-acp = { path = "../fabro-acp", default-features = false } -fabro-graphviz = { path = "../fabro-graphviz" } -fabro-llm = { path = "../fabro-llm" } -fabro-types = { path = "../../foundation/fabro-types" } -serde = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -fabro-llm = { path = "../fabro-llm", features = ["test-support"] } -toml = { workspace = true } diff --git a/lib/components/fabro-validate/src/lib.rs b/lib/components/fabro-validate/src/lib.rs deleted file mode 100644 index 1605dcc3a..000000000 --- a/lib/components/fabro-validate/src/lib.rs +++ /dev/null @@ -1,461 +0,0 @@ -pub mod rules; - -use fabro_graphviz::graph::Graph; -use fabro_llm::lithos_catalog::Catalog; -use serde::{Deserialize, Serialize}; - -/// Severity level for validation diagnostics. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum Severity { - Error, - Warning, - Info, -} - -/// A validation diagnostic produced by a lint rule. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Diagnostic { - pub rule: String, - pub severity: Severity, - pub message: String, - pub node_id: Option, - pub edge: Option<(String, String)>, - pub fix: Option, - pub source_path: Option, - pub line: Option, - pub column: Option, - pub span_start: Option, - pub span_len: Option, - #[serde(default)] - pub related: Vec, -} - -impl Default for Diagnostic { - fn default() -> Self { - Self { - rule: String::new(), - severity: Severity::Info, - message: String::new(), - node_id: None, - edge: None, - fix: None, - source_path: None, - line: None, - column: None, - span_start: None, - span_len: None, - related: Vec::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RelatedDiagnostic { - pub message: String, - pub source_path: Option, - pub line: Option, - pub column: Option, -} - -/// A lint rule that validates a graph. -pub trait LintRule { - fn name(&self) -> &'static str; - fn apply(&self, graph: &Graph) -> Vec; -} - -/// Validation error returned when error-severity diagnostics are present. -#[derive(Debug, thiserror::Error)] -#[error("Validation error: {0}")] -pub struct ValidationError(pub String); - -/// Run all built-in lint rules (and any extra rules) against the graph. -#[must_use] -pub fn validate(graph: &Graph, extra_rules: &[&dyn LintRule]) -> Vec { - let built_in = rules::built_in_rules(); - let mut diagnostics = Vec::new(); - for rule in &built_in { - diagnostics.extend(rule.apply(graph)); - } - for rule in extra_rules { - diagnostics.extend(rule.apply(graph)); - } - diagnostics -} - -/// Run all built-in catalog-free lint rules, caller-supplied model catalog -/// rules, and any extra rules against the graph. -#[must_use] -pub fn validate_with_catalog( - graph: &Graph, - catalog: &Catalog, - extra_rules: &[&dyn LintRule], -) -> Vec { - let mut diagnostics = validate(graph, &[]); - let catalog_rules = rules::catalog_rules(catalog); - for rule in &catalog_rules { - diagnostics.extend(rule.apply(graph)); - } - for rule in extra_rules { - diagnostics.extend(rule.apply(graph)); - } - diagnostics -} - -/// If any Error-severity diagnostics are present, return `ValidationError`. -/// -/// # Errors -/// Returns `ValidationError` with joined error messages. -pub fn raise_on_errors(diagnostics: &[Diagnostic]) -> Result<(), ValidationError> { - let mut errors = diagnostics - .iter() - .filter(|d| d.severity == Severity::Error) - .peekable(); - if errors.peek().is_some() { - let message = errors - .map(|d| d.message.as_str()) - .collect::>() - .join("; "); - return Err(ValidationError(message)); - } - Ok(()) -} - -/// Run all built-in lint rules (and any extra rules). Returns Err if any -/// Error-severity diagnostics are found. -/// -/// # Errors -/// Returns `ValidationError` if any Error-severity diagnostics are found. -pub fn validate_or_raise( - graph: &Graph, - extra_rules: &[&dyn LintRule], -) -> Result, ValidationError> { - let diagnostics = validate(graph, extra_rules); - raise_on_errors(&diagnostics)?; - Ok(diagnostics) -} - -/// Run catalog-aware validation and return `ValidationError` if any -/// Error-severity diagnostics are found. -/// -/// # Errors -/// Returns `ValidationError` if any Error-severity diagnostics are found. -pub fn validate_with_catalog_or_raise( - graph: &Graph, - catalog: &Catalog, - extra_rules: &[&dyn LintRule], -) -> Result, ValidationError> { - let diagnostics = validate_with_catalog(graph, catalog, extra_rules); - raise_on_errors(&diagnostics)?; - Ok(diagnostics) -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; - use fabro_graphviz::parser; - use fabro_llm::lithos_catalog::Catalog; - use fabro_llm::test_support::test_catalog_with_overlay; - - use super::*; - - fn minimal_valid_graph() -> Graph { - let mut g = Graph::new("test"); - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("start".to_string(), start); - - let mut exit = Node::new("exit"); - exit.attrs.insert( - "shape".to_string(), - AttrValue::String("Msquare".to_string()), - ); - g.nodes.insert("exit".to_string(), exit); - - g.edges.push(Edge::new("start", "exit")); - g - } - - fn graph_with_model_and_provider(model: &str, provider: &str) -> Graph { - let mut g = Graph::new("test"); - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("start".to_string(), start); - - let mut work = Node::new("work"); - work.attrs - .insert("model".to_string(), AttrValue::String(model.to_string())); - work.attrs.insert( - "provider".to_string(), - AttrValue::String(provider.to_string()), - ); - g.nodes.insert("work".to_string(), work); - - let mut exit = Node::new("exit"); - exit.attrs.insert( - "shape".to_string(), - AttrValue::String("Msquare".to_string()), - ); - g.nodes.insert("exit".to_string(), exit); - - g.edges.push(Edge::new("start", "work")); - g.edges.push(Edge::new("work", "exit")); - g - } - - /// An operator-defined provider layered over the built-ins, the shape an - /// `[llm]` overlay produces. - fn custom_catalog() -> Catalog { - test_catalog_with_overlay( - r#" -[providers.acme-venice] -display_name = "Acme Venice" -adapter = "openai-compatible" -codec = "openai-chat" -base_url = "https://api.venice.ai/api/v1" -auth = { type = "bearer" } -default_model = "venice-large" - -[providers.acme-venice.metadata.agent] -profile = "openai" - -[providers.acme-venice.models.venice-large] -display_name = "Venice Large" -api_model = "venice-large" -limits = { context_tokens = 128000, max_output_tokens = 8192 } -capabilities = { text = true, tools = true } -"#, - ) - } - - #[test] - fn validate_minimal_valid_graph_has_no_errors() { - let g = minimal_valid_graph(); - let diagnostics = validate(&g, &[]); - let errors: Vec<_> = diagnostics - .iter() - .filter(|d| d.severity == Severity::Error) - .collect(); - assert!(errors.is_empty(), "Expected no errors, got: {errors:?}"); - } - - #[test] - fn validate_or_raise_passes_for_valid_graph() { - let g = minimal_valid_graph(); - let result = validate_or_raise(&g, &[]); - assert!(result.is_ok()); - } - - #[test] - fn validate_rejects_join_policy_on_any_node() { - let mut g = minimal_valid_graph(); - - let mut fork = Node::new("fork"); - fork.attrs.insert( - "shape".to_string(), - AttrValue::String("component".to_string()), - ); - fork.attrs.insert( - "join_policy".to_string(), - AttrValue::String("wait_all".to_string()), - ); - g.nodes.insert("fork".to_string(), fork); - - let mut custom = Node::new("custom"); - custom.attrs.insert( - "type".to_string(), - AttrValue::String("custom.handler".to_string()), - ); - custom.attrs.insert( - "join_policy".to_string(), - AttrValue::String("first_success".to_string()), - ); - g.nodes.insert("custom".to_string(), custom); - - let diagnostics = validate(&g, &[]); - let removed = diagnostics - .iter() - .filter(|d| d.rule == "join_policy_removed") - .collect::>(); - - assert_eq!(removed.len(), 2, "diagnostics: {diagnostics:?}"); - assert!(removed.iter().all(|d| d.severity == Severity::Error)); - assert!( - removed - .iter() - .all(|d| d.message.contains("Remove 'join_policy'")) - ); - assert_eq!( - removed - .iter() - .filter_map(|d| d.node_id.as_deref()) - .collect::>(), - std::collections::BTreeSet::from(["custom", "fork"]), - ); - } - - #[test] - fn validate_or_raise_fails_for_missing_start() { - let mut g = Graph::new("test"); - let mut exit = Node::new("exit"); - exit.attrs.insert( - "shape".to_string(), - AttrValue::String("Msquare".to_string()), - ); - g.nodes.insert("exit".to_string(), exit); - let result = validate_or_raise(&g, &[]); - assert!(result.is_err()); - } - - #[test] - fn validate_or_raise_fails_for_missing_exit() { - let mut g = Graph::new("test"); - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("start".to_string(), start); - let result = validate_or_raise(&g, &[]); - assert!(result.is_err()); - } - - #[test] - fn validate_runs_extra_rules() { - struct AlwaysWarnRule; - impl LintRule for AlwaysWarnRule { - fn name(&self) -> &'static str { - "always_warn" - } - fn apply(&self, _graph: &Graph) -> Vec { - vec![Diagnostic { - rule: "always_warn".to_string(), - severity: Severity::Warning, - message: "custom warning".to_string(), - node_id: None, - edge: None, - fix: None, - - ..Diagnostic::default() - }] - } - } - let g = minimal_valid_graph(); - let extra = AlwaysWarnRule; - let diagnostics = validate(&g, &[&extra]); - let custom: Vec<_> = diagnostics - .iter() - .filter(|d| d.rule == "always_warn") - .collect(); - assert_eq!(custom.len(), 1); - } - - #[test] - fn validate_does_not_run_catalog_aware_model_rules() { - let g = graph_with_model_and_provider("not-in-any-catalog", "not-a-provider"); - - let diagnostics = validate(&g, &[]); - - assert!( - diagnostics - .iter() - .all(|d| d.rule != "node_model_known" && d.rule != "stylesheet_model_known"), - "catalog-free validation should not emit model/provider diagnostics: {diagnostics:?}" - ); - } - - #[test] - fn validate_with_catalog_accepts_custom_catalog_entries() { - let g = graph_with_model_and_provider("venice-large", "acme-venice"); - let catalog = custom_catalog(); - - let diagnostics = validate_with_catalog(&g, &catalog, &[]); - - assert!( - diagnostics - .iter() - .all(|d| d.rule != "node_model_known" && d.rule != "stylesheet_model_known"), - "custom catalog entries should validate cleanly: {diagnostics:?}" - ); - } - - #[test] - fn validate_with_catalog_warns_for_unknown_model_and_provider() { - let g = graph_with_model_and_provider("missing-model", "missing-provider"); - let catalog = custom_catalog(); - - let diagnostics = validate_with_catalog(&g, &catalog, &[]); - - assert!( - diagnostics - .iter() - .any(|d| d.rule == "node_model_known" && d.message.contains("missing-model")), - "missing model diagnostic not found: {diagnostics:?}" - ); - assert!( - diagnostics.iter().any(|d| d.rule == "node_model_known" - && d.message.contains("missing-provider") - && d.message.contains("acme-venice")), - "missing provider diagnostic not found: {diagnostics:?}" - ); - } - - #[test] - fn diagnostic_severity_eq() { - assert_eq!(Severity::Error, Severity::Error); - assert_ne!(Severity::Error, Severity::Warning); - assert_ne!(Severity::Warning, Severity::Info); - } - - #[expect( - clippy::disallowed_methods, - reason = "unit test reads checked-in DOT compatibility fixtures synchronously" - )] - #[test] - fn dot_compatibility_corpus_validates() { - let fixtures = dot_compatibility_fixtures(); - - assert_eq!( - fixtures.len(), - 3, - "dot compatibility corpus should stay intentionally small" - ); - - for fixture in fixtures { - let source = std::fs::read_to_string(&fixture) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture.display())); - let graph = parser::parse(&source) - .unwrap_or_else(|err| panic!("failed to parse {}: {err}", fixture.display())); - - validate_or_raise(&graph, &[]) - .unwrap_or_else(|err| panic!("failed to validate {}: {err}", fixture.display())); - } - } - - #[expect( - clippy::disallowed_methods, - reason = "unit test helper enumerates checked-in DOT compatibility fixtures synchronously" - )] - fn dot_compatibility_fixtures() -> Vec { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../../test/dot-compatibility"); - let mut fixtures = std::fs::read_dir(&dir) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) - .map(|entry| { - entry - .unwrap_or_else(|err| { - panic!("failed to read entry in {}: {err}", dir.display()) - }) - .path() - }) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("fabro")) - .collect::>(); - fixtures.sort(); - fixtures - } -} diff --git a/lib/components/fabro-validate/src/rules/all_conditional_edges.rs b/lib/components/fabro-validate/src/rules/all_conditional_edges.rs deleted file mode 100644 index 81028c99f..000000000 --- a/lib/components/fabro-validate/src/rules/all_conditional_edges.rs +++ /dev/null @@ -1,116 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "all_conditional_edges" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - let outgoing = graph.outgoing_edges(&node.id); - if outgoing.is_empty() { - continue; - } - let all_conditional = outgoing - .iter() - .all(|e| e.condition().is_some_and(|c| !c.is_empty())); - if all_conditional { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Node '{}' has all conditional outgoing edges with no unconditional fallback", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Add at least one unconditional edge as a fallback".to_string(), - ), - - ..Diagnostic::default()}); - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn all_conditional_edges_rule_all_conditional() { - let mut g = minimal_graph(); - g.nodes.insert("work".to_string(), Node::new("work")); - g.edges.push({ - let mut e = Edge::new("work", "exit"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded".to_string()), - ); - e - }); - g.edges.push({ - let mut e = Edge::new("work", "start"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=failed".to_string()), - ); - e - }); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert_eq!(d[0].node_id.as_deref(), Some("work")); - } - - #[test] - fn all_conditional_edges_rule_mix_conditional_unconditional() { - let mut g = minimal_graph(); - g.nodes.insert("work".to_string(), Node::new("work")); - g.edges.push({ - let mut e = Edge::new("work", "exit"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded".to_string()), - ); - e - }); - g.edges.push(Edge::new("work", "start")); // unconditional fallback - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn all_conditional_edges_rule_only_unconditional() { - let g = minimal_graph(); // start -> exit is unconditional - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn all_conditional_edges_rule_exit_node_no_outgoing() { - let g = minimal_graph(); // exit has no outgoing edges - let rule = Rule; - let d = rule.apply(&g); - // exit node has no outgoing edges, so rule doesn't fire - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/auto_status_deprecated.rs b/lib/components/fabro-validate/src/rules/auto_status_deprecated.rs deleted file mode 100644 index 5f258a52b..000000000 --- a/lib/components/fabro-validate/src/rules/auto_status_deprecated.rs +++ /dev/null @@ -1,163 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -/// `auto_status=true` is the deprecated spelling of `on_failure="succeed"`. -/// The runtime still honors it as an alias; this rule points workflows at the -/// explicit policy. -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "auto_status_deprecated" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut nodes: Vec<_> = graph - .nodes - .values() - .filter(|node| node.attrs.contains_key("auto_status")) - .collect(); - nodes.sort_unstable_by(|a, b| a.id.cmp(&b.id)); - - nodes - .into_iter() - .map(|node| { - let (message, fix) = if node.attrs.contains_key("on_failure") { - ( - format!( - "Node '{}' sets deprecated 'auto_status', which is ignored because \ - 'on_failure' is set", - node.id - ), - "Remove 'auto_status'", - ) - } else if node.auto_status() { - ( - format!("Node '{}' sets deprecated 'auto_status=true'", node.id), - "Use on_failure=\"succeed\" instead", - ) - } else { - ( - format!( - "Node '{}' sets deprecated 'auto_status', which has no effect \ - unless it is true", - node.id - ), - "Remove 'auto_status'", - ) - }; - Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message, - node_id: Some(node.id.clone()), - fix: Some(fix.to_string()), - ..Diagnostic::default() - } - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Graph}; - - use super::Rule; - use crate::rules::test_support::{minimal_graph, node_with_attrs}; - use crate::{LintRule, Severity}; - - fn graph_with_auto_status(value: AttrValue, on_failure: Option<&str>) -> Graph { - let mut graph = minimal_graph(); - let mut node = match on_failure { - Some(policy) => node_with_attrs("work", &[("on_failure", policy)]), - None => node_with_attrs("work", &[]), - }; - node.attrs.insert("auto_status".to_string(), value); - graph.nodes.insert("work".to_string(), node); - graph - } - - #[test] - fn accepts_graphs_without_auto_status() { - let mut graph = minimal_graph(); - graph.nodes.insert( - "work".to_string(), - node_with_attrs("work", &[("on_failure", "succeed")]), - ); - assert!(Rule.apply(&graph).is_empty()); - } - - #[test] - fn warns_for_auto_status_true_and_suggests_succeed_policy() { - let graph = graph_with_auto_status(AttrValue::Boolean(true), None); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Warning); - assert_eq!( - diagnostics[0].message, - "Node 'work' sets deprecated 'auto_status=true'" - ); - assert_eq!(diagnostics[0].node_id.as_deref(), Some("work")); - assert_eq!( - diagnostics[0].fix.as_deref(), - Some("Use on_failure=\"succeed\" instead") - ); - } - - #[test] - fn warns_that_auto_status_is_ignored_when_on_failure_is_set() { - let graph = graph_with_auto_status(AttrValue::Boolean(true), Some("exit")); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Warning); - assert_eq!( - diagnostics[0].message, - "Node 'work' sets deprecated 'auto_status', which is ignored because 'on_failure' is set" - ); - assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'")); - } - - #[test] - fn warns_for_auto_status_false() { - let graph = graph_with_auto_status(AttrValue::Boolean(false), None); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Warning); - assert_eq!( - diagnostics[0].message, - "Node 'work' sets deprecated 'auto_status', which has no effect unless it is true" - ); - assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'")); - } - - #[test] - fn reports_nodes_in_id_order() { - let mut graph = minimal_graph(); - for id in ["zeta", "alpha"] { - let mut node = node_with_attrs(id, &[]); - node.attrs - .insert("auto_status".to_string(), AttrValue::Boolean(true)); - graph.nodes.insert(id.to_string(), node); - } - - let ids: Vec<_> = Rule - .apply(&graph) - .into_iter() - .map(|diagnostic| diagnostic.node_id.unwrap()) - .collect(); - - assert_eq!(ids, ["alpha", "zeta"]); - } -} diff --git a/lib/components/fabro-validate/src/rules/backend_valid.rs b/lib/components/fabro-validate/src/rules/backend_valid.rs deleted file mode 100644 index b59500254..000000000 --- a/lib/components/fabro-validate/src/rules/backend_valid.rs +++ /dev/null @@ -1,447 +0,0 @@ -use fabro_acp::{AcpCommandError, AcpProcessSpec}; -use fabro_graphviz::graph::{Graph, Node}; -use fabro_types::AgentBackend; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "backend_valid" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if let Some(backend) = node.backend() { - match node.agent_backend() { - Some(Err(_)) => { - diagnostics.push(unsupported_backend_diagnostic( - self.name(), - node, - backend, - )); - } - Some(Ok(AgentBackend::Acp)) => { - diagnostics.extend(validate_acp_node(self.name(), node)); - } - Some(Ok(_)) | None => {} - } - } - } - diagnostics - } -} - -fn unsupported_backend_diagnostic(rule: &str, node: &Node, backend: &str) -> Diagnostic { - if backend == "cli" { - return Diagnostic { - rule: rule.to_string(), - severity: Severity::Error, - message: "backend=\"cli\" is no longer supported; external agents must be launched \ - through backend=\"acp\" with acp.command or acp.config" - .to_string(), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Use backend=\"api\" for Fabro-owned provider execution, or backend=\"acp\" with \ - acp.command/acp.config for a user-supplied ACP process" - .to_string(), - ), - - ..Diagnostic::default() - }; - } - - let expected = AgentBackend::expected_values(); - Diagnostic { - rule: rule.to_string(), - severity: Severity::Error, - message: format!("unsupported agent backend \"{backend}\"; expected one of: {expected}"), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(format!("Use one of: {expected}")), - - ..Diagnostic::default() - } -} - -fn validate_acp_node(rule: &str, node: &Node) -> Vec { - let mut diagnostics = Vec::new(); - - if node.handler_type() != Some("agent") { - diagnostics.push(Diagnostic { - rule: rule.to_string(), - severity: Severity::Error, - message: "backend=\"acp\" is only valid on agent nodes; prompt nodes are API-only" - .to_string(), - node_id: Some(node.id.clone()), - edge: None, - fix: Some("Use backend=\"api\" on prompt nodes".to_string()), - - ..Diagnostic::default() - }); - } - - if let Err(error) = AcpProcessSpec::from_attrs( - node.legacy_acp_command_attr(), - node.acp_command_attr(), - node.acp_config_attr(), - ) { - diagnostics.push(acp_process_diagnostic(rule, node, &error)); - } - - let api_only_attrs = api_only_attrs_present(node); - if !api_only_attrs.is_empty() { - diagnostics.push(Diagnostic { - rule: rule.to_string(), - severity: Severity::Error, - message: format!( - "backend=\"acp\" does not support API-only attributes: {}", - api_only_attrs.join(", ") - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some("Remove API model/provider/control attributes from ACP nodes".to_string()), - - ..Diagnostic::default() - }); - } - - diagnostics -} - -fn acp_process_diagnostic(rule: &str, node: &Node, error: &AcpCommandError) -> Diagnostic { - match error { - AcpCommandError::LegacyCommandAttribute => Diagnostic { - rule: rule.to_string(), - severity: Severity::Error, - message: "acp_command is no longer supported; use acp.command for shell commands or \ - acp.config for JSON stdio ACP configs" - .to_string(), - node_id: Some(node.id.clone()), - edge: None, - fix: Some("Rename acp_command to acp.command".to_string()), - - ..Diagnostic::default() - }, - AcpCommandError::EmptyOverride - | AcpCommandError::MissingOverride - | AcpCommandError::InvalidCommandString => Diagnostic { - rule: rule.to_string(), - severity: Severity::Error, - message: render_acp_process_error(error), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Set acp.command to a shell command, or acp.config to a JSON stdio ACP config" - .to_string(), - ), - - ..Diagnostic::default() - }, - AcpCommandError::InvalidConfigJson(_) - | AcpCommandError::InvalidConfigShape(_) - | AcpCommandError::UnsupportedTransport => Diagnostic { - rule: rule.to_string(), - severity: Severity::Error, - message: format!( - "acp.config must be a JSON stdio ACP config: {}", - render_acp_process_error(error) - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Provide a JSON config with type=\"stdio\", command, and optional args".to_string(), - ), - - ..Diagnostic::default() - }, - } -} - -fn render_acp_process_error(error: &AcpCommandError) -> String { - error.to_string() -} - -fn api_only_attrs_present(node: &Node) -> Vec<&'static str> { - const API_ONLY_ATTRS: &[&str] = &[ - "model", - "provider", - "reasoning_effort", - "max_tokens", - "speed", - ]; - API_ONLY_ATTRS - .iter() - .copied() - .filter(|attr| node.attrs.contains_key(*attr)) - .collect() -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn backend_valid_accepts_absent_and_api() { - for backend in [None, Some("api")] { - let mut graph = minimal_graph(); - let mut node = Node::new("work"); - if let Some(backend) = backend { - node.attrs.insert( - "backend".to_string(), - AttrValue::String(backend.to_string()), - ); - } - graph.nodes.insert("work".to_string(), node); - - assert!(Rule.apply(&graph).is_empty(), "backend: {backend:?}"); - } - } - - #[test] - fn backend_valid_rejects_unknown_backend() { - let mut graph = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "backend".to_string(), - AttrValue::String("codex".to_string()), - ); - graph.nodes.insert("work".to_string(), node); - - let diagnostics = Rule.apply(&graph); - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert!( - diagnostics[0] - .message - .contains("unsupported agent backend \"codex\"; expected one of: api, acp") - ); - } - - #[test] - fn backend_valid_requires_acp_process_attr_for_acp_backend() { - let mut graph = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - graph.nodes.insert("work".to_string(), node); - - let diagnostics = Rule.apply(&graph); - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert!( - diagnostics[0] - .message - .contains("requires exactly one of acp.command or acp.config") - ); - } - - #[test] - fn backend_valid_accepts_acp_backend_with_acp_command_attr() { - let mut graph = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - node.attrs.insert( - "acp.command".to_string(), - AttrValue::String("agent-acp".to_string()), - ); - graph.nodes.insert("work".to_string(), node); - - assert!(Rule.apply(&graph).is_empty()); - } - - #[test] - fn backend_valid_rejects_cli_backend_with_migration_guidance() { - let mut graph = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("backend".to_string(), AttrValue::String("cli".to_string())); - graph.nodes.insert("work".to_string(), node); - - let diagnostics = Rule.apply(&graph); - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert!( - diagnostics[0] - .message - .contains("backend=\"cli\" is no longer supported") - ); - assert!( - diagnostics[0] - .fix - .as_deref() - .unwrap() - .contains("backend=\"acp\"") - ); - } - - #[test] - fn backend_valid_requires_exactly_one_acp_process_attr() { - let mut missing = minimal_graph(); - let mut missing_node = Node::new("missing"); - missing_node - .attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - missing.nodes.insert("missing".to_string(), missing_node); - - let diagnostics = Rule.apply(&missing); - assert_eq!(diagnostics.len(), 1); - assert!( - diagnostics[0] - .message - .contains("requires exactly one of acp.command or acp.config") - ); - - let mut both = minimal_graph(); - let mut both_node = Node::new("both"); - both_node - .attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - both_node.attrs.insert( - "acp.command".to_string(), - AttrValue::String("python3 agent.py".to_string()), - ); - both_node.attrs.insert( - "acp.config".to_string(), - AttrValue::String( - r#"{"type":"stdio","name":"agent","command":"python3","args":["agent.py"]}"# - .to_string(), - ), - ); - both.nodes.insert("both".to_string(), both_node); - - let diagnostics = Rule.apply(&both); - assert_eq!(diagnostics.len(), 1); - assert!( - diagnostics[0] - .message - .contains("requires exactly one of acp.command or acp.config") - ); - } - - #[test] - fn backend_valid_rejects_legacy_acp_command_attr() { - let mut graph = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - node.attrs.insert( - "acp_command".to_string(), - AttrValue::String("python3 agent.py".to_string()), - ); - graph.nodes.insert("work".to_string(), node); - - let diagnostics = Rule.apply(&graph); - assert_eq!(diagnostics.len(), 1); - assert!( - diagnostics[0] - .message - .contains("acp_command is no longer supported") - ); - } - - #[test] - fn backend_valid_rejects_acp_on_prompt_nodes_and_api_only_attrs() { - let mut graph = minimal_graph(); - let mut node = Node::new("prompt"); - node.attrs - .insert("type".to_string(), AttrValue::String("prompt".to_string())); - node.attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - node.attrs.insert( - "acp.command".to_string(), - AttrValue::String("python3 agent.py".to_string()), - ); - node.attrs.insert( - "model".to_string(), - AttrValue::String("gpt-5.4".to_string()), - ); - node.attrs.insert( - "reasoning_effort".to_string(), - AttrValue::String("high".to_string()), - ); - graph.nodes.insert("prompt".to_string(), node); - - let diagnostics = Rule.apply(&graph); - assert_eq!(diagnostics.len(), 2); - assert!(diagnostics.iter().any(|diagnostic| { - diagnostic - .message - .contains("backend=\"acp\" is only valid on agent nodes") - })); - assert!(diagnostics.iter().any(|diagnostic| { - diagnostic - .message - .contains("backend=\"acp\" does not support API-only attributes") - })); - } - - #[test] - fn backend_valid_rejects_invalid_acp_config_but_accepts_json_shaped_command() { - let mut command_graph = minimal_graph(); - let mut command_node = Node::new("command"); - command_node - .attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - command_node.attrs.insert( - "acp.command".to_string(), - AttrValue::String(r#"{"type":"stdio"}"#.to_string()), - ); - command_graph - .nodes - .insert("command".to_string(), command_node); - assert!(Rule.apply(&command_graph).is_empty()); - - let mut config_graph = minimal_graph(); - let mut config_node = Node::new("config"); - config_node - .attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - config_node.attrs.insert( - "acp.config".to_string(), - AttrValue::String("python3 agent.py".to_string()), - ); - config_graph.nodes.insert("config".to_string(), config_node); - - let diagnostics = Rule.apply(&config_graph); - assert_eq!(diagnostics.len(), 1); - assert!( - diagnostics[0] - .message - .contains("acp.config must be a JSON stdio ACP config") - ); - } - - #[test] - fn backend_valid_rejects_invalid_acp_command() { - let mut graph = minimal_graph(); - let mut node = Node::new("command"); - node.attrs - .insert("backend".to_string(), AttrValue::String("acp".to_string())); - node.attrs.insert( - "acp.command".to_string(), - AttrValue::String("python 'unterminated".to_string()), - ); - graph.nodes.insert("command".to_string(), node); - - let diagnostics = Rule.apply(&graph); - assert_eq!(diagnostics.len(), 1); - assert!( - diagnostics[0] - .message - .contains("failed to parse acp.command as a shell command") - ); - } -} diff --git a/lib/components/fabro-validate/src/rules/command_requires_script.rs b/lib/components/fabro-validate/src/rules/command_requires_script.rs deleted file mode 100644 index 447ac3232..000000000 --- a/lib/components/fabro-validate/src/rules/command_requires_script.rs +++ /dev/null @@ -1,146 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "command_requires_script" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if node.handler_type() != Some("command") { - continue; - } - if node.script().is_some_and(|s| !s.trim().is_empty()) { - continue; - } - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!("Command node '{}' has no 'script' to run", node.id), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Add a 'script' attribute, or remove the command shape or type if this was \ - meant to be an agent node" - .to_string(), - ), - ..Diagnostic::default() - }); - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support; - use crate::{LintRule, Severity}; - - #[test] - fn errors_on_command_shape_without_script() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "run".to_string(), - test_support::node_with_attrs("run", &[("shape", "parallelogram"), ("label", "Build")]), - ); - - let d = Rule.apply(&g); - - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert!(d[0].message.contains("no 'script'")); - assert_eq!(d[0].node_id.as_deref(), Some("run")); - } - - #[test] - fn errors_on_explicit_command_type_without_script() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "run".to_string(), - test_support::node_with_attrs("run", &[("type", "command")]), - ); - - let d = Rule.apply(&g); - - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn errors_on_legacy_tool_type_without_script() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "run".to_string(), - test_support::node_with_attrs("run", &[("type", "tool")]), - ); - - assert_eq!(Rule.apply(&g).len(), 1); - } - - #[test] - fn errors_on_blank_script() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "run".to_string(), - test_support::node_with_attrs("run", &[("shape", "parallelogram"), ("script", " ")]), - ); - - assert_eq!(Rule.apply(&g).len(), 1); - } - - #[test] - fn errors_on_non_string_script() { - let mut g = test_support::minimal_graph(); - let mut node = Node::new("run"); - node.attrs - .insert("script".to_string(), AttrValue::Integer(123)); - g.nodes.insert("run".to_string(), node); - - assert_eq!(Rule.apply(&g).len(), 1); - } - - #[test] - fn accepts_command_node_with_script() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "run".to_string(), - test_support::node_with_attrs("run", &[ - ("shape", "parallelogram"), - ("script", "cargo build"), - ]), - ); - g.nodes.insert( - "build".to_string(), - test_support::node_with_attrs("build", &[("script", "cargo build")]), - ); - - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn ignores_non_command_nodes() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "plan".to_string(), - test_support::node_with_attrs("plan", &[("prompt", "do it")]), - ); - g.nodes.insert( - "gate".to_string(), - test_support::node_with_attrs("gate", &[("shape", "hexagon")]), - ); - - assert!(Rule.apply(&g).is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/condition_syntax.rs b/lib/components/fabro-validate/src/rules/condition_syntax.rs deleted file mode 100644 index 986422e7a..000000000 --- a/lib/components/fabro-validate/src/rules/condition_syntax.rs +++ /dev/null @@ -1,276 +0,0 @@ -use fabro_graphviz::condition::parse_condition; -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "condition_syntax" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for edge in &graph.edges { - let Some(condition) = edge.condition() else { - continue; - }; - if condition.is_empty() { - continue; - } - if let Err(e) = parse_condition(condition) { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Condition '{condition}' on edge {} -> {} failed parse: {e}", - edge.from, edge.to - ), - node_id: None, - edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some( - "Use key=value, key!=value, key>value, key contains value, \ - key matches pattern, or bare key syntax" - .to_string(), - ), - - ..Diagnostic::default() - }); - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn condition_syntax_rule_valid_condition() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn condition_syntax_rule_invalid_clause() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("bad clause here".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn condition_syntax_rule_not_equals() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome!=failed".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn condition_syntax_rule_empty_condition() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs - .insert("condition".to_string(), AttrValue::String(String::new())); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn condition_syntax_rule_compound_and() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded && retries=0".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- Additional coverage: terminal_node two terminals --- - - #[test] - fn condition_syntax_rule_bare_key_truthy() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("context.passed".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- condition_syntax: context-prefixed clause with spaces is rejected --- - - #[test] - fn condition_syntax_rule_context_prefix_with_space() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("context.foo bar".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - // "context.foo bar" has an unexpected trailing word — parse error - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - // --- terminal_node: by "Exit" capitalized id --- - - #[test] - fn condition_syntax_rule_no_condition_attr() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- freeform_edge_count: freeform=false does not count --- - - #[test] - fn condition_syntax_rule_empty_key_fails_parse() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("=value".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - // parse_condition catches empty key - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert!(d[0].message.contains("failed parse")); - } - - #[test] - fn condition_syntax_rule_valid_passes_both_checks() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- condition_syntax: new operators accepted --- - - #[test] - fn condition_syntax_rule_accepts_or() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded || outcome=failed".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn condition_syntax_rule_accepts_not() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("!outcome=failed".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn condition_syntax_rule_accepts_contains() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("context.x contains y".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn condition_syntax_rule_accepts_numeric() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("context.score > 80".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn condition_syntax_rule_rejects_invalid_regex() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "condition".to_string(), - AttrValue::String("context.x matches [bad".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } -} diff --git a/lib/components/fabro-validate/src/rules/direction_valid.rs b/lib/components/fabro-validate/src/rules/direction_valid.rs deleted file mode 100644 index 1e50b94a8..000000000 --- a/lib/components/fabro-validate/src/rules/direction_valid.rs +++ /dev/null @@ -1,85 +0,0 @@ -use fabro_graphviz::graph::{AttrValue, Graph}; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -const VALID_DIRECTIONS: &[&str] = &["TB", "LR", "BT", "RL"]; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "direction_valid" - } - - fn apply(&self, graph: &Graph) -> Vec { - let Some(rankdir) = graph.attrs.get("rankdir").and_then(AttrValue::as_str) else { - return Vec::new(); - }; - if VALID_DIRECTIONS.contains(&rankdir) { - return Vec::new(); - } - vec![Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!("Graph has invalid rankdir '{rankdir}'"), - node_id: None, - edge: None, - fix: Some(format!("Use one of: {}", VALID_DIRECTIONS.join(", "))), - - ..Diagnostic::default() - }] - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::AttrValue; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn direction_valid_rule_no_rankdir() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn direction_valid_rule_valid_directions() { - let rule = Rule; - let mut g = minimal_graph(); - g.attrs - .insert("rankdir".to_string(), AttrValue::String("LR".to_string())); - assert!(rule.apply(&g).is_empty()); - - g.attrs - .insert("rankdir".to_string(), AttrValue::String("TB".to_string())); - assert!(rule.apply(&g).is_empty()); - - g.attrs - .insert("rankdir".to_string(), AttrValue::String("BT".to_string())); - assert!(rule.apply(&g).is_empty()); - - g.attrs - .insert("rankdir".to_string(), AttrValue::String("RL".to_string())); - assert!(rule.apply(&g).is_empty()); - } - - #[test] - fn direction_valid_rule_invalid_direction() { - let mut g = minimal_graph(); - g.attrs - .insert("rankdir".to_string(), AttrValue::String("XY".to_string())); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } -} diff --git a/lib/components/fabro-validate/src/rules/edge_target_exists.rs b/lib/components/fabro-validate/src/rules/edge_target_exists.rs deleted file mode 100644 index 3cfbe2130..000000000 --- a/lib/components/fabro-validate/src/rules/edge_target_exists.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::collections::HashSet; - -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl Rule { - fn diagnostic(&self, node_id: &str, from: &str, to: &str) -> Diagnostic { - Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Node '{node_id}' is referenced by edge '{from} -> {to}' but has no node \ - declaration" - ), - node_id: Some(node_id.to_string()), - edge: Some((from.to_string(), to.to_string())), - fix: Some(format!( - "Declare node '{node_id}' or correct the edge endpoint" - )), - - ..Diagnostic::default() - } - } -} - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "edge_target_exists" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - let mut reported = HashSet::new(); - for edge in &graph.edges { - for endpoint in [&edge.to, &edge.from] { - if !graph.nodes.contains_key(endpoint) && reported.insert(endpoint) { - diagnostics.push(self.diagnostic(endpoint, &edge.from, &edge.to)); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{Edge, Graph}; - use fabro_graphviz::parser; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{Diagnostic, LintRule, Severity}; - - fn parse(dot: &str) -> Graph { - parser::parse(dot).expect("fixture should parse") - } - - fn undeclared_nodes(graph: &Graph) -> Vec { - Rule.apply(graph) - .into_iter() - .map(|d| d.node_id.expect("diagnostic should name a node")) - .collect() - } - - #[test] - fn edge_only_node_is_rejected() { - let graph = parse( - r"digraph EdgeOnly { - start [shape=Mdiamond] - exit [shape=Msquare] - start -> misspelled_node - misspelled_node -> exit - }", - ); - - let diagnostics = Rule.apply(&graph); - assert_eq!(diagnostics.len(), 1, "diagnostics: {diagnostics:?}"); - let Diagnostic { - severity, - node_id, - edge, - .. - } = &diagnostics[0]; - assert_eq!(*severity, Severity::Error); - assert_eq!(node_id.as_deref(), Some("misspelled_node")); - assert_eq!( - edge.clone(), - Some(("start".to_string(), "misspelled_node".to_string())) - ); - } - - #[test] - fn declaration_after_the_edge_is_accepted() { - let graph = parse( - r#"digraph DeclaredLater { - start -> work - work [prompt="Do the work"] - work -> exit - start [shape=Mdiamond] - exit [shape=Msquare] - }"#, - ); - - assert!(Rule.apply(&graph).is_empty()); - } - - #[test] - fn chained_edges_report_every_undeclared_endpoint() { - let graph = parse( - r"digraph Chained { - start [shape=Mdiamond] - exit [shape=Msquare] - start -> first -> second -> exit - }", - ); - - assert_eq!(undeclared_nodes(&graph), vec!["first", "second"]); - } - - #[test] - fn a_node_is_reported_once_no_matter_how_many_edges_use_it() { - let graph = parse( - r"digraph Repeated { - start [shape=Mdiamond] - exit [shape=Msquare] - start -> typo - typo -> exit - typo -> start - }", - ); - - assert_eq!(undeclared_nodes(&graph), vec!["typo"]); - } - - #[test] - fn subgraph_declaration_is_accepted() { - let graph = parse( - r#"digraph Subgraphed { - start [shape=Mdiamond] - exit [shape=Msquare] - - subgraph cluster_loop { - label = "Loop A" - plan [prompt="Plan the work"] - } - - start -> plan -> exit - }"#, - ); - - assert!(Rule.apply(&graph).is_empty()); - } - - #[test] - fn edge_target_exists_rule_missing_target() { - let mut g = minimal_graph(); - g.edges.push(Edge::new("start", "nonexistent")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn edge_target_exists_rule_valid() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn edge_target_exists_rule_missing_source() { - let mut g = minimal_graph(); - g.edges.push(Edge::new("nonexistent_source", "exit")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert!(d[0].message.contains("nonexistent_source")); - } - - // --- Additional coverage: reachability no start node --- - - #[test] - fn edge_target_exists_rule_both_missing() { - let mut g = minimal_graph(); - g.edges - .push(Edge::new("nonexistent_source", "nonexistent_target")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 2); - assert_eq!(d[0].severity, Severity::Error); - assert_eq!(d[1].severity, Severity::Error); - } - - // --- reachability: multiple unreachable nodes --- - - #[test] - fn edge_target_exists_rule_no_edges() { - let mut g = minimal_graph(); - g.edges.clear(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- goal_gate_has_retry: goal_gate=false explicitly --- -} diff --git a/lib/components/fabro-validate/src/rules/exit_no_outgoing.rs b/lib/components/fabro-validate/src/rules/exit_no_outgoing.rs deleted file mode 100644 index 5ea797d34..000000000 --- a/lib/components/fabro-validate/src/rules/exit_no_outgoing.rs +++ /dev/null @@ -1,139 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "exit_no_outgoing" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for (id, node) in &graph.nodes { - let is_terminal = node.shape() == "Msquare" - || *id == "exit" - || *id == "Exit" - || *id == "end" - || *id == "End"; - if is_terminal { - let outgoing = graph.outgoing_edges(&node.id); - if !outgoing.is_empty() { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Exit node '{}' has {} outgoing edge(s) but must have none", - node.id, - outgoing.len() - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some("Remove outgoing edges from the exit node".to_string()), - - ..Diagnostic::default() - }); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn exit_no_outgoing_rule_with_outgoing() { - let mut g = minimal_graph(); - g.edges.push(Edge::new("exit", "start")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn exit_no_outgoing_rule_clean() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn exit_no_outgoing_rule_end_id_with_outgoing() { - let mut g = Graph::new("test"); - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("start".to_string(), start); - let end_node = Node::new("end"); - g.nodes.insert("end".to_string(), end_node); - g.edges.push(Edge::new("start", "end")); - g.edges.push(Edge::new("end", "start")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert_eq!(d[0].node_id, Some("end".to_string())); - } - - // --- condition_syntax: bare key (truthy check) is valid --- - - #[test] - fn exit_no_outgoing_rule_exit_capitalized_with_outgoing() { - let mut g = Graph::new("test"); - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("start".to_string(), start); - let exit_node = Node::new("Exit"); - g.nodes.insert("Exit".to_string(), exit_node); - g.edges.push(Edge::new("start", "Exit")); - g.edges.push(Edge::new("Exit", "start")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert_eq!(d[0].node_id, Some("Exit".to_string())); - } - - // --- exit_no_outgoing: by "End" capitalized id --- - - #[test] - fn exit_no_outgoing_rule_end_capitalized_with_outgoing() { - let mut g = Graph::new("test"); - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("start".to_string(), start); - let end_node = Node::new("End"); - g.nodes.insert("End".to_string(), end_node); - g.edges.push(Edge::new("start", "End")); - g.edges.push(Edge::new("End", "start")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert_eq!(d[0].node_id, Some("End".to_string())); - } - - // --- stylesheet_syntax: valid multi-rule stylesheet --- -} diff --git a/lib/components/fabro-validate/src/rules/fidelity_valid.rs b/lib/components/fabro-validate/src/rules/fidelity_valid.rs deleted file mode 100644 index a3449b3ce..000000000 --- a/lib/components/fabro-validate/src/rules/fidelity_valid.rs +++ /dev/null @@ -1,244 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl Rule { - fn fix_message() -> String { - use fabro_graphviz::Fidelity; - let modes: Vec<_> = Fidelity::variants() - .iter() - .map(ToString::to_string) - .collect(); - format!("Use one of: {}", modes.join(", ")) - } -} - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "fidelity_valid" - } - - fn apply(&self, graph: &Graph) -> Vec { - use fabro_graphviz::Fidelity; - - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if let Some(fidelity) = node.fidelity() { - if fidelity.parse::().is_err() { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node '{}' has invalid fidelity mode '{fidelity}'", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(Self::fix_message()), - - ..Diagnostic::default() - }); - } - } - } - for edge in &graph.edges { - if let Some(fidelity) = edge.fidelity() { - if fidelity.parse::().is_err() { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Edge {} -> {} has invalid fidelity mode '{fidelity}'", - edge.from, edge.to - ), - node_id: None, - edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some(Self::fix_message()), - - ..Diagnostic::default() - }); - } - } - } - if let Some(fidelity) = graph.default_fidelity() { - if fidelity.parse::().is_err() { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!("Graph has invalid default_fidelity '{fidelity}'"), - node_id: None, - edge: None, - fix: Some(Self::fix_message()), - - ..Diagnostic::default() - }); - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn fidelity_valid_rule_invalid_mode() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("invalid_mode".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - #[test] - fn fidelity_valid_rule_valid_mode() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn fidelity_valid_rule_invalid_edge_fidelity() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "fidelity".to_string(), - AttrValue::String("bogus".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].edge.is_some()); - } - - #[test] - fn fidelity_valid_rule_valid_edge_fidelity() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "fidelity".to_string(), - AttrValue::String("compact".to_string()), - ); - g.edges = vec![edge]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn fidelity_valid_rule_invalid_graph_default() { - let mut g = minimal_graph(); - g.attrs.insert( - "default_fidelity".to_string(), - AttrValue::String("wrong".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("default_fidelity")); - } - - #[test] - fn fidelity_valid_rule_valid_graph_default() { - let mut g = minimal_graph(); - g.attrs.insert( - "default_fidelity".to_string(), - AttrValue::String("summary:high".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn fidelity_valid_rule_all_summary_modes() { - let rule = Rule; - - let mut g = minimal_graph(); - let mut node = Node::new("w1"); - node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("summary:low".to_string()), - ); - g.nodes.insert("w1".to_string(), node); - assert!(rule.apply(&g).is_empty()); - - let mut g = minimal_graph(); - let mut node = Node::new("w2"); - node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("summary:medium".to_string()), - ); - g.nodes.insert("w2".to_string(), node); - assert!(rule.apply(&g).is_empty()); - - let mut g = minimal_graph(); - let mut node = Node::new("w3"); - node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("truncate".to_string()), - ); - g.nodes.insert("w3".to_string(), node); - assert!(rule.apply(&g).is_empty()); - } - - // --- Additional coverage: freeform_edge_count non-wait.human --- - - #[test] - fn fidelity_valid_rule_node_and_edge_and_graph_all_invalid() { - let mut g = minimal_graph(); - - let mut node = Node::new("work"); - node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("invalid_node".to_string()), - ); - g.nodes.insert("work".to_string(), node); - - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "fidelity".to_string(), - AttrValue::String("invalid_edge".to_string()), - ); - g.edges = vec![edge]; - - g.attrs.insert( - "default_fidelity".to_string(), - AttrValue::String("invalid_graph".to_string()), - ); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 3); - } - - // --- retry_target_exists: both retry_target and fallback on same node, - // both invalid --- -} diff --git a/lib/components/fabro-validate/src/rules/for_each_contract.rs b/lib/components/fabro-validate/src/rules/for_each_contract.rs deleted file mode 100644 index a2970a6df..000000000 --- a/lib/components/fabro-validate/src/rules/for_each_contract.rs +++ /dev/null @@ -1,231 +0,0 @@ -use fabro_graphviz::graph::{Graph, is_llm_handler_type}; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -fn diagnostic(node_id: &str, message: String, fix: impl Into) -> Diagnostic { - Diagnostic { - rule: "for_each_contract".to_string(), - severity: Severity::Error, - message, - node_id: Some(node_id.to_string()), - edge: None, - fix: Some(fix.into()), - ..Diagnostic::default() - } -} - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "for_each_contract" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - - for node in graph.nodes.values() { - if !node.attrs.contains_key("for_each") { - continue; - } - - // Reported as an error rather than through `inert_attribute`: an - // ignored tuning knob is a warning, but a fan-out that silently - // never happens is not. - if node.handler_type() != Some("parallel") { - diagnostics.push(diagnostic( - &node.id, - format!( - "Node '{}' sets 'for_each', but only parallel nodes can fan out over runtime items", - node.id - ), - "Remove 'for_each' or change the node to type=\"parallel\"", - )); - continue; - } - - if node - .for_each() - .is_none_or(|source| source.trim().is_empty()) - { - diagnostics.push(diagnostic( - &node.id, - format!( - "Node '{}' has an empty or non-string 'for_each' source", - node.id - ), - "Set 'for_each' to a context key such as \"context.candidates\"", - )); - } - - let outgoing = graph.outgoing_edges(&node.id); - if outgoing.len() != 1 { - diagnostics.push(diagnostic( - &node.id, - format!( - "Parallel node '{}' sets 'for_each' and must have exactly one outgoing template edge, but has {}", - node.id, - outgoing.len() - ), - "Keep one outgoing edge whose target is the agent or prompt to run for each item", - )); - continue; - } - - let target_id = &outgoing[0].to; - let Some(target) = graph.nodes.get(target_id) else { - continue; - }; - if target.attrs.contains_key("for_each") { - diagnostics.push(diagnostic( - &node.id, - format!( - "Parallel node '{}' targets '{}', which also sets 'for_each'; nested for_each is not supported", - node.id, target.id - ), - "Remove the nested 'for_each' and use a single runtime fan-out", - )); - } - if !is_llm_handler_type(target.handler_type()) { - diagnostics.push(diagnostic( - &node.id, - format!( - "Parallel node '{}' sets 'for_each', but template target '{}' is not an agent or prompt node", - node.id, target.id - ), - "Target one agent or prompt node from the for_each parallel node", - )); - } - } - - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - fn for_each_node(id: &str) -> Node { - let mut node = Node::new(id); - node.attrs.insert( - "type".to_string(), - AttrValue::String("parallel".to_string()), - ); - node.attrs.insert( - "for_each".to_string(), - AttrValue::String("context.items".to_string()), - ); - node - } - - #[test] - fn accepts_one_agent_template_target() { - let mut graph = minimal_graph(); - graph - .nodes - .insert("fanout".to_string(), for_each_node("fanout")); - graph - .nodes - .insert("worker".to_string(), Node::new("worker")); - graph.edges.push(Edge::new("fanout", "worker")); - - assert!(Rule.apply(&graph).is_empty()); - } - - #[test] - fn rejects_for_each_on_non_parallel_node() { - let mut graph = minimal_graph(); - let mut worker = Node::new("worker"); - worker.attrs.insert( - "for_each".to_string(), - AttrValue::String("items".to_string()), - ); - graph.nodes.insert("worker".to_string(), worker); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert!(diagnostics[0].message.contains("only parallel")); - } - - #[test] - fn rejects_multiple_template_edges() { - let mut graph = minimal_graph(); - graph - .nodes - .insert("fanout".to_string(), for_each_node("fanout")); - graph.nodes.insert("one".to_string(), Node::new("one")); - graph.nodes.insert("two".to_string(), Node::new("two")); - graph.edges.push(Edge::new("fanout", "one")); - graph.edges.push(Edge::new("fanout", "two")); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert!(diagnostics[0].message.contains("exactly one")); - } - - #[test] - fn rejects_non_llm_and_nested_template_targets() { - let mut graph = minimal_graph(); - graph - .nodes - .insert("outer".to_string(), for_each_node("outer")); - graph - .nodes - .insert("inner".to_string(), for_each_node("inner")); - graph.edges.push(Edge::new("outer", "inner")); - - let diagnostics = Rule.apply(&graph); - let outer_diagnostics = diagnostics - .iter() - .filter(|diagnostic| diagnostic.node_id.as_deref() == Some("outer")) - .collect::>(); - - assert_eq!(outer_diagnostics.len(), 2); - assert!(diagnostics.iter().all(|d| d.severity == Severity::Error)); - assert!( - outer_diagnostics - .iter() - .any(|d| d.message.contains("nested")) - ); - assert!( - outer_diagnostics - .iter() - .any(|d| d.message.contains("not an agent or prompt")) - ); - } - - #[test] - fn rejects_empty_or_non_string_source() { - for value in [ - AttrValue::String(String::new()), - AttrValue::String(" ".to_string()), - AttrValue::Integer(3), - ] { - let mut graph = minimal_graph(); - let mut fanout = for_each_node("fanout"); - fanout.attrs.insert("for_each".to_string(), value); - graph.nodes.insert("fanout".to_string(), fanout); - graph - .nodes - .insert("worker".to_string(), Node::new("worker")); - graph.edges.push(Edge::new("fanout", "worker")); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert!(diagnostics[0].message.contains("empty or non-string")); - } - } -} diff --git a/lib/components/fabro-validate/src/rules/freeform_edge_count.rs b/lib/components/fabro-validate/src/rules/freeform_edge_count.rs deleted file mode 100644 index d16ae4fc4..000000000 --- a/lib/components/fabro-validate/src/rules/freeform_edge_count.rs +++ /dev/null @@ -1,197 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "freeform_edge_count" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if node.handler_type() == Some("human") { - let freeform_count = graph - .outgoing_edges(&node.id) - .iter() - .filter(|e| e.freeform()) - .count(); - if freeform_count > 1 { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "wait.human node '{}' has {freeform_count} freeform edges but at most one is allowed", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Remove extra freeform=true edges so at most one remains".to_string(), - ), - - ..Diagnostic::default()}); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn freeform_edge_count_rule_two_freeform() { - let mut g = minimal_graph(); - let mut gate = Node::new("gate"); - gate.attrs.insert( - "shape".to_string(), - AttrValue::String("hexagon".to_string()), - ); - g.nodes.insert("gate".to_string(), gate); - g.nodes.insert("a".to_string(), Node::new("a")); - g.nodes.insert("b".to_string(), Node::new("b")); - - let mut e1 = Edge::new("gate", "a"); - e1.attrs - .insert("freeform".to_string(), AttrValue::Boolean(true)); - let mut e2 = Edge::new("gate", "b"); - e2.attrs - .insert("freeform".to_string(), AttrValue::Boolean(true)); - g.edges.push(e1); - g.edges.push(e2); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn freeform_edge_count_rule_one_freeform() { - let mut g = minimal_graph(); - let mut gate = Node::new("gate"); - gate.attrs.insert( - "shape".to_string(), - AttrValue::String("hexagon".to_string()), - ); - g.nodes.insert("gate".to_string(), gate); - g.nodes.insert("a".to_string(), Node::new("a")); - - let mut e1 = Edge::new("gate", "a"); - e1.attrs - .insert("freeform".to_string(), AttrValue::Boolean(true)); - g.edges.push(e1); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn freeform_edge_count_rule_non_wait_human_ignored() { - let mut g = minimal_graph(); - // Regular codergen node (box shape) with multiple freeform edges should not - // trigger - g.nodes.insert("a".to_string(), Node::new("a")); - g.nodes.insert("b".to_string(), Node::new("b")); - g.nodes.insert("work".to_string(), Node::new("work")); - - let mut e1 = Edge::new("work", "a"); - e1.attrs - .insert("freeform".to_string(), AttrValue::Boolean(true)); - let mut e2 = Edge::new("work", "b"); - e2.attrs - .insert("freeform".to_string(), AttrValue::Boolean(true)); - g.edges.push(e1); - g.edges.push(e2); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn freeform_edge_count_rule_zero_freeform() { - let mut g = minimal_graph(); - let mut gate = Node::new("gate"); - gate.attrs - .insert("type".to_string(), AttrValue::String("human".to_string())); - g.nodes.insert("gate".to_string(), gate); - g.nodes.insert("a".to_string(), Node::new("a")); - g.edges.push(Edge::new("gate", "a")); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- Additional coverage: stylesheet_syntax no stylesheet --- - - #[test] - fn freeform_edge_count_rule_explicit_type_two_freeform() { - let mut g = minimal_graph(); - let mut gate = Node::new("gate"); - gate.attrs - .insert("type".to_string(), AttrValue::String("human".to_string())); - g.nodes.insert("gate".to_string(), gate); - g.nodes.insert("a".to_string(), Node::new("a")); - g.nodes.insert("b".to_string(), Node::new("b")); - - let mut e1 = Edge::new("gate", "a"); - e1.attrs - .insert("freeform".to_string(), AttrValue::Boolean(true)); - let mut e2 = Edge::new("gate", "b"); - e2.attrs - .insert("freeform".to_string(), AttrValue::Boolean(true)); - g.edges.push(e1); - g.edges.push(e2); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - // --- exit_no_outgoing: by "Exit" capitalized id --- - - #[test] - fn freeform_edge_count_rule_freeform_false_ignored() { - let mut g = minimal_graph(); - let mut gate = Node::new("gate"); - gate.attrs.insert( - "shape".to_string(), - AttrValue::String("hexagon".to_string()), - ); - g.nodes.insert("gate".to_string(), gate); - g.nodes.insert("a".to_string(), Node::new("a")); - g.nodes.insert("b".to_string(), Node::new("b")); - - let mut e1 = Edge::new("gate", "a"); - e1.attrs - .insert("freeform".to_string(), AttrValue::Boolean(false)); - let mut e2 = Edge::new("gate", "b"); - e2.attrs - .insert("freeform".to_string(), AttrValue::Boolean(false)); - g.edges.push(e1); - g.edges.push(e2); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- reachability: chain of reachable nodes --- -} diff --git a/lib/components/fabro-validate/src/rules/goal_gate_has_retry.rs b/lib/components/fabro-validate/src/rules/goal_gate_has_retry.rs deleted file mode 100644 index 3e4c52f0b..000000000 --- a/lib/components/fabro-validate/src/rules/goal_gate_has_retry.rs +++ /dev/null @@ -1,158 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "goal_gate_has_retry" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if node.goal_gate() { - let has_node_retry = - node.retry_target().is_some() || node.fallback_retry_target().is_some(); - let has_graph_retry = - graph.retry_target().is_some() || graph.fallback_retry_target().is_some(); - if !has_node_retry && !has_graph_retry { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node '{}' has goal_gate=true but no retry_target or fallback_retry_target", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Add retry_target or fallback_retry_target attribute".to_string(), - ), - - ..Diagnostic::default()}); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn goal_gate_has_retry_rule_no_retry() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("goal_gate".to_string(), AttrValue::Boolean(true)); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - #[test] - fn goal_gate_has_retry_rule_with_retry() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("goal_gate".to_string(), AttrValue::Boolean(true)); - node.attrs.insert( - "retry_target".to_string(), - AttrValue::String("start".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn goal_gate_has_retry_rule_with_graph_retry_target() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("goal_gate".to_string(), AttrValue::Boolean(true)); - g.nodes.insert("work".to_string(), node); - g.attrs.insert( - "retry_target".to_string(), - AttrValue::String("start".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn goal_gate_has_retry_rule_with_fallback_retry_target() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("goal_gate".to_string(), AttrValue::Boolean(true)); - node.attrs.insert( - "fallback_retry_target".to_string(), - AttrValue::String("start".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn goal_gate_has_retry_rule_not_goal_gate() { - let mut g = minimal_graph(); - let node = Node::new("work"); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- Additional coverage: prompt_on_llm_nodes with label --- - - #[test] - fn goal_gate_has_retry_rule_with_graph_fallback_retry_target() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("goal_gate".to_string(), AttrValue::Boolean(true)); - g.nodes.insert("work".to_string(), node); - g.attrs.insert( - "fallback_retry_target".to_string(), - AttrValue::String("start".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- freeform_edge_count: with explicit type=human --- - - #[test] - fn goal_gate_has_retry_rule_explicit_false() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("goal_gate".to_string(), AttrValue::Boolean(false)); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- stylesheet_syntax: empty string stylesheet --- -} diff --git a/lib/components/fabro-validate/src/rules/import_error.rs b/lib/components/fabro-validate/src/rules/import_error.rs deleted file mode 100644 index e615a09b0..000000000 --- a/lib/components/fabro-validate/src/rules/import_error.rs +++ /dev/null @@ -1,108 +0,0 @@ -use fabro_graphviz::graph::{AttrValue, Graph}; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "import_error" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - - for node in graph.nodes.values() { - if let Some(AttrValue::String(message)) = node.attrs.get("import_error") { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: message.clone(), - node_id: Some(node.id.clone()), - edge: None, - fix: Some("Fix the imported workflow or import path".to_string()), - - ..Diagnostic::default() - }); - } - - if node.attrs.contains_key("import") { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: "unresolved import (no base directory available)".to_string(), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Load the workflow from a file so imports can resolve relative to it" - .to_string(), - ), - - ..Diagnostic::default() - }); - } - } - - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn import_error_rule_fires_on_import_error_attr() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "import_error".to_string(), - AttrValue::String("file not found: ./missing.fabro".to_string()), - ); - g.nodes.insert("work".to_string(), node); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert_eq!(d[0].message, "file not found: ./missing.fabro"); - assert_eq!(d[0].node_id.as_deref(), Some("work")); - } - - #[test] - fn import_error_rule_fires_on_unresolved_import_attr() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "import".to_string(), - AttrValue::String("./validate.fabro".to_string()), - ); - g.nodes.insert("work".to_string(), node); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert_eq!( - d[0].message, - "unresolved import (no base directory available)" - ); - assert_eq!(d[0].node_id.as_deref(), Some("work")); - } - - #[test] - fn import_error_rule_silent_for_clean_nodes() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/inert_attribute.rs b/lib/components/fabro-validate/src/rules/inert_attribute.rs deleted file mode 100644 index 716b52b59..000000000 --- a/lib/components/fabro-validate/src/rules/inert_attribute.rs +++ /dev/null @@ -1,394 +0,0 @@ -use fabro_graphviz::graph::{self, Graph, Node}; -use fabro_types::StageHandler; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -/// Attributes that only specific handlers read, paired with the handlers that -/// consume them. On every other node type the attribute is inert: accepted by -/// the parser and read by nothing at runtime. -/// -/// Node types are compared after canonicalization through -/// [`StageHandler::from_handler_type`], so alias types (`tool` runs the -/// command handler) accept the same attributes as their canonical form. -/// -/// Attributes read by several handlers (`timeout`), resolved for every node -/// (`fidelity`, `retry_policy`, `max_visits`, `goal_gate`), or injectable via -/// model stylesheets (`model`, `provider`, `reasoning_effort`, `speed`, -/// `backend`) are deliberately not listed. -const HANDLER_SPECIFIC_ATTRS: &[(&str, &[StageHandler])] = &[ - ("script", &[StageHandler::Command]), - ("language", &[StageHandler::Command]), - ("stdin_source", &[StageHandler::Command]), - ("duration", &[StageHandler::Wait]), - ("max_parallel", &[StageHandler::Parallel]), - ("output_retries", &[ - StageHandler::Agent, - StageHandler::Prompt, - ]), - ("output_schema", &[ - StageHandler::Agent, - StageHandler::Prompt, - StageHandler::Command, - ]), - ("prompt", &[ - StageHandler::Agent, - StageHandler::Prompt, - StageHandler::ParallelFanIn, - ]), - ("review_target", &[StageHandler::Human]), -]; - -const SCRIPT_PROMPT_CONFLICT_RULE: &str = "script_prompt_conflict"; - -struct Rule; - -fn script_prompt_conflict(node: &Node) -> Diagnostic { - Diagnostic { - rule: SCRIPT_PROMPT_CONFLICT_RULE.to_string(), - severity: Severity::Error, - message: format!( - "Node '{}' sets both 'script' and 'prompt'. No built-in handler reads both: command \ - handlers consume 'script', while LLM handlers consume 'prompt'", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Remove whichever attribute is wrong, or split the node into a command node and an \ - agent node" - .to_string(), - ), - ..Diagnostic::default() - } -} - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "inert_attribute" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - let has_script_prompt_conflict = - node.attrs.contains_key("script") && node.attrs.contains_key("prompt"); - if has_script_prompt_conflict { - diagnostics.push(script_prompt_conflict(node)); - } - - // An unknown shape or type is covered by the type_known rule; a - // node this rule cannot classify is skipped rather than guessed at. - let Some(raw_type) = node.handler_type() else { - continue; - }; - if !graph::is_known_handler_type(raw_type) { - continue; - } - let handler = StageHandler::from_handler_type(Some(raw_type)); - for (attr, consumers) in HANDLER_SPECIFIC_ATTRS { - if !node.attrs.contains_key(*attr) { - continue; - } - if has_script_prompt_conflict && matches!(*attr, "script" | "prompt") { - continue; - } - if consumers.contains(&handler) { - continue; - } - let consumer_names = consumers - .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node '{}' (type '{raw_type}') sets '{attr}', which is only read by {consumer_names} nodes and has no effect here", - node.id, - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(format!( - "Remove '{attr}' or change the node to a type that reads it ({consumer_names})", - )), - ..Diagnostic::default() - }); - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; - - use super::Rule; - use crate::rules::test_support; - use crate::{LintRule, Severity}; - - fn node_with_attr(id: &str, shape: &str, attr: &str, value: &str) -> Node { - test_support::node_with_attrs(id, &[("shape", shape), (attr, value)]) - } - - #[test] - fn warns_on_script_on_agent_node() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "work".to_string(), - node_with_attr("work", "box", "script", "echo hi"), - ); - let d = Rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("'script'")); - assert!(d[0].message.contains("command")); - assert_eq!(d[0].node_id.as_deref(), Some("work")); - } - - #[test] - fn built_in_rules_report_script_prompt_conflict_once() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "work".to_string(), - test_support::node_with_attrs("work", &[ - ("script", "cargo build"), - ("prompt", "do it"), - ]), - ); - g.edges = vec![Edge::new("start", "work"), Edge::new("work", "exit")]; - - let diagnostics = crate::validate(&g, &[]); - let work_diagnostics = diagnostics - .iter() - .filter(|diagnostic| diagnostic.node_id.as_deref() == Some("work")) - .collect::>(); - - assert_eq!(work_diagnostics.len(), 1, "diagnostics: {diagnostics:?}"); - assert_eq!(work_diagnostics[0].rule, "script_prompt_conflict"); - assert_eq!(work_diagnostics[0].severity, Severity::Error); - } - - #[test] - fn conflict_error_replaces_inert_warning_for_explicit_shapes() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "run".to_string(), - test_support::node_with_attrs("run", &[ - ("shape", "parallelogram"), - ("script", "cargo build"), - ("prompt", "do it"), - ]), - ); - g.nodes.insert( - "plan".to_string(), - test_support::node_with_attrs("plan", &[ - ("shape", "box"), - ("script", "cargo build"), - ("prompt", "do it"), - ]), - ); - - let diagnostics = Rule.apply(&g); - - assert_eq!(diagnostics.len(), 2); - assert!( - diagnostics - .iter() - .all(|diagnostic| diagnostic.rule == "script_prompt_conflict" - && diagnostic.severity == Severity::Error) - ); - } - - #[test] - fn conflict_uses_attribute_presence() { - let mut g = test_support::minimal_graph(); - let mut node = test_support::node_with_attrs("work", &[("prompt", "do it")]); - node.attrs - .insert("script".to_string(), AttrValue::Integer(123)); - g.nodes.insert("work".to_string(), node); - - let diagnostics = Rule.apply(&g); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].rule, "script_prompt_conflict"); - } - - #[test] - fn warns_on_prompt_on_start_and_command_nodes() { - let mut g = test_support::minimal_graph(); - g.nodes - .get_mut("start") - .expect("minimal graph has start") - .attrs - .insert( - "prompt".to_string(), - AttrValue::String("do things".to_string()), - ); - g.nodes.insert( - "run".to_string(), - node_with_attr("run", "parallelogram", "prompt", "do things"), - ); - let d = Rule.apply(&g); - assert_eq!(d.len(), 2); - assert!(d.iter().all(|d| d.message.contains("'prompt'"))); - } - - #[test] - fn warns_on_duration_on_command_node() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "run".to_string(), - node_with_attr("run", "parallelogram", "duration", "30s"), - ); - let d = Rule.apply(&g); - assert_eq!(d.len(), 1); - assert!(d[0].message.contains("'duration'")); - assert!(d[0].message.contains("wait")); - } - - #[test] - fn warns_on_parallel_attrs_on_agent_node() { - let mut g = test_support::minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("max_parallel".to_string(), AttrValue::Integer(4)); - g.nodes.insert("work".to_string(), node); - let d = Rule.apply(&g); - assert_eq!(d.len(), 1); - } - - #[test] - fn warns_on_output_retries_on_command_node() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "run".to_string(), - node_with_attr("run", "parallelogram", "output_retries", "2"), - ); - - let d = Rule.apply(&g); - - assert_eq!(d.len(), 1); - assert!(d[0].message.contains("'output_retries'")); - assert!(d[0].message.contains("agent, prompt")); - } - - #[test] - fn accepts_attrs_on_their_own_handler_types() { - let mut g = test_support::minimal_graph(); - let mut run = node_with_attr("run", "parallelogram", "script", "echo hi"); - run.attrs.insert( - "stdin_source".to_string(), - AttrValue::String("context.parallel.results".to_string()), - ); - g.nodes.insert("run".to_string(), run); - g.nodes.insert( - "audit".to_string(), - node_with_attr("audit", "parallelogram", "output_schema", "routing"), - ); - g.nodes.insert( - "pause".to_string(), - node_with_attr("pause", "insulator", "duration", "30s"), - ); - g.nodes.insert( - "work".to_string(), - node_with_attr("work", "box", "prompt", "do things"), - ); - g.nodes.insert( - "review".to_string(), - node_with_attr("review", "box", "output_retries", "2"), - ); - g.nodes.insert( - "fork".to_string(), - node_with_attr("fork", "component", "max_parallel", "4"), - ); - g.nodes.insert( - "spec".to_string(), - node_with_attr("spec", "tab", "output_schema", "routing"), - ); - g.nodes.insert( - "prompt".to_string(), - node_with_attr("prompt", "tab", "output_retries", "2"), - ); - g.nodes.insert( - "human".to_string(), - node_with_attr("human", "hexagon", "review_target", "true"), - ); - g.nodes.insert( - "legacy_command".to_string(), - test_support::node_with_attrs("legacy_command", &[ - ("type", "tool"), - ("script", "echo legacy"), - ]), - ); - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn warns_on_review_target_on_non_human_node() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "work".to_string(), - node_with_attr("work", "box", "review_target", "true"), - ); - - let diagnostics = Rule.apply(&g); - - assert_eq!(diagnostics.len(), 1); - assert!(diagnostics[0].message.contains("'review_target'")); - assert!(diagnostics[0].message.contains("human")); - } - - #[test] - fn accepts_prompt_on_shapeless_node_defaulting_to_agent() { - let mut g = test_support::minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "prompt".to_string(), - AttrValue::String("do things".to_string()), - ); - g.nodes.insert("work".to_string(), node); - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn accepts_prompt_on_fan_in_judge() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "merge".to_string(), - node_with_attr("merge", "tripleoctagon", "prompt", "pick the best"), - ); - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn ignores_unclassifiable_node_shapes() { - let mut g = test_support::minimal_graph(); - g.nodes.insert( - "odd".to_string(), - node_with_attr("odd", "doubleoctagon", "script", "echo hi"), - ); - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn ignores_handler_specific_attrs_on_unrecognized_explicit_types() { - let mut g = test_support::minimal_graph(); - let mut node = Node::new("custom"); - node.attrs.insert( - "type".to_string(), - AttrValue::String("custom.handler".to_string()), - ); - node.attrs.insert( - "script".to_string(), - AttrValue::String("echo hi".to_string()), - ); - g.nodes.insert("custom".to_string(), node); - assert!(Rule.apply(&g).is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/join_policy_removed.rs b/lib/components/fabro-validate/src/rules/join_policy_removed.rs deleted file mode 100644 index b96f8b446..000000000 --- a/lib/components/fabro-validate/src/rules/join_policy_removed.rs +++ /dev/null @@ -1,35 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "join_policy_removed" - } - - fn apply(&self, graph: &Graph) -> Vec { - graph - .nodes - .values() - .filter(|node| node.attrs.contains_key("join_policy")) - .map(|node| Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Node '{}' sets the removed 'join_policy' attribute. Remove 'join_policy'; parallel nodes always wait for every branch to finish", - node.id, - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some("Remove 'join_policy' from this node".to_string()), - ..Diagnostic::default() - }) - .collect() - } -} diff --git a/lib/components/fabro-validate/src/rules/mod.rs b/lib/components/fabro-validate/src/rules/mod.rs deleted file mode 100644 index dc9270b87..000000000 --- a/lib/components/fabro-validate/src/rules/mod.rs +++ /dev/null @@ -1,91 +0,0 @@ -use fabro_llm::lithos_catalog::Catalog; -mod all_conditional_edges; -mod auto_status_deprecated; -mod backend_valid; -mod command_requires_script; -mod condition_syntax; -mod direction_valid; -mod edge_target_exists; -mod exit_no_outgoing; -mod fidelity_valid; -mod for_each_contract; -mod freeform_edge_count; -mod goal_gate_has_retry; -mod import_error; -mod inert_attribute; -mod join_policy_removed; -mod model_support; -mod node_model_known; -mod on_failure_valid; -mod orphan_custom_outcome; -mod parallel_branch; -mod parallel_branch_inert_attribute; -mod prompt_on_llm_nodes; -mod random_selection_no_conditions; -mod reachability; -mod reserved_keyword_node_id; -mod retry_target_exists; -mod script_absolute_cd; -mod selection_valid; -mod start_no_incoming; -mod start_node; -mod stdin_source_valid; -mod stylesheet_model_known; -mod stylesheet_syntax; -mod terminal_node; -#[cfg(test)] -pub(crate) mod test_support; -mod thread_id_requires_fidelity_full; -mod type_known; -mod unresolved_file_ref; - -use crate::LintRule; - -/// Returns all built-in lint rules. -#[must_use] -pub fn built_in_rules() -> Vec> { - vec![ - start_node::rule(), - terminal_node::rule(), - reachability::rule(), - edge_target_exists::rule(), - start_no_incoming::rule(), - exit_no_outgoing::rule(), - condition_syntax::rule(), - stylesheet_syntax::rule(), - type_known::rule(), - backend_valid::rule(), - fidelity_valid::rule(), - retry_target_exists::rule(), - goal_gate_has_retry::rule(), - prompt_on_llm_nodes::rule(), - freeform_edge_count::rule(), - for_each_contract::rule(), - stdin_source_valid::rule(), - direction_valid::rule(), - reserved_keyword_node_id::rule(), - all_conditional_edges::rule(), - orphan_custom_outcome::rule(), - on_failure_valid::rule(), - auto_status_deprecated::rule(), - script_absolute_cd::rule(), - command_requires_script::rule(), - import_error::rule(), - join_policy_removed::rule(), - unresolved_file_ref::rule(), - thread_id_requires_fidelity_full::rule(), - selection_valid::rule(), - random_selection_no_conditions::rule(), - inert_attribute::rule(), - parallel_branch_inert_attribute::rule(), - ] -} - -/// Returns lint rules that require the caller's resolved model catalog. -#[must_use] -pub fn catalog_rules(catalog: &Catalog) -> Vec> { - vec![ - stylesheet_model_known::rule(catalog), - node_model_known::rule(catalog), - ] -} diff --git a/lib/components/fabro-validate/src/rules/model_support.rs b/lib/components/fabro-validate/src/rules/model_support.rs deleted file mode 100644 index 50c4e20d6..000000000 --- a/lib/components/fabro-validate/src/rules/model_support.rs +++ /dev/null @@ -1,55 +0,0 @@ -use fabro_llm::lithos_catalog::Catalog; - -use crate::{Diagnostic, Severity}; - -pub(super) fn check_model_known( - rule_name: &str, - catalog: &Catalog, - model: &str, - context: &str, - node_id: Option, -) -> Option { - if catalog.is_model_selector(model) { - return None; - } - Some(Diagnostic { - rule: rule_name.to_string(), - severity: Severity::Warning, - message: format!( - "Unknown model '{model}' {context}. Run `fabro model list` to see available models" - ), - node_id, - edge: None, - fix: Some("Use a model ID from `fabro model list`".to_string()), - - ..Diagnostic::default() - }) -} - -pub(super) fn check_provider_known( - rule_name: &str, - catalog: &Catalog, - provider: &str, - context: &str, - node_id: Option, -) -> Option { - if catalog.enabled_provider(provider).is_some() { - return None; - } - let valid: Vec = catalog - .listed_providers() - .iter() - .map(|provider| provider.id().to_string()) - .collect(); - let valid_str = valid.join(", "); - Some(Diagnostic { - rule: rule_name.to_string(), - severity: Severity::Warning, - message: format!("Unknown provider '{provider}' {context}. Valid providers: {valid_str}"), - node_id, - edge: None, - fix: Some(format!("Use one of: {valid_str}")), - - ..Diagnostic::default() - }) -} diff --git a/lib/components/fabro-validate/src/rules/node_model_known.rs b/lib/components/fabro-validate/src/rules/node_model_known.rs deleted file mode 100644 index 0cfb13841..000000000 --- a/lib/components/fabro-validate/src/rules/node_model_known.rs +++ /dev/null @@ -1,129 +0,0 @@ -use fabro_graphviz::graph::Graph; -use fabro_llm::lithos_catalog::Catalog; - -use super::model_support::{check_model_known, check_provider_known}; -use crate::{Diagnostic, LintRule}; - -pub(super) fn rule(catalog: &Catalog) -> Box { - Box::new(Rule { catalog }) -} - -struct Rule<'a> { - catalog: &'a Catalog, -} - -impl LintRule for Rule<'_> { - fn name(&self) -> &'static str { - "node_model_known" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - let context = format!("on node '{}'", node.id); - let node_id = Some(node.id.clone()); - if let Some(model) = node.model() { - if let Some(d) = - check_model_known(self.name(), self.catalog, model, &context, node_id.clone()) - { - diagnostics.push(d); - } - } - if let Some(provider) = node.provider() { - if let Some(d) = check_provider_known( - self.name(), - self.catalog, - provider, - &context, - node_id.clone(), - ) { - diagnostics.push(d); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - use fabro_llm::test_support::test_catalog; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn node_model_known_rule_valid_model() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "model".to_string(), - AttrValue::String("claude-sonnet-4.5".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn node_model_known_rule_unknown_model() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "model".to_string(), - AttrValue::String("nonexistent-model-xyz".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("nonexistent-model-xyz")); - assert_eq!(d[0].node_id.as_deref(), Some("work")); - } - - #[test] - fn node_model_known_rule_alias() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("model".to_string(), AttrValue::String("opus".to_string())); - g.nodes.insert("work".to_string(), node); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn node_model_known_rule_unknown_provider() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "provider".to_string(), - AttrValue::String("nonexistent-provider".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("nonexistent-provider")); - assert_eq!(d[0].node_id.as_deref(), Some("work")); - } - - #[test] - fn node_model_known_rule_no_model_no_provider() { - let g = minimal_graph(); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/on_failure_valid.rs b/lib/components/fabro-validate/src/rules/on_failure_valid.rs deleted file mode 100644 index ee3b8dd38..000000000 --- a/lib/components/fabro-validate/src/rules/on_failure_valid.rs +++ /dev/null @@ -1,228 +0,0 @@ -use fabro_graphviz::graph::{AttrValue, Graph}; -use fabro_types::OnFailure; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -fn invalid_value_diagnostic( - rule: &str, - node_id: Option<&str>, - value: &AttrValue, -) -> Option { - let message = match value { - AttrValue::String(value) if value.parse::().is_ok() => return None, - AttrValue::String(value) => match node_id { - Some(node_id) => format!("Node '{node_id}' has invalid on_failure value '{value}'"), - None => format!("Graph has invalid on_failure value '{value}'"), - }, - _ => match node_id { - Some(node_id) => { - format!("Node '{node_id}' attribute 'on_failure' must be a string") - } - None => "Graph attribute 'on_failure' must be a string".to_string(), - }, - }; - - Some(Diagnostic { - rule: rule.to_string(), - severity: Severity::Error, - message, - node_id: node_id.map(str::to_string), - fix: Some(format!("Use one of: {}", OnFailure::expected_values())), - ..Diagnostic::default() - }) -} - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "on_failure_valid" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - - if let Some(value) = graph.attrs.get("on_failure") { - diagnostics.extend(invalid_value_diagnostic(self.name(), None, value)); - } - - let mut node_values: Vec<_> = graph - .nodes - .iter() - .filter_map(|(node_id, node)| { - node.attrs - .get("on_failure") - .map(|value| (node_id.as_str(), value)) - }) - .collect(); - node_values.sort_unstable_by_key(|(node_id, _)| *node_id); - for (node_id, value) in node_values { - diagnostics.extend(invalid_value_diagnostic(self.name(), Some(node_id), value)); - } - - for edge in &graph.edges { - if edge.attrs.contains_key("on_failure") { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Edge '{} -> {}' sets 'on_failure', which has no effect on edges", - edge.from, edge.to - ), - fix: Some("Set 'on_failure' on the graph or the source node".to_string()), - edge: Some((edge.from.clone(), edge.to.clone())), - ..Diagnostic::default() - }); - } - } - - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge}; - - use super::Rule; - use crate::rules::test_support::{minimal_graph, node_with_attrs}; - use crate::{LintRule, Severity}; - - #[test] - fn accepts_absent_and_supported_graph_values() { - let mut graph = minimal_graph(); - assert!(Rule.apply(&graph).is_empty()); - - for value in ["route", "exit", "succeed"] { - graph.attrs.insert( - "on_failure".to_string(), - AttrValue::String(value.to_string()), - ); - assert!(Rule.apply(&graph).is_empty()); - } - } - - #[test] - fn accepts_supported_node_values() { - let mut graph = minimal_graph(); - for value in ["route", "exit", "succeed"] { - graph.nodes.insert( - "work".to_string(), - node_with_attrs("work", &[("on_failure", value)]), - ); - assert!(Rule.apply(&graph).is_empty()); - } - } - - #[test] - fn rejects_unsupported_graph_value() { - let mut graph = minimal_graph(); - graph.attrs.insert( - "on_failure".to_string(), - AttrValue::String("stop".to_string()), - ); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert_eq!( - diagnostics[0].message, - "Graph has invalid on_failure value 'stop'" - ); - assert_eq!( - diagnostics[0].fix.as_deref(), - Some("Use one of: route, exit, succeed") - ); - } - - #[test] - fn rejects_non_string_graph_value() { - let mut graph = minimal_graph(); - graph - .attrs - .insert("on_failure".to_string(), AttrValue::Boolean(true)); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert_eq!( - diagnostics[0].message, - "Graph attribute 'on_failure' must be a string" - ); - } - - #[test] - fn rejects_unsupported_node_value() { - let mut graph = minimal_graph(); - graph.nodes.insert( - "work".to_string(), - node_with_attrs("work", &[("on_failure", "stop")]), - ); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert_eq!( - diagnostics[0].message, - "Node 'work' has invalid on_failure value 'stop'" - ); - assert_eq!(diagnostics[0].node_id.as_deref(), Some("work")); - assert_eq!( - diagnostics[0].fix.as_deref(), - Some("Use one of: route, exit, succeed") - ); - } - - #[test] - fn rejects_non_string_node_value() { - let mut graph = minimal_graph(); - let mut node = node_with_attrs("work", &[]); - node.attrs - .insert("on_failure".to_string(), AttrValue::Boolean(true)); - graph.nodes.insert("work".to_string(), node); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert_eq!( - diagnostics[0].message, - "Node 'work' attribute 'on_failure' must be a string" - ); - } - - #[test] - fn warns_for_edge_placement() { - let mut graph = minimal_graph(); - let mut edge = Edge::new("start", "work"); - edge.attrs.insert( - "on_failure".to_string(), - AttrValue::String("exit".to_string()), - ); - graph.edges.push(edge); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Warning); - assert_eq!( - diagnostics[0].message, - "Edge 'start -> work' sets 'on_failure', which has no effect on edges" - ); - assert_eq!( - diagnostics[0].fix.as_deref(), - Some("Set 'on_failure' on the graph or the source node") - ); - assert_eq!( - diagnostics[0].edge, - Some(("start".to_string(), "work".to_string())) - ); - } -} diff --git a/lib/components/fabro-validate/src/rules/orphan_custom_outcome.rs b/lib/components/fabro-validate/src/rules/orphan_custom_outcome.rs deleted file mode 100644 index 8f64ea533..000000000 --- a/lib/components/fabro-validate/src/rules/orphan_custom_outcome.rs +++ /dev/null @@ -1,139 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "orphan_custom_outcome" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - let outgoing = graph.outgoing_edges(&node.id); - if outgoing.is_empty() { - continue; - } - // Check if any edge uses outcome= (equality, not !=) - let has_outcome_eq = outgoing.iter().any(|e| { - e.condition().is_some_and(|c| { - c.split("&&") - .any(|clause| clause.trim().starts_with("outcome=")) - }) - }); - if !has_outcome_eq { - continue; - } - // Check if there's at least one unconditional edge - let has_unconditional = outgoing - .iter() - .any(|e| e.condition().is_none_or(str::is_empty)); - if !has_unconditional { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node '{}' uses outcome-based routing but has no unconditional fallback edge", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Add an unconditional edge as a safety net for unmatched outcomes" - .to_string(), - ), - - ..Diagnostic::default()}); - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn orphan_custom_outcome_rule_outcome_eq_no_fallback() { - let mut g = minimal_graph(); - g.nodes.insert("work".to_string(), Node::new("work")); - g.edges.push({ - let mut e = Edge::new("work", "exit"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded".to_string()), - ); - e - }); - g.edges.push({ - let mut e = Edge::new("work", "start"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=failed".to_string()), - ); - e - }); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[0].node_id.as_deref(), Some("work")); - } - - #[test] - fn orphan_custom_outcome_rule_outcome_eq_with_fallback() { - let mut g = minimal_graph(); - g.nodes.insert("work".to_string(), Node::new("work")); - g.edges.push({ - let mut e = Edge::new("work", "exit"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded".to_string()), - ); - e - }); - g.edges.push(Edge::new("work", "start")); // unconditional fallback - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn orphan_custom_outcome_rule_outcome_neq_only() { - let mut g = minimal_graph(); - g.nodes.insert("work".to_string(), Node::new("work")); - g.edges.push({ - let mut e = Edge::new("work", "exit"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome!=failed".to_string()), - ); - e - }); - let rule = Rule; - let d = rule.apply(&g); - // outcome!= is not outcome= equality, so rule doesn't fire - assert!(d.is_empty()); - } - - #[test] - fn orphan_custom_outcome_rule_no_outcome_conditions() { - let g = minimal_graph(); // no outcome conditions at all - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- condition_syntax + parse_condition (condition_eval) tests --- -} diff --git a/lib/components/fabro-validate/src/rules/parallel_branch.rs b/lib/components/fabro-validate/src/rules/parallel_branch.rs deleted file mode 100644 index d8a7d2fbd..000000000 --- a/lib/components/fabro-validate/src/rules/parallel_branch.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::collections::BTreeSet; - -use fabro_graphviz::graph::{Edge, Graph}; - -pub(super) struct ParallelBranches<'a> { - graph: &'a Graph, - fork_ids: BTreeSet<&'a str>, -} - -impl<'a> ParallelBranches<'a> { - pub(super) fn new(graph: &'a Graph) -> Self { - let fork_ids = graph - .nodes - .values() - .filter(|node| node.handler_type() == Some("parallel")) - .map(|node| node.id.as_str()) - .collect(); - Self { graph, fork_ids } - } - - pub(super) fn is_empty(&self) -> bool { - self.fork_ids.is_empty() - } - - pub(super) fn is_fork_edge(&self, edge: &Edge) -> bool { - self.fork_ids.contains(edge.from.as_str()) - } - - pub(super) fn branch_targets(&self) -> BTreeSet<&str> { - self.graph - .edges - .iter() - .filter(|edge| self.is_fork_edge(edge)) - .map(|edge| edge.to.as_str()) - .collect() - } - - /// True when every incoming edge of `node_id` comes from a parallel fork - /// (and there is at least one). Such a node only ever runs as a branch. - pub(super) fn is_branch_only_node(&self, node_id: &str) -> bool { - let incoming = self.graph.incoming_edges(node_id); - !incoming.is_empty() && incoming.iter().all(|edge| self.is_fork_edge(edge)) - } - - /// The sorted, deduplicated fork parents of a branch-only node, or `None` - /// when the node has a non-fork entry path (or no entry at all). - pub(super) fn branch_only_parents(&self, node_id: &str) -> Option> { - if !self.is_branch_only_node(node_id) { - return None; - } - let parents: BTreeSet<&str> = self - .graph - .incoming_edges(node_id) - .into_iter() - .map(|edge| edge.from.as_str()) - .collect(); - Some(parents.into_iter().map(String::from).collect()) - } -} diff --git a/lib/components/fabro-validate/src/rules/parallel_branch_inert_attribute.rs b/lib/components/fabro-validate/src/rules/parallel_branch_inert_attribute.rs deleted file mode 100644 index bac461103..000000000 --- a/lib/components/fabro-validate/src/rules/parallel_branch_inert_attribute.rs +++ /dev/null @@ -1,368 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use super::parallel_branch::ParallelBranches; -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -/// Attributes that parallel branch execution does not resolve. Only -/// `thread_id` is inert on branches (concurrent branches cannot share an LLM -/// session); per-branch `fidelity` is honored via pre-rendered preambles. -const BRANCH_IGNORED_ATTRS: &[&str] = &["thread_id"]; - -const FULL_FIDELITY_MESSAGE: &str = "Parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session"; - -const THREAD_ID_FIX: &str = "Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started"; - -struct Rule; - -/// Renders one or more parallel-node ids as `'a'` or `'a', 'b'`. -fn quoted_list(ids: &[String]) -> String { - ids.iter() - .map(|id| format!("'{id}'")) - .collect::>() - .join(", ") -} - -fn full_fidelity_fix(parallel_ids: &[String]) -> String { - let parent = if parallel_ids.len() == 1 { - format!("parallel node {}", quoted_list(parallel_ids)) - } else { - format!("parallel nodes {}", quoted_list(parallel_ids)) - }; - format!( - "Use fidelity=\"summary:high\" or another lower mode on this branch; to reuse a full session before fan-out, set fidelity=\"full\" on {parent} or its incoming edge" - ) -} - -fn full_fidelity_diagnostic( - rule_name: &str, - node_id: Option, - edge: Option<(String, String)>, - parallel_ids: &[String], -) -> Diagnostic { - Diagnostic { - rule: rule_name.to_string(), - severity: Severity::Warning, - message: FULL_FIDELITY_MESSAGE.to_string(), - node_id, - edge, - fix: Some(full_fidelity_fix(parallel_ids)), - ..Diagnostic::default() - } -} - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "parallel_branch_inert_attribute" - } - - fn apply(&self, graph: &Graph) -> Vec { - let branches = ParallelBranches::new(graph); - if branches.is_empty() { - return Vec::new(); - } - - let mut diagnostics = Vec::new(); - - for edge in &graph.edges { - if !branches.is_fork_edge(edge) { - continue; - } - if edge.fidelity() == Some("full") { - diagnostics.push(full_fidelity_diagnostic( - self.name(), - None, - Some((edge.from.clone(), edge.to.clone())), - std::slice::from_ref(&edge.from), - )); - } - for attr in BRANCH_IGNORED_ATTRS { - if !edge.attrs.contains_key(*attr) { - continue; - } - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Edge {} -> {} sets '{attr}', which is ignored on parallel branch edges: branches receive the context snapshot taken when '{}' started", - edge.from, edge.to, edge.from, - ), - node_id: None, - edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some(THREAD_ID_FIX.to_string()), - ..Diagnostic::default() - }); - } - } - - // A node with any normal incoming path still resolves its attributes on - // that path, so branch-only diagnostics do not apply to it. - for target in branches.branch_targets() { - let Some(parents) = branches.branch_only_parents(target) else { - continue; - }; - let Some(node) = graph.nodes.get(target) else { - continue; - }; - if node.fidelity() == Some("full") { - diagnostics.push(full_fidelity_diagnostic( - self.name(), - Some(node.id.clone()), - None, - &parents, - )); - } - for attr in BRANCH_IGNORED_ATTRS { - if !node.attrs.contains_key(*attr) { - continue; - } - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node '{}' sets '{attr}', but it only runs as a parallel branch (of {}), where '{attr}' is ignored: branches receive the context snapshot taken when the parallel node started", - node.id, - quoted_list(&parents), - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(THREAD_ID_FIX.to_string()), - ..Diagnostic::default() - }); - } - } - - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - fn shaped_node(id: &str, shape: &str) -> Node { - let mut node = Node::new(id); - node.attrs - .insert("shape".to_string(), AttrValue::String(shape.to_string())); - node - } - - /// start -> fork -> {branch_a, branch_b} -> merge -> exit - fn parallel_graph() -> Graph { - let mut g = minimal_graph(); - g.nodes - .insert("fork".to_string(), shaped_node("fork", "component")); - g.nodes - .insert("branch_a".to_string(), shaped_node("branch_a", "tab")); - g.nodes - .insert("branch_b".to_string(), shaped_node("branch_b", "tab")); - g.nodes - .insert("merge".to_string(), shaped_node("merge", "tripleoctagon")); - g.edges = vec![ - Edge::new("start", "fork"), - Edge::new("fork", "branch_a"), - Edge::new("fork", "branch_b"), - Edge::new("branch_a", "merge"), - Edge::new("branch_b", "merge"), - Edge::new("merge", "exit"), - ]; - g - } - - #[test] - fn accepts_non_full_fidelity_on_branch_node() { - let mut g = parallel_graph(); - g.nodes - .get_mut("branch_a") - .expect("graph has branch_a") - .attrs - .insert( - "fidelity".to_string(), - AttrValue::String("truncate".to_string()), - ); - - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn warns_when_full_fidelity_on_branch_node_degrades() { - let mut g = parallel_graph(); - g.nodes - .get_mut("branch_a") - .expect("graph has branch_a") - .attrs - .insert( - "fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - - let d = Rule.apply(&g); - - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[0].node_id.as_deref(), Some("branch_a")); - assert!(d[0].message.contains("full")); - assert!(d[0].message.contains("summary:high")); - assert!(d[0].fix.as_deref().is_some_and(|f| f.contains("'fork'"))); - } - - #[test] - fn accepts_every_non_full_fidelity_on_branch_edges() { - for fidelity in [ - "truncate", - "compact", - "summary:low", - "summary:medium", - "summary:high", - ] { - let mut g = parallel_graph(); - g.edges[1].attrs.insert( - "fidelity".to_string(), - AttrValue::String(fidelity.to_string()), - ); - - assert!( - Rule.apply(&g).is_empty(), - "{fidelity} should be accepted on a branch edge" - ); - } - } - - #[test] - fn warns_when_full_fidelity_on_branch_edge_degrades() { - let mut g = parallel_graph(); - g.edges[1].attrs.insert( - "fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - - let d = Rule.apply(&g); - - assert_eq!(d.len(), 1); - assert_eq!( - d[0].edge, - Some(("fork".to_string(), "branch_a".to_string())) - ); - assert!(d[0].message.contains("full")); - assert!(d[0].message.contains("summary:high")); - } - - #[test] - fn warns_on_thread_id_on_branch_edge() { - let mut g = parallel_graph(); - g.edges[1].attrs.insert( - "thread_id".to_string(), - AttrValue::String("impl".to_string()), - ); - let d = Rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!( - d[0].edge, - Some(("fork".to_string(), "branch_a".to_string())) - ); - assert!(d[0].message.contains("'thread_id'")); - assert_eq!( - d[0].fix.as_deref(), - Some( - "Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started" - ) - ); - } - - #[test] - fn warns_on_thread_id_on_branch_only_node() { - let mut g = parallel_graph(); - g.nodes - .get_mut("branch_a") - .expect("graph has branch_a") - .attrs - .insert( - "thread_id".to_string(), - AttrValue::String("impl".to_string()), - ); - - let d = Rule.apply(&g); - - assert_eq!(d.len(), 1); - assert_eq!(d[0].node_id.as_deref(), Some("branch_a")); - assert!(d[0].message.contains("'thread_id'")); - assert_eq!( - d[0].fix.as_deref(), - Some( - "Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started" - ) - ); - } - - #[test] - fn accepts_fidelity_on_the_parallel_node_itself() { - let mut g = parallel_graph(); - g.nodes - .get_mut("fork") - .expect("graph has fork") - .attrs - .insert( - "fidelity".to_string(), - AttrValue::String("truncate".to_string()), - ); - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn accepts_fidelity_on_branch_node_also_reached_by_normal_edge() { - let mut g = parallel_graph(); - // branch_a is also a normal successor of merge, so fidelity resolves - // on that path and is not inert. - g.edges.push(Edge::new("merge", "branch_a")); - g.nodes - .get_mut("branch_a") - .expect("graph has branch_a") - .attrs - .insert( - "fidelity".to_string(), - AttrValue::String("truncate".to_string()), - ); - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn names_every_parallel_parent_of_a_shared_branch_node() { - let mut g = parallel_graph(); - g.nodes - .insert("fork2".to_string(), shaped_node("fork2", "component")); - g.edges.push(Edge::new("start", "fork2")); - g.edges.push(Edge::new("fork2", "branch_a")); - g.nodes - .get_mut("branch_a") - .expect("graph has branch_a") - .attrs - .insert( - "fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - let d = Rule.apply(&g); - assert_eq!(d.len(), 1); - let fix = d[0].fix.as_deref().expect("diagnostic has a fix"); - assert!(fix.contains("'fork', 'fork2'")); - assert!(fix.contains("parallel nodes")); - } - - #[test] - fn accepts_graph_without_parallel_nodes() { - let mut g = minimal_graph(); - let mut node = shaped_node("work", "tab"); - node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("truncate".to_string()), - ); - g.nodes.insert("work".to_string(), node); - assert!(Rule.apply(&g).is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/prompt_on_llm_nodes.rs b/lib/components/fabro-validate/src/rules/prompt_on_llm_nodes.rs deleted file mode 100644 index 03cd130b2..000000000 --- a/lib/components/fabro-validate/src/rules/prompt_on_llm_nodes.rs +++ /dev/null @@ -1,156 +0,0 @@ -use fabro_graphviz::graph::{AttrValue, Graph, is_llm_handler_type}; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "prompt_on_llm_nodes" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if is_llm_handler_type(node.handler_type()) { - let has_prompt = node.prompt().is_some_and(|p| !p.is_empty()); - let has_label = node - .attrs - .get("label") - .and_then(AttrValue::as_str) - .is_some_and(|l| !l.is_empty()); - if !has_prompt && !has_label { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!("LLM node '{}' has no prompt or label attribute", node.id), - node_id: Some(node.id.clone()), - edge: None, - fix: Some("Add a prompt or label attribute".to_string()), - - ..Diagnostic::default() - }); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn prompt_on_llm_nodes_rule_no_prompt_no_label() { - let mut g = minimal_graph(); - let node = Node::new("work"); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - #[test] - fn prompt_on_llm_nodes_rule_with_prompt() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "prompt".to_string(), - AttrValue::String("Do the thing".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn prompt_on_llm_nodes_rule_with_label() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "label".to_string(), - AttrValue::String("Do something".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn prompt_on_llm_nodes_rule_non_codergen_no_warning() { - let mut g = minimal_graph(); - let mut node = Node::new("gate"); - node.attrs.insert( - "shape".to_string(), - AttrValue::String("hexagon".to_string()), - ); - g.nodes.insert("gate".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- Additional coverage: fidelity_valid edge and graph-level --- - - #[test] - fn prompt_on_llm_nodes_rule_explicit_agent_type_no_prompt() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("type".to_string(), AttrValue::String("agent".to_string())); - // No shape=box, but explicit type=agent - node.attrs.insert( - "shape".to_string(), - AttrValue::String("diamond".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - // --- goal_gate_has_retry: satisfied by graph-level fallback_retry_target --- - - #[test] - fn prompt_on_llm_nodes_rule_empty_prompt_no_label() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("prompt".to_string(), AttrValue::String(String::new())); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - // --- prompt_on_llm_nodes: empty label string still triggers --- - - #[test] - fn prompt_on_llm_nodes_rule_empty_label_no_prompt() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs - .insert("label".to_string(), AttrValue::String(String::new())); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - // --- condition_syntax: no condition attribute at all --- -} diff --git a/lib/components/fabro-validate/src/rules/random_selection_no_conditions.rs b/lib/components/fabro-validate/src/rules/random_selection_no_conditions.rs deleted file mode 100644 index b305443d2..000000000 --- a/lib/components/fabro-validate/src/rules/random_selection_no_conditions.rs +++ /dev/null @@ -1,114 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "random_selection_no_conditions" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if node.selection() != "random" { - continue; - } - let has_conditional = graph - .outgoing_edges(&node.id) - .iter() - .any(|e| e.condition().is_some_and(|c| !c.is_empty())); - if has_conditional { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Node '{}' has selection=\"random\" but also has conditional edges; random selection and conditions cannot be combined", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Remove the condition attributes from outgoing edges, or remove selection=\"random\" from the node".to_string(), - ), - - ..Diagnostic::default()}); - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn random_selection_no_conditions_clean() { - let mut g = minimal_graph(); - let mut node = Node::new("pick"); - node.attrs.insert( - "selection".to_string(), - AttrValue::String("random".to_string()), - ); - g.nodes.insert("pick".to_string(), node); - g.edges.push(Edge::new("pick", "start")); - g.edges.push(Edge::new("pick", "exit")); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn random_selection_with_conditions_errors() { - let mut g = minimal_graph(); - let mut node = Node::new("pick"); - node.attrs.insert( - "selection".to_string(), - AttrValue::String("random".to_string()), - ); - g.nodes.insert("pick".to_string(), node); - let mut e = Edge::new("pick", "exit"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded".to_string()), - ); - g.edges.push(e); - g.edges.push(Edge::new("pick", "start")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert_eq!(d[0].node_id.as_deref(), Some("pick")); - } - - #[test] - fn deterministic_selection_with_conditions_ok() { - let mut g = minimal_graph(); - let mut node = Node::new("gate"); - node.attrs.insert( - "selection".to_string(), - AttrValue::String("deterministic".to_string()), - ); - g.nodes.insert("gate".to_string(), node); - let mut e = Edge::new("gate", "exit"); - e.attrs.insert( - "condition".to_string(), - AttrValue::String("outcome=succeeded".to_string()), - ); - g.edges.push(e); - g.edges.push(Edge::new("gate", "start")); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/reachability.rs b/lib/components/fabro-validate/src/rules/reachability.rs deleted file mode 100644 index ca26a1f9d..000000000 --- a/lib/components/fabro-validate/src/rules/reachability.rs +++ /dev/null @@ -1,132 +0,0 @@ -use std::collections::{HashSet, VecDeque}; - -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "reachability" - } - - fn apply(&self, graph: &Graph) -> Vec { - let Some(start) = graph.find_start_node() else { - return Vec::new(); - }; - - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - queue.push_back(start.id.clone()); - visited.insert(start.id.clone()); - - while let Some(node_id) = queue.pop_front() { - for edge in graph.outgoing_edges(&node_id) { - if visited.insert(edge.to.clone()) { - queue.push_back(edge.to.clone()); - } - } - } - - let mut unreachable: Vec<&str> = graph - .nodes - .keys() - .filter(|id| !visited.contains(id.as_str())) - .map(std::string::String::as_str) - .collect(); - unreachable.sort_unstable(); - - unreachable - .into_iter() - .map(|node_id| Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!("Node '{node_id}' is not reachable from the start node"), - node_id: Some(node_id.to_string()), - edge: None, - fix: Some(format!( - "Add an edge path from the start node to '{node_id}'" - )), - - ..Diagnostic::default() - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{Edge, Graph, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn reachability_rule_unreachable_node() { - let mut g = minimal_graph(); - g.nodes.insert("orphan".to_string(), Node::new("orphan")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].node_id, Some("orphan".to_string())); - } - - #[test] - fn reachability_rule_all_reachable() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn reachability_rule_no_start_node() { - let mut g = Graph::new("test"); - g.nodes.insert("orphan".to_string(), Node::new("orphan")); - let rule = Rule; - let d = rule.apply(&g); - // No start node found, rule returns empty - assert!(d.is_empty()); - } - - // --- Additional coverage: retry_target_exists fallback and graph-level --- - - #[test] - fn reachability_rule_multiple_unreachable() { - let mut g = minimal_graph(); - g.nodes - .insert("orphan_a".to_string(), Node::new("orphan_a")); - g.nodes - .insert("orphan_b".to_string(), Node::new("orphan_b")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 2); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[1].severity, Severity::Warning); - } - - // --- type_known: all known handler types are accepted --- - - #[test] - fn reachability_rule_chain_all_reachable() { - let mut g = minimal_graph(); - g.nodes.insert("a".to_string(), Node::new("a")); - g.nodes.insert("b".to_string(), Node::new("b")); - g.edges = vec![ - Edge::new("start", "a"), - Edge::new("a", "b"), - Edge::new("b", "exit"), - ]; - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- edge_target_exists: no edges at all --- -} diff --git a/lib/components/fabro-validate/src/rules/reserved_keyword_node_id.rs b/lib/components/fabro-validate/src/rules/reserved_keyword_node_id.rs deleted file mode 100644 index a60a2edd2..000000000 --- a/lib/components/fabro-validate/src/rules/reserved_keyword_node_id.rs +++ /dev/null @@ -1,102 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -const DOT_RESERVED_KEYWORDS: &[&str] = &[ - "graph", "digraph", "subgraph", "node", "edge", "strict", "if", -]; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "reserved_keyword_node_id" - } - - fn apply(&self, graph: &Graph) -> Vec { - graph - .nodes - .values() - .filter(|node| DOT_RESERVED_KEYWORDS.contains(&node.id.to_lowercase().as_str())) - .map(|node| Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node ID '{}' is a DOT reserved keyword and may cause parsing failures", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(format!( - "Rename '{}' to '{}_step' or another non-reserved ID", - node.id, - node.id.to_lowercase() - )), - - ..Diagnostic::default() - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{Edge, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn reserved_keyword_node_id_warns_on_keyword() { - let mut g = minimal_graph(); - g.nodes.insert("graph".to_string(), Node::new("graph")); - g.edges.push(Edge::new("start", "graph")); - g.edges.push(Edge::new("graph", "exit")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[0].node_id.as_deref(), Some("graph")); - } - - #[test] - fn reserved_keyword_node_id_case_insensitive() { - let mut g = minimal_graph(); - g.nodes.insert("Node".to_string(), Node::new("Node")); - g.nodes.insert("EDGE".to_string(), Node::new("EDGE")); - g.edges.push(Edge::new("start", "Node")); - g.edges.push(Edge::new("Node", "EDGE")); - g.edges.push(Edge::new("EDGE", "exit")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 2); - } - - #[test] - fn reserved_keyword_node_id_normal_id_no_warning() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn reserved_keyword_node_id_multiple_keywords() { - let mut g = minimal_graph(); - g.nodes.insert("strict".to_string(), Node::new("strict")); - g.nodes.insert("digraph".to_string(), Node::new("digraph")); - g.nodes.insert("if".to_string(), Node::new("if")); - g.edges.push(Edge::new("start", "strict")); - g.edges.push(Edge::new("strict", "digraph")); - g.edges.push(Edge::new("digraph", "if")); - g.edges.push(Edge::new("if", "exit")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 3); - } -} diff --git a/lib/components/fabro-validate/src/rules/retry_target_exists.rs b/lib/components/fabro-validate/src/rules/retry_target_exists.rs deleted file mode 100644 index 1c66ca824..000000000 --- a/lib/components/fabro-validate/src/rules/retry_target_exists.rs +++ /dev/null @@ -1,254 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "retry_target_exists" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if let Some(target) = node.retry_target() { - if !graph.nodes.contains_key(target) { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node '{}' has retry_target '{}' that does not exist", - node.id, target - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(format!("Define node '{target}' or fix retry_target")), - - ..Diagnostic::default() - }); - } - } - if let Some(target) = node.fallback_retry_target() { - if !graph.nodes.contains_key(target) { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node '{}' has fallback_retry_target '{}' that does not exist", - node.id, target - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(format!( - "Define node '{target}' or fix fallback_retry_target" - )), - - ..Diagnostic::default() - }); - } - } - } - if let Some(target) = graph.retry_target() { - if !graph.nodes.contains_key(target) { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!("Graph has retry_target '{target}' that does not exist"), - node_id: None, - edge: None, - fix: Some(format!("Define node '{target}' or fix graph retry_target")), - - ..Diagnostic::default() - }); - } - } - if let Some(target) = graph.fallback_retry_target() { - if !graph.nodes.contains_key(target) { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Graph has fallback_retry_target '{target}' that does not exist" - ), - node_id: None, - edge: None, - fix: Some(format!( - "Define node '{target}' or fix graph fallback_retry_target" - )), - - ..Diagnostic::default() - }); - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn retry_target_exists_rule_missing() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "retry_target".to_string(), - AttrValue::String("nonexistent".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - #[test] - fn retry_target_exists_rule_valid() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "retry_target".to_string(), - AttrValue::String("start".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn retry_target_exists_rule_fallback_missing() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "fallback_retry_target".to_string(), - AttrValue::String("nonexistent".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("fallback_retry_target")); - } - - #[test] - fn retry_target_exists_rule_fallback_valid() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "fallback_retry_target".to_string(), - AttrValue::String("start".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn retry_target_exists_rule_graph_level_missing() { - let mut g = minimal_graph(); - g.attrs.insert( - "retry_target".to_string(), - AttrValue::String("nonexistent".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("Graph")); - } - - #[test] - fn retry_target_exists_rule_graph_level_valid() { - let mut g = minimal_graph(); - g.attrs.insert( - "retry_target".to_string(), - AttrValue::String("start".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn retry_target_exists_rule_graph_fallback_missing() { - let mut g = minimal_graph(); - g.attrs.insert( - "fallback_retry_target".to_string(), - AttrValue::String("nonexistent".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("fallback_retry_target")); - } - - #[test] - fn retry_target_exists_rule_graph_fallback_valid() { - let mut g = minimal_graph(); - g.attrs.insert( - "fallback_retry_target".to_string(), - AttrValue::String("exit".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- Additional coverage: goal_gate_has_retry with graph-level retry --- - - #[test] - fn retry_target_exists_rule_both_node_targets_invalid() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "retry_target".to_string(), - AttrValue::String("missing_a".to_string()), - ); - node.attrs.insert( - "fallback_retry_target".to_string(), - AttrValue::String("missing_b".to_string()), - ); - g.nodes.insert("work".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 2); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[1].severity, Severity::Warning); - } - - // --- retry_target_exists: both graph-level targets invalid --- - - #[test] - fn retry_target_exists_rule_both_graph_targets_invalid() { - let mut g = minimal_graph(); - g.attrs.insert( - "retry_target".to_string(), - AttrValue::String("missing_a".to_string()), - ); - g.attrs.insert( - "fallback_retry_target".to_string(), - AttrValue::String("missing_b".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 2); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[1].severity, Severity::Warning); - } - - // --- start_no_incoming: multiple incoming edges --- -} diff --git a/lib/components/fabro-validate/src/rules/script_absolute_cd.rs b/lib/components/fabro-validate/src/rules/script_absolute_cd.rs deleted file mode 100644 index 6b5083ad1..000000000 --- a/lib/components/fabro-validate/src/rules/script_absolute_cd.rs +++ /dev/null @@ -1,158 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -/// Returns true if `text` contains `cd` followed by whitespace and then `/`. -fn contains_cd_absolute(text: &str) -> bool { - let bytes = text.as_bytes(); - let len = bytes.len(); - let mut i = 0; - while i + 3 < len { - if bytes[i] == b'c' && bytes[i + 1] == b'd' && bytes[i + 2].is_ascii_whitespace() { - // found "cd", scan past remaining whitespace to check for '/' - let mut j = i + 2; - while j < len && bytes[j].is_ascii_whitespace() { - j += 1; - } - if j < len && bytes[j] == b'/' { - return true; - } - } - i += 1; - } - false -} - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "script_absolute_cd" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if node.handler_type() != Some("command") { - continue; - } - let script = node.script().unwrap_or(""); - if contains_cd_absolute(script) { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Script node '{}' contains `cd /…` with an absolute path", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Use a relative path; the engine sets the working directory to the worktree automatically" - .to_string(), - ), - - ..Diagnostic::default()}); - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn script_absolute_cd_warns_on_cd_abs_path() { - let mut g = minimal_graph(); - let mut node = Node::new("run"); - node.attrs.insert( - "shape".to_string(), - AttrValue::String("parallelogram".to_string()), - ); - node.attrs.insert( - "script".to_string(), - AttrValue::String("cd /tmp && ls".to_string()), - ); - g.nodes.insert("run".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - #[test] - fn script_absolute_cd_no_warning_on_relative() { - let mut g = minimal_graph(); - let mut node = Node::new("run"); - node.attrs.insert( - "shape".to_string(), - AttrValue::String("parallelogram".to_string()), - ); - node.attrs.insert( - "script".to_string(), - AttrValue::String("cd src && ls".to_string()), - ); - g.nodes.insert("run".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn script_absolute_cd_warns_on_shapeless_script_node() { - let mut g = minimal_graph(); - let mut node = Node::new("run"); - node.attrs.insert( - "script".to_string(), - AttrValue::String("cd /home/user && make".to_string()), - ); - g.nodes.insert("run".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - #[test] - fn script_absolute_cd_ignores_legacy_tool_command() { - let mut g = minimal_graph(); - let mut node = Node::new("run"); - node.attrs.insert( - "shape".to_string(), - AttrValue::String("parallelogram".to_string()), - ); - node.attrs.insert( - "tool_command".to_string(), - AttrValue::String("cd /home/user && make".to_string()), - ); - g.nodes.insert("run".to_string(), node); - let rule = Rule; - assert!(rule.apply(&g).is_empty()); - } - - #[test] - fn script_absolute_cd_skips_non_script_nodes() { - let mut g = minimal_graph(); - let mut node = Node::new("gen"); - node.attrs - .insert("shape".to_string(), AttrValue::String("box".to_string())); - node.attrs.insert( - "prompt".to_string(), - AttrValue::String("cd /tmp and do stuff".to_string()), - ); - g.nodes.insert("gen".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/selection_valid.rs b/lib/components/fabro-validate/src/rules/selection_valid.rs deleted file mode 100644 index caeed4374..000000000 --- a/lib/components/fabro-validate/src/rules/selection_valid.rs +++ /dev/null @@ -1,85 +0,0 @@ -use fabro_graphviz::graph::{AttrValue, Graph}; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -const VALID_SELECTIONS: &[&str] = &["deterministic", "random"]; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "selection_valid" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if let Some(sel) = node.attrs.get("selection").and_then(AttrValue::as_str) { - if !VALID_SELECTIONS.contains(&sel) { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!("Node '{}' has invalid selection mode '{sel}'", node.id), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(format!("Use one of: {}", VALID_SELECTIONS.join(", "))), - - ..Diagnostic::default() - }); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn selection_valid_known_values() { - let mut g = minimal_graph(); - let mut node = Node::new("pick"); - node.attrs.insert( - "selection".to_string(), - AttrValue::String("random".to_string()), - ); - g.nodes.insert("pick".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn selection_valid_unknown_value_warns() { - let mut g = minimal_graph(); - let mut node = Node::new("pick"); - node.attrs.insert( - "selection".to_string(), - AttrValue::String("randon".to_string()), - ); - g.nodes.insert("pick".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[0].node_id.as_deref(), Some("pick")); - } - - #[test] - fn selection_valid_no_attr_ok() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/start_no_incoming.rs b/lib/components/fabro-validate/src/rules/start_no_incoming.rs deleted file mode 100644 index 989ea100a..000000000 --- a/lib/components/fabro-validate/src/rules/start_no_incoming.rs +++ /dev/null @@ -1,91 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "start_no_incoming" - } - - fn apply(&self, graph: &Graph) -> Vec { - let Some(start) = graph.find_start_node() else { - return Vec::new(); - }; - let incoming = graph.incoming_edges(&start.id); - if !incoming.is_empty() { - return vec![Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Start node '{}' has {} incoming edge(s) but must have none", - start.id, - incoming.len() - ), - node_id: Some(start.id.clone()), - edge: None, - fix: Some("Remove incoming edges to the start node".to_string()), - - ..Diagnostic::default() - }]; - } - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{Edge, Graph, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn start_no_incoming_rule_with_incoming() { - let mut g = minimal_graph(); - g.edges.push(Edge::new("exit", "start")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn start_no_incoming_rule_clean() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn start_no_incoming_rule_no_start_node() { - let g = Graph::new("test"); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- Additional coverage: exit_no_outgoing by id variants --- - - #[test] - fn start_no_incoming_rule_multiple_incoming() { - let mut g = minimal_graph(); - g.nodes.insert("a".to_string(), Node::new("a")); - g.edges.push(Edge::new("exit", "start")); - g.edges.push(Edge::new("a", "start")); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert!(d[0].message.contains('2')); - } - - // --- prompt_on_llm_nodes: empty prompt string still triggers --- -} diff --git a/lib/components/fabro-validate/src/rules/start_node.rs b/lib/components/fabro-validate/src/rules/start_node.rs deleted file mode 100644 index ba44959b6..000000000 --- a/lib/components/fabro-validate/src/rules/start_node.rs +++ /dev/null @@ -1,120 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "start_node" - } - - fn apply(&self, graph: &Graph) -> Vec { - let start_count = graph - .nodes - .iter() - .filter(|(id, n)| n.shape() == "Mdiamond" || *id == "start" || *id == "Start") - .count(); - if start_count == 0 { - return vec![Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: - "Pipeline must have exactly one start node (shape=Mdiamond or id start/Start)" - .to_string(), - node_id: None, - edge: None, - fix: Some("Add a node with shape=Mdiamond or id 'start'".to_string()), - - ..Diagnostic::default() - }]; - } - if start_count > 1 { - return vec![Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Pipeline has {start_count} start nodes but must have exactly one" - ), - node_id: None, - edge: None, - fix: Some("Remove extra start nodes".to_string()), - - ..Diagnostic::default() - }]; - } - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Graph, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn start_node_rule_no_start() { - let g = Graph::new("test"); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn start_node_rule_two_starts() { - let mut g = Graph::new("test"); - let mut s1 = Node::new("s1"); - s1.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - let mut s2 = Node::new("s2"); - s2.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("s1".to_string(), s1); - g.nodes.insert("s2".to_string(), s2); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn start_node_rule_one_start() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn start_node_rule_by_id() { - let mut g = Graph::new("test"); - // Node with id "start" but no Mdiamond shape - let node = Node::new("start"); - g.nodes.insert("start".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn start_node_rule_by_capitalized_id() { - let mut g = Graph::new("test"); - let node = Node::new("Start"); - g.nodes.insert("Start".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/stdin_source_valid.rs b/lib/components/fabro-validate/src/rules/stdin_source_valid.rs deleted file mode 100644 index e9f62f3a3..000000000 --- a/lib/components/fabro-validate/src/rules/stdin_source_valid.rs +++ /dev/null @@ -1,95 +0,0 @@ -use fabro_graphviz::graph::{ContextKeyAttr, Graph}; -use fabro_types::StageHandler; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "stdin_source_valid" - } - - fn apply(&self, graph: &Graph) -> Vec { - graph - .nodes - .values() - .filter(|node| { - StageHandler::from_handler_type(node.handler_type()) == StageHandler::Command - }) - .filter(|node| { - matches!( - node.context_key_attr("stdin_source"), - ContextKeyAttr::Invalid - ) - }) - .map(|node| Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Node '{}' has an empty or non-string 'stdin_source'", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some( - "Set 'stdin_source' to a context key such as \"context.parallel.results\"" - .to_string(), - ), - ..Diagnostic::default() - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - fn command_node(value: AttrValue) -> Node { - let mut node = Node::new("merge"); - node.attrs.insert( - "shape".to_string(), - AttrValue::String("parallelogram".to_string()), - ); - node.attrs.insert("stdin_source".to_string(), value); - node - } - - #[test] - fn accepts_non_empty_context_source() { - let mut graph = minimal_graph(); - graph.nodes.insert( - "merge".to_string(), - command_node(AttrValue::String("context.parallel.results".to_string())), - ); - - assert!(Rule.apply(&graph).is_empty()); - } - - #[test] - fn rejects_empty_or_non_string_source() { - for value in [ - AttrValue::String(String::new()), - AttrValue::String(" ".to_string()), - AttrValue::Integer(3), - ] { - let mut graph = minimal_graph(); - graph.nodes.insert("merge".to_string(), command_node(value)); - - let diagnostics = Rule.apply(&graph); - - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, Severity::Error); - assert!(diagnostics[0].message.contains("empty or non-string")); - } - } -} diff --git a/lib/components/fabro-validate/src/rules/stylesheet_model_known.rs b/lib/components/fabro-validate/src/rules/stylesheet_model_known.rs deleted file mode 100644 index cafd16772..000000000 --- a/lib/components/fabro-validate/src/rules/stylesheet_model_known.rs +++ /dev/null @@ -1,151 +0,0 @@ -use fabro_graphviz::graph::Graph; -use fabro_graphviz::stylesheet::{Selector, parse_stylesheet}; -use fabro_llm::lithos_catalog::Catalog; - -use super::model_support::{check_model_known, check_provider_known}; -use crate::{Diagnostic, LintRule}; - -pub(super) fn rule(catalog: &Catalog) -> Box { - Box::new(Rule { catalog }) -} - -struct Rule<'a> { - catalog: &'a Catalog, -} - -impl Rule<'_> { - fn selector_label(selector: &Selector) -> String { - match selector { - Selector::Universal => "*".to_string(), - Selector::Shape(s) => s.clone(), - Selector::Class(c) => format!(".{c}"), - Selector::Id(id) => format!("#{id}"), - } - } -} - -impl LintRule for Rule<'_> { - fn name(&self) -> &'static str { - "stylesheet_model_known" - } - - fn apply(&self, graph: &Graph) -> Vec { - let stylesheet_str = graph.model_stylesheet(); - if stylesheet_str.is_empty() { - return Vec::new(); - } - let Ok(stylesheet) = parse_stylesheet(stylesheet_str) else { - return Vec::new(); // syntax errors caught by stylesheet_syntax rule - }; - - let mut diagnostics = Vec::new(); - for rule in &stylesheet.rules { - let label = Self::selector_label(&rule.selector); - for decl in &rule.declarations { - let context = format!("in stylesheet rule '{label}'"); - match decl.property.as_str() { - "model" => { - if let Some(d) = check_model_known( - self.name(), - self.catalog, - &decl.value, - &context, - None, - ) { - diagnostics.push(d); - } - } - "provider" => { - if let Some(d) = check_provider_known( - self.name(), - self.catalog, - &decl.value, - &context, - None, - ) { - diagnostics.push(d); - } - } - _ => {} - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::AttrValue; - use fabro_llm::test_support::test_catalog; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn stylesheet_model_known_rule_valid() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String("* { model: claude-sonnet-4.5; provider: anthropic; }".to_string()), - ); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn stylesheet_model_known_rule_unknown_model() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String("#opus { model: claude-opus-4-5; }".to_string()), - ); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("claude-opus-4-5")); - assert!(d[0].message.contains("#opus")); - } - - #[test] - fn stylesheet_model_known_rule_unknown_provider() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String("* { provider: nonexistent-provider; }".to_string()), - ); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("nonexistent-provider")); - } - - #[test] - fn stylesheet_model_known_rule_alias() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String("* { model: opus; }".to_string()), - ); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn stylesheet_model_known_rule_no_stylesheet() { - let g = minimal_graph(); - let catalog = test_catalog(); - let rule = Rule { catalog: &catalog }; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/stylesheet_syntax.rs b/lib/components/fabro-validate/src/rules/stylesheet_syntax.rs deleted file mode 100644 index 3964b7619..000000000 --- a/lib/components/fabro-validate/src/rules/stylesheet_syntax.rs +++ /dev/null @@ -1,125 +0,0 @@ -use fabro_graphviz::graph::Graph; -use fabro_graphviz::stylesheet::parse_stylesheet; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "stylesheet_syntax" - } - - fn apply(&self, graph: &Graph) -> Vec { - let stylesheet = graph.model_stylesheet(); - if stylesheet.is_empty() { - return Vec::new(); - } - match parse_stylesheet(stylesheet) { - Ok(_) => Vec::new(), - Err(e) => vec![Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!("Model stylesheet parse error: {e}"), - node_id: None, - edge: None, - fix: Some("Fix the model_stylesheet syntax".to_string()), - - ..Diagnostic::default() - }], - } - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::AttrValue; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn stylesheet_syntax_rule_unbalanced() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String("* { model: foo;".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn stylesheet_syntax_rule_balanced() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String("* { model: foo; }".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn stylesheet_syntax_rule_malformed_selector() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String("* { garbage garbage }".to_string()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - // --- Additional coverage: condition_syntax invalid case --- - - #[test] - fn stylesheet_syntax_rule_no_stylesheet() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- Additional coverage: type_known no type attr --- - - #[test] - fn stylesheet_syntax_rule_multi_rule_valid() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String( - "* { model: gpt-4; } .fast { model: gpt-3.5; reasoning_effort: low; }".to_string(), - ), - ); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- fidelity_valid: multiple simultaneous violations --- - - #[test] - fn stylesheet_syntax_rule_empty_string() { - let mut g = minimal_graph(); - g.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String(String::new()), - ); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- type_known: start and exit types from shape are not flagged --- -} diff --git a/lib/components/fabro-validate/src/rules/terminal_node.rs b/lib/components/fabro-validate/src/rules/terminal_node.rs deleted file mode 100644 index b24d042f5..000000000 --- a/lib/components/fabro-validate/src/rules/terminal_node.rs +++ /dev/null @@ -1,157 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "terminal_node" - } - - fn apply(&self, graph: &Graph) -> Vec { - let terminal_count = graph - .nodes - .iter() - .filter(|(id, n)| { - n.shape() == "Msquare" - || *id == "exit" - || *id == "Exit" - || *id == "end" - || *id == "End" - }) - .count(); - if terminal_count == 0 { - return vec![Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: - "Pipeline must have exactly one terminal node (shape=Msquare or id exit/end)" - .to_string(), - node_id: None, - edge: None, - fix: Some("Add a node with shape=Msquare or id 'exit'/'end'".to_string()), - - ..Diagnostic::default() - }]; - } - if terminal_count > 1 { - return vec![Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Pipeline must have exactly one terminal node, found {terminal_count}" - ), - node_id: None, - edge: None, - fix: Some("Remove extra terminal nodes so exactly one remains".to_string()), - - ..Diagnostic::default() - }]; - } - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Graph, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn terminal_node_rule_no_terminal() { - let mut g = Graph::new("test"); - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("start".to_string(), start); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - } - - #[test] - fn terminal_node_rule_with_terminal() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn terminal_node_rule_by_exit_id() { - let mut g = Graph::new("test"); - // Node with id "exit" but no Msquare shape - let node = Node::new("exit"); - g.nodes.insert("exit".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn terminal_node_rule_by_end_id() { - let mut g = Graph::new("test"); - let node = Node::new("end"); - g.nodes.insert("end".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn terminal_node_rule_by_capitalized_end_id() { - let mut g = Graph::new("test"); - let node = Node::new("End"); - g.nodes.insert("End".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn terminal_node_rule_two_terminals() { - let mut g = Graph::new("test"); - let mut e1 = Node::new("e1"); - e1.attrs.insert( - "shape".to_string(), - AttrValue::String("Msquare".to_string()), - ); - let mut e2 = Node::new("e2"); - e2.attrs.insert( - "shape".to_string(), - AttrValue::String("Msquare".to_string()), - ); - g.nodes.insert("e1".to_string(), e1); - g.nodes.insert("e2".to_string(), e2); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert!(d[0].message.contains("exactly one")); - } - - // --- Additional coverage: edge_target_exists missing source --- - - #[test] - fn terminal_node_rule_by_exit_capitalized_id() { - let mut g = Graph::new("test"); - let node = Node::new("Exit"); - g.nodes.insert("Exit".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- edge_target_exists: both source and target missing --- -} diff --git a/lib/components/fabro-validate/src/rules/test_support.rs b/lib/components/fabro-validate/src/rules/test_support.rs deleted file mode 100644 index 0846b7c13..000000000 --- a/lib/components/fabro-validate/src/rules/test_support.rs +++ /dev/null @@ -1,30 +0,0 @@ -use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; - -pub(crate) fn node_with_attrs(id: &str, attrs: &[(&str, &str)]) -> Node { - let mut node = Node::new(id); - for (key, value) in attrs { - node.attrs - .insert((*key).to_string(), AttrValue::String((*value).to_string())); - } - node -} - -pub(crate) fn minimal_graph() -> Graph { - let mut g = Graph::new("test"); - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - g.nodes.insert("start".to_string(), start); - - let mut exit = Node::new("exit"); - exit.attrs.insert( - "shape".to_string(), - AttrValue::String("Msquare".to_string()), - ); - g.nodes.insert("exit".to_string(), exit); - - g.edges.push(Edge::new("start", "exit")); - g -} diff --git a/lib/components/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs b/lib/components/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs deleted file mode 100644 index 157b2a032..000000000 --- a/lib/components/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs +++ /dev/null @@ -1,311 +0,0 @@ -use fabro_graphviz::graph::Graph; - -use super::parallel_branch::ParallelBranches; -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl Rule { - const FIX: &str = "Add fidelity=\"full\" to enable session reuse, or remove thread_id"; -} - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "thread_id_requires_fidelity_full" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - let graph_default_full = graph.default_fidelity() == Some("full"); - let branches = ParallelBranches::new(graph); - - // thread_id is inert on parallel branches, where - // parallel_branch_inert_attribute already says "remove thread_id" — - // advising fidelity="full" there would contradict it. - for node in graph.nodes.values() { - if node.thread_id().is_some() - && !branches.is_branch_only_node(&node.id) - && node.fidelity() != Some("full") - && !graph_default_full - { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Node '{}' has thread_id but fidelity is not 'full'", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(Self::FIX.to_string()), - - ..Diagnostic::default() - }); - } - } - - for edge in &graph.edges { - if edge.thread_id().is_some() && !branches.is_fork_edge(edge) { - let edge_full = edge.fidelity() == Some("full"); - let target_full = - graph.nodes.get(&edge.to).and_then(|n| n.fidelity()) == Some("full"); - if !edge_full && !target_full && !graph_default_full { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Edge {} -> {} has thread_id but fidelity is not 'full'", - edge.from, edge.to - ), - node_id: None, - edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some(Self::FIX.to_string()), - - ..Diagnostic::default() - }); - } - } - } - - if graph.default_thread().is_some() && !graph_default_full { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: "Graph has default_thread but default_fidelity is not 'full'".to_string(), - node_id: None, - edge: None, - fix: Some(Self::FIX.to_string()), - - ..Diagnostic::default() - }); - } - - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - fn parallel_graph() -> Graph { - let mut g = minimal_graph(); - let mut fork = Node::new("fork"); - fork.attrs.insert( - "shape".to_string(), - AttrValue::String("component".to_string()), - ); - g.nodes.insert("fork".to_string(), fork); - g.nodes.insert("branch".to_string(), Node::new("branch")); - g.edges = vec![ - Edge::new("start", "fork"), - Edge::new("fork", "branch"), - Edge::new("branch", "exit"), - ]; - g - } - - #[test] - fn thread_id_requires_fidelity_full_node_warns() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "thread_id".to_string(), - AttrValue::String("session1".to_string()), - ); - g.nodes.insert("work".to_string(), node); - g.edges.push(Edge::new("start", "work")); - g.edges.push(Edge::new("work", "exit")); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[0].node_id, Some("work".to_string())); - } - - #[test] - fn thread_id_requires_fidelity_full_node_ok() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "thread_id".to_string(), - AttrValue::String("session1".to_string()), - ); - node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - g.nodes.insert("work".to_string(), node); - g.edges.push(Edge::new("start", "work")); - g.edges.push(Edge::new("work", "exit")); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn thread_id_requires_fidelity_full_node_graph_default_ok() { - let mut g = minimal_graph(); - g.attrs.insert( - "default_fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - let mut node = Node::new("work"); - node.attrs.insert( - "thread_id".to_string(), - AttrValue::String("session1".to_string()), - ); - g.nodes.insert("work".to_string(), node); - g.edges.push(Edge::new("start", "work")); - g.edges.push(Edge::new("work", "exit")); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn thread_id_requires_fidelity_full_edge_warns() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "thread_id".to_string(), - AttrValue::String("session1".to_string()), - ); - g.edges = vec![edge]; - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert_eq!(d[0].edge, Some(("start".to_string(), "exit".to_string()))); - } - - #[test] - fn thread_id_requires_fidelity_full_edge_ok() { - let mut g = minimal_graph(); - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "thread_id".to_string(), - AttrValue::String("session1".to_string()), - ); - edge.attrs.insert( - "fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - g.edges = vec![edge]; - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn thread_id_requires_fidelity_full_edge_target_node_ok() { - let mut g = minimal_graph(); - if let Some(exit_node) = g.nodes.get_mut("exit") { - exit_node.attrs.insert( - "fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - } - let mut edge = Edge::new("start", "exit"); - edge.attrs.insert( - "thread_id".to_string(), - AttrValue::String("session1".to_string()), - ); - g.edges = vec![edge]; - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn skips_thread_id_on_parallel_branch_edge() { - let mut g = parallel_graph(); - g.edges[1].attrs.insert( - "thread_id".to_string(), - AttrValue::String("branch-thread".to_string()), - ); - - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn skips_thread_id_on_branch_only_node() { - let mut g = parallel_graph(); - g.nodes - .get_mut("branch") - .expect("graph has branch") - .attrs - .insert( - "thread_id".to_string(), - AttrValue::String("branch-thread".to_string()), - ); - - assert!(Rule.apply(&g).is_empty()); - } - - #[test] - fn checks_thread_id_on_branch_node_with_normal_entry() { - let mut g = parallel_graph(); - g.edges.push(Edge::new("start", "branch")); - g.nodes - .get_mut("branch") - .expect("graph has branch") - .attrs - .insert( - "thread_id".to_string(), - AttrValue::String("shared-thread".to_string()), - ); - - let d = Rule.apply(&g); - - assert_eq!(d.len(), 1); - assert_eq!(d[0].node_id.as_deref(), Some("branch")); - } - - #[test] - fn thread_id_requires_fidelity_full_graph_warns() { - let mut g = minimal_graph(); - g.attrs.insert( - "default_thread".to_string(), - AttrValue::String("session1".to_string()), - ); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].node_id.is_none()); - assert!(d[0].edge.is_none()); - } - - #[test] - fn thread_id_requires_fidelity_full_graph_ok() { - let mut g = minimal_graph(); - g.attrs.insert( - "default_thread".to_string(), - AttrValue::String("session1".to_string()), - ); - g.attrs.insert( - "default_fidelity".to_string(), - AttrValue::String("full".to_string()), - ); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-validate/src/rules/type_known.rs b/lib/components/fabro-validate/src/rules/type_known.rs deleted file mode 100644 index 36402e585..000000000 --- a/lib/components/fabro-validate/src/rules/type_known.rs +++ /dev/null @@ -1,146 +0,0 @@ -use fabro_graphviz::graph::{Graph, KNOWN_HANDLER_TYPES, is_known_handler_type}; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "type_known" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - for node in graph.nodes.values() { - if let Some(node_type) = node.node_type() { - if !is_known_handler_type(node_type) { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!("Node '{}' has unrecognized type '{node_type}'", node.id), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(format!("Use one of: {}", KNOWN_HANDLER_TYPES.join(", "))), - - ..Diagnostic::default() - }); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn type_known_rule_unknown_type() { - let mut g = minimal_graph(); - let mut node = Node::new("custom"); - node.attrs.insert( - "type".to_string(), - AttrValue::String("unknown_type".to_string()), - ); - g.nodes.insert("custom".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Warning); - } - - #[test] - fn type_known_rule_known_type() { - let mut g = minimal_graph(); - let mut node = Node::new("gate"); - node.attrs - .insert("type".to_string(), AttrValue::String("human".to_string())); - g.nodes.insert("gate".to_string(), node); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - #[test] - fn type_known_rule_no_type_attr() { - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - // Nodes without explicit type attr should not trigger warning - assert!(d.is_empty()); - } - - // --- Additional coverage: start_no_incoming no start node --- - - #[test] - fn type_known_rule_all_known_types_accepted() { - let mut g = minimal_graph(); - - let mut n1 = Node::new("n1"); - n1.attrs - .insert("type".to_string(), AttrValue::String("agent".to_string())); - g.nodes.insert("n1".to_string(), n1); - - let mut n2 = Node::new("n2"); - n2.attrs.insert( - "type".to_string(), - AttrValue::String("conditional".to_string()), - ); - g.nodes.insert("n2".to_string(), n2); - - let mut n3 = Node::new("n3"); - n3.attrs.insert( - "type".to_string(), - AttrValue::String("parallel".to_string()), - ); - g.nodes.insert("n3".to_string(), n3); - - let mut n4 = Node::new("n4"); - n4.attrs.insert( - "type".to_string(), - AttrValue::String("parallel.fan_in".to_string()), - ); - g.nodes.insert("n4".to_string(), n4); - - let mut n5 = Node::new("n5"); - n5.attrs - .insert("type".to_string(), AttrValue::String("command".to_string())); - g.nodes.insert("n5".to_string(), n5); - - let mut n6 = Node::new("n6"); - n6.attrs.insert( - "type".to_string(), - AttrValue::String("stack.manager_loop".to_string()), - ); - g.nodes.insert("n6".to_string(), n6); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- prompt_on_llm_nodes: explicit type=agent without prompt/label --- - - #[test] - fn type_known_rule_start_exit_shapes_no_warning() { - // The minimal_graph has start (Mdiamond) and exit (Msquare), which resolve - // to known handler types "start" and "exit" via shape mapping, not explicit - // type. Since they have no explicit `type` attr, the rule should not - // flag them. - let g = minimal_graph(); - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } - - // --- reserved_keyword_node_id tests --- -} diff --git a/lib/components/fabro-validate/src/rules/unresolved_file_ref.rs b/lib/components/fabro-validate/src/rules/unresolved_file_ref.rs deleted file mode 100644 index c7891fd12..000000000 --- a/lib/components/fabro-validate/src/rules/unresolved_file_ref.rs +++ /dev/null @@ -1,113 +0,0 @@ -use fabro_graphviz::graph::{AttrValue, Graph}; - -use crate::{Diagnostic, LintRule, Severity}; - -pub(super) fn rule() -> Box { - Box::new(Rule) -} - -struct Rule; - -impl LintRule for Rule { - fn name(&self) -> &'static str { - "unresolved_file_ref" - } - - fn apply(&self, graph: &Graph) -> Vec { - let mut diagnostics = Vec::new(); - - for node in graph.nodes.values() { - if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") { - if prompt.starts_with('@') { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Node '{}' has unresolved file reference: {prompt}", - node.id - ), - node_id: Some(node.id.clone()), - edge: None, - fix: Some("Check that the path is relative to the workflow file's directory and the file exists".to_string()), - - ..Diagnostic::default()}); - } - } - } - - if let Some(AttrValue::String(goal)) = graph.attrs.get("goal") { - if goal.starts_with('@') { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!("Graph goal has unresolved file reference: {goal}"), - node_id: None, - edge: None, - fix: Some("Check that the path is relative to the workflow file's directory and the file exists".to_string()), - - ..Diagnostic::default()}); - } - } - - diagnostics - } -} - -#[cfg(test)] -mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; - - use super::Rule; - use crate::rules::test_support::minimal_graph; - use crate::{LintRule, Severity}; - - #[test] - fn unresolved_file_ref_rule_prompt() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "prompt".to_string(), - AttrValue::String("@prompts/simplify.md".to_string()), - ); - g.nodes.insert("work".to_string(), node); - g.edges.push(Edge::new("start", "work")); - g.edges.push(Edge::new("work", "exit")); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert!(d[0].message.contains("@prompts/simplify.md")); - assert_eq!(d[0].node_id, Some("work".to_string())); - } - - #[test] - fn unresolved_file_ref_rule_goal() { - let mut g = minimal_graph(); - g.attrs.insert( - "goal".to_string(), - AttrValue::String("@goal.md".to_string()), - ); - - let rule = Rule; - let d = rule.apply(&g); - assert_eq!(d.len(), 1); - assert_eq!(d[0].severity, Severity::Error); - assert!(d[0].message.contains("@goal.md")); - } - - #[test] - fn unresolved_file_ref_rule_resolved_prompt() { - let mut g = minimal_graph(); - let mut node = Node::new("work"); - node.attrs.insert( - "prompt".to_string(), - AttrValue::String("Do the work".to_string()), - ); - g.nodes.insert("work".to_string(), node); - - let rule = Rule; - let d = rule.apply(&g); - assert!(d.is_empty()); - } -} diff --git a/lib/components/fabro-workflow/Cargo.toml b/lib/components/fabro-workflow/Cargo.toml index 31d3731f9..e3f175212 100644 --- a/lib/components/fabro-workflow/Cargo.toml +++ b/lib/components/fabro-workflow/Cargo.toml @@ -24,7 +24,6 @@ anyhow.workspace = true fabro-auth = { path = "../../foundation/fabro-auth" } fabro-config = { path = "../../foundation/fabro-config" } fabro-graphviz = { path = "../fabro-graphviz" } -fabro-validate = { path = "../fabro-validate" } fabro-sandbox = { path = "../fabro-sandbox" } sandbox-driver.workspace = true pebble-agent.workspace = true diff --git a/lib/components/fabro-workflow/src/error.rs b/lib/components/fabro-workflow/src/error.rs index 5db78d3c3..2ceb0b0e6 100644 --- a/lib/components/fabro-workflow/src/error.rs +++ b/lib/components/fabro-workflow/src/error.rs @@ -4,12 +4,12 @@ use std::sync::{Arc, LazyLock}; use fabro_graphviz::Error as GraphvizError; use fabro_llm::{ErrorData, ErrorKind, ModelSelectionError, failure_signature_hint}; use fabro_template::TemplateError; +use fabro_types::diagnostic::Diagnostic; pub use fabro_types::failure_signature::FailureSignature; pub use fabro_types::outcome::FailureCategory; use fabro_types::settings::{AmbiguousModelRef, ResolveError}; use fabro_types::{ExecOutputTail, FailureReason, RunFailure}; use fabro_util::error::{SharedError, collect_causes, collect_chain, render_with_causes}; -use fabro_validate::Diagnostic; use regex::Regex; use thiserror::Error as ThisError; @@ -712,12 +712,6 @@ impl From for Error { } } -impl From for Error { - fn from(e: fabro_validate::ValidationError) -> Self { - Self::Validation(e.0) - } -} - impl From for Error { fn from(err: RunEventPersistenceError) -> Self { Self::engine_with_source("run event persistence failed", err) @@ -795,7 +789,7 @@ mod tests { let err = Error::ValidationFailed { diagnostics: vec![Diagnostic { rule: "test".to_string(), - severity: fabro_validate::Severity::Error, + severity: fabro_types::diagnostic::Severity::Error, message: "missing start node".to_string(), node_id: None, edge: None, @@ -1982,7 +1976,7 @@ mod tests { Error::ValidationFailed { diagnostics: vec![Diagnostic { rule: "test".into(), - severity: fabro_validate::Severity::Error, + severity: fabro_types::diagnostic::Severity::Error, message: "bad".into(), node_id: None, edge: None, diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index 8617add00..554226cd6 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -8,11 +8,9 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use fabro_config::Storage; use fabro_graphviz::graph::{AttrValue, Graph}; -use fabro_llm::lithos_catalog::Catalog; use fabro_store::{BlobStore, Database}; use fabro_template::TemplateContext; use fabro_types::{ @@ -20,7 +18,6 @@ use fabro_types::{ RunProvenance, RunTarget, WorkflowSettings, WorkflowVersionId, }; use fabro_util::json::normalize_json_value; -use lithos_llm::catalog::ProviderId; use tokio::task::spawn_blocking; use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; @@ -30,105 +27,18 @@ use crate::pipeline::types::PersistOptions; use crate::pipeline::{self, Persisted, TransformOptions, Validated}; use crate::records::RunSpec; use crate::run_materialization; -use crate::transforms::{ModelResolutionTransform, RenderMode}; +use crate::transforms::RenderMode; use crate::workflow_bundle::{RunDefinition, WorkflowBundle}; -#[derive(Clone, Debug)] -pub struct CreateRunInput { - pub workflow: WorkflowInput, - pub settings: WorkflowSettings, - /// Run-scoped variables (`{{ vars.* }}`) snapshotted from the server's - /// variable store at create time, threaded into the template render - /// context for prompts and goals. Empty for offline/CLI callers. - pub vars: HashMap, - pub cwd: PathBuf, - pub workflow_slug: Option, - pub workflow_path: Option, - pub workflow_bundle: Option, - pub target: Option, - pub run_id: Option, - pub title: Option, - pub automation: Option, - pub git: Option, - pub fork_source_ref: Option, - pub parent_id: Option, - pub provenance: RunProvenance, - pub configured_providers: Vec, - /// Public URL where this run can be viewed in the web UI, when the server - /// has the web UI enabled. Recorded on the `run.created` event so attach - /// replays can surface the link. - pub web_url: Option, - /// What Petri admitted for the run. - pub admission: PetriAdmission, -} - -impl CreateRunInput { - /// Split into the compile-stage input and the persistence metadata for - /// `run_id`, the two halves of the create pipeline. - fn into_stages( - self, - run_id: RunId, - storage_root: PathBuf, - ) -> (CreateRunCompileInput, CreateRunPersistenceMetadata) { - let Self { - workflow, - settings, - vars, - cwd, - workflow_slug, - workflow_path, - workflow_bundle, - target, - run_id: _, - title, - automation, - git, - fork_source_ref, - parent_id, - provenance, - configured_providers, - web_url, - admission, - } = self; - ( - CreateRunCompileInput { - workflow, - settings, - vars, - cwd, - workflow_path, - workflow_bundle, - configured_providers, - }, - CreateRunPersistenceMetadata { - run_id, - storage_root, - workflow_slug, - workflow_version_id: None, - target, - title, - automation, - git, - fork_source_ref, - parent_id, - provenance, - web_url, - admission, - }, - ) - } -} - /// Inputs needed to resolve and compile a workflow for run creation. #[derive(Debug)] pub struct CreateRunCompileInput { - pub workflow: WorkflowInput, - pub settings: WorkflowSettings, - pub vars: HashMap, - pub cwd: PathBuf, - pub workflow_path: Option, - pub workflow_bundle: Option, - pub configured_providers: Vec, + pub workflow: WorkflowInput, + pub settings: WorkflowSettings, + pub vars: HashMap, + pub cwd: PathBuf, + pub workflow_path: Option, + pub workflow_bundle: Option, } /// Durable metadata joined to a materialized workflow before persistence. @@ -164,15 +74,14 @@ pub struct CreatedRun { /// for run creation. Model selectors in the graph are resolved, while the run /// settings still reflect the compiled source and have not been materialized. pub struct CompiledRun { - validated: Validated, - settings: WorkflowSettings, - raw_source: String, - workflow_slug: Option, - dot_path: Option, - definition: Option, - source_directory: String, - labels: HashMap, - configured_providers: Vec, + validated: Validated, + settings: WorkflowSettings, + raw_source: String, + workflow_slug: Option, + dot_path: Option, + definition: Option, + source_directory: String, + labels: HashMap, } impl CompiledRun { @@ -253,30 +162,6 @@ impl CreateRunPersistenceInput { } } -/// Resolve workflow inputs, normalize settings using the caller-provided -/// catalog, and persist a run directory. -pub async fn create( - store: &Database, - request: CreateRunInput, - storage_root: PathBuf, - catalog: Arc, -) -> Result { - let run_id = request.run_id.unwrap_or_default(); - let persistence_input = spawn_blocking(move || { - let (compile_input, metadata) = request.into_stages(run_id, storage_root); - let compiled = compile_create_run(compile_input, Arc::clone(&catalog))?; - let materialized = materialize_create_run(compiled, catalog.as_ref())?; - Ok::<_, Error>(assemble_create_run_persistence_input( - materialized, - metadata, - )) - }) - .await - .map_err(|err| Error::engine_with_source("workflow create task failed", err))??; - - Box::pin(persist_create_run(store, persistence_input)).await -} - /// Stage two for a run another engine admitted: the Fabro graph is parsed /// and transformed for the read side (the goal, the node count, labels), with /// no lint rule, no model resolution and no promotion of template @@ -291,7 +176,6 @@ pub fn compile_admitted_run(input: CreateRunCompileInput) -> Result Result Result MaterializedRun { definition, source_directory, labels, - configured_providers: _, } = compiled; run_materialization::materialize_goal_and_pull_request(&mut settings, validated.graph()); MaterializedRun { @@ -370,128 +251,6 @@ pub fn materialize_admitted_run(compiled: CompiledRun) -> MaterializedRun { } } -/// Resolve, preprocess, validate, and promote a workflow for run creation. -/// -/// This stage is synchronous and may read workflow files. Async callers must -/// run it on a blocking thread. -pub fn compile_create_run( - input: CreateRunCompileInput, - catalog: Arc, -) -> Result { - let CreateRunCompileInput { - workflow, - settings, - vars, - cwd, - workflow_path, - workflow_bundle, - configured_providers, - } = input; - let resolved = resolve_workflow(ResolveWorkflowInput { - workflow, - settings, - cwd, - }) - .map_err(|err| Error::Parse(err.to_string()))?; - let settings = resolved.settings; - let labels = settings.combined_labels(); - let source_name = resolved - .dot_path - .as_ref() - .map(|path| path.display().to_string()); - let definition = match (workflow_path, workflow_bundle) { - (Some(workflow_path), Some(workflow_bundle)) => { - let bundled = workflow_bundle.workflow(&workflow_path).ok_or_else(|| { - Error::Parse("workflow path is missing from workflow bundle".to_string()) - })?; - if bundled.source != resolved.raw_source { - return Err(Error::Parse( - "resolved workflow does not match workflow bundle entrypoint".to_string(), - )); - } - Some(RunDefinition::new(workflow_path, workflow_bundle)) - } - (None, None) => None, - _ => { - return Err(Error::Parse( - "workflow path and workflow bundle must be provided together".to_string(), - )); - } - }; - let mut validated = preprocess_and_validate( - &resolved.raw_source, - resolved.goal_override.as_deref(), - &TransformOptions { - current_dir: resolved.current_dir.clone(), - file_resolver: resolved.file_resolver.clone(), - template_context: template_context(Some(&settings), vars), - source_name, - render_mode: RenderMode::Structural, - custom_transforms: Vec::new(), - model_resolution: Some( - ModelResolutionTransform::for_eligible( - catalog, - configured_providers.iter().cloned().collect(), - ) - .with_default_provider(configured_default_provider(&settings)), - ), - }, - )?; - - validated.promote_template_undefined_variables_to_errors(); - if validated.has_errors() { - return Err(Error::ValidationFailed { - diagnostics: validated.diagnostics().to_vec(), - }); - } - - Ok(CompiledRun { - validated, - settings, - raw_source: resolved.raw_source, - workflow_slug: resolved.workflow_slug, - dot_path: resolved.dot_path, - definition, - source_directory: resolved.working_directory.to_string_lossy().to_string(), - labels, - configured_providers, - }) -} - -/// Materialize run-level model settings from a compiled workflow. -pub fn materialize_create_run( - compiled: CompiledRun, - catalog: &Catalog, -) -> Result { - let CompiledRun { - validated, - settings, - raw_source, - workflow_slug, - dot_path, - definition, - source_directory, - labels, - configured_providers, - } = compiled; - let settings = run_materialization::materialize_run( - settings, - validated.graph(), - catalog, - &configured_providers, - )?; - Ok(MaterializedRun { - validated, - settings, - raw_source, - workflow_slug, - dot_path, - definition, - source_directory, - labels, - }) -} - /// Assemble all inputs needed for persistence without I/O or recompilation. pub fn assemble_create_run_persistence_input( materialized: MaterializedRun, @@ -700,12 +459,8 @@ fn store_error(err: impl Into) -> Error { Error::engine_with_source("run store operation failed", err) } -/// Parse, transform, and validate `dot_source`. -/// -/// `options.model_resolution` drives both halves of catalog awareness: it -/// selects concrete models during TRANSFORM and enables the catalog-backed -/// lint rules during VALIDATE. `None` leaves authored model and provider -/// selectors untouched for offline structural validation. +/// Parse and transform `dot_source`, and carry the transform diagnostics +/// as the structural validation. Models and lint are Petri's at admission. pub(super) fn preprocess_and_validate( dot_source: &str, goal_override: Option<&str>, @@ -715,22 +470,7 @@ pub(super) fn preprocess_and_validate( apply_goal_override(&mut parsed.graph, goal_override); let transformed = pipeline::transform(parsed, options)?; - let catalog = options - .model_resolution - .as_ref() - .map(ModelResolutionTransform::catalog); - Ok(pipeline::validate(transformed, catalog, &[])) -} - -/// The workflow-level default provider, treating an empty setting as unset. -pub(super) fn configured_default_provider(settings: &WorkflowSettings) -> Option { - settings - .run - .model - .provider - .as_deref() - .filter(|provider| !provider.is_empty()) - .map(ProviderId::new) + Ok(pipeline::validate(transformed)) } pub(super) fn template_context( @@ -775,17 +515,17 @@ mod tests { }; use fabro_graphviz::graph::AttrValue; use fabro_store::Database; + use fabro_types::diagnostic::Severity; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunMode; use fabro_types::{EventBody, PetriAdmission, WorkflowSettings, fixtures, test_support}; use fabro_util::error::collect_chain; - use fabro_validate::Severity; use object_store::local::LocalFileSystem; use object_store::memory::InMemory; use super::*; use crate::file_resolver::FileResolver; - use crate::operations::{ValidateInput, validate, validate_with_catalog}; + use crate::operations::{ValidateInput, validate}; use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE}; use crate::transforms::Transform; use crate::workflow_bundle::BundledWorkflow; @@ -819,15 +559,95 @@ mod tests { .expect("default settings should resolve") } - fn test_catalog() -> Arc { - Arc::new(fabro_llm::test_support::test_catalog()) + /// A run to create, as the server's compiler hands it to the staged + /// create pipeline: the tests drive the same stages in one call. + #[derive(Clone)] + struct CreateRunInput { + workflow: WorkflowInput, + settings: WorkflowSettings, + vars: HashMap, + cwd: PathBuf, + workflow_slug: Option, + workflow_path: Option, + workflow_bundle: Option, + target: Option, + run_id: Option, + title: Option, + automation: Option, + git: Option, + fork_source_ref: Option, + parent_id: Option, + provenance: RunProvenance, + web_url: Option, + admission: PetriAdmission, } - fn test_provider_ids() -> Vec { - fabro_llm::test_support::test_catalog() - .enabled_provider_ids() - .into_iter() - .collect() + impl CreateRunInput { + fn into_stages( + self, + run_id: RunId, + storage_root: PathBuf, + ) -> (CreateRunCompileInput, CreateRunPersistenceMetadata) { + let Self { + workflow, + settings, + vars, + cwd, + workflow_slug, + workflow_path, + workflow_bundle, + target, + run_id: _, + title, + automation, + git, + fork_source_ref, + parent_id, + provenance, + web_url, + admission, + } = self; + ( + CreateRunCompileInput { + workflow, + settings, + vars, + cwd, + workflow_path, + workflow_bundle, + }, + CreateRunPersistenceMetadata { + run_id, + storage_root, + workflow_slug, + workflow_version_id: None, + target, + title, + automation, + git, + fork_source_ref, + parent_id, + provenance, + web_url, + admission, + }, + ) + } + } + + /// Compile, materialize, assemble and persist `request`: the create + /// pipeline as the server drives it for a run Petri admitted. + async fn create( + store: &Database, + request: CreateRunInput, + storage_root: PathBuf, + ) -> Result { + let run_id = request.run_id.unwrap_or_default(); + let (compile_input, metadata) = request.into_stages(run_id, storage_root); + let compiled = compile_admitted_run(compile_input)?; + let materialized = materialize_admitted_run(compiled); + let input = assemble_create_run_persistence_input(materialized, metadata); + Box::pin(persist_create_run(store, input)).await } fn compile_input(request: &CreateRunInput) -> CreateRunCompileInput { @@ -849,19 +669,16 @@ mod tests { } fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated { - validate_with_catalog( - ValidateInput { - workflow: WorkflowInput::DotSource { - source: dot_source.to_string(), - base_dir: None, - }, - settings, - vars: HashMap::new(), - cwd: PathBuf::from("."), - custom_transforms: Vec::new(), + validate(ValidateInput { + workflow: WorkflowInput::DotSource { + source: dot_source.to_string(), + base_dir: None, }, - test_catalog(), - ) + settings, + vars: HashMap::new(), + cwd: PathBuf::from("."), + custom_transforms: Vec::new(), + }) .unwrap() } @@ -896,7 +713,6 @@ mod tests { source_name: Some("workflow.fabro".to_string()), render_mode, custom_transforms: Vec::new(), - model_resolution: Some(ModelResolutionTransform::new(test_catalog())), } } @@ -1403,7 +1219,7 @@ mod tests { assert_eq!( validated.graph().nodes["work"].attrs.get("model"), - Some(&AttrValue::String("claude-sonnet-5".into())) + Some(&AttrValue::String("sonnet".into())) ); } @@ -1454,18 +1270,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn validate_returns_validation_diagnostics() { - let dot = r#"digraph Test { - graph [goal="Test"] - work [label="Work"] - }"#; - let validated = validate_dot(dot, WorkflowSettings::default()); - - assert!(validated.has_errors()); - assert!(validated.raise_on_errors().is_err()); - } - #[test] fn validate_supports_custom_transforms() { struct TagTransform; @@ -1693,33 +1497,31 @@ mod tests { workflow_source: None, }; let request = CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: test_default_settings(), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: Some("request-slug".to_string()), - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: Some(fixtures::RUN_1), - title: Some("Assembled run".to_string()), - automation: Some(automation.clone()), - git: None, - fork_source_ref: None, - parent_id: Some(fixtures::RUN_2), - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: Some("https://fabro.test/runs/1".to_string()), + settings: test_default_settings(), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("request-slug".to_string()), + workflow_path: None, + workflow_bundle: None, + target: None, + run_id: Some(fixtures::RUN_1), + title: Some("Assembled run".to_string()), + automation: Some(automation.clone()), + git: None, + fork_source_ref: None, + parent_id: Some(fixtures::RUN_2), + provenance: test_support::test_run_provenance(), + web_url: Some("https://fabro.test/runs/1".to_string()), }; - let catalog = test_catalog(); let resolved_run_id = fixtures::RUN_64; - let compiled = compile_create_run(compile_input(&request), Arc::clone(&catalog)).unwrap(); - let materialized = materialize_create_run(compiled, catalog.as_ref()).unwrap(); + let compiled = compile_admitted_run(compile_input(&request)).unwrap(); + let materialized = materialize_admitted_run(compiled); let metadata = persistence_metadata(&request, resolved_run_id, &storage_root); let input = assemble_create_run_persistence_input(materialized, metadata); @@ -1732,48 +1534,16 @@ mod tests { ); assert_eq!(input.workflow_slug(), Some("request-slug")); assert_eq!(input.automation(), Some(&automation)); + // The settings keep the model they named (none here): Petri pins + // the resolved model in its admitted graph, not in the settings. assert_eq!( input.materialized().settings().run.model.name.as_deref(), - Some("claude-sonnet-5") + None ); } #[test] - fn compile_create_run_rejects_mismatched_bundle_definition() { - let workflow_path = ManifestPath::from_wire("workflows/main.fabro").unwrap(); - let compiled_workflow = BundledWorkflow { - path: workflow_path.clone(), - source: MINIMAL_DOT.to_string(), - config: None, - files: HashMap::new(), - }; - let mismatched_bundle = - WorkflowBundle::new(HashMap::from([(workflow_path.clone(), BundledWorkflow { - source: MINIMAL_DOT.replace("Build feature", "Different goal"), - ..compiled_workflow.clone() - })])); - - let Err(error) = compile_create_run( - CreateRunCompileInput { - workflow: WorkflowInput::Bundled(compiled_workflow), - settings: test_default_settings(), - vars: HashMap::new(), - cwd: PathBuf::from("/tmp/project"), - workflow_path: Some(workflow_path), - workflow_bundle: Some(mismatched_bundle), - configured_providers: test_provider_ids(), - }, - test_catalog(), - ) else { - panic!("mismatched accepted definition should fail"); - }; - - assert!(matches!(error, Error::Parse(message) if message == - "resolved workflow does not match workflow bundle entrypoint")); - } - - #[test] - fn compile_create_run_exposes_resolved_metadata_and_definition() { + fn compile_admitted_run_exposes_resolved_metadata_and_definition() { let workflow_path = ManifestPath::from_wire("workflows/main.fabro").unwrap(); let bundled = BundledWorkflow { path: workflow_path.clone(), @@ -1782,24 +1552,20 @@ mod tests { files: HashMap::new(), }; let bundle = WorkflowBundle::new(HashMap::from([(workflow_path.clone(), bundled.clone())])); - let compiled = compile_create_run( - CreateRunCompileInput { - workflow: WorkflowInput::Bundled(bundled), - settings: test_default_settings(), - vars: HashMap::new(), - cwd: PathBuf::from("/tmp/project"), - workflow_path: Some(workflow_path.clone()), - workflow_bundle: Some(bundle), - configured_providers: test_provider_ids(), - }, - test_catalog(), - ) + let compiled = compile_admitted_run(CreateRunCompileInput { + workflow: WorkflowInput::Bundled(bundled), + settings: test_default_settings(), + vars: HashMap::new(), + cwd: PathBuf::from("/tmp/project"), + workflow_path: Some(workflow_path.clone()), + workflow_bundle: Some(bundle), + }) .unwrap(); assert_eq!(compiled.raw_source, MINIMAL_DOT); assert_eq!(compiled.dot_path.as_deref(), Some(workflow_path.as_path())); assert_eq!(compiled.labels, compiled.settings().combined_labels()); - let materialized = materialize_create_run(compiled, test_catalog().as_ref()).unwrap(); + let materialized = materialize_admitted_run(compiled); let input = assemble_create_run_persistence_input(materialized, CreateRunPersistenceMetadata { run_id: fixtures::RUN_1, @@ -1836,31 +1602,29 @@ mod tests { workflow_source: None, }; let request = CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::Path(dot_path.clone()), - settings: test_default_settings(), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: Some("compiled-slug".to_string()), - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: Some(fixtures::RUN_2), - title: Some("Compiled run".to_string()), - automation: Some(automation.clone()), - git: None, - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, + admission: PetriAdmission::default(), + workflow: WorkflowInput::Path(dot_path.clone()), + settings: test_default_settings(), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("compiled-slug".to_string()), + workflow_path: None, + workflow_bundle: None, + target: None, + run_id: Some(fixtures::RUN_2), + title: Some("Compiled run".to_string()), + automation: Some(automation.clone()), + git: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, }; - let catalog = test_catalog(); - let compiled = compile_create_run(compile_input(&request), Arc::clone(&catalog)).unwrap(); + let compiled = compile_admitted_run(compile_input(&request)).unwrap(); std::fs::write(&dot_path, "this is no longer a graph").unwrap(); - let materialized = materialize_create_run(compiled, catalog.as_ref()).unwrap(); + let materialized = materialize_admitted_run(compiled); let workflow_version_id = test_support::test_workflow_version_id(); let mut metadata = persistence_metadata(&request, fixtures::RUN_2, &storage_root); metadata.workflow_version_id = Some(workflow_version_id); @@ -1897,54 +1661,6 @@ mod tests { assert!(created.spec_blob.is_some()); } - #[tokio::test] - async fn create_returns_validation_failed_with_diagnostics() { - let dot = r#"digraph Test { - graph [goal="Test"] - work [label="Work"] - }"#; - let dir = tempfile::tempdir().unwrap(); - let storage_root = dir.path().join("storage"); - let store = memory_store(); - let err = create( - &store, - CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { - source: dot.to_string(), - base_dir: None, - }, - settings: test_default_settings(), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: None, - title: None, - automation: None, - git: None, - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, - }, - storage_root, - test_catalog(), - ) - .await - .unwrap_err(); - - match err { - Error::ValidationFailed { diagnostics } => { - assert!(!diagnostics.is_empty()); - } - other => panic!("expected ValidationFailed, got {other:?}"), - } - } - #[expect( clippy::disallowed_methods, reason = "test asserts the raw template source" @@ -1957,12 +1673,12 @@ mod tests { let created = create( &store, CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: settings_from_run_layer({ + settings: settings_from_run_layer({ let mut metadata = HashMap::new(); metadata.insert("env".to_string(), "test".to_string()); RunLayer { @@ -1983,29 +1699,27 @@ mod tests { ..RunLayer::default() } }), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: Some("slug".to_string()), - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: Some(fixtures::RUN_1), - title: None, - automation: None, - git: Some(fabro_types::GitContext { + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("slug".to_string()), + workflow_path: None, + workflow_bundle: None, + target: None, + run_id: Some(fixtures::RUN_1), + title: None, + automation: None, + git: Some(fabro_types::GitContext { origin_url: String::new(), branch: "main".to_string(), sha: None, dirty: fabro_types::DirtyStatus::Clean, }), - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, }, storage_root.clone(), - test_catalog(), ) .await .unwrap(); @@ -2021,7 +1735,7 @@ mod tests { .model .name .as_deref(), - Some("claude-sonnet-5") + Some("sonnet") ); assert_eq!( created @@ -2032,7 +1746,7 @@ mod tests { .model .provider .as_deref(), - Some("anthropic") + None ); assert_eq!( match &created.persisted.run_spec().settings.run.goal { @@ -2080,12 +1794,12 @@ mod tests { let created = create( &store, CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: settings_from_run_layer(RunLayer { + settings: settings_from_run_layer(RunLayer { prepare: Some(RunPrepareLayer { steps: vec![PrepareStep { script: None, @@ -2106,24 +1820,22 @@ mod tests { }), ..RunLayer::default() }), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: Some("secret-source".to_string()), - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: Some(fixtures::RUN_1), - title: None, - automation: None, - git: None, - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("secret-source".to_string()), + workflow_path: None, + workflow_bundle: None, + target: None, + run_id: Some(fixtures::RUN_1), + title: None, + automation: None, + git: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, }, storage_root, - test_catalog(), ) .await .unwrap(); @@ -2169,12 +1881,12 @@ mod tests { let created = create( &store, CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: settings_from_run_layer({ + settings: settings_from_run_layer({ RunLayer { working_dir: Some("workspace".to_string()), execution: Some(RunExecutionLayer { @@ -2184,24 +1896,22 @@ mod tests { ..RunLayer::default() } }), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: Some(fixtures::RUN_2), - title: None, - automation: None, - git: None, - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + target: None, + run_id: Some(fixtures::RUN_2), + title: None, + automation: None, + git: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, }, storage_root, - test_catalog(), ) .await .unwrap(); @@ -2220,30 +1930,28 @@ mod tests { let created = create( &store, CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: test_default_settings(), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - target: Some(RunTarget::None {}), - run_id: Some(fixtures::RUN_2), - title: None, - automation: None, - git: None, - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, + settings: test_default_settings(), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + target: Some(RunTarget::None {}), + run_id: Some(fixtures::RUN_2), + title: None, + automation: None, + git: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, }, storage_root, - test_catalog(), ) .await .unwrap(); @@ -2277,32 +1985,30 @@ mod tests { let created = create( &store, CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: test_default_settings(), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - target: Some(RunTarget::Folder { + settings: test_default_settings(), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + target: Some(RunTarget::Folder { path: canonical.clone(), }), - run_id: Some(fixtures::RUN_2), - title: None, - automation: None, - git: Some(git.clone()), - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, + run_id: Some(fixtures::RUN_2), + title: None, + automation: None, + git: Some(git.clone()), + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, }, storage_root, - test_catalog(), ) .await .unwrap(); @@ -2328,35 +2034,33 @@ mod tests { let created = create( &store, CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: dry_run_only_settings(), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: Some(fixtures::RUN_2), - title: None, - automation: None, - git: Some(fabro_types::GitContext { + settings: dry_run_only_settings(), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + target: None, + run_id: Some(fixtures::RUN_2), + title: None, + automation: None, + git: Some(fabro_types::GitContext { origin_url: "https://github.com/acme/widgets".to_string(), branch: String::new(), sha: None, dirty: fabro_types::DirtyStatus::Clean, }), - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, }, storage_root, - test_catalog(), ) .await .unwrap(); @@ -2409,30 +2113,28 @@ mod tests { let created = create( store.as_ref(), CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: dry_run_with_storage(&storage_dir), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: Some("slug".to_string()), - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: Some(fixtures::RUN_3), - title: None, - automation: Some(automation.clone()), - git: None, - fork_source_ref: None, - parent_id: None, - provenance: test_support::test_run_provenance(), - configured_providers: test_provider_ids(), - web_url: None, + settings: dry_run_with_storage(&storage_dir), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("slug".to_string()), + workflow_path: None, + workflow_bundle: None, + target: None, + run_id: Some(fixtures::RUN_3), + title: None, + automation: Some(automation.clone()), + git: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, }, storage_dir.clone(), - test_catalog(), ) .await .unwrap(); @@ -2464,25 +2166,25 @@ mod tests { let created = create( store.as_ref(), CreateRunInput { - admission: PetriAdmission::default(), - workflow: WorkflowInput::DotSource { + admission: PetriAdmission::default(), + workflow: WorkflowInput::DotSource { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: dry_run_with_storage(&storage_dir), - vars: HashMap::new(), - cwd: dir.path().to_path_buf(), - workflow_slug: Some("slug".to_string()), - workflow_path: None, - workflow_bundle: None, - target: None, - run_id: Some(fixtures::RUN_64), - title: None, - automation: None, - git: None, - fork_source_ref: None, - parent_id: None, - provenance: fabro_types::RunProvenance { + settings: dry_run_with_storage(&storage_dir), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("slug".to_string()), + workflow_path: None, + workflow_bundle: None, + target: None, + run_id: Some(fixtures::RUN_64), + title: None, + automation: None, + git: None, + fork_source_ref: None, + parent_id: None, + provenance: fabro_types::RunProvenance { server: Some(fabro_types::RunServerProvenance { version: "0.9.0".to_string(), }), @@ -2497,11 +2199,9 @@ mod tests { fabro_types::AuthMethod::Github, ), }, - configured_providers: test_provider_ids(), - web_url: None, + web_url: None, }, storage_dir, - test_catalog(), ) .await .unwrap(); diff --git a/lib/components/fabro-workflow/src/operations/mod.rs b/lib/components/fabro-workflow/src/operations/mod.rs index 554f466fa..a3d06f304 100644 --- a/lib/components/fabro-workflow/src/operations/mod.rs +++ b/lib/components/fabro-workflow/src/operations/mod.rs @@ -9,12 +9,11 @@ pub use archive::{ unarchive, }; pub use create::{ - CompiledRun, CreateRunCompileInput, CreateRunInput, CreateRunPersistenceInput, - CreateRunPersistenceMetadata, CreatedRun, MaterializedRun, - assemble_create_run_persistence_input, compile_admitted_run, compile_create_run, create, - make_run_dir, materialize_admitted_run, materialize_create_run, persist_create_run, + CompiledRun, CreateRunCompileInput, CreateRunPersistenceInput, CreateRunPersistenceMetadata, + CreatedRun, MaterializedRun, assemble_create_run_persistence_input, compile_admitted_run, + make_run_dir, materialize_admitted_run, persist_create_run, }; pub use source::WorkflowInput; -pub use validate::{ValidateInput, validate, validate_with_catalog, validate_with_ready_providers}; +pub use validate::{ValidateInput, validate}; pub use crate::transforms::RenderMode; diff --git a/lib/components/fabro-workflow/src/operations/validate.rs b/lib/components/fabro-workflow/src/operations/validate.rs index 7f99eb887..7cb55aef9 100644 --- a/lib/components/fabro-workflow/src/operations/validate.rs +++ b/lib/components/fabro-workflow/src/operations/validate.rs @@ -1,17 +1,14 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; -use fabro_llm::lithos_catalog::Catalog; use fabro_types::WorkflowSettings; -use lithos_llm::catalog::ProviderId; -use super::create::{configured_default_provider, preprocess_and_validate, template_context}; +use super::create::{preprocess_and_validate, template_context}; use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; use crate::error::Error; use crate::operations::RenderMode; use crate::pipeline::{TransformOptions, Validated}; -use crate::transforms::{ModelResolutionTransform, Transform}; +use crate::transforms::Transform; pub struct ValidateInput { pub workflow: WorkflowInput, @@ -23,51 +20,14 @@ pub struct ValidateInput { pub custom_transforms: Vec>, } -/// Parse, transform, and structurally validate a DOT source string without a -/// model catalog. Model and provider availability is left to the caller that -/// owns a catalog — typically the server. +/// Parse and transform a DOT source string: the structural validation Fabro +/// does itself. Its diagnostics are the transforms' (an unbound template +/// variable, a missing file); the workflow's rules and its models are +/// Petri's to judge at admission. /// /// Returns `Validated` even when validation produced errors. Call /// `validated.raise_on_errors()` if the caller wants to fail fast. pub fn validate(input: ValidateInput) -> Result { - validate_resolving_models(input, None) -} - -/// Parse, transform, and validate a DOT source string against `catalog`. -pub fn validate_with_catalog( - input: ValidateInput, - catalog: Arc, -) -> Result { - validate_resolving_models(input, Some(ModelResolutionTransform::new(catalog))) -} - -/// Parse, transform, and validate, resolving models against the ready -/// providers first and falling back to the full catalog only for -/// provider-readiness selection failures. -pub fn validate_with_ready_providers( - input: ValidateInput, - catalog: Arc, - ready_providers: &[ProviderId], -) -> Result { - validate_resolving_models( - input, - Some( - ModelResolutionTransform::for_eligible( - catalog, - ready_providers.iter().cloned().collect(), - ) - .with_catalog_fallback(true), - ), - ) -} - -/// The workflow's own default provider is only known once the workflow is -/// resolved, so callers hand in a partially built transform and it is -/// completed here. -fn validate_resolving_models( - input: ValidateInput, - model_resolution: Option, -) -> Result { let ValidateInput { workflow, settings, @@ -82,10 +42,6 @@ fn validate_resolving_models( }) .map_err(|err| Error::Parse(err.to_string()))?; - let model_resolution = model_resolution.map(|resolution| { - resolution.with_default_provider(configured_default_provider(&resolved.settings)) - }); - preprocess_and_validate( &resolved.raw_source, resolved.goal_override.as_deref(), @@ -99,7 +55,6 @@ fn validate_resolving_models( .map(|path| path.display().to_string()), render_mode: RenderMode::Structural, custom_transforms, - model_resolution, }, ) } diff --git a/lib/components/fabro-workflow/src/pipeline/transform.rs b/lib/components/fabro-workflow/src/pipeline/transform.rs index 2768c2496..01c40193e 100644 --- a/lib/components/fabro-workflow/src/pipeline/transform.rs +++ b/lib/components/fabro-workflow/src/pipeline/transform.rs @@ -87,10 +87,6 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Result model_resolution.apply(graph)?, - None => graph, - }; // Custom transforms let graph = options @@ -113,13 +109,11 @@ mod tests { use std::sync::Arc; use fabro_graphviz::graph::AttrValue; - use fabro_llm::lithos_catalog::Catalog; use super::*; use crate::file_resolver::FilesystemFileResolver; use crate::pipeline::parse::parse; use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE}; - use crate::transforms::ModelResolutionTransform; fn write_file(path: &Path, contents: &str) { if let Some(parent) = path.parent() { @@ -128,10 +122,6 @@ mod tests { std::fs::write(path, contents).unwrap(); } - fn test_catalog() -> Arc { - Arc::new(fabro_llm::test_support::test_catalog()) - } - fn transform_options() -> TransformOptions { TransformOptions { current_dir: None, @@ -140,7 +130,6 @@ mod tests { source_name: None, render_mode: crate::operations::RenderMode::Strict, custom_transforms: vec![], - model_resolution: Some(ModelResolutionTransform::new(test_catalog())), } } @@ -176,7 +165,7 @@ mod tests { let transformed = transform(parsed, &transform_options()).unwrap(); assert_eq!( transformed.graph.nodes["work"].attrs.get("model"), - Some(&AttrValue::String("claude-sonnet-5".into())) + Some(&AttrValue::String("sonnet".into())) ); } @@ -231,7 +220,7 @@ mod tests { .attrs .get("model") .and_then(AttrValue::as_str), - Some("claude-sonnet-5") + Some("sonnet") ); assert_eq!( transformed.graph.nodes["explicit"] @@ -279,7 +268,7 @@ mod tests { .attrs .get("model") .and_then(AttrValue::as_str), - Some("claude-sonnet-5") + Some("sonnet") ); } @@ -295,7 +284,6 @@ mod tests { let parsed = parse(dot).unwrap(); let transformed = transform(parsed, &TransformOptions { render_mode: crate::operations::RenderMode::Structural, - model_resolution: None, ..transform_options() }) .unwrap(); @@ -395,7 +383,7 @@ mod tests { ); assert_eq!( lint.attrs.get("model"), - Some(&AttrValue::String("claude-sonnet-5".into())) + Some(&AttrValue::String("sonnet".into())) ); } @@ -589,7 +577,6 @@ mod tests { }"#; let parsed = parse(dot).unwrap(); let transformed = transform(parsed, &TransformOptions { - model_resolution: None, ..transform_options() }) .unwrap(); diff --git a/lib/components/fabro-workflow/src/pipeline/types.rs b/lib/components/fabro-workflow/src/pipeline/types.rs index 613f353f9..e87a60566 100644 --- a/lib/components/fabro-workflow/src/pipeline/types.rs +++ b/lib/components/fabro-workflow/src/pipeline/types.rs @@ -3,12 +3,12 @@ use std::sync::Arc; use fabro_graphviz::graph::Graph; use fabro_template::TemplateContext; -use fabro_validate::{Diagnostic, Severity}; +use fabro_types::diagnostic::{Diagnostic, Severity}; use crate::error::Error; use crate::file_resolver::FileResolver; use crate::records::RunSpec; -use crate::transforms::{ModelResolutionTransform, RenderMode, Transform}; +use crate::transforms::{RenderMode, Transform}; /// Output of the PARSE phase. #[non_exhaustive] @@ -81,6 +81,12 @@ impl Validated { self.promote_rule_to_error(TEMPLATE_UNDEFINED_VARIABLE_RULE); } + /// Add diagnostics from another judge of the workflow (Petri's check), + /// after the transforms' own. + pub fn extend_diagnostics(&mut self, diagnostics: impl IntoIterator) { + self.diagnostics.extend(diagnostics); + } + /// True if any diagnostic has Error severity. #[must_use] pub fn has_errors(&self) -> bool { @@ -212,7 +218,4 @@ pub struct TransformOptions { pub source_name: Option, pub render_mode: RenderMode, pub custom_transforms: Vec>, - /// Catalog-backed model resolution to perform. `None` preserves authored - /// model and provider selectors for catalog-free structural validation. - pub model_resolution: Option, } diff --git a/lib/components/fabro-workflow/src/pipeline/validate.rs b/lib/components/fabro-workflow/src/pipeline/validate.rs index 200a66de8..9e557ecf0 100644 --- a/lib/components/fabro-workflow/src/pipeline/validate.rs +++ b/lib/components/fabro-workflow/src/pipeline/validate.rs @@ -1,30 +1,18 @@ -use fabro_llm::lithos_catalog::Catalog; -use fabro_validate::LintRule; - use super::types::{Transformed, Validated}; -/// VALIDATE phase: run lint rules against the transformed graph. -/// -/// Catalog-backed rules (model and provider availability) run only when -/// `catalog` is `Some`. Offline callers pass `None` so a workflow naming a -/// server-owned model is left for the server to judge. +/// VALIDATE phase: the transformed graph with the transforms' diagnostics. +/// Fabro's lint rules went with the legacy executor; the workflow's rules +/// and its models are Petri's to judge at admission. /// /// **Infallible.** Always returns `Validated` with diagnostics. Caller decides /// whether to fail via `validated.raise_on_errors()`. -pub fn validate( - transformed: Transformed, - catalog: Option<&Catalog>, - extra_rules: &[&dyn LintRule], -) -> Validated { +#[must_use] +pub fn validate(transformed: Transformed) -> Validated { let Transformed { graph, source, - mut diagnostics, + diagnostics, } = transformed; - diagnostics.extend(match catalog { - Some(catalog) => fabro_validate::validate_with_catalog(&graph, catalog, extra_rules), - None => fabro_validate::validate(&graph, extra_rules), - }); Validated::new(graph, source, diagnostics) } @@ -32,6 +20,8 @@ pub fn validate( mod tests { use std::collections::HashMap; + use fabro_types::diagnostic::Severity; + use super::*; use crate::pipeline::parse::parse; use crate::pipeline::transform; @@ -45,14 +35,13 @@ mod tests { source_name: None, render_mode: crate::operations::RenderMode::Strict, custom_transforms: vec![], - model_resolution: None, } } fn run_pipeline(dot: &str) -> Validated { let parsed = parse(dot).unwrap(); let transformed = transform::transform(parsed, &transform_options()).unwrap(); - validate(transformed, None, &[]) + validate(transformed) } #[test] @@ -68,17 +57,6 @@ mod tests { assert!(validated.raise_on_errors().is_ok()); } - #[test] - fn validate_missing_start_node() { - let dot = r#"digraph Test { - graph [goal="Test"] - work [label="Work"] - }"#; - let validated = run_pipeline(dot); - assert!(validated.has_errors()); - assert!(validated.raise_on_errors().is_err()); - } - #[test] fn validate_into_parts() { let dot = r#"digraph Test { @@ -91,61 +69,11 @@ mod tests { let (graph, source, diagnostics) = validated.into_parts(); assert_eq!(graph.name, "Test"); assert_eq!(source, dot); - assert!( - diagnostics - .iter() - .all(|d| d.severity != fabro_validate::Severity::Error) - ); + assert!(diagnostics.iter().all(|d| d.severity != Severity::Error)); } #[test] - fn validate_diagnostics_accessible_before_raise() { - let dot = r#"digraph Test { - graph [goal="Test"] - work [label="Work"] - }"#; - let validated = run_pipeline(dot); - // Can read diagnostics before raising - let diags = validated.diagnostics(); - assert!(!diags.is_empty()); - // Then raise - assert!(validated.raise_on_errors().is_err()); - } - - #[test] - fn validates_fully_rendered_model_stylesheet_syntax() { - let dot = r#"digraph Test { - graph [model_stylesheet="* { {{ inputs.declaration }} }"] - start [shape=Mdiamond] - exit [shape=Msquare] - start -> exit - }"#; - let transformed = transform::transform(parse(dot).unwrap(), &TransformOptions { - template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::from([ - ( - "declaration".to_string(), - toml::Value::String("garbage garbage".to_string()), - ), - ])), - source_name: Some("workflow.fabro".to_string()), - render_mode: crate::operations::RenderMode::Structural, - ..transform_options() - }) - .unwrap(); - let validated = validate(transformed, None, &[]); - - assert!( - validated - .diagnostics() - .iter() - .any(|diagnostic| diagnostic.rule == "stylesheet_syntax"), - "{:?}", - validated.diagnostics() - ); - } - - #[test] - fn unresolved_model_stylesheet_skips_stylesheet_rules() { + fn unresolved_template_variables_are_the_transforms_diagnostics() { let dot = r#"digraph Test { graph [model_stylesheet="* { model: {{ vars.MODEL }}; }"] start [shape=Mdiamond] @@ -153,19 +81,17 @@ mod tests { start -> exit }"#; let transformed = transform::transform(parse(dot).unwrap(), &TransformOptions { + template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::new()), source_name: Some("workflow.fabro".to_string()), render_mode: crate::operations::RenderMode::Structural, ..transform_options() }) .unwrap(); - let validated = validate(transformed, None, &[]); + let validated = validate(transformed); assert!(validated.diagnostics().iter().any(|diagnostic| { diagnostic.rule == "template_undefined_variable" && diagnostic.message.contains("vars.MODEL") })); - assert!(validated.diagnostics().iter().all(|diagnostic| { - diagnostic.rule != "stylesheet_syntax" && diagnostic.rule != "stylesheet_model_known" - })); } } diff --git a/lib/components/fabro-workflow/src/run_materialization.rs b/lib/components/fabro-workflow/src/run_materialization.rs index c7c4a2289..89e6a58e1 100644 --- a/lib/components/fabro-workflow/src/run_materialization.rs +++ b/lib/components/fabro-workflow/src/run_materialization.rs @@ -1,72 +1,7 @@ -use std::collections::HashSet; - use fabro_graphviz::graph::Graph; -use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{ModelSelectionError, selection}; use fabro_types::WorkflowSettings; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; -use lithos_llm::catalog::ProviderId; - -use crate::error::Error; - -pub fn materialize_run( - settings: WorkflowSettings, - graph: &Graph, - catalog: &Catalog, - configured_providers: &[ProviderId], -) -> Result { - materialize_run_with_eligible_providers(settings, graph, catalog, configured_providers, false) -} - -/// Materialize while resolving the run model against the ready providers -/// first, falling back to the full catalog only for provider-readiness -/// selection failures. -pub fn materialize_run_with_ready_providers( - settings: WorkflowSettings, - graph: &Graph, - catalog: &Catalog, - ready_providers: &[ProviderId], -) -> Result { - materialize_run_with_eligible_providers(settings, graph, catalog, ready_providers, true) -} - -fn materialize_run_with_eligible_providers( - mut settings: WorkflowSettings, - graph: &Graph, - catalog: &Catalog, - eligible_providers: &[ProviderId], - catalog_fallback: bool, -) -> Result { - let configured_model = settings.run.model.name.take(); - let configured_provider = settings.run.model.provider.take(); - let graph_provider = graph - .attrs - .get("default_provider") - .and_then(|value| value.as_str()) - .map(str::to_string); - let graph_model = graph - .attrs - .get("default_model") - .and_then(|value| value.as_str()) - .map(str::to_string); - - let provider = configured_provider.or(graph_provider); - let model = configured_model.or(graph_model); - let eligible = eligible_providers.iter().cloned().collect::>(); - let (resolved_model, resolved_provider) = resolve_run_model( - catalog, - &eligible, - model.as_deref(), - provider.as_deref(), - catalog_fallback, - )?; - - settings.run.model.name = Some(resolved_model); - settings.run.model.provider = Some(resolved_provider.into_string()); - materialize_goal_and_pull_request(&mut settings, graph); - Ok(settings) -} /// The graph's goal becomes the run's inline goal (none when the graph has /// none), and a pull request block the settings disable is dropped. @@ -87,26 +22,3 @@ pub fn materialize_goal_and_pull_request(settings: &mut WorkflowSettings, graph: settings.run.pull_request = None; } } - -pub(crate) fn resolve_run_model( - catalog: &Catalog, - eligible: &HashSet, - model: Option<&str>, - provider: Option<&str>, - catalog_fallback: bool, -) -> Result<(String, ProviderId), ModelSelectionError> { - let provider = provider - .filter(|provider| !provider.is_empty()) - .map(ProviderId::new); - let selected = if catalog_fallback { - selection::resolve_selection_with_catalog_fallback( - catalog, - model, - provider.as_ref(), - eligible, - )? - } else { - selection::resolve_selection(catalog, model, provider.as_ref(), eligible)? - }; - Ok((selected.model, selected.provider)) -} diff --git a/lib/components/fabro-workflow/src/transforms/file_inlining.rs b/lib/components/fabro-workflow/src/transforms/file_inlining.rs index 565edf7d8..d1bf7f832 100644 --- a/lib/components/fabro-workflow/src/transforms/file_inlining.rs +++ b/lib/components/fabro-workflow/src/transforms/file_inlining.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_template::{TemplateContext, TemplateSource, TemplateStore}; use fabro_types::ManifestPath; -use fabro_validate::Diagnostic; +use fabro_types::diagnostic::Diagnostic; use super::Transform; use super::importable_field::ImportableField; diff --git a/lib/components/fabro-workflow/src/transforms/import.rs b/lib/components/fabro-workflow/src/transforms/import.rs index 6317f1778..c53be71cd 100644 --- a/lib/components/fabro-workflow/src/transforms/import.rs +++ b/lib/components/fabro-workflow/src/transforms/import.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_graphviz::parser; use fabro_template::{TemplateContext, validate_static_reference}; +use fabro_types::diagnostic::{Diagnostic, Severity}; use fabro_types::graph::ReferenceKind; -use fabro_validate::{Diagnostic, Severity}; use super::file_inlining::template_render_store; use super::{FileInliningTransform, Transform}; diff --git a/lib/components/fabro-workflow/src/transforms/mod.rs b/lib/components/fabro-workflow/src/transforms/mod.rs index 54df5d172..e3ed639c7 100644 --- a/lib/components/fabro-workflow/src/transforms/mod.rs +++ b/lib/components/fabro-workflow/src/transforms/mod.rs @@ -11,7 +11,6 @@ pub trait Transform { mod file_inlining; mod import; mod importable_field; -mod model_resolution; mod model_stylesheet_template; pub mod stylesheet; mod stylesheet_application; @@ -19,7 +18,6 @@ pub mod variable_expansion; pub use file_inlining::FileInliningTransform; pub use import::ImportTransform; -pub use model_resolution::ModelResolutionTransform; pub(crate) use model_stylesheet_template::ModelStylesheetTemplateTransform; pub use stylesheet_application::StylesheetApplicationTransform; pub use variable_expansion::{RenderMode, ScriptInterpolationTransform, TemplateTransform}; diff --git a/lib/components/fabro-workflow/src/transforms/model_resolution.rs b/lib/components/fabro-workflow/src/transforms/model_resolution.rs deleted file mode 100644 index 450fb65b6..000000000 --- a/lib/components/fabro-workflow/src/transforms/model_resolution.rs +++ /dev/null @@ -1,417 +0,0 @@ -use std::collections::HashSet; -use std::sync::Arc; - -use fabro_graphviz::graph::{AttrValue, Graph}; -use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::selection; -use lithos_llm::catalog::ProviderId; - -use super::Transform; -use crate::error::Error; - -/// Resolves model aliases to canonical IDs and infers the provider from the -/// model catalog. -pub struct ModelResolutionTransform { - catalog: Arc, - default_provider: Option, - eligible_providers: HashSet, - catalog_fallback: bool, -} - -impl ModelResolutionTransform { - #[must_use] - pub fn new(catalog: Arc) -> Self { - let eligible_providers = catalog.enabled_provider_ids().into_iter().collect(); - Self { - catalog, - default_provider: None, - eligible_providers, - catalog_fallback: false, - } - } - - #[must_use] - pub fn for_eligible(catalog: Arc, eligible_providers: HashSet) -> Self { - Self { - catalog, - default_provider: None, - eligible_providers, - catalog_fallback: false, - } - } - - #[must_use] - pub fn with_default_provider(mut self, provider: Option) -> Self { - self.default_provider = provider; - self - } - - /// When enabled, provider-readiness selection failures fall back to the - /// full catalog instead of erroring. - #[must_use] - pub fn with_catalog_fallback(mut self, catalog_fallback: bool) -> Self { - self.catalog_fallback = catalog_fallback; - self - } - - /// The catalog this transform resolves against, so callers can run the - /// matching catalog-backed lint rules. - #[must_use] - pub fn catalog(&self) -> &Catalog { - &self.catalog - } - - fn resolve_model( - &self, - model: &str, - explicit_provider: Option<&ProviderId>, - ) -> Result<(String, ProviderId), Error> { - let selected = if self.catalog_fallback { - selection::resolve_selection_with_catalog_fallback( - &self.catalog, - Some(model), - explicit_provider, - &self.eligible_providers, - ) - } else { - selection::resolve_selection( - &self.catalog, - Some(model), - explicit_provider, - &self.eligible_providers, - ) - }?; - Ok((selected.model, selected.provider)) - } -} - -impl Transform for ModelResolutionTransform { - fn apply(&self, graph: Graph) -> Result { - let mut graph = graph; - let graph_default_provider = graph - .attrs - .get("default_provider") - .and_then(AttrValue::as_str) - .filter(|provider| !provider.is_empty()) - .map(ProviderId::new); - let requested_default_provider = self - .default_provider - .as_ref() - .or(graph_default_provider.as_ref()); - if let Some(default_model) = graph - .attrs - .get("default_model") - .and_then(AttrValue::as_str) - .map(str::to_string) - { - let (model, provider) = - self.resolve_model(&default_model, requested_default_provider)?; - graph - .attrs - .insert("default_model".to_string(), AttrValue::String(model)); - graph.attrs.insert( - "default_provider".to_string(), - AttrValue::String(provider.to_string()), - ); - } - let default_provider = self.default_provider.clone().or_else(|| { - graph - .attrs - .get("default_provider") - .and_then(AttrValue::as_str) - .filter(|provider| !provider.is_empty()) - .map(ProviderId::new) - }); - for node in graph.nodes.values_mut() { - let model = node - .attrs - .get("model") - .and_then(AttrValue::as_str) - .map(String::from); - if let Some(model) = model { - let explicit_provider = node - .attrs - .get("provider") - .and_then(AttrValue::as_str) - .filter(|provider| !provider.is_empty()) - .map(ProviderId::new) - .or_else(|| default_provider.clone()); - let (model, provider) = self.resolve_model(&model, explicit_provider.as_ref())?; - node.attrs - .insert("model".to_string(), AttrValue::String(model)); - node.attrs.insert( - "provider".to_string(), - AttrValue::String(provider.to_string()), - ); - } - } - - Ok(graph) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use fabro_graphviz::graph::{AttrValue, Graph, Node}; - use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay}; - - use super::*; - - /// An operator-defined provider with one aliased model, the shape an - /// `[llm]` overlay produces. It joins the built-ins rather than replacing - /// them: lithos catalogs are layered, never standalone. - fn custom_catalog() -> Arc { - Arc::new(test_catalog_with_overlay( - r#" -[providers.acme-venice] -display_name = "Acme Venice" -adapter = "openai-compatible" -codec = "openai-chat" -base_url = "https://api.venice.ai/api/v1" -auth = { type = "bearer" } -priority = 200 -default_model = "venice-large" - -[providers.acme-venice.metadata.agent] -profile = "openai" - -[providers.acme-venice.models.venice-large] -display_name = "Venice Large" -aliases = ["vl"] -api_model = "venice-large" -limits = { context_tokens = 128000, max_output_tokens = 8192 } -capabilities = { text = true, tools = true } -"#, - )) - } - - fn builtin_transform() -> ModelResolutionTransform { - ModelResolutionTransform::new(Arc::new(test_catalog())) - } - - #[test] - fn provider_inference_sets_provider_from_catalog() { - let mut graph = Graph::new("test"); - let mut node = Node::new("a"); - node.attrs.insert( - "model".to_string(), - AttrValue::String("claude-sonnet-4.5".to_string()), - ); - graph.nodes.insert("a".to_string(), node); - - let graph = builtin_transform().apply(graph).unwrap(); - - assert_eq!( - graph.nodes["a"] - .attrs - .get("provider") - .and_then(AttrValue::as_str), - Some("anthropic") - ); - } - - #[test] - fn explicit_provider_allows_unknown_model_passthrough() { - let mut graph = Graph::new("test"); - let mut node = Node::new("a"); - node.attrs.insert( - "model".to_string(), - AttrValue::String("claude-sonnet-4.5".to_string()), - ); - node.attrs.insert( - "provider".to_string(), - AttrValue::String("openai".to_string()), - ); - graph.nodes.insert("a".to_string(), node); - - let graph = builtin_transform().apply(graph).unwrap(); - - assert_eq!( - graph.nodes["a"] - .attrs - .get("provider") - .and_then(AttrValue::as_str), - Some("openai") - ); - assert_eq!( - graph.nodes["a"] - .attrs - .get("model") - .and_then(AttrValue::as_str), - Some("claude-sonnet-4.5") - ); - } - - #[test] - fn provider_inference_unknown_model_pins_default_eligible_provider() { - let mut graph = Graph::new("test"); - let mut node = Node::new("a"); - node.attrs.insert( - "model".to_string(), - AttrValue::String("unknown-model-xyz".to_string()), - ); - graph.nodes.insert("a".to_string(), node); - - let graph = builtin_transform().apply(graph).unwrap(); - - assert_eq!( - graph.nodes["a"] - .attrs - .get("provider") - .and_then(AttrValue::as_str), - Some("anthropic") - ); - } - - #[test] - fn provider_inference_no_model_no_change() { - let mut graph = Graph::new("test"); - let node = Node::new("a"); - graph.nodes.insert("a".to_string(), node); - - let graph = builtin_transform().apply(graph).unwrap(); - - assert_eq!(graph.nodes["a"].attrs.get("provider"), None); - } - - #[test] - fn model_resolution_resolves_alias_to_canonical_id() { - let mut graph = Graph::new("test"); - let mut node = Node::new("a"); - node.attrs - .insert("model".to_string(), AttrValue::String("gpt-54".to_string())); - graph.nodes.insert("a".to_string(), node); - - let graph = builtin_transform().apply(graph).unwrap(); - - assert_eq!( - graph.nodes["a"] - .attrs - .get("model") - .and_then(AttrValue::as_str), - Some("gpt-5.4") - ); - assert_eq!( - graph.nodes["a"] - .attrs - .get("provider") - .and_then(AttrValue::as_str), - Some("openai") - ); - } - - #[test] - fn model_resolution_keeps_canonical_id_unchanged() { - let mut graph = Graph::new("test"); - let mut node = Node::new("a"); - node.attrs.insert( - "model".to_string(), - AttrValue::String("gpt-5.4".to_string()), - ); - graph.nodes.insert("a".to_string(), node); - - let graph = builtin_transform().apply(graph).unwrap(); - - assert_eq!( - graph.nodes["a"] - .attrs - .get("model") - .and_then(AttrValue::as_str), - Some("gpt-5.4") - ); - } - - #[test] - fn model_resolution_uses_injected_catalog_for_alias_and_provider() { - let mut graph = Graph::new("test"); - let mut node = Node::new("a"); - node.attrs - .insert("model".to_string(), AttrValue::String("vl".to_string())); - graph.nodes.insert("a".to_string(), node); - - let graph = ModelResolutionTransform::new(custom_catalog()) - .apply(graph) - .unwrap(); - - assert_eq!( - graph.nodes["a"] - .attrs - .get("model") - .and_then(AttrValue::as_str), - Some("venice-large") - ); - assert_eq!( - graph.nodes["a"] - .attrs - .get("provider") - .and_then(AttrValue::as_str), - Some("acme-venice") - ); - } - - #[test] - fn fallback_resolution_keeps_ready_preference_for_unpinned_nodes() { - let catalog = Arc::new(test_catalog_with_overlay( - "[providers.openrouter]\nenabled = true\n", - )); - let mut graph = Graph::new("test"); - let mut portable = Node::new("portable"); - portable.attrs.insert( - "model".to_string(), - AttrValue::String("claude-fable".to_string()), - ); - graph.nodes.insert("portable".to_string(), portable); - let mut pinned = Node::new("pinned"); - pinned.attrs.insert( - "model".to_string(), - AttrValue::String("claude-fable".to_string()), - ); - pinned.attrs.insert( - "provider".to_string(), - AttrValue::String("anthropic".to_string()), - ); - graph.nodes.insert("pinned".to_string(), pinned); - - let graph = ModelResolutionTransform::for_eligible( - Arc::clone(&catalog), - HashSet::from([ProviderId::new("openrouter")]), - ) - .with_catalog_fallback(true) - .apply(graph) - .unwrap(); - - assert_eq!( - graph.nodes["portable"].provider(), - Some("openrouter"), - "the unrelated unavailable pin must not force catalog-wide routing" - ); - assert_eq!(graph.nodes["pinned"].provider(), Some("anthropic")); - } - - #[test] - fn graph_default_alias_materializes_to_canonical_offering() { - let mut graph = Graph::new("test"); - graph.attrs.insert( - "default_model".to_string(), - AttrValue::String("vl".to_string()), - ); - - let graph = ModelResolutionTransform::new(custom_catalog()) - .apply(graph) - .unwrap(); - - assert_eq!( - graph.attrs.get("default_model").and_then(AttrValue::as_str), - Some("venice-large") - ); - assert_eq!( - graph - .attrs - .get("default_provider") - .and_then(AttrValue::as_str), - Some("acme-venice") - ); - } -} diff --git a/lib/components/fabro-workflow/src/transforms/model_stylesheet_template.rs b/lib/components/fabro-workflow/src/transforms/model_stylesheet_template.rs index 7071091d4..d38036364 100644 --- a/lib/components/fabro-workflow/src/transforms/model_stylesheet_template.rs +++ b/lib/components/fabro-workflow/src/transforms/model_stylesheet_template.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_template::TemplateContext; -use fabro_validate::Diagnostic; +use fabro_types::diagnostic::Diagnostic; use super::file_inlining::template_render_store; use super::variable_expansion::{ diff --git a/lib/components/fabro-workflow/src/transforms/variable_expansion.rs b/lib/components/fabro-workflow/src/transforms/variable_expansion.rs index a5872b2b3..a8ba8a368 100644 --- a/lib/components/fabro-workflow/src/transforms/variable_expansion.rs +++ b/lib/components/fabro-workflow/src/transforms/variable_expansion.rs @@ -8,12 +8,12 @@ use fabro_template::{ TemplateContext, TemplateError, TemplateRenderMode, TemplateSource, TemplateSourceOrigin, TemplateStore, validate_static_reference, }; +use fabro_types::diagnostic::{Diagnostic, Severity}; use fabro_types::graph::{AttributeScope, ReferenceKind, reference_kind_for_attribute}; use fabro_types::settings::interp::Namespace; use fabro_types::settings::{InterpString, ResolveCtx, ResolveError, ResolveErrorKind}; use fabro_util::error::collect_chain; use fabro_util::shell; -use fabro_validate::{Diagnostic, Severity}; use super::Transform; use crate::error::Error; diff --git a/lib/components/fabro-workflow/tests/materialize_run.rs b/lib/components/fabro-workflow/tests/materialize_run.rs deleted file mode 100644 index 04939cb0a..000000000 --- a/lib/components/fabro-workflow/tests/materialize_run.rs +++ /dev/null @@ -1,72 +0,0 @@ -use fabro_graphviz::graph::Graph; -use fabro_graphviz::parser; -use fabro_llm::test_support::test_catalog; -use fabro_types::WorkflowSettings; -use fabro_types::settings::InterpString; -use fabro_types::settings::run::{PullRequestSettings, RunGoal, RunModelSettings, RunNamespace}; -use fabro_workflow::run_materialization::materialize_run; -use lithos_llm::catalog::builtin; - -fn graph(source: &str) -> Graph { - parser::parse(source).expect("graph should parse") -} - -#[test] -fn materialize_run_applies_graph_and_catalog_defaults() { - let source = r#"digraph Test { - graph [goal="Build feature"] - start [shape=Mdiamond] - exit [shape=Msquare] - start -> exit - }"#; - - let settings = WorkflowSettings { - run: RunNamespace { - model: RunModelSettings { - name: Some("sonnet".to_string()), - ..RunModelSettings::default() - }, - pull_request: Some(PullRequestSettings { - enabled: false, - ..PullRequestSettings::default() - }), - ..RunNamespace::default() - }, - ..WorkflowSettings::default() - }; - - let materialized = materialize_run(settings, &graph(source), &test_catalog(), &[ - builtin::anthropic(), - ]) - .unwrap(); - let resolved = &materialized.run; - - assert_eq!(resolved.model.name.as_deref(), Some("claude-sonnet-5")); - assert_eq!(resolved.model.provider.as_deref(), Some("anthropic")); - assert_eq!( - materialized.run.goal.as_ref(), - Some(&RunGoal::Inline(InterpString::parse("Build feature"))) - ); - assert!(resolved.pull_request.is_none()); -} - -#[test] -fn materialize_run_uses_configured_provider_defaults() { - let source = r#"digraph Test { - graph [goal="Build feature"] - start [shape=Mdiamond] - exit [shape=Msquare] - start -> exit - }"#; - - let materialized = materialize_run( - WorkflowSettings::default(), - &graph(source), - &test_catalog(), - &[builtin::openai()], - ) - .unwrap(); - let resolved = &materialized.run; - - assert_eq!(resolved.model.provider.as_deref(), Some("openai")); -} diff --git a/lib/foundation/fabro-types/src/diagnostic.rs b/lib/foundation/fabro-types/src/diagnostic.rs new file mode 100644 index 000000000..f633acf4f --- /dev/null +++ b/lib/foundation/fabro-types/src/diagnostic.rs @@ -0,0 +1,60 @@ +//! A workflow diagnostic: what validation, a transform or Petri's check says +//! about a workflow, with the source position when known. + +use serde::{Deserialize, Serialize}; + +/// Severity level for workflow diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Severity { + Error, + Warning, + Info, +} + +/// One diagnostic about a workflow. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Diagnostic { + /// The rule or code that raised it (`template_undefined_variable`, + /// `attractor.model.unknown`). + pub rule: String, + pub severity: Severity, + pub message: String, + pub node_id: Option, + pub edge: Option<(String, String)>, + pub fix: Option, + pub source_path: Option, + pub line: Option, + pub column: Option, + pub span_start: Option, + pub span_len: Option, + #[serde(default)] + pub related: Vec, +} + +impl Default for Diagnostic { + fn default() -> Self { + Self { + rule: String::new(), + severity: Severity::Info, + message: String::new(), + node_id: None, + edge: None, + fix: None, + source_path: None, + line: None, + column: None, + span_start: None, + span_len: None, + related: Vec::new(), + } + } +} + +/// Another position a diagnostic points at. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelatedDiagnostic { + pub message: String, + pub source_path: Option, + pub line: Option, + pub column: Option, +} diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index 38d0769b2..2f3ad48bd 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -9,6 +9,7 @@ pub mod checkpoint; pub mod command_output; pub mod conclusion; pub mod dense; +pub mod diagnostic; pub mod diff; pub mod engine; pub mod event_envelope;