mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-02 02:13:49 +00:00
Petri judges a workflow at admission, so Fabro's lint rules go: `fabro-validate` (its 35 rules and the `LintRule` trait) is deleted, and with it `fabro-acp` (only a rule and two legacy executor tests used it), the model-resolution transform, the legacy `create`, `compile_create_run` and `materialize_create_run` stages, and `fabro-graphviz`'s `condition` and `fidelity` modules. `Diagnostic`, `RelatedDiagnostic` and `Severity` move to `fabro_types::diagnostic`, the one shape every diagnostic takes. Validation is now the same question the create handler asks. A new server module, `petri_check`, builds Petri's check request from a workflow bundle and the run's settings (every workflow of the bundle at its bundle-relative path, the inputs, the run variables, the launch), runs the check, and maps the diagnostics; Fabro adds one rule of its own, `fabro.model.no_ready_provider`, refusing a model node when no provider is ready. Admission, the validate and preflight endpoints and the offline `fabro validate` all go through it: - `validate_prepared_manifest` runs Fabro's structural pass (parse and transform, whose diagnostics stay) and then Petri's check, on the blocking pool from the handlers; - the offline `validate_manifest` checks with no model client and with an unbound input as a warning (`CheckRequest.unbound_is_warning`), so a workflow validates before its inputs exist; a collected workflow before upload checks with unbound inputs as errors, as before; - preflight resolves each LLM node's selector against the ready providers and the catalog for its probe, as the deleted transform did, and no longer probes a model Petri refused; - the graph render endpoint needs only the structural pass; - a run manifest now carries its `[run.goal] file`, which Petri reads from the bundle as it does for a version. The transforms keep the authored model selector (`sonnet` stays `sonnet`): Petri pins the catalog model in the admitted graph, not in the graph Fabro displays or in the settings snapshot. Tests assert that, and the CLI's validate, preflight and graph snapshots carry Petri's diagnostics (`attractor.no_start`, `attractor.undeclared_node`, `attractor.bad_on_failure`, ...) in place of the lint rules' text. Known gaps, Petri's side: a `workflow.toml` whose `[run.environment]` names an environment the server catalog defines but the file does not is refused (`unsupported.workflow_toml.run.environment`), as admission already refused it; an unbound input inside an included template partial is a render error rather than the unbound-input warning. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
110 lines
4.6 KiB
Rust
110 lines
4.6 KiB
Rust
use fabro_test::{fabro_snapshot, test_context};
|
||
use serde_json::Value;
|
||
|
||
use super::support::fixture;
|
||
|
||
#[test]
|
||
fn help() {
|
||
let context = test_context!();
|
||
let mut cmd = context.command();
|
||
cmd.args(["preflight", "--help"]);
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: true
|
||
exit_code: 0
|
||
----- stdout -----
|
||
Validate run configuration without executing
|
||
|
||
Usage: fabro preflight [OPTIONS] <WORKFLOW>
|
||
|
||
Arguments:
|
||
<WORKFLOW> Path to a .fabro workflow file or .toml task config
|
||
|
||
Options:
|
||
--json Output as JSON [env: FABRO_JSON=]
|
||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
|
||
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
|
||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||
--goal-file <GOAL_FILE> Read the workflow goal from a file
|
||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||
--model <MODEL> Override default LLM model
|
||
--provider <PROVIDER> Override default LLM provider
|
||
-v, --verbose Enable verbose output
|
||
--environment <ENVIRONMENT> Named environment for agent tools
|
||
-h, --help Print help
|
||
----- stderr -----
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn preflight_invalid_workflow_fails_with_validation_output() {
|
||
let context = test_context!();
|
||
let workflow = fixture("invalid.fabro");
|
||
let mut cmd = context.command();
|
||
cmd.args(["preflight", workflow.to_str().unwrap()]);
|
||
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: false
|
||
exit_code: 1
|
||
----- stdout -----
|
||
----- stderr -----
|
||
Workflow: Invalid (2 nodes, 1 edges)
|
||
Graph: [FIXTURES]/invalid.fabro
|
||
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
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn preflight_rejects_unbound_template_inputs() {
|
||
let context = test_context!();
|
||
let workflow = fixture("templated_unbound.fabro");
|
||
let mut cmd = context.command();
|
||
cmd.args(["preflight", workflow.to_str().unwrap()]);
|
||
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: false
|
||
exit_code: 1
|
||
----- stdout -----
|
||
----- stderr -----
|
||
Workflow: TemplatedUnbound (3 nodes, 2 edges)
|
||
Graph: [FIXTURES]/templated_unbound.fabro
|
||
Goal: Demo
|
||
|
||
error: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
|
||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||
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=<value>`
|
||
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
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn preflight_invalid_workflow_json_emits_diagnostics() {
|
||
let context = test_context!();
|
||
let workflow = fixture("invalid.fabro");
|
||
let output = context
|
||
.command()
|
||
.args(["--json", "preflight", workflow.to_str().unwrap()])
|
||
.output()
|
||
.expect("command should run");
|
||
|
||
assert!(!output.status.success());
|
||
let value: Value =
|
||
serde_json::from_slice(&output.stdout).expect("preflight --json should parse");
|
||
assert_eq!(value["workflow"]["name"], "Invalid");
|
||
assert!(
|
||
value["workflow"]["diagnostics"]
|
||
.as_array()
|
||
.is_some_and(|diagnostics| !diagnostics.is_empty())
|
||
);
|
||
assert_eq!(value["checks"]["title"], "Run Preflight");
|
||
|
||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||
assert!(stderr.contains("Validation failed"));
|
||
}
|