diff --git a/docs/administration/troubleshooting.mdx b/docs/administration/troubleshooting.mdx index 01b36e29d..93f149083 100644 --- a/docs/administration/troubleshooting.mdx +++ b/docs/administration/troubleshooting.mdx @@ -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 ``` diff --git a/docs/execution/run-configuration.mdx b/docs/execution/run-configuration.mdx index 9160687ce..1077ea7b1 100644 --- a/docs/execution/run-configuration.mdx +++ b/docs/execution/run-configuration.mdx @@ -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 ``` diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 52bc66977..cc010e2ac 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -49,7 +49,6 @@ fabro run run.toml | `` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name (resolved from `fabro/workflows/` in the project, then `~/.fabro/workflows/`). | | `--run-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 ` | Override default LLM model | | `--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`. | - -`--preflight` conflicts with `--dry-run` and `--detach`. - +## `fabro preflight` + +Validate run configuration (sandbox, LLM providers, GitHub tokens) without executing the workflow. + +```bash +fabro preflight +fabro preflight run.toml +``` + +| Argument / Flag | Description | +|---|---| +| `` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name. | +| `--goal ` | Override the workflow goal (exposed as `$goal` in prompts) | +| `--goal-file ` | Read the goal from a file instead of inline text | +| `--model ` | Override default LLM model | +| `--provider ` | Override default LLM provider | +| `-v, --verbose` | Enable verbose output | +| `--sandbox ` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` | ## `fabro resume` diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 6d12c964a..43116fbab 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -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, } +#[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, + + /// Read the workflow goal from a file + #[arg(long, conflicts_with = "goal")] + pub(crate) goal_file: Option, + + /// Override default LLM model + #[arg(long)] + pub(crate) model: Option, + + /// Override default LLM provider + #[arg(long)] + pub(crate) provider: Option, + + /// Enable verbose output + #[arg(short, long)] + pub(crate) verbose: bool, + + /// Sandbox for agent tools + #[arg(long, value_enum)] + pub(crate) sandbox: Option, +} + #[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", diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 6e53c2f37..f79717014 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs new file mode 100644 index 000000000..1879ecc23 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -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 +} diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index f8e9de490..fa3b4c427 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -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() diff --git a/lib/crates/fabro-cli/src/commands/run/execute.rs b/lib/crates/fabro-cli/src/commands/run/execute.rs index 0a3f3d521..37a0edfa0 100644 --- a/lib/crates/fabro-cli/src/commands/run/execute.rs +++ b/lib/crates/fabro-cli/src/commands/run/execute.rs @@ -40,11 +40,11 @@ use crate::shared::{ /// Resolve goal from `--goal` string or `--goal-file` path. pub(crate) fn resolve_cli_goal( - goal: &Option, - goal_file: &Option, + goal: Option<&str>, + goal_file: Option<&Path>, ) -> anyhow::Result> { 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, } +#[allow(dead_code)] enum WorkflowState { Source(Box), Persisted(Box), } 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 { - 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, - 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, - 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); } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index ecc6e8eeb..2d3ca6d84 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -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)?; diff --git a/skills/fabro-create-workflow/SKILL.md b/skills/fabro-create-workflow/SKILL.md index 28b30c5b8..9132c367f 100644 --- a/skills/fabro-create-workflow/SKILL.md +++ b/skills/fabro-create-workflow/SKILL.md @@ -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. diff --git a/skills/fabro-create-workflow/references/run-configuration.md b/skills/fabro-create-workflow/references/run-configuration.md index a00738bf9..5b9d92496 100644 --- a/skills/fabro-create-workflow/references/run-configuration.md +++ b/skills/fabro-create-workflow/references/run-configuration.md @@ -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 ```