Extract --preflight into fabro preflight subcommand

Preflight validation is conceptually distinct from running a workflow —
it deserves its own top-level command rather than being a flag on `run`.

- Add `PreflightArgs` struct and `Commands::Preflight` variant
- Create `commands/preflight.rs` with dedicated `execute()` function
- Remove `--preflight` flag from `RunArgs`
- Refactor `load_workflow_source_input` to take individual params
  instead of `&RunArgs`
- Refactor `resolve_cli_goal` to take `Option<&str>` / `Option<&Path>`
- Refactor `run_preflight` to take `cli_model`/`cli_provider` instead
  of `&RunArgs`, make `pub(crate)`
- Update docs and skills references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-27 10:52:41 -04:00
parent e5c60a84b3
commit fc3d364c84
No known key found for this signature in database
11 changed files with 201 additions and 141 deletions

View file

@ -32,8 +32,8 @@ It checks:
**SSE streams disconnecting** — If using a reverse proxy, ensure buffering is disabled and the connection timeout is long enough for workflow runs. See the [reverse proxy example](/administration/deployment#binding-and-tls).
**Run config validation errors** — Use `--preflight` to validate without executing:
**Run config validation errors** — Use `fabro preflight` to validate without executing:
```bash
fabro run run.toml --preflight
fabro preflight run.toml
```

View file

@ -488,8 +488,8 @@ Fabro validates the run config when it loads:
- **Unknown fields** — Extra fields not listed above are silently ignored.
- **Variable check** — Any `$variable` in the Graphviz file without a matching `[vars]` entry produces an error.
Use `--preflight` to validate a run config without executing it:
Use `fabro preflight` to validate a run config without executing it:
```bash
fabro run run.toml --preflight
fabro preflight run.toml
```

View file

@ -49,7 +49,6 @@ fabro run run.toml
| `<WORKFLOW>` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name (resolved from `fabro/workflows/` in the project, then `~/.fabro/workflows/`). |
| `--run-dir <DIR>` | Run output directory |
| `--dry-run` | Execute with a simulated LLM backend |
| `--preflight` | Validate run configuration without executing |
| `--auto-approve` | Auto-approve all human gates |
| `--model <MODEL>` | Override default LLM model |
| `--provider <PROVIDER>` | Override default LLM provider |
@ -62,9 +61,24 @@ fabro run run.toml
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `-d, --detach` | Fork the workflow as a background process and print the run ID. Reconnect later with `fabro logs -f`. |
<Note>
`--preflight` conflicts with `--dry-run` and `--detach`.
</Note>
## `fabro preflight`
Validate run configuration (sandbox, LLM providers, GitHub tokens) without executing the workflow.
```bash
fabro preflight <WORKFLOW>
fabro preflight run.toml
```
| Argument / Flag | Description |
|---|---|
| `<WORKFLOW>` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name. |
| `--goal <GOAL>` | Override the workflow goal (exposed as `$goal` in prompts) |
| `--goal-file <FILE>` | Read the goal from a file instead of inline text |
| `--model <MODEL>` | Override default LLM model |
| `--provider <PROVIDER>` | Override default LLM provider |
| `-v, --verbose` | Enable verbose output |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` |
## `fabro resume`

View file

@ -99,10 +99,6 @@ pub(crate) struct RunArgs {
#[arg(long)]
pub(crate) dry_run: bool,
/// Validate run configuration without executing
#[arg(long, conflicts_with = "dry_run")]
pub(crate) preflight: bool,
/// Auto-approve all human gates
#[arg(long)]
pub(crate) auto_approve: bool,
@ -144,7 +140,7 @@ pub(crate) struct RunArgs {
pub(crate) preserve_sandbox: bool,
/// Run the workflow in the background and print the run ID
#[arg(short = 'd', long, conflicts_with = "preflight")]
#[arg(short = 'd', long)]
pub(crate) detach: bool,
/// Pre-generated run ID (used internally by --detach)
@ -152,6 +148,36 @@ pub(crate) struct RunArgs {
pub(crate) run_id: Option<String>,
}
#[derive(Args)]
pub(crate) struct PreflightArgs {
/// Path to a .fabro workflow file or .toml task config
pub(crate) workflow: PathBuf,
/// Override the workflow goal (exposed as $goal in prompts)
#[arg(long)]
pub(crate) goal: Option<String>,
/// Read the workflow goal from a file
#[arg(long, conflicts_with = "goal")]
pub(crate) goal_file: Option<PathBuf>,
/// Override default LLM model
#[arg(long)]
pub(crate) model: Option<String>,
/// Override default LLM provider
#[arg(long)]
pub(crate) provider: Option<String>,
/// Enable verbose output
#[arg(short, long)]
pub(crate) verbose: bool,
/// Sandbox for agent tools
#[arg(long, value_enum)]
pub(crate) sandbox: Option<CliSandboxProvider>,
}
#[derive(Args)]
pub(crate) struct RunFilterArgs {
/// Only include runs started before this date (YYYY-MM-DD prefix match)
@ -694,6 +720,8 @@ pub(crate) enum Commands {
Exec(fabro_agent::cli::AgentArgs),
#[command(flatten)]
RunCmd(RunCommands),
/// Validate run configuration without executing
Preflight(PreflightArgs),
/// Validate a workflow
Validate(ValidateArgs),
/// Render a workflow graph as SVG or PNG
@ -780,6 +808,7 @@ impl Commands {
},
Self::Exec(_) => "exec",
Self::RunCmd(cmd) => cmd.name(),
Self::Preflight(_) => "preflight",
Self::Validate(_) => "validate",
Self::Graph(_) => "graph",
Self::Parse(_) => "parse",

View file

@ -7,6 +7,7 @@ pub mod llm;
pub mod model;
pub mod parse;
pub mod pr;
pub mod preflight;
pub mod provider;
pub mod repo;
pub mod run;

View file

@ -0,0 +1,93 @@
use std::path::Path;
use anyhow::bail;
use fabro_util::terminal::Styles;
use crate::args::PreflightArgs;
use crate::cli_config;
use super::run::execute::{
apply_execution_overrides, load_workflow_source_input, print_workflow_report,
resolve_sandbox_provider, run_preflight, ExecutionOverrides,
};
pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_config = cli_config::load_cli_config(None)?;
args.verbose = args.verbose || cli_config.verbose_enabled();
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
let run_defaults = cli_config;
let source_input = load_workflow_source_input(
&args.workflow,
args.goal.as_deref(),
args.goal_file.as_deref(),
run_defaults,
true,
)?;
let original_cwd = std::env::current_dir()?;
let (origin_url, detected_base_branch) =
fabro_sandbox::daytona::detect_repo_info(&original_cwd)
.map(|(url, branch)| (Some(url), branch))
.unwrap_or((None, None));
let git_status =
fabro_workflows::git::sync_status(&original_cwd, "origin", detected_base_branch.as_deref());
let sandbox_provider = resolve_sandbox_provider(
args.sandbox.map(Into::into),
Some(&source_input.config),
&source_input.run_defaults,
)?;
let mut config = source_input.config.clone();
apply_execution_overrides(
&mut config,
&ExecutionOverrides {
dry_run: false,
auto_approve: false,
no_retro: false,
verbose: args.verbose,
preserve_sandbox: false,
model: args.model.as_deref(),
provider: args.provider.as_deref(),
sandbox_provider,
},
);
let validated = fabro_workflows::operations::validate(
&source_input.raw_source,
fabro_workflows::operations::ValidateOptions {
base_dir: Some(
source_input
.dot_path
.parent()
.unwrap_or(Path::new("."))
.to_path_buf(),
),
config: Some(config.clone()),
goal_override: source_input.goal_override.clone(),
..Default::default()
},
)?;
print_workflow_report(&validated, &source_input.dot_path, styles);
if validated.has_errors() {
bail!("Validation failed");
}
run_preflight(
validated.graph(),
&Some(config),
args.model.as_deref(),
args.provider.as_deref(),
&source_input.run_defaults,
git_status,
sandbox_provider,
styles,
github_app,
origin_url.as_deref(),
)
.await
}

View file

@ -21,7 +21,17 @@ pub async fn create_run(
styles: &Styles,
quiet: bool,
) -> anyhow::Result<(String, PathBuf)> {
let source_input = load_workflow_source_input(args, run_defaults, true)?;
let workflow_path = args
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let source_input = load_workflow_source_input(
workflow_path,
args.goal.as_deref(),
args.goal_file.as_deref(),
run_defaults,
true,
)?;
let run_id = args
.run_id
.clone()

View file

@ -40,11 +40,11 @@ use crate::shared::{
/// Resolve goal from `--goal` string or `--goal-file` path.
pub(crate) fn resolve_cli_goal(
goal: &Option<String>,
goal_file: &Option<PathBuf>,
goal: Option<&str>,
goal_file: Option<&Path>,
) -> anyhow::Result<Option<String>> {
match (goal, goal_file) {
(Some(g), _) => Ok(Some(g.clone())),
(Some(g), _) => Ok(Some(g.to_string())),
(_, Some(path)) => {
let path = fabro_util::path::expand_tilde(path);
let content = std::fs::read_to_string(&path)
@ -484,21 +484,19 @@ pub(crate) struct WorkflowSourceInput {
pub goal_override: Option<String>,
}
#[allow(dead_code)]
enum WorkflowState {
Source(Box<WorkflowSourceInput>),
Persisted(Box<Persisted>),
}
pub(crate) fn load_workflow_source_input(
args: &RunArgs,
workflow: &Path,
goal: Option<&str>,
goal_file: Option<&Path>,
mut run_defaults: FabroConfig,
apply_project_config: bool,
) -> anyhow::Result<WorkflowSourceInput> {
let workflow_path = args
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
if apply_project_config {
// Apply project-level config overrides (fabro.toml) on top of CLI defaults.
if let Ok(Some((_config_path, project_config))) =
@ -511,7 +509,7 @@ pub(crate) fn load_workflow_source_input(
// Resolve workflow arg, load run config if TOML, merge with defaults.
let (resolved_workflow_path, dot_path, config) = {
let (resolved, dot, cfg) = resolve_workflow_source(workflow_path)?;
let (resolved, dot, cfg) = resolve_workflow_source(workflow)?;
match cfg {
Some(cfg) => {
let mut merged = run_defaults.clone();
@ -529,7 +527,7 @@ pub(crate) fn load_workflow_source_input(
}
let raw_source = read_workflow_file(&dot_path)?;
let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?;
let cli_goal = resolve_cli_goal(goal, goal_file)?;
let goal_override = cli_goal.or_else(|| config.goal.clone());
let workflow_toml_path = if resolved_workflow_path
@ -600,7 +598,7 @@ pub async fn run_from_record(
workflow: None,
run_dir: Some(run_dir),
dry_run: record.config.dry_run_enabled(),
preflight: false,
auto_approve: record.config.auto_approve_enabled(),
goal: record.config.goal.clone(),
goal_file: None,
@ -675,7 +673,7 @@ pub async fn resume_from_record(
workflow: None,
run_dir: Some(run_dir),
dry_run: record.config.dry_run_enabled(),
preflight: false,
auto_approve: record.config.auto_approve_enabled(),
goal: record.config.goal.clone(),
goal_file: None,
@ -738,63 +736,28 @@ pub async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> anyhow::Result
let cli_config = cli_config::load_cli_config(None)?;
args.verbose = args.verbose || cli_config.verbose_enabled();
if args.preflight {
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
let git_author = fabro_workflows::git::GitAuthor::from_options(
cli_config.git_author().and_then(|a| a.name.clone()),
cli_config.git_author().and_then(|a| a.email.clone()),
);
run_command(args, cli_config, styles, github_app, git_author).await?;
let quiet = args.detach;
let _prevent_idle_sleep = cli_config.prevent_idle_sleep_enabled();
let (run_id, run_dir) = super::create::create_run(&args, cli_config, styles, quiet).await?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = fabro_beastie::guard(_prevent_idle_sleep);
let child = super::start::start_run(&run_dir, false)?;
if args.detach {
println!("{run_id}");
} else {
let quiet = args.detach;
let _prevent_idle_sleep = cli_config.prevent_idle_sleep_enabled();
let (run_id, run_dir) = super::create::create_run(&args, cli_config, styles, quiet).await?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = fabro_beastie::guard(_prevent_idle_sleep);
let child = super::start::start_run(&run_dir, false)?;
if args.detach {
println!("{run_id}");
} else {
let exit_code = super::attach::attach_run(&run_dir, true, styles, Some(child)).await?;
print_run_summary(&run_dir, &run_id, styles);
if exit_code != std::process::ExitCode::SUCCESS {
std::process::exit(1);
}
let exit_code = super::attach::attach_run(&run_dir, true, styles, Some(child)).await?;
print_run_summary(&run_dir, &run_id, styles);
if exit_code != std::process::ExitCode::SUCCESS {
std::process::exit(1);
}
}
Ok(())
}
pub async fn run_command(
args: RunArgs,
run_defaults: FabroConfig,
styles: &'static Styles,
github_app: Option<fabro_github::GitHubAppCredentials>,
git_author: fabro_workflows::git::GitAuthor,
) -> anyhow::Result<()> {
let source_input = load_workflow_source_input(&args, run_defaults, true)?;
let resolved_run_defaults = source_input.run_defaults.clone();
let record_run = RecordBasedRun {
workflow: WorkflowState::Source(Box::new(source_input)),
run_defaults: resolved_run_defaults,
};
run_command_impl(
args,
styles,
github_app,
git_author,
Some(record_run),
false,
)
.await
}
async fn run_command_impl(
args: RunArgs,
styles: &'static Styles,
@ -817,8 +780,6 @@ async fn run_command_impl(
fabro_sandbox::daytona::detect_repo_info(&original_cwd)
.map(|(url, branch)| (Some(url), branch))
.unwrap_or((None, None));
let git_status =
fabro_workflows::git::sync_status(&original_cwd, "origin", detected_base_branch.as_deref());
// 3. Create logs directory
// Extract values from args before partial move
@ -879,40 +840,6 @@ async fn run_command_impl(
},
);
if args.preflight {
let validated = fabro_workflows::operations::validate(
&source_input.raw_source,
fabro_workflows::operations::ValidateOptions {
base_dir: Some(
source_input
.dot_path
.parent()
.unwrap_or(Path::new("."))
.to_path_buf(),
),
config: Some(config.clone()),
goal_override: source_input.goal_override.clone(),
..Default::default()
},
)?;
print_workflow_report(&validated, &source_input.dot_path, styles);
if validated.has_errors() {
bail!("Validation failed");
}
return run_preflight(
validated.graph(),
&Some(config),
&args,
&run_defaults,
git_status,
sandbox_provider,
styles,
github_app,
origin_url.as_deref(),
)
.await;
}
match fabro_workflows::operations::create(
&source_input.raw_source,
fabro_workflows::operations::RunCreateOptions {
@ -1501,10 +1428,11 @@ pub(crate) fn print_assets(run_dir: &std::path::Path, styles: &Styles) {
/// resolves the model/provider through the full precedence chain, and prints
/// a styled check report.
#[allow(clippy::too_many_arguments)]
async fn run_preflight(
pub(crate) async fn run_preflight(
graph: &fabro_graphviz::graph::Graph,
run_cfg: &Option<FabroConfig>,
args: &RunArgs,
cli_model: Option<&str>,
cli_provider: Option<&str>,
run_defaults: &FabroConfig,
git_status: GitSyncStatus,
sandbox_provider: SandboxProvider,
@ -1558,8 +1486,8 @@ async fn run_preflight(
// 2. Workflow metadata
let (model, provider) = resolve_model_provider(
args.model.as_deref(),
args.provider.as_deref(),
cli_model,
cli_provider,
run_cfg.as_ref(),
run_defaults,
graph,
@ -1979,27 +1907,11 @@ include = ["*.md"]
)
.unwrap();
let args = RunArgs {
workflow: Some(dir.path().join("workflow.toml")),
run_dir: None,
dry_run: false,
preflight: false,
auto_approve: false,
goal: None,
goal_file: None,
model: None,
provider: None,
verbose: false,
sandbox: None,
label: Vec::new(),
no_retro: false,
preserve_sandbox: false,
detach: false,
run_id: None,
};
let workflow_path = dir.path().join("workflow.toml");
let source_input =
load_workflow_source_input(&args, FabroConfig::default(), false).unwrap();
load_workflow_source_input(&workflow_path, None, None, FabroConfig::default(), false)
.unwrap();
let validated = fabro_workflows::operations::validate(
&source_input.raw_source,
fabro_workflows::operations::ValidateOptions {
@ -2058,19 +1970,19 @@ include = ["*.md"]
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("goal.md");
std::fs::write(&path, "goal from file").unwrap();
let result = resolve_cli_goal(&None, &Some(path)).unwrap();
let result = resolve_cli_goal(None, Some(path.as_path())).unwrap();
assert_eq!(result, Some("goal from file".to_string()));
}
#[test]
fn resolve_cli_goal_from_string() {
let result = resolve_cli_goal(&Some("inline goal".to_string()), &None).unwrap();
let result = resolve_cli_goal(Some("inline goal"), None).unwrap();
assert_eq!(result, Some("inline goal".to_string()));
}
#[test]
fn resolve_cli_goal_none() {
let result = resolve_cli_goal(&None, &None).unwrap();
let result = resolve_cli_goal(None, None).unwrap();
assert_eq!(result, None);
}

View file

@ -156,6 +156,7 @@ async fn main_inner() -> (String, Result<()>) {
Commands::Llm(ns) => commands::llm::dispatch(ns, &globals).await?,
Commands::Exec(args) => commands::exec::execute(args, &globals).await?,
Commands::RunCmd(cmd) => commands::run::dispatch(cmd, &globals).await?,
Commands::Preflight(args) => commands::preflight::execute(args).await?,
Commands::Validate(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
commands::validate::run(&args, &styles)?;

View file

@ -133,7 +133,7 @@ NODE_ENV = "test"
### Step 7: Validate
Run `fabro run --preflight workflow.toml` (or `fabro run --preflight workflow.fabro`) to validate without executing.
Run `fabro preflight workflow.toml` (or `fabro preflight workflow.fabro`) to validate without executing.
If validation fails, fix the reported errors and re-validate.

View file

@ -139,5 +139,5 @@ Node-level attribute > Stylesheet > TOML config > CLI flags > Server defaults >
## Validation
```bash
fabro run --preflight workflow.toml # validate without executing
fabro preflight workflow.toml # validate without executing
```