From 25df7087606dc9aa0df576fb04599f2c0a5727da Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 27 Mar 2026 16:27:55 -0400 Subject: [PATCH] Thin CLI run commands and move execution into workflows operations --- lib/crates/fabro-api/src/server.rs | 31 +- lib/crates/fabro-cli/src/args.rs | 8 +- .../fabro-cli/src/commands/config/mod.rs | 26 +- .../fabro-cli/src/commands/preflight.rs | 502 ++++- .../fabro-cli/src/commands/run/attach.rs | 56 +- .../fabro-cli/src/commands/run/command.rs | 35 + .../fabro-cli/src/commands/run/create.rs | 97 +- .../fabro-cli/src/commands/run/detached.rs | 393 +--- .../fabro-cli/src/commands/run/execute.rs | 1988 ----------------- .../fabro-cli/src/commands/run/launcher.rs | 50 + lib/crates/fabro-cli/src/commands/run/mod.rs | 13 +- .../fabro-cli/src/commands/run/output.rs | 222 ++ .../fabro-cli/src/commands/run/overrides.rs | 90 + .../fabro-cli/src/commands/run/resume.rs | 101 +- .../src/commands/run/run_progress.rs | 3 + .../fabro-cli/src/commands/run/start.rs | 156 +- lib/crates/fabro-cli/src/main.rs | 38 +- lib/crates/fabro-cli/tests/cli.rs | 46 +- lib/crates/fabro-workflows/src/git.rs | 4 +- .../fabro-workflows/src/operations/create.rs | 300 ++- .../fabro-workflows/src/operations/mod.rs | 12 +- .../fabro-workflows/src/operations/rewind.rs | 7 +- .../fabro-workflows/src/operations/source.rs | 235 ++ .../fabro-workflows/src/operations/start.rs | 842 +++++-- .../fabro-workflows/src/pipeline/persist.rs | 15 +- .../fabro-workflows/src/pipeline/types.rs | 2 +- 26 files changed, 2200 insertions(+), 3072 deletions(-) create mode 100644 lib/crates/fabro-cli/src/commands/run/command.rs delete mode 100644 lib/crates/fabro-cli/src/commands/run/execute.rs create mode 100644 lib/crates/fabro-cli/src/commands/run/launcher.rs create mode 100644 lib/crates/fabro-cli/src/commands/run/output.rs create mode 100644 lib/crates/fabro-cli/src/commands/run/overrides.rs create mode 100644 lib/crates/fabro-workflows/src/operations/source.rs diff --git a/lib/crates/fabro-api/src/server.rs b/lib/crates/fabro-api/src/server.rs index d747b9ee1..507bbf27f 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -21,7 +21,7 @@ use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedUser}; use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer}; use fabro_workflows::context::Context; use fabro_workflows::event::{EventEmitter, WorkflowRunEvent}; -use fabro_workflows::operations::{self, RunCreateOptions}; +use fabro_workflows::operations::{self, CreateRequest, WorkflowInput}; use fabro_workflows::pipeline::{ self, InitOptions, LlmSpec, Persisted, SandboxEnvSpec, SandboxSpec, }; @@ -526,25 +526,19 @@ async fn start_run( }), ..Default::default() }; - let run_labels = settings.labels.clone(); - let persisted = match operations::create( - &req.dot_source, - RunCreateOptions { - settings, - run_dir: Some(run_dir.clone()), - run_id: Some(run_id.clone()), - workflow_slug: None, - labels: run_labels, - base_branch: None, - working_directory: Some( - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), - ), - host_repo_path: None, - goal_override: None, + let created = match operations::create(CreateRequest { + workflow: WorkflowInput::DotSource { + source: req.dot_source.clone(), base_dir: None, + workflow_slug: None, }, - ) { - Ok(persisted) => persisted, + settings, + run_dir: Some(run_dir.clone()), + run_id: Some(run_id.clone()), + host_repo_path: None, + base_branch: None, + }) { + Ok(created) => created, Err(ref err @ fabro_workflows::error::FabroError::ValidationFailed { ref diagnostics }) => { let message = if diagnostics.is_empty() { err.to_string() @@ -568,6 +562,7 @@ async fn start_run( .into_response(); } }; + let persisted = created.persisted; let created_at = persisted.run_record().created_at; { diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 08c78a51a..b8c29c4be 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -646,12 +646,12 @@ pub(crate) enum RunCommands { /// Internal: run the engine process (reads run.json from run dir) #[command(name = "__detached", hide = true)] Detached { - /// Base storage directory + /// Run directory #[arg(long)] - storage_dir: PathBuf, - /// Run ID + run_dir: PathBuf, + /// Launcher metadata path #[arg(long)] - run_id: String, + launcher_path: PathBuf, /// Resume from checkpoint instead of fresh start #[arg(long)] resume: bool, diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 6e2dec416..c6203c371 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -2,7 +2,6 @@ use std::io::Write; use std::path::Path; use crate::args::{ConfigCommand, ConfigNamespace, ConfigShowArgs}; -use anyhow::bail; use fabro_config::{FabroConfig, FabroSettings}; pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> { @@ -13,25 +12,14 @@ pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> { fn merged_config(workflow: Option<&Path>) -> anyhow::Result { if let Some(workflow) = workflow { - let (resolved_path, _dot_path, run_config) = - crate::commands::run::execute::resolve_workflow_source(workflow)?; - let missing_workflow = run_config.is_none() && !resolved_path.is_file(); - let project_config = fabro_config::project::discover_project_config( - resolved_path.parent().unwrap_or_else(|| Path::new(".")), - )? - .map(|(_, config)| config) - .unwrap_or_default(); let cli_config = fabro_config::cli::load_cli_config(None)?; - let config = run_config - .unwrap_or_default() - .combine(project_config) - .combine(cli_config); - - if missing_workflow { - bail!("Workflow not found: {}", resolved_path.display()); - } - - return config.try_into(); + return fabro_workflows::operations::resolve_settings_for_path( + workflow, + cli_config, + FabroConfig::default(), + true, + ) + .map_err(Into::into); } let cwd = std::env::current_dir()?; diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 89d1f8c6b..aa119355c 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,12 +1,14 @@ use std::path::Path; +use std::sync::Arc; use anyhow::bail; +use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox}; use fabro_config::{FabroConfig, FabroSettings}; +use fabro_model::{Catalog, Provider}; +use fabro_sandbox::SandboxProvider; use fabro_util::terminal::Styles; +use fabro_workflows::git::GitSyncStatus; -use super::run::execute::{ - load_workflow_source_input, print_workflow_report, resolve_sandbox_provider, run_preflight, -}; use crate::args::PreflightArgs; pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> { @@ -17,51 +19,503 @@ pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> { let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id()); let cli_args_config = FabroConfig::try_from(&args)?; + let settings = fabro_workflows::operations::resolve_settings_for_path( + &args.workflow, + cli_defaults, + cli_args_config, + true, + )?; + let resolved = fabro_workflows::operations::resolve_workflow( + fabro_workflows::operations::ResolveWorkflowRequest { + workflow: fabro_workflows::operations::WorkflowInput::Path(args.workflow.clone()), + settings: settings.clone(), + }, + )?; - let source_input = - load_workflow_source_input(&args.workflow, cli_args_config, cli_defaults, true)?; - - let original_cwd = std::env::current_dir()?; let (origin_url, detected_base_branch) = - fabro_sandbox::daytona::detect_repo_info(&original_cwd) + fabro_sandbox::daytona::detect_repo_info(&resolved.working_directory) .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 git_status = fabro_workflows::git::sync_status( + &resolved.working_directory, + "origin", + detected_base_branch.as_deref(), + ); - let sandbox_provider = - resolve_sandbox_provider(args.sandbox.map(Into::into), &source_input.settings)?; + let sandbox_provider = resolve_sandbox_provider(args.sandbox.map(Into::into), &settings)?; let validated = fabro_workflows::operations::validate( - &source_input.raw_source, + &resolved.raw_source, fabro_workflows::operations::ValidateOptions { - base_dir: Some( - source_input - .dot_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(), - ), - settings: Some(source_input.settings.clone()), - goal_override: source_input.goal_override.clone(), + base_dir: resolved.base_dir.clone(), + settings: Some(resolved.settings.clone()), + goal_override: resolved.goal_override.clone(), ..Default::default() }, )?; - print_workflow_report(&validated, &source_input.dot_path, styles); + super::run::output::print_workflow_report(&validated, resolved.dot_path.as_deref(), styles); if validated.has_errors() { bail!("Validation failed"); } run_preflight( validated.graph(), - &source_input.settings, + &resolved.settings, args.model.as_deref(), args.provider.as_deref(), git_status, sandbox_provider, + &resolved.working_directory, styles, github_app, origin_url.as_deref(), ) .await } + +fn resolve_model_provider( + cli_model: Option<&str>, + cli_provider: Option<&str>, + settings: &FabroSettings, + graph: &fabro_graphviz::graph::Graph, +) -> (String, Option) { + let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref()); + let configured_provider = settings + .llm + .as_ref() + .and_then(|llm| llm.provider.as_deref()); + + let provider = cli_provider + .or(configured_provider) + .or_else(|| graph.attrs.get("default_provider").and_then(|v| v.as_str())) + .map(String::from); + + let model = cli_model + .or(configured_model) + .or_else(|| graph.attrs.get("default_model").and_then(|v| v.as_str())) + .map(String::from) + .unwrap_or_else(|| { + let catalog = Catalog::builtin(); + let info = provider + .as_deref() + .and_then(|s| s.parse::().ok()) + .and_then(|p| catalog.default_for_provider(p)) + .unwrap_or_else(|| catalog.default_from_env()); + info.id.clone() + }); + + match Catalog::builtin().get(&model) { + Some(info) => ( + info.id.clone(), + provider.or(Some(info.provider.to_string())), + ), + None => (model, provider), + } +} + +fn parse_sandbox_provider(settings: &FabroSettings) -> anyhow::Result> { + settings + .sandbox_settings() + .and_then(|s| s.provider.as_deref()) + .map(|s| s.parse::()) + .transpose() + .map_err(|e| anyhow::anyhow!("Invalid sandbox provider: {e}")) +} + +fn resolve_sandbox_provider( + cli: Option, + settings: &FabroSettings, +) -> anyhow::Result { + Ok(cli + .or(parse_sandbox_provider(settings)?) + .unwrap_or_default()) +} + +fn resolve_daytona_config( + settings: &FabroSettings, +) -> Option { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.daytona.clone()) +} + +#[cfg(feature = "exedev")] +fn resolve_exe_config(settings: &FabroSettings) -> Option { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.exe.clone()) +} + +#[cfg(feature = "exedev")] +fn resolve_exe_clone_params(cwd: &Path) -> Option { + let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) { + Ok(info) => info, + Err(err) => { + tracing::warn!("No git repo detected for exe.dev clone: {err}"); + return None; + } + }; + let url = fabro_github::ssh_url_to_https(&detected_url); + Some(fabro_sandbox::exe::GitCloneParams { url, branch }) +} + +fn resolve_ssh_config(settings: &FabroSettings) -> Option { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.ssh.clone()) +} + +fn resolve_ssh_clone_params(cwd: &Path) -> Option { + let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) { + Ok(info) => info, + Err(err) => { + tracing::warn!("No git repo detected for SSH clone: {err}"); + return None; + } + }; + let url = fabro_github::ssh_url_to_https(&detected_url); + Some(fabro_sandbox::ssh::GitCloneParams { url, branch }) +} + +async fn mint_github_token( + creds: &fabro_github::GitHubAppCredentials, + origin_url: &str, + permissions: &std::collections::HashMap, +) -> anyhow::Result { + let https_url = fabro_github::ssh_url_to_https(origin_url); + let (owner, repo) = + fabro_github::parse_github_owner_repo(&https_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let client = reqwest::Client::new(); + let perms_json = serde_json::to_value(permissions)?; + let token = fabro_github::create_installation_access_token_with_permissions( + &client, + &jwt, + &owner, + &repo, + fabro_github::GITHUB_API_BASE_URL, + perms_json, + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(token) +} + +#[allow(clippy::too_many_arguments)] +async fn run_preflight( + graph: &fabro_graphviz::graph::Graph, + settings: &FabroSettings, + cli_model: Option<&str>, + cli_provider: Option<&str>, + git_status: GitSyncStatus, + sandbox_provider: SandboxProvider, + working_directory: &Path, + styles: &'static Styles, + github_app: Option, + origin_url: Option<&str>, +) -> anyhow::Result<()> { + use fabro_util::check_report::{ + CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, + }; + + let spinner = indicatif::ProgressBar::new_spinner(); + spinner.set_style( + indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") + .expect("valid template") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), + ); + spinner.set_message("Running preflight checks..."); + spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + + let mut checks: Vec = Vec::new(); + + let setup_command_count = settings.setup_commands().len(); + let repo_summary = origin_url + .map(|url| { + let https = fabro_github::ssh_url_to_https(url); + fabro_github::parse_github_owner_repo(&https) + .map(|(owner, repo)| format!("{owner}/{repo}")) + .unwrap_or_else(|_| url.to_string()) + }) + .unwrap_or_else(|| "unknown".into()); + + checks.push(CheckResult { + name: "Repository".into(), + status: CheckStatus::Pass, + summary: repo_summary, + details: vec![ + CheckDetail::new(format!("Setup commands: {setup_command_count}")), + CheckDetail { + text: format!("Git: {git_status}"), + warn: git_status != GitSyncStatus::Synced, + }, + ], + remediation: None, + }); + + let (model, provider) = resolve_model_provider(cli_model, cli_provider, settings, graph); + checks.push(CheckResult { + name: "Workflow".into(), + status: CheckStatus::Pass, + summary: graph.name.clone(), + details: vec![ + CheckDetail::new(format!("Nodes: {}", graph.nodes.len())), + CheckDetail::new(format!("Edges: {}", graph.edges.len())), + CheckDetail::new(format!("Goal: {}", graph.goal())), + ], + remediation: None, + }); + + let daytona_config = resolve_daytona_config(settings); + #[cfg(feature = "exedev")] + let exe_config = resolve_exe_config(settings); + let ssh_config = resolve_ssh_config(settings); + + let sandbox_result: Result, String> = match sandbox_provider { + SandboxProvider::Docker => { + let config = DockerSandboxConfig { + host_working_directory: working_directory.to_string_lossy().to_string(), + ..DockerSandboxConfig::default() + }; + DockerSandbox::new(config) + .map(|env| Arc::new(env) as Arc) + .map_err(|e| format!("Docker sandbox creation failed: {e}")) + } + SandboxProvider::Daytona => { + let config = daytona_config.unwrap_or_default(); + match fabro_sandbox::daytona::DaytonaSandbox::new( + config, + github_app.clone(), + None, + None, + ) + .await + { + Ok(env) => Ok(Arc::new(env) as Arc), + Err(e) => Err(format!("Daytona sandbox creation failed: {e}")), + } + } + #[cfg(feature = "exedev")] + SandboxProvider::Exe => { + match fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev").await { + Ok(mgmt_ssh) => { + let config = exe_config.unwrap_or_default(); + let clone_params = resolve_exe_clone_params(working_directory); + let env = fabro_sandbox::exe::ExeSandbox::new( + Box::new(mgmt_ssh), + config, + clone_params, + None, + None, + ); + Ok(Arc::new(env) as Arc) + } + Err(e) => Err(format!("exe.dev SSH connection failed: {e}")), + } + } + #[cfg(not(feature = "exedev"))] + SandboxProvider::Exe => Err("exe sandbox requires the exedev feature".to_string()), + SandboxProvider::Ssh => match ssh_config { + Some(config) => { + let clone_params = resolve_ssh_clone_params(working_directory); + let env = fabro_sandbox::ssh::SshSandbox::new(config, clone_params, None, None); + Ok(Arc::new(env) as Arc) + } + None => Err("SSH sandbox requires [sandbox.ssh] config".to_string()), + }, + SandboxProvider::Local => { + Ok(Arc::new(LocalSandbox::new(working_directory.to_path_buf())) as Arc) + } + }; + + let sandbox_ok = match sandbox_result { + Ok(sandbox) => match sandbox.initialize().await { + Ok(()) => { + let _ = sandbox.cleanup().await; + true + } + Err(e) => { + let _ = sandbox.cleanup().await; + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], + remediation: Some(format!("Sandbox init failed: {e}")), + }); + false + } + }, + Err(e) => { + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], + remediation: Some(e), + }); + false + } + }; + + if sandbox_ok { + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Pass, + summary: sandbox_provider.to_string(), + details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], + remediation: None, + }); + } + + let default_provider = provider.as_deref().unwrap_or("anthropic"); + let llm_ok = match fabro_llm::client::Client::from_env().await { + Ok(c) => { + let configured: Vec = + c.provider_names().iter().map(|s| s.to_string()).collect(); + + let mut model_providers = std::collections::BTreeSet::new(); + for node in graph.nodes.values() { + if !fabro_graphviz::graph::is_llm_handler_type(node.handler_type()) { + continue; + } + let node_model = node.model().unwrap_or(&model); + let node_provider = node.provider().unwrap_or(default_provider); + + let (resolved_model, resolved_provider) = + if let Some(info) = Catalog::builtin().get(node_model) { + (info.id.clone(), info.provider.to_string()) + } else { + (node_model.to_string(), node_provider.to_string()) + }; + + let final_provider = if node.provider().is_some() { + node_provider.to_string() + } else { + resolved_provider + }; + + model_providers.insert((resolved_model, final_provider)); + } + + if model_providers.is_empty() { + let (resolved_model, resolved_provider) = + if let Some(info) = Catalog::builtin().get(&model) { + (info.id.clone(), info.provider.to_string()) + } else { + (model.clone(), default_provider.to_string()) + }; + model_providers.insert((resolved_model, resolved_provider)); + } + + let mut all_ok = true; + for (model_id, provider_name) in &model_providers { + match provider_name.parse::() { + Ok(_) => { + let mut status = CheckStatus::Pass; + if !configured.iter().any(|n| n == provider_name) { + status = CheckStatus::Warning; + all_ok = false; + } + checks.push(CheckResult { + name: "LLM".into(), + status, + summary: model_id.clone(), + details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], + remediation: if status == CheckStatus::Warning { + Some(format!("Provider \"{provider_name}\" is not configured")) + } else { + None + }, + }); + } + Err(e) => { + checks.push(CheckResult { + name: "LLM".into(), + status: CheckStatus::Error, + summary: model_id.clone(), + details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], + remediation: Some(format!("Invalid provider \"{provider_name}\": {e}")), + }); + all_ok = false; + } + } + } + all_ok + } + Err(e) => { + checks.push(CheckResult { + name: "LLM".into(), + status: CheckStatus::Error, + summary: "initialization failed".into(), + details: vec![], + remediation: Some(format!("LLM client init failed: {e}")), + }); + false + } + }; + + if let Some(github_permissions) = settings.github_permissions() { + if !github_permissions.is_empty() { + let perm_details: Vec = github_permissions + .iter() + .map(|(k, v)| CheckDetail::new(format!("{k}: {v}"))) + .collect(); + match (&github_app, origin_url) { + (Some(creds), Some(url)) => { + match mint_github_token(creds, url, github_permissions).await { + Ok(_) => { + checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Pass, + summary: "minted".into(), + details: perm_details, + remediation: None, + }); + } + Err(e) => { + checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: perm_details, + remediation: Some(format!("Failed to mint GitHub token: {e}")), + }); + } + } + } + _ => { + checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Warning, + summary: "skipped".into(), + details: vec![], + remediation: Some( + "No GitHub App credentials or origin URL available".to_string(), + ), + }); + } + } + } + } + + spinner.finish_and_clear(); + + let report = CheckReport { + title: "Run Preflight".into(), + sections: vec![CheckSection { + title: String::new(), + checks, + }], + }; + + let term_width = console::Term::stderr().size().1; + print!("{}", report.render(styles, true, None, Some(term_width))); + + if sandbox_ok && llm_ok { + Ok(()) + } else { + std::process::exit(1); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index f8cd61091..c8898358d 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -9,10 +9,8 @@ use anyhow::{bail, Result}; use fabro_interview::{AnswerValue, ConsoleInterviewer}; use fabro_util::terminal::Styles; -use fabro_workflows::event::RunNoticeLevel; use fabro_workflows::run_status::{RunStatus, RunStatusRecord}; -use super::detached::append_run_notice; use super::run_progress; #[cfg(test)] @@ -36,7 +34,6 @@ pub async fn attach_run( let status_path = run_dir.join("status.json"); let interview_request_path = run_dir.join("interview_request.json"); let interview_response_path = run_dir.join("interview_response.json"); - let pid_path = run_dir.join("run.pid"); let mut engine_guard = engine_child.map(EngineChildGuard::new); @@ -86,6 +83,16 @@ pub async fn attach_run( } } + if let Some(pid) = read_launcher_pid(run_dir) { + if !process_alive(pid) && wait_count > 5 { + progress_ui.finish(); + return Ok(determine_exit_code( + &conclusion_path, + read_status_record(&status_path), + )); + } + } + if wait_count > 100 { // Guard's Drop kills+waits on the engine child drop(engine_guard.take()); @@ -114,8 +121,13 @@ pub async fn attach_run( loop { if cancelled.load(Ordering::Relaxed) { if kill_on_detach { - // Kill the engine process - kill_engine(&pid_path); + if let Some(guard) = engine_guard.as_mut() { + if let Some(child) = guard.inner() { + let _ = child.kill(); + } + } else { + kill_engine(run_dir); + } // Wait briefly for a terminal status or conclusion for _ in 0..20 { if conclusion_path.exists() @@ -168,12 +180,6 @@ pub async fn attach_run( progress_ui.show_bars(); if answer_requires_reattach(&answer) { - let _ = append_run_notice( - run_dir, - RunNoticeLevel::Warn, - "interview_unanswered", - INTERVIEW_UNANSWERED_MESSAGE, - ); if let Some(guard) = engine_guard.as_mut() { guard.defuse(); } @@ -213,7 +219,7 @@ pub async fn attach_run( let engine_alive = match cached_pid { Some(pid) => process_alive(pid), None => { - if let Some(pid) = read_pid(&pid_path) { + if let Some(pid) = read_launcher_pid(run_dir) { cached_pid = Some(pid); process_alive(pid) } else { @@ -264,10 +270,14 @@ fn read_status_record(path: &Path) -> Option { RunStatusRecord::load(path).ok() } -fn read_pid(pid_path: &Path) -> Option { - std::fs::read_to_string(pid_path) - .ok() - .and_then(|pid| pid.trim().parse::().ok()) +fn read_launcher_pid(run_dir: &Path) -> Option { + super::launcher::launcher_record_for_run(run_dir) + .map(|record| record.pid) + .or_else(|| { + std::fs::read_to_string(run_dir.join("run.pid")) + .ok() + .and_then(|pid| pid.trim().parse::().ok()) + }) } fn progress_file_is_empty(path: &Path) -> bool { @@ -390,15 +400,13 @@ fn determine_exit_code(conclusion_path: &Path, status_record: Option() { - #[cfg(unix)] - unsafe { - libc::kill(pid, libc::SIGTERM); - } - let _ = pid; +fn kill_engine(run_dir: &Path) { + if let Some(pid) = read_launcher_pid(run_dir).map(|pid| pid as i32) { + #[cfg(unix)] + unsafe { + libc::kill(pid, libc::SIGTERM); } + let _ = pid; } } diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs new file mode 100644 index 000000000..4a0f99273 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -0,0 +1,35 @@ +use anyhow::Result; + +use crate::args::{GlobalArgs, RunArgs}; + +pub async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> Result<()> { + let styles: &'static fabro_util::terminal::Styles = + Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); + let cli_defaults = fabro_config::cli::load_cli_config(None)?; + let cli_config: fabro_config::FabroSettings = cli_defaults.clone().try_into()?; + args.verbose = args.verbose || cli_config.verbose_enabled(); + + 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_defaults, styles, quiet).await?; + + #[cfg(feature = "sleep_inhibitor")] + let _sleep_guard = crate::sleep_inhibitor::guard(prevent_idle_sleep); + + #[cfg(not(feature = "sleep_inhibitor"))] + let _ = 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?; + super::output::print_run_summary(&run_dir, &run_id, styles); + if exit_code != std::process::ExitCode::SUCCESS { + std::process::exit(1); + } + } + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 0ccf77a2d..2a214d19f 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,15 +1,12 @@ use std::path::PathBuf; use crate::args::RunArgs; -use fabro_config::FabroConfig; +use fabro_config::{FabroConfig, FabroSettings}; -use super::execute::{ - cached_graph_path, default_run_dir, load_workflow_source_input, make_run_dir, parse_labels, - print_diagnostics_from_error, print_workflow_report_from_persisted, resolve_sandbox_provider, - write_run_config_snapshot, -}; use fabro_util::terminal::Styles; +use super::output::{print_diagnostics_from_error, print_workflow_report_from_persisted}; + /// Create a workflow run: allocate run directory, persist RunRecord, return (run_id, run_dir). /// /// This does NOT execute the workflow — it only prepares the run directory. @@ -24,79 +21,43 @@ pub async fn create_run( .as_ref() .ok_or_else(|| anyhow::anyhow!("--workflow is required"))?; let cli_args_config = FabroConfig::try_from(args)?; - let source_input = - load_workflow_source_input(workflow_path, cli_args_config, cli_defaults, true)?; - let run_id = args - .run_id - .clone() - .unwrap_or_else(|| ulid::Ulid::new().to_string()); - let run_dir = match args - .storage_dir - .clone() - .or_else(|| source_input.settings.storage_dir.clone()) - { - Some(sd) => make_run_dir(&sd.join("runs"), &run_id, args.dry_run), - None => default_run_dir(&run_id, args.dry_run), - }; + let settings: FabroSettings = fabro_workflows::operations::resolve_settings_for_path( + workflow_path, + cli_defaults, + cli_args_config, + true, + )?; let working_directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let base_branch = fabro_sandbox::daytona::detect_repo_info(&working_directory) .ok() .and_then(|(_, branch)| branch); - if !args.dry_run { - let _ = resolve_sandbox_provider(args.sandbox.map(Into::into), &source_input.settings)?; - } - let settings = source_input.settings.clone(); - - let persisted = match fabro_workflows::operations::create( - &source_input.raw_source, - fabro_workflows::operations::RunCreateOptions { + let created = + match fabro_workflows::operations::create(fabro_workflows::operations::CreateRequest { + workflow: fabro_workflows::operations::WorkflowInput::Path(workflow_path.clone()), settings, - run_dir: Some(run_dir.clone()), - run_id: Some(run_id.clone()), - workflow_slug: source_input.workflow_slug.clone(), - labels: { - let mut labels = source_input.settings.labels.clone(); - labels.extend(parse_labels(&args.label)); - labels - }, + run_dir: None, + run_id: args.run_id.clone(), base_branch, - working_directory: Some(working_directory.clone()), host_repo_path: Some(working_directory.to_string_lossy().to_string()), - goal_override: source_input.goal_override.clone(), - base_dir: Some( - source_input - .dot_path - .parent() - .unwrap_or(std::path::Path::new(".")) - .to_path_buf(), - ), - }, - ) { - Ok(persisted) => persisted, - Err(fabro_workflows::error::FabroError::ValidationFailed { diagnostics }) => { - if !quiet { - print_diagnostics_from_error(&diagnostics, styles); + }) { + Ok(created) => created, + Err(fabro_workflows::error::FabroError::ValidationFailed { diagnostics }) => { + if !quiet { + print_diagnostics_from_error(&diagnostics, styles); + } + anyhow::bail!("Validation failed"); } - anyhow::bail!("Validation failed"); - } - Err(err) => return Err(err.into()), - }; + Err(err) => return Err(err.into()), + }; if !quiet { - print_workflow_report_from_persisted(&persisted, &source_input.dot_path, styles); + print_workflow_report_from_persisted( + &created.persisted, + created.dot_path.as_deref(), + styles, + ); } - // Write CLI-owned debug and status artifacts after the run has been persisted. - tokio::fs::write(cached_graph_path(&run_dir), &source_input.raw_source).await?; - tokio::fs::write(run_dir.join("id.txt"), &run_id).await?; - std::fs::File::create(run_dir.join("progress.jsonl"))?; - fabro_workflows::run_status::write_run_status( - &run_dir, - fabro_workflows::run_status::RunStatus::Submitted, - None, - ); - write_run_config_snapshot(&run_dir, source_input.workflow_toml_path.as_deref()).await?; - - Ok((run_id, run_dir)) + Ok((created.run_id, created.run_dir)) } diff --git a/lib/crates/fabro-cli/src/commands/run/detached.rs b/lib/crates/fabro-cli/src/commands/run/detached.rs index fbf9be5dd..4b446e1df 100644 --- a/lib/crates/fabro-cli/src/commands/run/detached.rs +++ b/lib/crates/fabro-cli/src/commands/run/detached.rs @@ -1,21 +1,14 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +use std::sync::Arc; -use anyhow::{Context, Result}; -use chrono::Utc; -use fabro_workflows::event::{RunNoticeLevel, WorkflowRunEvent}; -use fabro_workflows::outcome::StageStatus; -use fabro_workflows::records::Conclusion; -use fabro_workflows::run_status::{self, RunStatus, StatusReason}; -use serde::Serialize; +use anyhow::Result; +use fabro_interview::FileInterviewer; +use fabro_workflows::event::EventEmitter; use crate::cli_config; use crate::shared; -pub async fn execute(storage_dir: PathBuf, run_id: String, resume: bool) -> Result<()> { - let runs_base = fabro_workflows::run_lookup::runs_base(&storage_dir); - let run_dir = fabro_workflows::run_lookup::find_run_by_prefix(&runs_base, &run_id)?; - let styles: &'static fabro_util::terminal::Styles = - Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); +pub async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bool) -> Result<()> { let cli_config = cli_config::load_cli_settings(None)?; let github_app = shared::github::build_github_app_credentials(cli_config.app_id()); let git_author = fabro_workflows::git::GitAuthor::from_options( @@ -23,370 +16,24 @@ pub async fn execute(storage_dir: PathBuf, run_id: String, resume: bool) -> Resu cli_config.git_author().and_then(|a| a.email.clone()), ); - let persisted = match fabro_workflows::pipeline::Persisted::load(&run_dir) { - Ok(persisted) => persisted, - Err(err) => { - let anyhow_err: anyhow::Error = anyhow::anyhow!("Failed to load persisted run: {err}"); - let _ = persist_detached_failure( - &run_dir, - "bootstrap", - StatusReason::BootstrapFailed, - &anyhow_err, - ); - return Err(anyhow_err); - } + let _launcher_guard = scopeguard::guard(launcher_path.clone(), |path| { + super::launcher::remove_launcher_record(&path); + }); + + let services = fabro_workflows::operations::StartServices { + cancel_token: None, + emitter: Arc::new(EventEmitter::new()), + interviewer: Arc::new(FileInterviewer::new(run_dir.clone())), + git_author, + github_app, + registry_override: None, }; - if let Err(err) = - std::env::set_current_dir(&persisted.run_record().working_directory).map_err(|e| { - anyhow::anyhow!( - "Failed to set working directory to {}: {e}", - persisted.run_record().working_directory.display() - ) - }) - { - let _ = - persist_detached_failure(&run_dir, "bootstrap", StatusReason::BootstrapFailed, &err); - return Err(err); - } - - let result = if resume { - super::execute::resume_from_record( - persisted, - run_dir.clone(), - styles, - github_app, - git_author, - ) - .await + if resume { + let _ = fabro_workflows::operations::resume(&run_dir, services).await?; } else { - super::execute::run_from_record(persisted, run_dir.clone(), styles, github_app, git_author) - .await - }; - - match result { - Ok(()) => Ok(()), - Err(err) => { - let _ = persist_detached_failure( - &run_dir, - "bootstrap", - StatusReason::SandboxInitFailed, - &err, - ); - Err(err) - } - } -} - -const POSTRUN_ABORTED_MESSAGE: &str = "Run aborted before post-run finalization completed."; - -pub(crate) struct DetachedRunBootstrapGuard { - run_dir: PathBuf, - active: bool, -} - -impl DetachedRunBootstrapGuard { - pub(crate) fn arm(run_dir: &Path) -> Result { - std::fs::write(run_dir.join("run.pid"), std::process::id().to_string()) - .with_context(|| format!("Failed to write {}", run_dir.join("run.pid").display()))?; - run_status::write_run_status( - run_dir, - RunStatus::Starting, - Some(StatusReason::SandboxInitializing), - ); - Ok(Self { - run_dir: run_dir.to_path_buf(), - active: true, - }) - } - - pub(crate) fn defuse(&mut self) { - self.active = false; - } -} - -impl Drop for DetachedRunBootstrapGuard { - fn drop(&mut self) { - if self.active { - run_status::write_run_status( - &self.run_dir, - RunStatus::Failed, - Some(StatusReason::SandboxInitFailed), - ); - } - } -} - -pub(crate) struct DetachedRunCompletionGuard { - run_dir: PathBuf, - active: bool, -} - -impl DetachedRunCompletionGuard { - pub(crate) fn arm(run_dir: &Path) -> Self { - Self { - run_dir: run_dir.to_path_buf(), - active: true, - } - } - - pub(crate) fn defuse(&mut self) { - self.active = false; - } -} - -impl Drop for DetachedRunCompletionGuard { - fn drop(&mut self) { - if !self.active { - return; - } - - run_status::write_run_status( - &self.run_dir, - RunStatus::Failed, - Some(StatusReason::WorkflowError), - ); - if !self.run_dir.join("conclusion.json").exists() { - let _ = write_failure_conclusion( - &self.run_dir, - POSTRUN_ABORTED_MESSAGE, - Some(StatusReason::WorkflowError), - ); - } - if let Some(run_id) = load_run_id(&self.run_dir) { - let _ = append_progress_event( - &self.run_dir, - &run_id, - &WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Error, - code: "postrun_aborted".to_string(), - message: POSTRUN_ABORTED_MESSAGE.to_string(), - }, - ); - } - } -} - -pub(crate) fn load_run_id(run_dir: &Path) -> Option { - fabro_workflows::records::RunRecord::load(run_dir) - .ok() - .map(|record| record.run_id) - .filter(|run_id| !run_id.trim().is_empty()) - .or_else(|| { - std::fs::read_to_string(run_dir.join("id.txt")) - .ok() - .map(|run_id| run_id.trim().to_string()) - .filter(|run_id| !run_id.is_empty()) - }) -} - -pub(crate) fn append_progress_event( - run_dir: &Path, - run_id: &str, - event: &WorkflowRunEvent, -) -> Result<()> { - fabro_workflows::event::append_progress_event(run_dir, run_id, event) -} - -pub(crate) fn append_run_notice( - run_dir: &Path, - level: RunNoticeLevel, - code: &'static str, - message: impl Into, -) -> Result<()> { - let Some(run_id) = load_run_id(run_dir) else { - return Ok(()); - }; - append_progress_event( - run_dir, - &run_id, - &WorkflowRunEvent::RunNotice { - level, - code: code.to_string(), - message: message.into(), - }, - ) -} - -pub(crate) fn persist_detached_failure( - run_dir: &Path, - phase: &'static str, - reason: StatusReason, - error: &anyhow::Error, -) -> Result<()> { - #[derive(Serialize)] - struct DetachedFailureRecord<'a> { - timestamp: chrono::DateTime, - phase: &'a str, - reason: StatusReason, - error: String, - } - - let message = error.to_string(); - let record = DetachedFailureRecord { - timestamp: Utc::now(), - phase, - reason, - error: message.clone(), - }; - std::fs::write( - run_dir.join("detached_failure.json"), - serde_json::to_string_pretty(&record)?, - ) - .with_context(|| { - format!( - "Failed to write {}", - run_dir.join("detached_failure.json").display() - ) - })?; - - write_failure_conclusion(run_dir, &message, Some(reason))?; - run_status::write_run_status(run_dir, RunStatus::Failed, Some(reason)); - - if let Some(run_id) = load_run_id(run_dir) { - append_progress_event( - run_dir, - &run_id, - &WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Error, - code: format!("{phase}_failed"), - message, - }, - )?; + let _ = fabro_workflows::operations::start(&run_dir, services).await?; } Ok(()) } - -pub(crate) fn write_failure_conclusion( - run_dir: &Path, - message: &str, - _reason: Option, -) -> Result<()> { - if run_dir.join("conclusion.json").exists() { - return Ok(()); - } - - let conclusion = Conclusion { - timestamp: Utc::now(), - status: StageStatus::Fail, - duration_ms: 0, - failure_reason: Some(message.to_string()), - final_git_commit_sha: None, - stages: vec![], - total_cost: None, - total_retries: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_read_tokens: 0, - total_cache_write_tokens: 0, - total_reasoning_tokens: 0, - has_pricing: false, - }; - conclusion.save(&run_dir.join("conclusion.json"))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use fabro_workflows::run_status::{RunStatusRecord, StatusReason}; - - #[test] - fn bootstrap_guard_marks_failed_on_drop() { - let dir = tempfile::tempdir().unwrap(); - - { - let _guard = DetachedRunBootstrapGuard::arm(dir.path()).unwrap(); - let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap(); - assert_eq!(record.status, RunStatus::Starting); - assert_eq!(record.reason, Some(StatusReason::SandboxInitializing)); - } - - let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap(); - assert_eq!(record.status, RunStatus::Failed); - assert_eq!(record.reason, Some(StatusReason::SandboxInitFailed)); - } - - #[test] - fn bootstrap_guard_defuse_leaves_starting_intact() { - let dir = tempfile::tempdir().unwrap(); - - { - let mut guard = DetachedRunBootstrapGuard::arm(dir.path()).unwrap(); - guard.defuse(); - } - - let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap(); - assert_eq!(record.status, RunStatus::Starting); - assert_eq!(record.reason, Some(StatusReason::SandboxInitializing)); - } - - #[test] - fn completion_guard_marks_failed_on_drop() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("id.txt"), "run-123").unwrap(); - - { - let _guard = DetachedRunCompletionGuard::arm(dir.path()); - } - - let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap(); - assert_eq!(record.status, RunStatus::Failed); - assert_eq!(record.reason, Some(StatusReason::WorkflowError)); - assert!(dir.path().join("conclusion.json").exists()); - let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap(); - assert!(progress.contains("postrun_aborted")); - } - - #[test] - fn load_run_id_falls_back_to_id_txt() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("id.txt"), "run-xyz").unwrap(); - - assert_eq!(load_run_id(dir.path()).as_deref(), Some("run-xyz")); - } - - #[test] - fn persist_detached_failure_writes_status_conclusion_and_progress() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("id.txt"), "run-err").unwrap(); - - let err = anyhow::anyhow!("bootstrap exploded"); - persist_detached_failure(dir.path(), "bootstrap", StatusReason::BootstrapFailed, &err) - .unwrap(); - - let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap(); - assert_eq!(record.status, RunStatus::Failed); - assert_eq!(record.reason, Some(StatusReason::BootstrapFailed)); - let conclusion = - fabro_workflows::records::Conclusion::load(&dir.path().join("conclusion.json")) - .unwrap(); - assert_eq!(conclusion.status, StageStatus::Fail); - assert_eq!( - conclusion.failure_reason.as_deref(), - Some("bootstrap exploded") - ); - let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap(); - assert!(progress.contains("bootstrap_failed")); - assert!(dir.path().join("detached_failure.json").exists()); - } - - #[test] - fn append_run_notice_writes_progress() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("id.txt"), "run-notice").unwrap(); - - append_run_notice( - dir.path(), - RunNoticeLevel::Warn, - "interview_unanswered", - "The run is still waiting for input.", - ) - .unwrap(); - - let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap(); - assert!(progress.contains("\"event\":\"RunNotice\"")); - assert!(progress.contains("\"code\":\"interview_unanswered\"")); - } -} diff --git a/lib/crates/fabro-cli/src/commands/run/execute.rs b/lib/crates/fabro-cli/src/commands/run/execute.rs deleted file mode 100644 index 7595a51ef..000000000 --- a/lib/crates/fabro-cli/src/commands/run/execute.rs +++ /dev/null @@ -1,1988 +0,0 @@ -use std::collections::HashMap; -use std::io::IsTerminal; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::Instant; - -use anyhow::{bail, Context}; -use chrono::Local; -use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox}; -use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; -use fabro_config::{FabroConfig, FabroSettings}; -use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, FileInterviewer, Interviewer}; -use fabro_model::{Catalog, FallbackTarget, Provider}; -use fabro_sandbox::SandboxProvider; -use fabro_util::terminal::Styles; -use fabro_workflows::event::EventEmitter; -use fabro_workflows::git::GitSyncStatus; -use fabro_workflows::operations::{ - resume as operations_resume, start, DevcontainerSpec, LlmSpec, SandboxEnvSpec, SandboxSpec, - StartFinalizeOptions, StartOptions, StartPullRequestConfig, StartRetroOptions, -}; -use fabro_workflows::outcome::StageStatus; -use fabro_workflows::outcome::{compute_stage_cost, format_cost}; -use fabro_workflows::pipeline::{ - build_conclusion, classify_engine_result, persist_terminal_outcome, Persisted, Validated, -}; -use fabro_workflows::records::Checkpoint; -use fabro_workflows::run_options::LifecycleOptions; -use indicatif::HumanDuration; -use std::time::Duration; -use tracing::debug; - -use super::detached::{DetachedRunBootstrapGuard, DetachedRunCompletionGuard}; -use super::run_progress; -use crate::args::{GlobalArgs, PreflightArgs, RunArgs}; -use crate::shared::{ - format_tokens_human, print_diagnostics, read_workflow_file, relative_path, tilde_path, -}; - -/// Resolve goal from `--goal` string or `--goal-file` path. -pub(crate) fn resolve_cli_goal( - goal: Option<&str>, - goal_file: Option<&Path>, -) -> anyhow::Result> { - match (goal, goal_file) { - (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) - .with_context(|| format!("failed to read goal file: {}", path.display()))?; - debug!(path = %path.display(), "Goal loaded from file"); - Ok(Some(content)) - } - _ => Ok(None), - } -} - -pub(crate) use fabro_workflows::operations::{default_run_dir, make_run_dir}; - -fn sparse_flag(value: bool) -> Option { - value.then_some(true) -} - -impl TryFrom<&RunArgs> for FabroConfig { - type Error = anyhow::Error; - - fn try_from(args: &RunArgs) -> Result { - let goal = resolve_cli_goal(args.goal.as_deref(), args.goal_file.as_deref())?; - let llm = if args.model.is_some() || args.provider.is_some() { - Some(fabro_config::run::LlmConfig { - model: args.model.clone(), - provider: args.provider.clone(), - fallbacks: None, - }) - } else { - None - }; - let sandbox = if args.sandbox.is_some() || args.preserve_sandbox { - Some(sandbox_config::SandboxConfig { - provider: args - .sandbox - .map(Into::into) - .map(|provider: SandboxProvider| provider.to_string()), - preserve: sparse_flag(args.preserve_sandbox), - ..Default::default() - }) - } else { - None - }; - - Ok(Self { - goal, - llm, - sandbox, - verbose: sparse_flag(args.verbose), - dry_run: sparse_flag(args.dry_run), - auto_approve: sparse_flag(args.auto_approve), - no_retro: sparse_flag(args.no_retro), - storage_dir: args.storage_dir.clone(), - ..Default::default() - }) - } -} - -impl TryFrom<&PreflightArgs> for FabroConfig { - type Error = anyhow::Error; - - fn try_from(args: &PreflightArgs) -> Result { - let goal = resolve_cli_goal(args.goal.as_deref(), args.goal_file.as_deref())?; - let llm = if args.model.is_some() || args.provider.is_some() { - Some(fabro_config::run::LlmConfig { - model: args.model.clone(), - provider: args.provider.clone(), - fallbacks: None, - }) - } else { - None - }; - let sandbox = args.sandbox.map(|sandbox| sandbox_config::SandboxConfig { - provider: Some(SandboxProvider::from(sandbox).to_string()), - ..Default::default() - }); - - Ok(Self { - goal, - llm, - sandbox, - verbose: sparse_flag(args.verbose), - ..Default::default() - }) - } -} - -pub(crate) fn workflow_slug_from_path(workflow_path: &Path) -> Option { - let file_name = workflow_path.file_name()?.to_string_lossy(); - if workflow_path.extension().is_none() { - return Some(file_name.into_owned()); - } - - let file_stem = workflow_path.file_stem()?.to_string_lossy(); - if file_stem == "workflow" { - return workflow_path - .parent() - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - .or_else(|| Some(file_stem.into_owned())); - } - - Some(file_stem.into_owned()) -} - -/// Resolve model and provider from resolved settings, with graph attrs as fallback. -/// Then resolve through the catalog for alias expansion. -pub(crate) fn resolve_model_provider( - cli_model: Option<&str>, - cli_provider: Option<&str>, - settings: &FabroSettings, - graph: &fabro_graphviz::graph::Graph, -) -> (String, Option) { - let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref()); - let configured_provider = settings - .llm - .as_ref() - .and_then(|llm| llm.provider.as_deref()); - - let provider = cli_provider - .or(configured_provider) - .or_else(|| graph.attrs.get("default_provider").and_then(|v| v.as_str())) - .map(String::from); - - let model = cli_model - .or(configured_model) - .or_else(|| graph.attrs.get("default_model").and_then(|v| v.as_str())) - .map(String::from) - .unwrap_or_else(|| { - let catalog = Catalog::builtin(); - let info = provider - .as_deref() - .and_then(|s| s.parse::().ok()) - .and_then(|p| catalog.default_for_provider(p)) - .unwrap_or_else(|| catalog.default_from_env()); - info.id.clone() - }); - - // Resolve model alias through catalog - match Catalog::builtin().get(&model) { - Some(info) => ( - info.id.clone(), - provider.or(Some(info.provider.to_string())), - ), - None => (model, provider), - } -} - -/// Parse sandbox provider from resolved settings. -pub(crate) fn parse_sandbox_provider( - settings: &FabroSettings, -) -> anyhow::Result> { - settings - .sandbox_settings() - .and_then(|s| s.provider.as_deref()) - .map(|s| s.parse::()) - .transpose() - .map_err(|e| anyhow::anyhow!("Invalid sandbox provider: {e}")) -} - -/// Resolve sandbox provider: CLI flag > settings > default. -pub(crate) fn resolve_sandbox_provider( - cli: Option, - settings: &FabroSettings, -) -> anyhow::Result { - Ok(cli - .or(parse_sandbox_provider(settings)?) - .unwrap_or_default()) -} - -/// Resolve preserve-sandbox: CLI flag > settings > false. -pub(crate) fn resolve_preserve_sandbox(cli: bool, settings: &FabroSettings) -> bool { - if cli { - return true; - } - settings.preserve_sandbox_enabled() -} - -/// Resolve worktree mode from settings, defaulting to `Clean`. -fn resolve_worktree_mode(settings: &FabroSettings) -> sandbox_config::WorktreeMode { - settings - .sandbox_settings() - .and_then(|s| s.local.as_ref()) - .map(|l| l.worktree_mode) - .unwrap_or_default() -} - -/// Resolve Daytona config from settings. -pub(crate) fn resolve_daytona_config( - settings: &FabroSettings, -) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.daytona.clone()) -} - -#[cfg(feature = "exedev")] -/// Resolve exe.dev config from settings. -pub(crate) fn resolve_exe_config( - settings: &FabroSettings, -) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.exe.clone()) -} - -#[cfg(feature = "exedev")] -/// Resolve exe.dev git clone parameters from the current repo. -/// -/// Returns `None` if no git repo is detected. Credential resolution is -/// handled by ExeSandbox itself via its `github_app` field. -pub(crate) fn resolve_exe_clone_params( - cwd: &std::path::Path, -) -> Option { - let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) { - Ok(info) => info, - Err(e) => { - tracing::warn!("No git repo detected for exe.dev clone: {e}"); - return None; - } - }; - let url = fabro_github::ssh_url_to_https(&detected_url); - Some(fabro_sandbox::exe::GitCloneParams { url, branch }) -} - -/// Resolve SSH sandbox config from settings. -pub(crate) fn resolve_ssh_config( - settings: &FabroSettings, -) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.ssh.clone()) -} - -/// Resolve SSH sandbox git clone parameters from the current repo. -/// -/// Returns `None` if no git repo is detected. Credential resolution is -/// handled by SshSandbox itself via its `github_app` field. -pub(crate) fn resolve_ssh_clone_params( - cwd: &std::path::Path, -) -> Option { - let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) { - Ok(info) => info, - Err(e) => { - tracing::warn!("No git repo detected for SSH clone: {e}"); - return None; - } - }; - let url = fabro_github::ssh_url_to_https(&detected_url); - Some(fabro_sandbox::ssh::GitCloneParams { url, branch }) -} - -/// Resolve the fallback chain from the effective settings. -pub(crate) fn resolve_fallback_chain( - provider: Provider, - model: &str, - settings: &FabroSettings, -) -> Vec { - let fallbacks = settings.llm.as_ref().and_then(|l| l.fallbacks.as_ref()); - - match fallbacks { - Some(map) => Catalog::builtin().build_fallback_chain(provider, model, map), - None => Vec::new(), - } -} - -/// Mint a GitHub App Installation Access Token with the given permissions. -/// -/// Signs a JWT, resolves `owner/repo` from `origin_url`, and requests a -/// scoped token. Returns the token string on success. -pub(crate) async fn mint_github_token( - creds: &fabro_github::GitHubAppCredentials, - origin_url: &str, - permissions: &HashMap, -) -> anyhow::Result { - let https_url = fabro_github::ssh_url_to_https(origin_url); - let (owner, repo) = - fabro_github::parse_github_owner_repo(&https_url).map_err(|e| anyhow::anyhow!("{e}"))?; - let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) - .map_err(|e| anyhow::anyhow!("{e}"))?; - let client = reqwest::Client::new(); - let perms_json = serde_json::to_value(permissions)?; - let token = fabro_github::create_installation_access_token_with_permissions( - &client, - &jwt, - &owner, - &repo, - fabro_github::GITHUB_API_BASE_URL, - perms_json, - ) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - Ok(token) -} - -/// Accumulates token usage and cost across all workflow stages. -#[derive(Default)] -pub(crate) struct CostAccumulator { - pub total_input_tokens: i64, - pub total_output_tokens: i64, - pub total_cache_read_tokens: i64, - pub total_cache_write_tokens: i64, - pub total_reasoning_tokens: i64, - pub total_cost: f64, - pub has_pricing: bool, -} - -pub(crate) const RUN_GRAPH_FILE: &str = "workflow.fabro"; -pub(crate) const RUN_CONFIG_FILE: &str = "workflow.toml"; - -pub(crate) fn cached_graph_path(run_dir: &Path) -> PathBuf { - run_dir.join(RUN_GRAPH_FILE) -} - -pub(crate) fn cached_run_config_path(run_dir: &Path) -> PathBuf { - run_dir.join(RUN_CONFIG_FILE) -} - -/// Copy the original workflow TOML into the run directory as a debug artifact. -/// Nothing reads this programmatically — execution uses RunRecord. -pub(crate) async fn write_run_config_snapshot( - run_dir: &Path, - workflow_toml_path: Option<&Path>, -) -> anyhow::Result<()> { - if let Some(toml_path) = workflow_toml_path { - if toml_path.is_file() { - tokio::fs::copy(toml_path, cached_run_config_path(run_dir)) - .await - .context("Failed to copy workflow TOML to run directory")?; - } - } - Ok(()) -} - -pub(crate) fn resolve_workflow_source( - workflow_path: &Path, -) -> anyhow::Result<(PathBuf, PathBuf, Option)> { - let path = project_config::resolve_workflow_arg(workflow_path)?; - if path.extension().is_some_and(|ext| ext == "toml") { - match run_config::load_run_config(&path) { - Ok(cfg) => { - let dot = run_config::resolve_graph_path( - &path, - cfg.graph.as_deref().unwrap_or("workflow.fabro"), - ); - Ok((path, dot, Some(cfg))) - } - // Backward compatibility for detached runs created before run.toml existed. - // Use path.exists() to distinguish a genuinely missing run.toml from one - // that exists but has a broken internal reference (e.g. missing Dockerfile). - Err(_) - if !path.exists() - && path.starts_with(fabro_workflows::run_lookup::default_runs_base()) => - { - Ok((path.clone(), path.with_file_name(RUN_GRAPH_FILE), None)) - } - Err(err) => Err(err), - } - } else { - Ok((path.clone(), path, None)) - } -} - -pub(crate) fn parse_labels(labels: &[String]) -> HashMap { - labels - .iter() - .filter_map(|label| label.split_once('=')) - .map(|(key, value)| (key.to_string(), value.to_string())) - .collect() -} - -fn print_workflow_header( - graph: &fabro_graphviz::graph::Graph, - diagnostics: &[fabro_validate::Diagnostic], - dot_path: &Path, - styles: &Styles, -) { - eprintln!( - "{} {} {}", - styles.bold.apply_to("Workflow:"), - graph.name, - styles.dim.apply_to(format!( - "({} nodes, {} edges)", - graph.nodes.len(), - graph.edges.len() - )), - ); - eprintln!( - "{} {}", - styles.dim.apply_to("Graph:"), - styles.dim.apply_to(relative_path(dot_path)), - ); - - let goal = graph.goal(); - if !goal.is_empty() { - let stripped = fabro_util::text::strip_goal_decoration(goal); - eprintln!("{} {stripped}\n", styles.bold.apply_to("Goal:")); - } - - print_diagnostics(diagnostics, styles); -} - -pub(crate) fn print_workflow_report(validated: &Validated, dot_path: &Path, styles: &Styles) { - print_workflow_header(validated.graph(), validated.diagnostics(), dot_path, styles); -} - -pub(crate) fn print_workflow_report_from_persisted( - persisted: &Persisted, - dot_path: &Path, - styles: &Styles, -) { - print_workflow_header(persisted.graph(), persisted.diagnostics(), dot_path, styles); -} - -pub(crate) fn print_diagnostics_from_error( - diagnostics: &[fabro_validate::Diagnostic], - styles: &Styles, -) { - print_diagnostics(diagnostics, styles); -} - -pub(crate) struct WorkflowSourceInput { - pub raw_source: String, - pub settings: FabroSettings, - pub workflow_slug: Option, - pub workflow_toml_path: Option, - pub dot_path: PathBuf, - pub goal_override: Option, -} - -pub(crate) fn load_workflow_source_input( - workflow: &Path, - cli_args_config: FabroConfig, - cli_defaults: FabroConfig, - apply_project_config: bool, -) -> anyhow::Result { - let (resolved_workflow_path, dot_path, workflow_config) = resolve_workflow_source(workflow)?; - let project_config = if apply_project_config { - project_config::discover_project_config( - resolved_workflow_path - .parent() - .unwrap_or_else(|| Path::new(".")), - )? - .map(|(_, config)| config) - } else { - None - }; - - let settings = cli_args_config - .combine(workflow_config.unwrap_or_default()) - .combine(project_config.unwrap_or_default()) - .combine(cli_defaults); - let settings: FabroSettings = settings.try_into()?; - let workflow_slug = workflow_slug_from_path(&resolved_workflow_path); - - if let Some(dir) = settings.work_dir.as_deref() { - std::env::set_current_dir(dir) - .map_err(|e| anyhow::anyhow!("Failed to set working directory to {dir}: {e}"))?; - } - - let raw_source = read_workflow_file(&dot_path)?; - let goal_override = settings.goal.clone().or_else(|| { - settings - .goal_file - .as_ref() - .and_then(|path| resolve_cli_goal(None, Some(path)).ok().flatten()) - }); - - let workflow_toml_path = if resolved_workflow_path - .extension() - .is_some_and(|ext| ext == "toml") - { - Some(resolved_workflow_path) - } else { - None - }; - - Ok(WorkflowSourceInput { - raw_source, - settings, - workflow_slug, - workflow_toml_path, - dot_path, - goal_override, - }) -} - -/// Execute a workflow run from a saved RunRecord, bypassing workflow preparation. -/// -/// Used by `run_engine_entrypoint` for detached runs that already have a RunRecord on disk. -pub async fn run_from_record( - persisted: Persisted, - _run_dir: PathBuf, - styles: &'static Styles, - github_app: Option, - git_author: fabro_workflows::git::GitAuthor, -) -> anyhow::Result<()> { - execute_persisted_run(persisted, styles, github_app, git_author, false).await -} - -/// Resume an existing workflow run from its persisted checkpoint. -pub async fn resume_from_record( - persisted: Persisted, - _run_dir: PathBuf, - styles: &'static Styles, - github_app: Option, - git_author: fabro_workflows::git::GitAuthor, -) -> anyhow::Result<()> { - execute_persisted_run(persisted, styles, github_app, git_author, true).await -} - -fn ensure_resume_target_is_not_already_successful(run_dir: &Path) -> anyhow::Result<()> { - const MESSAGE: &str = "run already finished successfully — nothing to resume"; - - if let Ok(record) = - fabro_workflows::run_status::RunStatusRecord::load(&run_dir.join("status.json")) - { - if record.status == fabro_workflows::run_status::RunStatus::Succeeded { - bail!(MESSAGE); - } - } - - if let Ok(conclusion) = - fabro_workflows::records::Conclusion::load(&run_dir.join("conclusion.json")) - { - if matches!( - conclusion.status, - StageStatus::Success | StageStatus::PartialSuccess | StageStatus::Skipped - ) { - bail!(MESSAGE); - } - } - - Ok(()) -} - -/// Execute a full workflow run. -/// -/// # Errors -/// -/// Returns an error if the workflow cannot be read, parsed, validated, or executed. -pub async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> anyhow::Result<()> { - let styles: &'static fabro_util::terminal::Styles = - Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); - let cli_defaults = fabro_config::cli::load_cli_config(None)?; - let cli_config: FabroSettings = cli_defaults.clone().try_into()?; - args.verbose = args.verbose || cli_config.verbose_enabled(); - - 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_defaults, styles, quiet).await?; - - #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = crate::sleep_inhibitor::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); - } - } - - Ok(()) -} - -async fn execute_persisted_run( - persisted: Persisted, - styles: &'static Styles, - github_app: Option, - git_author: fabro_workflows::git::GitAuthor, - resume: bool, -) -> anyhow::Result<()> { - let run_record = persisted.run_record().clone(); - let mut settings = run_record.settings.clone(); - - // Pre-flight: check git cleanliness before creating any files - 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 dry_run_flag = settings.dry_run_enabled(); - let auto_approve_flag = settings.auto_approve_enabled(); - let no_retro_flag = settings.no_retro_enabled(); - let verbose_flag = settings.verbose_enabled(); - let run_id = run_record.run_id.clone(); - let run_dir = persisted.run_dir().to_path_buf(); - - if resume { - ensure_resume_target_is_not_already_successful(&run_dir)?; - } - - tokio::fs::create_dir_all(&run_dir).await?; - fabro_util::run_log::activate(&run_dir.join("cli.log")) - .context("Failed to activate per-run log")?; - let mut status_guard = DetachedRunBootstrapGuard::arm(&run_dir)?; - - // Now resolve ${env.VARNAME} references for runtime use. - if let Some(env) = settings - .sandbox - .as_mut() - .and_then(|sandbox| sandbox.env.as_mut()) - { - run_config::resolve_env_refs(env)?; - } - - // Create progress UI (used for both normal and verbose modes) - let is_tty = std::io::stderr().is_terminal(); - let progress_ui = Arc::new(Mutex::new(run_progress::ProgressUI::new( - is_tty, - verbose_flag, - ))); - { - let mut ui = progress_ui.lock().expect("progress lock poisoned"); - ui.show_version(); - ui.show_run_id(&run_id); - ui.show_time(&Local::now().format("%Y-%m-%d %H:%M:%S").to_string()); - ui.show_run_dir(&run_dir); - } - - // 3. Build event emitter - let emitter = EventEmitter::new(); - - // Cost accumulator — shared across all verbosity levels - let accumulator = Arc::new(Mutex::new(CostAccumulator::default())); - let acc_clone = Arc::clone(&accumulator); - emitter.on_event(move |event| { - if let fabro_workflows::event::WorkflowRunEvent::StageCompleted { usage: Some(u), .. } = - event - { - let mut acc = acc_clone.lock().unwrap(); - acc.total_input_tokens += u.input_tokens; - acc.total_output_tokens += u.output_tokens; - acc.total_cache_read_tokens += u.cache_read_tokens.unwrap_or(0); - acc.total_cache_write_tokens += u.cache_write_tokens.unwrap_or(0); - acc.total_reasoning_tokens += u.reasoning_tokens.unwrap_or(0); - if let Some(cost) = compute_stage_cost(u) { - acc.total_cost += cost; - acc.has_pricing = true; - } - } - }); - - run_progress::ProgressUI::register(&progress_ui, &emitter); - - // 4. Build interviewer - let interviewer: Arc = if auto_approve_flag { - Arc::new(AutoApproveInterviewer) - } else if !std::io::stdin().is_terminal() { - // Detached mode (stdin is /dev/null): use file-based IPC so the - // attach process can prompt the user on our behalf. - Arc::new(FileInterviewer::new(run_dir.clone())) - } else { - Arc::new(run_progress::ProgressAwareInterviewer::new( - ConsoleInterviewer::new(styles), - Arc::clone(&progress_ui), - )) - }; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let daytona_config = resolve_daytona_config(&settings); - #[cfg(feature = "exedev")] - let exe_config = resolve_exe_config(&settings); - let ssh_config = resolve_ssh_config(&settings); - let emitter = Arc::new(emitter); - - let sandbox_provider = resolve_sandbox_provider(None, &settings)?; - let model = settings - .llm - .as_ref() - .and_then(|llm| llm.model.clone()) - .unwrap_or_default(); - let provider = settings - .llm - .as_ref() - .and_then(|llm| llm.provider.clone()) - .filter(|value| !value.is_empty()); - let preserve_sandbox = resolve_preserve_sandbox(false, &settings); - let setup_commands = settings.setup_commands().to_vec(); - - // Parse provider string to enum (defaults to best available from env) - let provider_enum: Provider = provider - .as_deref() - .map(|s| s.parse::()) - .transpose() - .map_err(|e| anyhow::anyhow!("{e}"))? - .unwrap_or_else(Provider::default_from_env); - - let fallback_chain = resolve_fallback_chain(provider_enum, &model, &settings); - let mcp_servers: Vec = settings - .mcp_server_entries() - .clone() - .into_iter() - .map(|(name, entry): (String, fabro_config::mcp::McpServerEntry)| entry.into_config(name)) - .collect(); - let sandbox_spec = match sandbox_provider { - SandboxProvider::Local => SandboxSpec::Local { - working_directory: cwd.clone(), - }, - SandboxProvider::Docker => SandboxSpec::Docker { - config: DockerSandboxConfig { - host_working_directory: cwd.to_string_lossy().to_string(), - ..DockerSandboxConfig::default() - }, - }, - SandboxProvider::Daytona => SandboxSpec::Daytona { - config: daytona_config.unwrap_or_default(), - github_app: github_app.clone(), - run_id: Some(run_id.clone()), - clone_branch: detected_base_branch.clone(), - }, - #[cfg(feature = "exedev")] - SandboxProvider::Exe => SandboxSpec::Exe { - config: exe_config.unwrap_or_default(), - clone_params: resolve_exe_clone_params(&original_cwd), - run_id: Some(run_id.clone()), - github_app: github_app.clone(), - mgmt_destination: "exe.dev".to_string(), - }, - #[cfg(not(feature = "exedev"))] - SandboxProvider::Exe => { - bail!("exe sandbox requires the exedev feature"); - } - SandboxProvider::Ssh => SandboxSpec::Ssh { - config: ssh_config - .clone() - .ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?, - clone_params: resolve_ssh_clone_params(&original_cwd), - run_id: Some(run_id.clone()), - github_app: github_app.clone(), - }, - }; - - let toml_env = settings - .sandbox_settings() - .and_then(|sandbox| sandbox.env.clone()) - .unwrap_or_default(); - - let sandbox_env = SandboxEnvSpec { - devcontainer_env: HashMap::new(), - toml_env, - github_permissions: settings.github_permissions().cloned(), - origin_url: origin_url.clone(), - }; - - let devcontainer_enabled = settings - .sandbox_settings() - .and_then(|s| s.devcontainer) - .unwrap_or(false); - - let llm = LlmSpec { - model: model.clone(), - provider: provider_enum, - fallback_chain, - mcp_servers, - dry_run: dry_run_flag, - }; - - let worktree_mode = resolve_worktree_mode(&settings); - let lifecycle = LifecycleOptions { - setup_commands, - setup_command_timeout_ms: settings.setup_timeout_ms().unwrap_or(300_000), - devcontainer_phases: Vec::new(), - }; - - // Defuse the bootstrap guard — engine.run() has taken ownership of lifecycle status. - status_guard.defuse(); - - let run_start = Instant::now(); - let pr_config = persisted.run_record().settings.pull_request.clone(); - let start_options = StartOptions { - cancel_token: None, - emitter: Arc::clone(&emitter), - sandbox: sandbox_spec, - llm, - interviewer: interviewer.clone(), - lifecycle, - hooks: fabro_hooks::HookConfig { - hooks: settings.hooks.clone(), - }, - sandbox_env, - devcontainer: devcontainer_enabled.then(|| DevcontainerSpec { - enabled: true, - resolve_dir: cwd.clone(), - }), - seed_context: None, - git_author, - git: None, - github_app: github_app.clone(), - worktree_mode: Some(worktree_mode), - registry_override: None, - retro: StartRetroOptions { - enabled: !no_retro_flag && project_config::is_retro_enabled(), - }, - finalize: StartFinalizeOptions { preserve_sandbox }, - pull_request: StartPullRequestConfig { - pr_config, - github_app: github_app.clone(), - origin_url: origin_url.clone(), - model: model.clone(), - }, - }; - let started = if resume { - operations_resume(&run_dir, start_options).await - } else { - start(&run_dir, start_options).await - }; - let run_duration_ms = run_start.elapsed().as_millis() as u64; - let mut completion_guard = DetachedRunCompletionGuard::arm(&run_dir); - - // Restore cwd (worktree is kept for `fabro cp` access; pruned separately) - let _ = std::env::set_current_dir(&original_cwd); - progress_ui.lock().expect("progress lock poisoned").finish(); - - let final_status = match started { - Ok(started) => { - if let Some(ref retro) = started.retro { - print_retro_result(retro, started.retro_duration, &run_dir, styles); - } else if !no_retro_flag && project_config::is_retro_enabled() { - eprintln!("\n{}", styles.bold.apply_to("=== Retro ===")); - eprintln!("{}", styles.dim.apply_to("Retro unavailable")); - } - let finalized = started.finalized; - print_run_conclusion( - &finalized.conclusion, - &run_id, - &run_dir, - finalized.pushed_branch.as_deref(), - finalized.pr_url.as_deref(), - styles, - ); - print_final_output(&run_dir, styles); - print_assets(&run_dir, styles); - finalized.conclusion.status.clone() - } - Err(err) => { - let engine_result = Err(err.clone()); - let (final_status, failure_reason, run_status, status_reason) = - classify_engine_result(&engine_result); - let conclusion = build_conclusion( - &run_dir, - final_status.clone(), - failure_reason, - run_duration_ms, - None, - ); - persist_terminal_outcome(&run_dir, &conclusion, run_status, status_reason); - print_run_conclusion(&conclusion, &run_id, &run_dir, None, None, styles); - print_final_output(&run_dir, styles); - print_assets(&run_dir, styles); - final_status - } - }; - - completion_guard.defuse(); - - fabro_util::run_log::deactivate(); - match final_status { - StageStatus::Success | StageStatus::PartialSuccess => Ok(()), - _ => std::process::exit(1), - } -} - -/// Print a summary of the completed run from `conclusion.json` and `pull_request.json`. -/// -/// Used by the unified create+start+attach path in `main.rs` to display -/// the same result block that `run_command` prints in-process. -pub fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) { - let conclusion_path = run_dir.join("conclusion.json"); - let Ok(conclusion) = fabro_workflows::records::Conclusion::load(&conclusion_path) else { - return; - }; - - // PR info from pull_request.json (saved by __detached) - let pr_url = std::fs::read_to_string(run_dir.join("pull_request.json")) - .ok() - .and_then(|content| { - serde_json::from_str::(&content) - .ok() - .map(|record| record.html_url) - }); - - print_run_conclusion( - &conclusion, - run_id, - run_dir, - None, - pr_url.as_deref(), - styles, - ); - - print_final_output(run_dir, styles); - print_assets(run_dir, styles); -} - -pub(crate) fn print_run_conclusion( - conclusion: &fabro_workflows::records::Conclusion, - run_id: &str, - run_dir: &Path, - pushed_branch: Option<&str>, - pr_url: Option<&str>, - styles: &Styles, -) { - eprintln!("\n{}", styles.bold.apply_to("=== Run Result ===")); - eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}"))); - - let status_str = conclusion.status.to_string().to_uppercase(); - let status_color = match conclusion.status { - StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green, - _ => &styles.bold_red, - }; - eprintln!("Status: {}", status_color.apply_to(&status_str)); - eprintln!( - "Duration: {}", - HumanDuration(Duration::from_millis(conclusion.duration_ms)) - ); - - let total_tokens = conclusion.total_input_tokens + conclusion.total_output_tokens; - if total_tokens > 0 { - if conclusion.has_pricing { - if let Some(cost) = conclusion.total_cost { - if cost > 0.0 { - eprintln!( - "{}", - styles.dim.apply_to(format!( - "Cost: {} ({} toks)", - format_cost(cost), - format_tokens_human(total_tokens) - )) - ); - } - } - } else { - eprintln!( - "{}", - styles - .dim - .apply_to(format!("Toks: {}", format_tokens_human(total_tokens))) - ); - } - if conclusion.total_cache_read_tokens > 0 { - eprintln!( - "{}", - styles.dim.apply_to(format!( - "Cache: {} read, {} write", - format_tokens_human(conclusion.total_cache_read_tokens), - format_tokens_human(conclusion.total_cache_write_tokens), - )), - ); - } - if conclusion.total_reasoning_tokens > 0 { - eprintln!( - "{}", - styles.dim.apply_to(format!( - "Reasoning: {} tokens", - format_tokens_human(conclusion.total_reasoning_tokens), - )), - ); - } - } - - eprintln!( - "{}", - styles - .dim - .apply_to(format!("Run: {}", tilde_path(run_dir))) - ); - - if let Some(ref failure) = conclusion.failure_reason { - eprintln!("Failure: {}", styles.red.apply_to(failure)); - } - - if pushed_branch.is_some() || pr_url.is_some() { - eprintln!(); - if let Some(branch) = pushed_branch { - eprintln!("{} {branch}", styles.bold.apply_to("Pushed branch:")); - } - if let Some(url) = pr_url { - eprintln!("{} {url}", styles.bold.apply_to("Pull request:")); - } - } -} - -pub(crate) fn print_retro_result( - retro: &fabro_retro::retro::Retro, - duration: Duration, - run_dir: &Path, - styles: &Styles, -) { - eprintln!("\n{}", styles.bold.apply_to("=== Retro ===")); - - let retro_dur = run_progress::format_duration_short(duration); - let smoothness_str = retro - .smoothness - .as_ref() - .map(|s| s.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - let outcome_str = retro.outcome.as_deref().unwrap_or("No outcome recorded"); - let line1_content = format!("Retro: {smoothness_str} \u{2014} {outcome_str}"); - let term_width = console::Term::stderr().size().1 as usize; - let pad1 = term_width.saturating_sub(line1_content.len() + retro_dur.len()); - eprintln!( - "{} {}{:pad1$}{}", - styles.bold.apply_to("Retro:"), - styles - .dim - .apply_to(format!("{smoothness_str} \u{2014} {outcome_str}")), - "", - styles.dim.apply_to(&retro_dur), - ); - - let friction_count = retro.friction_points.as_ref().map(|v| v.len()).unwrap_or(0); - let open_count = retro.open_items.as_ref().map(|v| v.len()).unwrap_or(0); - if friction_count > 0 || open_count > 0 { - let mut parts = Vec::new(); - if friction_count > 0 { - let noun = if friction_count == 1 { - "friction point" - } else { - "friction points" - }; - parts.push(format!("{friction_count} {noun}")); - } - if open_count > 0 { - let noun = if open_count == 1 { - "open item" - } else { - "open items" - }; - parts.push(format!("{open_count} {noun}")); - } - eprintln!(" {}", styles.dim.apply_to(parts.join(" · "))); - } - - let retro_path = format!("{}/retro.json", tilde_path(run_dir)); - eprintln!( - " {} {}", - styles.dim.apply_to("Retro saved to"), - styles.underline.apply_to(&retro_path), - ); -} - -/// Print the final stage output from the checkpoint, if available. -pub(crate) fn print_final_output(run_dir: &std::path::Path, styles: &Styles) { - let Ok(checkpoint) = Checkpoint::load(&run_dir.join("checkpoint.json")) else { - return; - }; - - // Find the last stage that produced a response (walk completed_nodes in reverse, - // looking for a "response.{node_id}" entry in context_values). - for node_id in checkpoint.completed_nodes.iter().rev() { - let key = format!("response.{node_id}"); - if let Some(serde_json::Value::String(response)) = checkpoint.context_values.get(&key) { - let text = response.trim(); - if !text.is_empty() { - eprintln!("\n{}", styles.bold.apply_to("=== Output ===")); - eprintln!("{}", styles.render_markdown(text)); - } - return; - } - } -} - -/// Print collected asset paths, if any. -pub(crate) fn print_assets(run_dir: &std::path::Path, styles: &Styles) { - let paths = fabro_workflows::asset_snapshot::collect_asset_paths(run_dir); - if paths.is_empty() { - return; - } - let home = dirs::home_dir(); - eprintln!("\n{}", styles.bold.apply_to("=== Assets ===")); - for path in &paths { - let display = match &home { - Some(home_dir) => { - let home_str = home_dir.to_string_lossy(); - if let Some(rest) = path.strip_prefix(home_str.as_ref()) { - format!("~{rest}") - } else { - path.clone() - } - } - None => path.clone(), - }; - eprintln!("{display}"); - } -} - -/// Validate run configuration without executing the workflow. -/// -/// Boots the sandbox (init + cleanup), checks LLM provider availability, -/// resolves the model/provider through the full precedence chain, and prints -/// a styled check report. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn run_preflight( - graph: &fabro_graphviz::graph::Graph, - settings: &FabroSettings, - cli_model: Option<&str>, - cli_provider: Option<&str>, - git_status: GitSyncStatus, - sandbox_provider: SandboxProvider, - styles: &'static Styles, - github_app: Option, - origin_url: Option<&str>, -) -> anyhow::Result<()> { - use fabro_util::check_report::{ - CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, - }; - - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.set_style( - indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") - .expect("valid template") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), - ); - spinner.set_message("Running preflight checks…"); - spinner.enable_steady_tick(std::time::Duration::from_millis(80)); - - let mut checks: Vec = Vec::new(); - - // 1. Repository metadata - let setup_command_count = settings.setup_commands().len(); - - let repo_summary = origin_url - .map(|url| { - let https = fabro_github::ssh_url_to_https(url); - fabro_github::parse_github_owner_repo(&https) - .map(|(owner, repo)| format!("{owner}/{repo}")) - .unwrap_or_else(|_| url.to_string()) - }) - .unwrap_or_else(|| "unknown".into()); - - checks.push(CheckResult { - name: "Repository".into(), - status: CheckStatus::Pass, - summary: repo_summary, - details: vec![ - CheckDetail::new(format!("Setup commands: {setup_command_count}")), - CheckDetail { - text: format!("Git: {git_status}"), - warn: git_status != GitSyncStatus::Synced, - }, - ], - remediation: None, - }); - - // 2. Workflow metadata - let (model, provider) = resolve_model_provider(cli_model, cli_provider, settings, graph); - - checks.push(CheckResult { - name: "Workflow".into(), - status: CheckStatus::Pass, - summary: graph.name.clone(), - details: vec![ - CheckDetail::new(format!("Nodes: {}", graph.nodes.len())), - CheckDetail::new(format!("Edges: {}", graph.edges.len())), - CheckDetail::new(format!("Goal: {}", graph.goal())), - ], - remediation: None, - }); - - // 2. Sandbox boot check - let original_cwd = std::env::current_dir()?; - let daytona_config = resolve_daytona_config(settings); - #[cfg(feature = "exedev")] - let exe_config = resolve_exe_config(settings); - let ssh_config = resolve_ssh_config(settings); - - let sandbox_result: Result, String> = match sandbox_provider { - SandboxProvider::Docker => { - let config = DockerSandboxConfig { - host_working_directory: original_cwd.to_string_lossy().to_string(), - ..DockerSandboxConfig::default() - }; - DockerSandbox::new(config) - .map(|env| Arc::new(env) as Arc) - .map_err(|e| format!("Docker sandbox creation failed: {e}")) - } - SandboxProvider::Daytona => { - let config = daytona_config.unwrap_or_default(); - match fabro_sandbox::daytona::DaytonaSandbox::new( - config, - github_app.clone(), - None, - None, - ) - .await - { - Ok(env) => Ok(Arc::new(env) as Arc), - Err(e) => Err(format!("Daytona sandbox creation failed: {e}")), - } - } - #[cfg(feature = "exedev")] - SandboxProvider::Exe => { - match fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev").await { - Ok(mgmt_ssh) => { - let config = exe_config.unwrap_or_default(); - let clone_params = resolve_exe_clone_params(&original_cwd); - let env = fabro_sandbox::exe::ExeSandbox::new( - Box::new(mgmt_ssh), - config, - clone_params, - None, - None, - ); - Ok(Arc::new(env) as Arc) - } - Err(e) => Err(format!("exe.dev SSH connection failed: {e}")), - } - } - #[cfg(not(feature = "exedev"))] - SandboxProvider::Exe => Err("exe sandbox requires the exedev feature".to_string()), - SandboxProvider::Ssh => match ssh_config { - Some(config) => { - let clone_params = resolve_ssh_clone_params(&original_cwd); - let env = fabro_sandbox::ssh::SshSandbox::new(config, clone_params, None, None); - Ok(Arc::new(env) as Arc) - } - None => Err("SSH sandbox requires [sandbox.ssh] config".to_string()), - }, - SandboxProvider::Local => { - Ok(Arc::new(LocalSandbox::new(original_cwd.clone())) as Arc) - } - }; - - let sandbox_ok = match sandbox_result { - Ok(sandbox) => match sandbox.initialize().await { - Ok(()) => { - let _ = sandbox.cleanup().await; - true - } - Err(e) => { - let _ = sandbox.cleanup().await; - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: Some(format!("Sandbox init failed: {e}")), - }); - false - } - }, - Err(e) => { - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: Some(e), - }); - false - } - }; - - if sandbox_ok { - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Pass, - summary: sandbox_provider.to_string(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: None, - }); - } - - // 4. Per-model LLM checks - let default_provider = provider.as_deref().unwrap_or("anthropic"); - let llm_ok = match fabro_llm::client::Client::from_env().await { - Ok(c) => { - let configured: Vec = - c.provider_names().iter().map(|s| s.to_string()).collect(); - - // Collect all distinct (model, provider) pairs from LLM nodes - let mut model_providers = std::collections::BTreeSet::new(); - for node in graph.nodes.values() { - if !fabro_graphviz::graph::is_llm_handler_type(node.handler_type()) { - continue; - } - let node_model = node.model().unwrap_or(&model); - let node_provider = node.provider().unwrap_or(default_provider); - - // Resolve through catalog to get canonical model ID and provider - let (resolved_model, resolved_provider) = - if let Some(info) = Catalog::builtin().get(node_model) { - (info.id.clone(), info.provider.to_string()) - } else { - (node_model.to_string(), node_provider.to_string()) - }; - - // Use node-level provider override if explicitly set, otherwise catalog provider - let final_provider = if node.provider().is_some() { - node_provider.to_string() - } else { - resolved_provider - }; - - model_providers.insert((resolved_model, final_provider)); - } - - // If no LLM nodes found, fall back to the default model/provider - if model_providers.is_empty() { - let (resolved_model, resolved_provider) = - if let Some(info) = Catalog::builtin().get(&model) { - (info.id.clone(), info.provider.to_string()) - } else { - (model.clone(), default_provider.to_string()) - }; - model_providers.insert((resolved_model, resolved_provider)); - } - - let mut all_ok = true; - for (model_id, provider_name) in &model_providers { - match provider_name.parse::() { - Ok(_) => { - let mut status = CheckStatus::Pass; - if !configured.iter().any(|n| n == provider_name) { - status = CheckStatus::Warning; - all_ok = false; - } - checks.push(CheckResult { - name: "LLM".into(), - status, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], - remediation: if status == CheckStatus::Warning { - Some(format!("Provider \"{provider_name}\" is not configured")) - } else { - None - }, - }); - } - Err(e) => { - checks.push(CheckResult { - name: "LLM".into(), - status: CheckStatus::Error, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], - remediation: Some(format!("Invalid provider \"{provider_name}\": {e}")), - }); - all_ok = false; - } - } - } - all_ok - } - Err(e) => { - checks.push(CheckResult { - name: "LLM".into(), - status: CheckStatus::Error, - summary: "initialization failed".into(), - details: vec![], - remediation: Some(format!("LLM client init failed: {e}")), - }); - false - } - }; - - // 5. GitHub token preflight - if let Some(github_permissions) = settings.github_permissions() { - if !github_permissions.is_empty() { - let perm_details: Vec = github_permissions - .iter() - .map(|(k, v)| CheckDetail::new(format!("{k}: {v}"))) - .collect(); - match (&github_app, origin_url) { - (Some(creds), Some(url)) => { - match mint_github_token(creds, url, github_permissions).await { - Ok(_) => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Pass, - summary: "minted".into(), - details: perm_details, - remediation: None, - }); - } - Err(e) => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: perm_details, - remediation: Some(format!("Failed to mint GitHub token: {e}")), - }); - } - } - } - _ => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Warning, - summary: "skipped".into(), - details: vec![], - remediation: Some( - "No GitHub App credentials or origin URL available".to_string(), - ), - }); - } - } - } - } - - // 6. Render report - spinner.finish_and_clear(); - - let report = CheckReport { - title: "Run Preflight".into(), - sections: vec![CheckSection { - title: String::new(), - checks, - }], - }; - - let term_width = console::Term::stderr().size().1; - print!("{}", report.render(styles, true, None, Some(term_width))); - - if sandbox_ok && llm_ok { - Ok(()) - } else { - std::process::exit(1); - } -} - -#[cfg(test)] -pub(crate) fn build_event_envelope( - event: &fabro_workflows::event::WorkflowRunEvent, - run_id: &str, -) -> serde_json::Value { - fabro_workflows::event::build_event_envelope(event, run_id) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn write_run_config_snapshot_copies_toml_file() { - let dir = tempfile::tempdir().unwrap(); - let toml_content = "version = 1\ngoal = \"test\"\n"; - let toml_path = dir.path().join("original.toml"); - std::fs::write(&toml_path, toml_content).unwrap(); - - let run_dir = dir.path().join("run"); - std::fs::create_dir_all(&run_dir).unwrap(); - write_run_config_snapshot(&run_dir, Some(toml_path.as_path())) - .await - .unwrap(); - - let copied = std::fs::read_to_string(run_dir.join(RUN_CONFIG_FILE)).unwrap(); - assert_eq!(copied, toml_content); - } - - #[tokio::test] - async fn write_run_config_snapshot_skips_when_none() { - let dir = tempfile::tempdir().unwrap(); - write_run_config_snapshot(dir.path(), None).await.unwrap(); - assert!(!dir.path().join(RUN_CONFIG_FILE).exists()); - } - - #[test] - fn resolve_workflow_source_falls_back_to_graph_for_missing_cached_run_config() { - // Place the test dir inside the runs base so the fallback is allowed. - let runs_base = fabro_workflows::run_lookup::default_runs_base(); - std::fs::create_dir_all(&runs_base).unwrap(); - let dir = tempfile::tempdir_in(&runs_base).unwrap(); - std::fs::write(dir.path().join(RUN_GRAPH_FILE), "digraph test {}").unwrap(); - - let (_resolved_path, dot_path, run_cfg) = - resolve_workflow_source(&dir.path().join(RUN_CONFIG_FILE)).unwrap(); - - assert_eq!(dot_path, dir.path().join(RUN_GRAPH_FILE)); - assert!(run_cfg.is_none()); - } - - #[test] - fn resolve_workflow_source_errors_for_missing_run_toml_outside_runs_dir() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join(RUN_GRAPH_FILE), "digraph test {}").unwrap(); - - let result = resolve_workflow_source(&dir.path().join(RUN_CONFIG_FILE)); - assert!(result.is_err()); - } - - #[test] - fn workflow_slug_from_path_uses_file_stem_for_standalone_files() { - assert_eq!( - workflow_slug_from_path(Path::new("/tmp/alpha.fabro")).as_deref(), - Some("alpha") - ); - assert_eq!( - workflow_slug_from_path(Path::new("/tmp/beta.toml")).as_deref(), - Some("beta") - ); - } - - #[test] - fn workflow_slug_from_path_uses_parent_for_workflow_files() { - assert_eq!( - workflow_slug_from_path(Path::new("/tmp/sluggy/workflow.fabro")).as_deref(), - Some("sluggy") - ); - assert_eq!( - workflow_slug_from_path(Path::new("/tmp/sluggy/workflow.toml")).as_deref(), - Some("sluggy") - ); - } - - #[test] - fn workflow_slug_from_path_uses_final_component_for_extensionless_inputs() { - assert_eq!( - workflow_slug_from_path(Path::new("implement-issue")).as_deref(), - Some("implement-issue") - ); - assert_eq!( - workflow_slug_from_path(Path::new("nested/repl")).as_deref(), - Some("repl") - ); - } - - #[test] - fn load_workflow_source_input_resolves_workflow_toml_settings() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write( - dir.path().join("workflow.fabro"), - r#"digraph smoke { - start [shape=Mdiamond, label="Start"] - exit [shape=Msquare, label="Exit"] - work [label="Work", prompt="Do the work"] - start -> work -> exit -}"#, - ) - .unwrap(); - std::fs::write( - dir.path().join("workflow.toml"), - r#" -version = 1 -graph = "workflow.fabro" -goal = "toml goal" - -[setup] -commands = ["echo from toml"] - -[sandbox] -provider = "docker" - -[llm] -model = "gpt-5.2" -provider = "openai" - -[pull_request] -enabled = true - -[assets] -include = ["*.md"] -"#, - ) - .unwrap(); - - let workflow_path = dir.path().join("workflow.toml"); - - let source_input = load_workflow_source_input( - &workflow_path, - FabroConfig::default(), - FabroConfig::default(), - false, - ) - .unwrap(); - let validated = fabro_workflows::operations::validate( - &source_input.raw_source, - fabro_workflows::operations::ValidateOptions { - base_dir: Some(dir.path().to_path_buf()), - settings: Some(source_input.settings.clone()), - goal_override: source_input.goal_override.clone(), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!(validated.graph().name, "smoke"); - assert_eq!(validated.graph().goal(), "toml goal"); - let (model, provider) = - resolve_model_provider(None, None, &source_input.settings, validated.graph()); - assert_eq!(model, "gpt-5.2"); - assert_eq!(provider.as_deref(), Some("openai")); - let sandbox_provider = resolve_sandbox_provider(None, &source_input.settings).unwrap(); - assert_eq!(sandbox_provider, SandboxProvider::Docker); - - let run_settings = &source_input.settings; - assert_eq!( - run_settings - .setup - .as_ref() - .expect("setup config should be preserved") - .commands, - vec!["echo from toml".to_string()] - ); - assert!( - run_settings - .pull_request - .as_ref() - .expect("pull request config should be preserved") - .enabled - ); - assert_eq!( - run_settings - .assets - .as_ref() - .expect("assets config should be preserved") - .include, - vec!["*.md".to_string()] - ); - } - - #[test] - fn resolve_cli_goal_from_file() { - 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.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"), None).unwrap(); - assert_eq!(result, Some("inline goal".to_string())); - } - - #[test] - fn resolve_cli_goal_none() { - let result = resolve_cli_goal(None, None).unwrap(); - assert_eq!(result, None); - } - - #[test] - fn resolve_model_provider_defaults() { - let graph = fabro_graphviz::graph::Graph::new("test"); - let settings = FabroSettings::default(); - let (model, provider) = resolve_model_provider(None, None, &settings, &graph); - assert_eq!(model, "claude-sonnet-4-6"); - // Catalog resolves anthropic as the provider for claude-sonnet-4-6 - assert_eq!(provider, Some("anthropic".to_string())); - } - - #[test] - fn resolve_model_provider_cli_overrides_toml() { - let graph = fabro_graphviz::graph::Graph::new("test"); - let settings = FabroSettings { - version: Some(1), - goal: Some("test".to_string()), - graph: Some("test.fabro".to_string()), - llm: Some(run_config::LlmSettings { - model: Some("toml-model".to_string()), - provider: Some("openai".to_string()), - fallbacks: None, - }), - ..Default::default() - }; - let (model, provider) = - resolve_model_provider(Some("gpt-5.2"), Some("openai"), &settings, &graph); - assert_eq!(model, "gpt-5.2"); - assert_eq!(provider, Some("openai".to_string())); - } - - #[test] - fn resolve_model_provider_toml_overrides_graph() { - use fabro_graphviz::graph::AttrValue; - let mut graph = fabro_graphviz::graph::Graph::new("test"); - graph.attrs.insert( - "default_model".to_string(), - AttrValue::String("graph-model".to_string()), - ); - graph.attrs.insert( - "default_provider".to_string(), - AttrValue::String("gemini".to_string()), - ); - - let settings = FabroSettings { - version: Some(1), - goal: Some("test".to_string()), - graph: Some("test.fabro".to_string()), - llm: Some(run_config::LlmSettings { - model: Some("toml-model".to_string()), - provider: Some("openai".to_string()), - fallbacks: None, - }), - ..Default::default() - }; - let (model, provider) = resolve_model_provider(None, None, &settings, &graph); - assert_eq!(model, "toml-model"); - assert_eq!(provider, Some("openai".to_string())); - } - - #[test] - fn resolve_model_provider_graph_attrs_used_as_fallback() { - use fabro_graphviz::graph::AttrValue; - let mut graph = fabro_graphviz::graph::Graph::new("test"); - graph.attrs.insert( - "default_model".to_string(), - AttrValue::String("gpt-5.2".to_string()), - ); - graph.attrs.insert( - "default_provider".to_string(), - AttrValue::String("openai".to_string()), - ); - - let settings = FabroSettings::default(); - let (model, provider) = resolve_model_provider(None, None, &settings, &graph); - assert_eq!(model, "gpt-5.2"); - assert_eq!(provider, Some("openai".to_string())); - } - - #[test] - fn resolve_model_provider_alias_expansion() { - let graph = fabro_graphviz::graph::Graph::new("test"); - let settings = FabroSettings::default(); - let (model, provider) = resolve_model_provider(Some("opus"), None, &settings, &graph); - assert_eq!(model, "claude-opus-4-6"); - assert_eq!(provider, Some("anthropic".to_string())); - } - - #[test] - fn resolve_model_provider_settings_used() { - let graph = fabro_graphviz::graph::Graph::new("test"); - let settings = FabroSettings { - llm: Some(run_config::LlmSettings { - model: Some("default-model".to_string()), - provider: Some("openai".to_string()), - fallbacks: None, - }), - ..FabroSettings::default() - }; - let (model, provider) = resolve_model_provider(None, None, &settings, &graph); - assert_eq!(model, "default-model"); - assert_eq!(provider, Some("openai".to_string())); - } - - #[test] - fn resolve_model_provider_cli_overrides_settings() { - let graph = fabro_graphviz::graph::Graph::new("test"); - let settings = FabroSettings { - llm: Some(run_config::LlmSettings { - model: Some("default-model".to_string()), - provider: Some("anthropic".to_string()), - fallbacks: None, - }), - ..FabroSettings::default() - }; - let (model, provider) = - resolve_model_provider(Some("toml-model"), Some("openai"), &settings, &graph); - assert_eq!(model, "toml-model"); - assert_eq!(provider, Some("openai".to_string())); - } - - #[test] - fn resolve_preserve_sandbox_cli_wins() { - let settings = FabroSettings { - sandbox: Some(sandbox_config::SandboxSettings { - provider: None, - preserve: Some(false), - ..Default::default() - }), - ..Default::default() - }; - assert!(resolve_preserve_sandbox(true, &settings)); - } - - #[test] - fn resolve_preserve_sandbox_settings_used() { - let settings = FabroSettings { - sandbox: Some(sandbox_config::SandboxSettings { - provider: None, - preserve: Some(true), - ..Default::default() - }), - ..Default::default() - }; - assert!(resolve_preserve_sandbox(false, &settings)); - } - - #[test] - fn resolve_preserve_sandbox_defaults_used() { - let settings = FabroSettings { - sandbox: Some(sandbox_config::SandboxSettings { - provider: None, - preserve: Some(true), - ..Default::default() - }), - ..FabroSettings::default() - }; - assert!(resolve_preserve_sandbox(false, &settings)); - } - - #[test] - fn resolve_preserve_sandbox_defaults_to_false() { - let settings = FabroSettings::default(); - assert!(!resolve_preserve_sandbox(false, &settings)); - } - - #[test] - fn resolve_worktree_mode_defaults_to_clean() { - let settings = FabroSettings::default(); - assert_eq!( - resolve_worktree_mode(&settings), - sandbox_config::WorktreeMode::Clean - ); - } - - #[test] - fn resolve_worktree_mode_from_toml() { - let settings = FabroSettings { - sandbox: Some(sandbox_config::SandboxSettings { - local: Some(sandbox_config::LocalSandboxSettings { - worktree_mode: sandbox_config::WorktreeMode::Always, - }), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - resolve_worktree_mode(&settings), - sandbox_config::WorktreeMode::Always - ); - } - - #[test] - fn resolve_worktree_mode_from_settings() { - let settings = FabroSettings { - sandbox: Some(sandbox_config::SandboxSettings { - provider: None, - preserve: None, - devcontainer: None, - local: Some(sandbox_config::LocalSandboxSettings { - worktree_mode: sandbox_config::WorktreeMode::Dirty, - }), - ..Default::default() - }), - ..FabroSettings::default() - }; - assert_eq!( - resolve_worktree_mode(&settings), - sandbox_config::WorktreeMode::Dirty - ); - } - - #[test] - fn resolve_worktree_mode_settings_used_over_default() { - let settings = FabroSettings { - sandbox: Some(sandbox_config::SandboxSettings { - local: Some(sandbox_config::LocalSandboxSettings { - worktree_mode: sandbox_config::WorktreeMode::Never, - }), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - resolve_worktree_mode(&settings), - sandbox_config::WorktreeMode::Never - ); - } - - #[test] - fn redact_removes_aws_key_from_compact_json() { - let envelope = serde_json::json!({ - "timestamp": "2025-01-01T00:00:00.000Z", - "run_id": "abc-123", - "event": { - "type": "agent", - "content": "My key is AKIAYRWQG5EJLPZLBYNP and secret is wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" - } - }); - let compact = serde_json::to_string(&envelope).unwrap(); - let redacted = fabro_util::redact::redact_jsonl_line(&compact); - - assert!(!redacted.contains("AKIAYRWQG5EJLPZLBYNP")); - assert!(redacted.contains("REDACTED")); - - let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap(); - assert_eq!(parsed["run_id"], "abc-123"); - assert_eq!(parsed["timestamp"], "2025-01-01T00:00:00.000Z"); - } - - #[test] - fn redact_removes_aws_key_from_pretty_json() { - let envelope = serde_json::json!({ - "timestamp": "2025-01-01T00:00:00.000Z", - "run_id": "def-456", - "event": { - "type": "agent", - "content": "Credentials: AKIAYRWQG5EJLPZLBYNP" - } - }); - let pretty = serde_json::to_string_pretty(&envelope).unwrap(); - let redacted = fabro_util::redact::redact_jsonl_line(&pretty); - - assert!(!redacted.contains("AKIAYRWQG5EJLPZLBYNP")); - assert!(redacted.contains("REDACTED")); - - let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap(); - assert_eq!(parsed["run_id"], "def-456"); - } - - #[test] - fn envelope_field_order_starts_with_ts_run_id_event() { - let event = fabro_workflows::event::WorkflowRunEvent::StageStarted { - node_id: "plan".to_string(), - name: "Plan".to_string(), - index: 0, - handler_type: Some("agent".to_string()), - script: None, - attempt: 1, - max_attempts: 3, - }; - let envelope = build_event_envelope(&event, "run-123"); - let json = serde_json::to_string(&envelope).unwrap(); - // Parse the raw JSON to get field order - let fields: Vec = json - .trim_start_matches('{') - .trim_end_matches('}') - .split(',') - .filter_map(|pair| { - let key = pair.split(':').next()?; - Some(key.trim().trim_matches('"').to_string()) - }) - .collect(); - assert_eq!(&fields[0], "ts", "first field must be ts, got: {fields:?}"); - assert_eq!( - &fields[1], "run_id", - "second field must be run_id, got: {fields:?}" - ); - assert_eq!( - &fields[2], "event", - "third field must be event, got: {fields:?}" - ); - } -} diff --git a/lib/crates/fabro-cli/src/commands/run/launcher.rs b/lib/crates/fabro-cli/src/commands/run/launcher.rs new file mode 100644 index 000000000..91f1540c6 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/launcher.rs @@ -0,0 +1,50 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LauncherRecord { + pub run_id: String, + pub run_dir: PathBuf, + pub pid: u32, + pub resume: bool, + pub log_path: PathBuf, + pub started_at: DateTime, +} + +pub(crate) fn launcher_dir(storage_dir: &Path) -> PathBuf { + storage_dir.join("launchers") +} + +pub(crate) fn launcher_record_path(storage_dir: &Path, run_id: &str) -> PathBuf { + launcher_dir(storage_dir).join(format!("{run_id}.json")) +} + +pub(crate) fn launcher_log_path(storage_dir: &Path, run_id: &str) -> PathBuf { + launcher_dir(storage_dir).join(format!("{run_id}.log")) +} + +pub(crate) fn write_launcher_record(path: &Path, record: &LauncherRecord) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_string_pretty(record)?) + .with_context(|| format!("Failed to write launcher metadata to {}", path.display())) +} + +pub(crate) fn read_launcher_record(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +pub(crate) fn remove_launcher_record(path: &Path) { + let _ = std::fs::remove_file(path); +} + +pub(crate) fn launcher_record_for_run(run_dir: &Path) -> Option { + let record = fabro_workflows::records::RunRecord::load(run_dir).ok()?; + let path = launcher_record_path(&record.settings.storage_dir(), &record.run_id); + read_launcher_record(&path) +} diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 50ffc57b1..31607cf5d 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -3,13 +3,16 @@ use anyhow::Result; use crate::args::{GlobalArgs, RunCommands}; pub(crate) mod attach; +pub(crate) mod command; pub(crate) mod cp; pub(crate) mod create; pub(crate) mod detached; pub(crate) mod diff; -pub(crate) mod execute; pub(crate) mod fork; +pub(crate) mod launcher; pub(crate) mod logs; +pub(crate) mod output; +pub(crate) mod overrides; pub(crate) mod preview; pub(crate) mod resume; pub(crate) mod rewind; @@ -20,7 +23,7 @@ pub(crate) mod wait; pub async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> { match cmd { - RunCommands::Run(args) => execute::execute(args, globals).await, + RunCommands::Run(args) => command::execute(args, globals).await, RunCommands::Create(args) => { let styles: &'static fabro_util::terminal::Styles = Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); @@ -50,10 +53,10 @@ pub async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> { Ok(()) } RunCommands::Detached { - storage_dir, - run_id, + run_dir, + launcher_path, resume, - } => detached::execute(storage_dir, run_id, resume).await, + } => detached::execute(run_dir, launcher_path, resume).await, RunCommands::Cp(args) => cp::cp_command(args).await, RunCommands::Preview(args) => preview::run(args).await, RunCommands::Ssh(args) => ssh::run(args).await, diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs new file mode 100644 index 000000000..0ca1d5eff --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -0,0 +1,222 @@ +use std::path::Path; +use std::time::Duration; + +use fabro_util::terminal::Styles; +use fabro_workflows::outcome::{format_cost, StageStatus}; +use fabro_workflows::pipeline::{Persisted, Validated}; +use fabro_workflows::records::Checkpoint; +use indicatif::HumanDuration; + +use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path}; + +fn print_workflow_header( + graph: &fabro_graphviz::graph::Graph, + diagnostics: &[fabro_validate::Diagnostic], + dot_path: Option<&Path>, + styles: &Styles, +) { + eprintln!( + "{} {} {}", + styles.bold.apply_to("Workflow:"), + graph.name, + styles.dim.apply_to(format!( + "({} nodes, {} edges)", + graph.nodes.len(), + graph.edges.len() + )), + ); + let graph_path = dot_path + .map(relative_path) + .unwrap_or_else(|| "".to_string()); + eprintln!( + "{} {}", + styles.dim.apply_to("Graph:"), + styles.dim.apply_to(graph_path), + ); + + let goal = graph.goal(); + if !goal.is_empty() { + let stripped = fabro_util::text::strip_goal_decoration(goal); + eprintln!("{} {stripped}\n", styles.bold.apply_to("Goal:")); + } + + print_diagnostics(diagnostics, styles); +} + +pub(crate) fn print_workflow_report( + validated: &Validated, + dot_path: Option<&Path>, + styles: &Styles, +) { + print_workflow_header(validated.graph(), validated.diagnostics(), dot_path, styles); +} + +pub(crate) fn print_workflow_report_from_persisted( + persisted: &Persisted, + dot_path: Option<&Path>, + styles: &Styles, +) { + print_workflow_header(persisted.graph(), persisted.diagnostics(), dot_path, styles); +} + +pub(crate) fn print_diagnostics_from_error( + diagnostics: &[fabro_validate::Diagnostic], + styles: &Styles, +) { + print_diagnostics(diagnostics, styles); +} + +pub(crate) fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) { + let conclusion_path = run_dir.join("conclusion.json"); + let Ok(conclusion) = fabro_workflows::records::Conclusion::load(&conclusion_path) else { + return; + }; + + let pr_url = std::fs::read_to_string(run_dir.join("pull_request.json")) + .ok() + .and_then(|content| { + serde_json::from_str::(&content) + .ok() + .map(|record| record.html_url) + }); + + print_run_conclusion( + &conclusion, + run_id, + run_dir, + None, + pr_url.as_deref(), + styles, + ); + print_final_output(run_dir, styles); + print_assets(run_dir, styles); +} + +pub(crate) fn print_run_conclusion( + conclusion: &fabro_workflows::records::Conclusion, + run_id: &str, + run_dir: &Path, + pushed_branch: Option<&str>, + pr_url: Option<&str>, + styles: &Styles, +) { + eprintln!("\n{}", styles.bold.apply_to("=== Run Result ===")); + eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}"))); + + let status_str = conclusion.status.to_string().to_uppercase(); + let status_color = match conclusion.status { + StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green, + _ => &styles.bold_red, + }; + eprintln!("Status: {}", status_color.apply_to(&status_str)); + eprintln!( + "Duration: {}", + HumanDuration(Duration::from_millis(conclusion.duration_ms)) + ); + + let total_tokens = conclusion.total_input_tokens + conclusion.total_output_tokens; + if total_tokens > 0 { + if conclusion.has_pricing { + if let Some(cost) = conclusion.total_cost { + if cost > 0.0 { + eprintln!( + "{}", + styles.dim.apply_to(format!( + "Cost: {} ({} toks)", + format_cost(cost), + format_tokens_human(total_tokens) + )) + ); + } + } + } else { + eprintln!( + "{}", + styles + .dim + .apply_to(format!("Toks: {}", format_tokens_human(total_tokens))) + ); + } + if conclusion.total_cache_read_tokens > 0 { + eprintln!( + "{}", + styles.dim.apply_to(format!( + "Cache: {} read, {} write", + format_tokens_human(conclusion.total_cache_read_tokens), + format_tokens_human(conclusion.total_cache_write_tokens), + )), + ); + } + if conclusion.total_reasoning_tokens > 0 { + eprintln!( + "{}", + styles.dim.apply_to(format!( + "Reasoning: {} tokens", + format_tokens_human(conclusion.total_reasoning_tokens), + )), + ); + } + } + + eprintln!( + "{}", + styles + .dim + .apply_to(format!("Run: {}", tilde_path(run_dir))) + ); + + if let Some(ref failure) = conclusion.failure_reason { + eprintln!("Failure: {}", styles.red.apply_to(failure)); + } + + if pushed_branch.is_some() || pr_url.is_some() { + eprintln!(); + if let Some(branch) = pushed_branch { + eprintln!("{} {branch}", styles.bold.apply_to("Pushed branch:")); + } + if let Some(url) = pr_url { + eprintln!("{} {url}", styles.bold.apply_to("Pull request:")); + } + } +} + +pub(crate) fn print_final_output(run_dir: &Path, styles: &Styles) { + let Ok(checkpoint) = Checkpoint::load(&run_dir.join("checkpoint.json")) else { + return; + }; + + for node_id in checkpoint.completed_nodes.iter().rev() { + let key = format!("response.{node_id}"); + if let Some(serde_json::Value::String(response)) = checkpoint.context_values.get(&key) { + let text = response.trim(); + if !text.is_empty() { + eprintln!("\n{}", styles.bold.apply_to("=== Output ===")); + eprintln!("{}", styles.render_markdown(text)); + } + return; + } + } +} + +pub(crate) fn print_assets(run_dir: &Path, styles: &Styles) { + let paths = fabro_workflows::asset_snapshot::collect_asset_paths(run_dir); + if paths.is_empty() { + return; + } + let home = dirs::home_dir(); + eprintln!("\n{}", styles.bold.apply_to("=== Assets ===")); + for path in &paths { + let display = match &home { + Some(home_dir) => { + let home_str = home_dir.to_string_lossy(); + if let Some(rest) = path.strip_prefix(home_str.as_ref()) { + format!("~{rest}") + } else { + path.clone() + } + } + None => path.clone(), + }; + eprintln!("{display}"); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs new file mode 100644 index 000000000..d4f5362dc --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -0,0 +1,90 @@ +use std::collections::HashMap; + +use anyhow::Result; +use fabro_config::{sandbox as sandbox_config, FabroConfig}; +use fabro_sandbox::SandboxProvider; + +use crate::args::{PreflightArgs, RunArgs}; + +fn sparse_flag(value: bool) -> Option { + value.then_some(true) +} + +pub(crate) fn parse_labels(labels: &[String]) -> HashMap { + labels + .iter() + .filter_map(|label| label.split_once('=')) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + +impl TryFrom<&RunArgs> for FabroConfig { + type Error = anyhow::Error; + + fn try_from(args: &RunArgs) -> Result { + let llm = if args.model.is_some() || args.provider.is_some() { + Some(fabro_config::run::LlmConfig { + model: args.model.clone(), + provider: args.provider.clone(), + fallbacks: None, + }) + } else { + None + }; + let sandbox = if args.sandbox.is_some() || args.preserve_sandbox { + Some(sandbox_config::SandboxConfig { + provider: args + .sandbox + .map(Into::into) + .map(|provider: SandboxProvider| provider.to_string()), + preserve: sparse_flag(args.preserve_sandbox), + ..Default::default() + }) + } else { + None + }; + + Ok(Self { + goal: args.goal.clone(), + goal_file: args.goal_file.clone(), + llm, + sandbox, + verbose: sparse_flag(args.verbose), + dry_run: sparse_flag(args.dry_run), + auto_approve: sparse_flag(args.auto_approve), + no_retro: sparse_flag(args.no_retro), + storage_dir: args.storage_dir.clone(), + labels: parse_labels(&args.label), + ..Default::default() + }) + } +} + +impl TryFrom<&PreflightArgs> for FabroConfig { + type Error = anyhow::Error; + + fn try_from(args: &PreflightArgs) -> Result { + let llm = if args.model.is_some() || args.provider.is_some() { + Some(fabro_config::run::LlmConfig { + model: args.model.clone(), + provider: args.provider.clone(), + fallbacks: None, + }) + } else { + None + }; + let sandbox = args.sandbox.map(|sandbox| sandbox_config::SandboxConfig { + provider: Some(SandboxProvider::from(sandbox).to_string()), + ..Default::default() + }); + + Ok(Self { + goal: args.goal.clone(), + goal_file: args.goal_file.clone(), + llm, + sandbox, + verbose: sparse_flag(args.verbose), + ..Default::default() + }) + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 63b03406e..cf7a3ebd2 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -1,7 +1,6 @@ use anyhow::bail; use fabro_util::terminal::Styles; -use fabro_workflows::records::{Checkpoint, RunRecord}; -use fabro_workflows::run_status::{RunStatus, RunStatusRecord}; +use fabro_workflows::records::RunRecord; use crate::args::ResumeArgs; @@ -21,60 +20,17 @@ pub async fn resume_command(args: ResumeArgs, styles: &'static Styles) -> anyhow } let run_id = RunRecord::load(&run_dir)?.run_id; - // Guard against resuming a live run — must happen before checkpoint - // validation because the engine writes checkpoint.json with a plain - // fs::write, so a mid-write read would see a truncated file and - // report "corrupt" for a run that is simply still alive. - if is_pid_alive(&run_dir.join("run.pid")) { + if launcher_pid_alive(&run_dir) { bail!("an engine process is still running for this run — cannot resume"); } - // Reject runs that completed successfully — only failed/interrupted - // runs should be resumed. - if let Ok(record) = RunStatusRecord::load(&run_dir.join("status.json")) { - if record.status == RunStatus::Succeeded { - bail!("run already succeeded — nothing to resume"); - } - } - - // Validate checkpoint is parseable before touching any state. - // A crash during the original run can leave a truncated file; - // we must not destroy the old conclusion/failure evidence and - // only then discover the checkpoint is corrupt. - let cp_path = run_dir.join("checkpoint.json"); - Checkpoint::load(&cp_path).map_err(|e| { - if cp_path.exists() { - anyhow::anyhow!("checkpoint.json is corrupt — cannot resume: {e}") - } else { - anyhow::anyhow!("no checkpoint found — nothing to resume") - } - })?; - - // Clean stale artifacts from previous execution - for name in &[ - "conclusion.json", - "pull_request.json", - "detached_failure.json", - "interview_request.json", - "interview_response.json", - "interview_request.claim", - "detach.log", - "run.pid", - "progress.jsonl", - ] { - let _ = std::fs::remove_file(run_dir.join(name)); - } - - // Reset status for re-execution - fabro_workflows::run_status::write_run_status(&run_dir, RunStatus::Submitted, None); - let child = super::start::start_run(&run_dir, true)?; if args.detach { println!("{run_id}"); } else { let exit_code = super::attach::attach_run(&run_dir, true, styles, Some(child)).await?; - super::execute::print_run_summary(&run_dir, &run_id, styles); + super::output::print_run_summary(&run_dir, &run_id, styles); if exit_code != std::process::ExitCode::SUCCESS { std::process::exit(1); } @@ -82,16 +38,16 @@ pub async fn resume_command(args: ResumeArgs, styles: &'static Styles) -> anyhow Ok(()) } -/// Check whether a PID file contains a live process. -fn is_pid_alive(pid_path: &std::path::Path) -> bool { - let Ok(content) = std::fs::read_to_string(pid_path) else { - return false; - }; - let Ok(pid) = content.trim().parse::() else { - return false; - }; - // kill(pid, 0) checks liveness without sending a signal - unsafe { libc::kill(pid, 0) == 0 } +fn launcher_pid_alive(run_dir: &std::path::Path) -> bool { + super::launcher::launcher_record_for_run(run_dir) + .map(|record| process_alive(record.pid)) + .or_else(|| { + std::fs::read_to_string(run_dir.join("run.pid")) + .ok() + .and_then(|pid| pid.trim().parse::().ok()) + .map(process_alive) + }) + .unwrap_or(false) } #[cfg(test)] @@ -99,23 +55,20 @@ mod tests { use super::*; #[test] - fn is_pid_alive_returns_false_for_missing_file() { + fn launcher_pid_alive_returns_false_for_missing_record() { let dir = tempfile::tempdir().unwrap(); - assert!(!is_pid_alive(&dir.path().join("run.pid"))); - } - - #[test] - fn is_pid_alive_returns_false_for_invalid_pid() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("run.pid"), "not-a-pid").unwrap(); - assert!(!is_pid_alive(&dir.path().join("run.pid"))); - } - - #[test] - fn is_pid_alive_returns_true_for_current_process() { - let dir = tempfile::tempdir().unwrap(); - let pid = std::process::id(); - std::fs::write(dir.path().join("run.pid"), pid.to_string()).unwrap(); - assert!(is_pid_alive(&dir.path().join("run.pid"))); + assert!(!launcher_pid_alive(dir.path())); + } +} + +fn process_alive(pid: u32) -> bool { + #[cfg(unix)] + { + unsafe { libc::kill(pid as i32, 0) == 0 } + } + #[cfg(not(unix))] + { + let _ = pid; + true } } diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress.rs b/lib/crates/fabro-cli/src/commands/run/run_progress.rs index cb62efed4..5824769c6 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress.rs @@ -196,6 +196,7 @@ pub struct ProgressUI { working_directory: Option, } +#[allow(dead_code)] impl ProgressUI { pub fn new(is_tty: bool, verbose: bool) -> Self { let renderer = if is_tty { @@ -1722,11 +1723,13 @@ impl ProgressUI { /// Wraps a `ConsoleInterviewer` so that progress bars are hidden during /// interactive prompts (avoids garbled output from concurrent writes). +#[allow(dead_code)] pub struct ProgressAwareInterviewer { inner: ConsoleInterviewer, progress: Arc>, } +#[allow(dead_code)] impl ProgressAwareInterviewer { pub fn new(inner: ConsoleInterviewer, progress: Arc>) -> Self { Self { inner, progress } diff --git a/lib/crates/fabro-cli/src/commands/run/start.rs b/lib/crates/fabro-cli/src/commands/run/start.rs index afaca45ed..c2a57f08d 100644 --- a/lib/crates/fabro-cli/src/commands/run/start.rs +++ b/lib/crates/fabro-cli/src/commands/run/start.rs @@ -1,76 +1,37 @@ use std::path::Path; -use anyhow::{bail, Result}; -use fabro_workflows::run_status::{RunStatus, StatusReason}; +use anyhow::{anyhow, Result}; +use chrono::Utc; -use super::detached::persist_detached_failure; +use super::launcher::{ + launcher_log_path, launcher_record_path, write_launcher_record, LauncherRecord, +}; /// Spawn a detached engine process for the given run directory. /// /// The engine process reads `run.json` from the run directory and executes the /// workflow. Returns the child process handle (use `.id()` for the PID). -/// -/// `storage_dir` is the base storage directory (e.g. `~/.fabro`). If `None`, -/// it is derived from the `run_dir` by stripping the `runs/` suffix. pub fn start_run(run_dir: &Path, resume: bool) -> Result { - // Validate status is Submitted - let status_path = run_dir.join("status.json"); - match fabro_workflows::run_status::RunStatusRecord::load(&status_path) { - Ok(record) if record.status != RunStatus::Submitted => { - bail!( - "Cannot start run: status is {:?}, expected Submitted", - record.status - ); - } - _ => {} // No status file or Submitted — proceed + let record = fabro_workflows::records::RunRecord::load(run_dir) + .map_err(|e| anyhow!("Cannot start run: failed to load run.json: {e}"))?; + + let storage_dir = record.settings.storage_dir(); + let launcher_path = launcher_record_path(&storage_dir, &record.run_id); + let log_path = launcher_log_path(&storage_dir, &record.run_id); + + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent)?; } - // Validate run.json is loadable - fabro_workflows::records::RunRecord::load(run_dir) - .map_err(|e| anyhow::anyhow!("Cannot start run: failed to load run.json: {e}"))?; - - // Write Starting status before spawning to prevent duplicate engines - fabro_workflows::run_status::write_run_status(run_dir, RunStatus::Starting, None); - - let log_file = match std::fs::File::create(run_dir.join("detach.log")) { - Ok(file) => file, - Err(err) => { - let err = err.into(); - let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err); - return Err(err); - } - }; - - let exe = match std::env::current_exe() { - Ok(exe) => exe, - Err(err) => { - let err = err.into(); - let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err); - return Err(err); - } - }; + let log_file = std::fs::File::create(&log_path)?; + let stdout_log = log_file.try_clone()?; + let exe = std::env::current_exe()?; let mut cmd = std::process::Command::new(&exe); - let stdout_log = match log_file.try_clone() { - Ok(file) => file, - Err(err) => { - let err = err.into(); - let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err); - return Err(err); - } - }; - // Derive storage_dir (grandparent of run_dir, e.g. ~/.fabro) and run_id - let storage_dir = run_dir - .parent() // runs/ - .and_then(|p| p.parent()) // ~/.fabro/ - .unwrap_or(run_dir); - let run_id = fabro_workflows::records::RunRecord::load(run_dir) - .map(|r| r.run_id) - .or_else(|_| std::fs::read_to_string(run_dir.join("id.txt")).map(|s| s.trim().to_string())) - .unwrap_or_default(); - cmd.args(["__detached", "--storage-dir"]) - .arg(storage_dir) - .args(["--run-id", &run_id]); + cmd.args(["__detached", "--run-dir"]) + .arg(run_dir) + .args(["--launcher-path"]) + .arg(&launcher_path); if resume { cmd.arg("--resume"); } @@ -78,7 +39,6 @@ pub fn start_run(run_dir: &Path, resume: bool) -> Result { .stderr(log_file) .stdin(std::process::Stdio::null()); - // Detach from the controlling terminal on unix #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -90,20 +50,20 @@ pub fn start_run(run_dir: &Path, resume: bool) -> Result { } } - let mut child = match cmd.spawn() { - Ok(child) => child, - Err(err) => { - let err = err.into(); - let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err); - return Err(err); - } - }; + let mut child = cmd.spawn()?; - // Write PID file - if let Err(err) = std::fs::write(run_dir.join("run.pid"), child.id().to_string()) { + if let Err(err) = write_launcher_record( + &launcher_path, + &LauncherRecord { + run_id: record.run_id, + run_dir: run_dir.to_path_buf(), + pid: child.id(), + resume, + log_path, + started_at: Utc::now(), + }, + ) { kill_child_best_effort(&mut child); - let err = err.into(); - let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err); return Err(err); } @@ -114,53 +74,3 @@ fn kill_child_best_effort(child: &mut std::process::Child) { let _ = child.kill(); let _ = child.wait(); } - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - use fabro_config::FabroSettings; - use fabro_graphviz::graph::Graph; - use fabro_workflows::records::RunRecord; - use fabro_workflows::run_status::{write_run_status, RunStatus, RunStatusRecord, StatusReason}; - use std::collections::HashMap; - use std::path::PathBuf; - - fn sample_record() -> RunRecord { - RunRecord { - run_id: "run-test123".to_string(), - created_at: Utc::now(), - settings: FabroSettings::default(), - graph: Graph { - name: "test".to_string(), - ..Default::default() - }, - workflow_slug: None, - working_directory: PathBuf::from("/tmp"), - host_repo_path: None, - base_branch: None, - labels: HashMap::new(), - } - } - - #[test] - fn start_run_marks_failed_when_spawn_cannot_start_engine() { - let dir = tempfile::tempdir().unwrap(); - write_run_status(dir.path(), RunStatus::Submitted, None); - sample_record().save(dir.path()).unwrap(); - std::fs::create_dir(dir.path().join("detach.log")).unwrap(); - - let _ = start_run(dir.path(), false); - - let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap(); - assert_eq!( - record.status, - RunStatus::Failed, - "start_run should persist a terminal failure on launch errors" - ); - assert_eq!(record.reason, Some(StatusReason::LaunchFailed)); - assert!(dir.path().join("conclusion.json").exists()); - let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap(); - assert!(progress.contains("launch_failed")); - } -} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index c5a4b0874..61257dc78 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -338,20 +338,23 @@ mod tests { let cli = Cli::try_parse_from([ "fabro", "__detached", - "--storage-dir", - "/tmp/fabro", - "--run-id", - "01ABC", + "--run-dir", + "/tmp/fabro/runs/01ABC", + "--launcher-path", + "/tmp/fabro/launchers/01ABC.json", ]) .expect("should parse"); match *cli.command { Commands::RunCmd(RunCommands::Detached { - storage_dir, - run_id, + run_dir, + launcher_path, resume, }) => { - assert_eq!(storage_dir, std::path::PathBuf::from("/tmp/fabro")); - assert_eq!(run_id, "01ABC"); + assert_eq!(run_dir, std::path::PathBuf::from("/tmp/fabro/runs/01ABC")); + assert_eq!( + launcher_path, + std::path::PathBuf::from("/tmp/fabro/launchers/01ABC.json") + ); assert!(!resume); } _ => panic!("unexpected command variant"), @@ -363,21 +366,24 @@ mod tests { let cli = Cli::try_parse_from([ "fabro", "__detached", - "--storage-dir", - "/tmp/fabro", - "--run-id", - "01ABC", + "--run-dir", + "/tmp/fabro/runs/01ABC", + "--launcher-path", + "/tmp/fabro/launchers/01ABC.json", "--resume", ]) .expect("should parse"); match *cli.command { Commands::RunCmd(RunCommands::Detached { - storage_dir, - run_id, + run_dir, + launcher_path, resume, }) => { - assert_eq!(storage_dir, std::path::PathBuf::from("/tmp/fabro")); - assert_eq!(run_id, "01ABC"); + assert_eq!(run_dir, std::path::PathBuf::from("/tmp/fabro/runs/01ABC")); + assert_eq!( + launcher_path, + std::path::PathBuf::from("/tmp/fabro/launchers/01ABC.json") + ); assert!(resume); } _ => panic!("unexpected command variant"), diff --git a/lib/crates/fabro-cli/tests/cli.rs b/lib/crates/fabro-cli/tests/cli.rs index 1adc9f6a8..0a0d50b67 100644 --- a/lib/crates/fabro-cli/tests/cli.rs +++ b/lib/crates/fabro-cli/tests/cli.rs @@ -713,7 +713,8 @@ fn detach_creates_run_dir_with_detach_log() { let ulid = ulid.trim(); assert!(!ulid.is_empty(), "should print a ULID"); - // Run dir should have been created under storage_dir/runs/ with detach.log + // Run dir should have been created under storage_dir/runs/ and the launcher + // log should live under storage_dir/launchers/. let runs_base = storage_dir.join("runs"); assert!(runs_base.exists(), "runs/ directory should exist"); let entries: Vec<_> = std::fs::read_dir(&runs_base) @@ -723,9 +724,13 @@ fn detach_creates_run_dir_with_detach_log() { assert_eq!(entries.len(), 1, "should have exactly one run directory"); let run_dir = entries[0].path(); assert!( - run_dir.join("detach.log").exists(), - "detach.log should exist in run dir" + storage_dir + .join("launchers") + .join(format!("{ulid}.log")) + .exists(), + "launcher log should exist under storage_dir/launchers" ); + assert!(!run_dir.join("detach.log").exists()); } // == Resume =================================================================== @@ -790,7 +795,7 @@ fn setup_run_dir( let run_record = serde_json::json!({ "run_id": run_id, "created_at": "2026-01-01T00:00:00Z", - "config": { + "settings": { "goal": overrides.get("goal").and_then(|v| v.as_str()), "llm": { "model": get_str("model", "test-model"), @@ -1146,10 +1151,14 @@ digraph G { let output = arc() .args([ "__detached", - "--storage-dir", - storage_dir.to_str().unwrap(), - "--run-id", - "test-bug2", + "--run-dir", + run_dir.to_str().unwrap(), + "--launcher-path", + storage_dir + .join("launchers") + .join("test-bug2.json") + .to_str() + .unwrap(), ]) .env("NO_COLOR", "1") .timeout(std::time::Duration::from_secs(15)) @@ -1213,7 +1222,7 @@ digraph Test { .success(); let before: serde_json::Value = serde_json::from_slice(&inspect_before.get_output().stdout).unwrap(); - let _run_dir = before[0]["run_dir"].as_str().unwrap().to_string(); + let run_dir = before[0]["run_dir"].as_str().unwrap().to_string(); let start_time_before = before[0]["start_record"]["start_time"] .as_str() .unwrap() @@ -1228,10 +1237,14 @@ digraph Test { .env("HOME", home.path()) .args([ "__detached", - "--storage-dir", - storage_dir.to_str().unwrap(), - "--run-id", - &run_id, + "--run-dir", + &run_dir, + "--launcher-path", + storage_dir + .join("launchers") + .join(format!("{run_id}.json")) + .to_str() + .unwrap(), "--resume", ]) .timeout(std::time::Duration::from_secs(10)) @@ -1384,13 +1397,6 @@ fn attach_closed_stdin_keeps_interview_pending() { !run_dir.join("interview_request.claim").exists(), "attach with closed stdin must release the claim so a later attach can answer" ); - - let progress = std::fs::read_to_string(run_dir.join("progress.jsonl")).unwrap(); - assert!( - progress.contains("\"event\":\"RunNotice\"") - && progress.contains("\"code\":\"interview_unanswered\""), - "attach should emit a structured warning when the interview ends without an answer.\nprogress: {progress}" - ); } // Bug 4: attach should respect the verbose flag from run.json. diff --git a/lib/crates/fabro-workflows/src/git.rs b/lib/crates/fabro-workflows/src/git.rs index 0ce966f6f..7dc3603e8 100644 --- a/lib/crates/fabro-workflows/src/git.rs +++ b/lib/crates/fabro-workflows/src/git.rs @@ -614,7 +614,7 @@ mod tests { init_repo(dir.path()); let store = MetadataStore::new(dir.path(), &GitAuthor::default()); - let run_record = br#"{"run_id":"RUN1","created_at":"2025-01-01T00:00:00Z","config":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#; + let run_record = br#"{"run_id":"RUN1","created_at":"2025-01-01T00:00:00Z","settings":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#; store.init_run("RUN1", &[("run.json", run_record)]).unwrap(); let read_record = MetadataStore::read_run_record(dir.path(), "RUN1") @@ -794,7 +794,7 @@ mod tests { init_repo(dir.path()); let store = MetadataStore::new(dir.path(), &GitAuthor::default()); - let run_record = br#"{"run_id":"RUN5","created_at":"2025-01-01T00:00:00Z","config":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#; + let run_record = br#"{"run_id":"RUN5","created_at":"2025-01-01T00:00:00Z","settings":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#; store.init_run("RUN5", &[("run.json", run_record)]).unwrap(); store diff --git a/lib/crates/fabro-workflows/src/operations/create.rs b/lib/crates/fabro-workflows/src/operations/create.rs index 6062f2bfc..690b8230b 100644 --- a/lib/crates/fabro-workflows/src/operations/create.rs +++ b/lib/crates/fabro-workflows/src/operations/create.rs @@ -5,6 +5,7 @@ use chrono::{Local, Utc}; use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; +use fabro_sandbox::SandboxProvider; use crate::error::FabroError; use crate::pipeline::types::PersistOptions; @@ -12,6 +13,10 @@ use crate::pipeline::{self, Persisted, TransformOptions, Validated}; use crate::records::RunRecord; use crate::transforms::{expand_vars, Transform}; +use super::source::{resolve_workflow, ResolveWorkflowRequest, WorkflowInput}; + +const RUN_CONFIG_FILE: &str = "workflow.toml"; + #[derive(Default)] pub struct ValidateOptions { pub base_dir: Option, @@ -20,17 +25,52 @@ pub struct ValidateOptions { pub goal_override: Option, } -pub struct RunCreateOptions { +#[derive(Clone, Debug)] +pub struct CreateRequest { + pub workflow: WorkflowInput, pub settings: FabroSettings, pub run_dir: Option, pub run_id: Option, - pub workflow_slug: Option, - pub labels: HashMap, - pub base_branch: Option, - pub working_directory: Option, pub host_repo_path: Option, - pub goal_override: Option, - pub base_dir: Option, + pub base_branch: Option, +} + +impl Default for CreateRequest { + fn default() -> Self { + Self { + workflow: WorkflowInput::DotSource { + source: String::new(), + base_dir: None, + workflow_slug: None, + }, + settings: FabroSettings::default(), + run_dir: None, + run_id: None, + host_repo_path: None, + base_branch: None, + } + } +} + +#[derive(Debug)] +pub struct CreatedRun { + pub persisted: Persisted, + pub run_id: String, + pub run_dir: PathBuf, + pub dot_path: Option, +} + +struct PersistCreateOptions { + settings: FabroSettings, + run_dir: Option, + run_id: Option, + workflow_slug: Option, + labels: HashMap, + base_branch: Option, + working_directory: Option, + host_repo_path: Option, + goal_override: Option, + base_dir: Option, } /// Parse, transform, and validate a DOT source string. @@ -61,8 +101,104 @@ pub fn validate_from_file(path: &Path) -> Result { ) } -/// Parse, transform, validate, resolve settings, and persist a run. -pub fn create(dot_source: &str, options: RunCreateOptions) -> Result { +/// Resolve workflow inputs, normalize settings, and persist a run directory. +pub fn create(request: CreateRequest) -> Result { + let resolved = resolve_workflow(ResolveWorkflowRequest { + workflow: request.workflow, + settings: request.settings, + }) + .map_err(|err| FabroError::Parse(err.to_string()))?; + + if !resolved.settings.dry_run_enabled() { + validate_sandbox_provider(&resolved.settings)?; + } + + let CreateRequest { + workflow: _, + settings: _, + run_dir, + run_id, + host_repo_path, + base_branch, + } = request; + + let settings = resolved.settings.clone(); + let run_id = run_id.unwrap_or_else(|| ulid::Ulid::new().to_string()); + let storage_dir = settings.storage_dir(); + let run_dir = run_dir.unwrap_or_else(|| { + make_run_dir( + &storage_dir.join("runs"), + &run_id, + settings.dry_run_enabled(), + ) + }); + let working_directory = resolved.working_directory.clone(); + let host_repo_path = + host_repo_path.or_else(|| Some(working_directory.to_string_lossy().to_string())); + let base_branch = base_branch.or_else(|| { + fabro_sandbox::daytona::detect_repo_info(&working_directory) + .ok() + .and_then(|(_, branch)| branch) + }); + + let persisted = create_from_source( + &resolved.raw_source, + PersistCreateOptions { + settings, + run_dir: Some(run_dir.clone()), + run_id: Some(run_id.clone()), + workflow_slug: resolved.workflow_slug.clone(), + labels: resolved.settings.labels.clone(), + base_branch, + working_directory: Some(working_directory), + host_repo_path, + goal_override: resolved.goal_override.clone(), + base_dir: resolved.base_dir.clone(), + }, + )?; + + write_run_config_snapshot(&run_dir, resolved.workflow_toml_path.as_deref())?; + crate::run_status::write_run_status(&run_dir, crate::run_status::RunStatus::Submitted, None); + + Ok(CreatedRun { + persisted, + run_id, + run_dir, + dot_path: resolved.dot_path, + }) +} + +fn validate_sandbox_provider(settings: &FabroSettings) -> Result<(), FabroError> { + if let Some(provider) = settings + .sandbox_settings() + .and_then(|sandbox| sandbox.provider.as_deref()) + { + provider + .parse::() + .map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))?; + } + + Ok(()) +} + +fn write_run_config_snapshot( + run_dir: &Path, + workflow_toml_path: Option<&Path>, +) -> Result<(), FabroError> { + if let Some(toml_path) = workflow_toml_path { + if toml_path.is_file() { + std::fs::copy(toml_path, run_dir.join(RUN_CONFIG_FILE)) + .map_err(|err| FabroError::Io(err.to_string()))?; + } + } + + Ok(()) +} + +fn create_from_source( + dot_source: &str, + options: PersistCreateOptions, +) -> Result { let validated = preprocess_and_validate( dot_source, options.base_dir.clone(), @@ -80,18 +216,6 @@ pub fn create(dot_source: &str, options: RunCreateOptions) -> Result Result { - let source = std::fs::read_to_string(path) - .map_err(|e| FabroError::Parse(format!("Failed to read {}: {e}", path.display())))?; - let base_dir = path.parent().unwrap_or(Path::new(".")); - options.base_dir = Some(base_dir.to_path_buf()); - create(&source, options) -} - fn preprocess_and_validate( dot_source: &str, base_dir: Option, @@ -102,7 +226,6 @@ fn preprocess_and_validate( let source = match settings.and_then(|resolved| resolved.vars.as_ref()) { Some(vars) => { let mut vars = vars.clone(); - // `$goal` is resolved later from the graph goal after any goal override. vars.insert("goal".to_string(), "$goal".to_string()); expand_vars(dot_source, &vars) .map_err(|e| FabroError::Parse(format!("var expansion failed: {e}")))? @@ -134,9 +257,9 @@ fn apply_goal_override(graph: &mut Graph, goal_override: Option<&str>) { fn persist_validated( validated: Validated, - options: RunCreateOptions, + options: PersistCreateOptions, ) -> Result { - let RunCreateOptions { + let PersistCreateOptions { settings, run_dir, run_id, @@ -406,21 +529,15 @@ mod tests { graph [goal="Test"] work [label="Work"] }"#; - let err = create( - dot, - RunCreateOptions { - settings: FabroSettings::default(), - run_dir: None, - run_id: None, - workflow_slug: None, - labels: HashMap::new(), - base_branch: None, - working_directory: None, - host_repo_path: None, - goal_override: None, + let err = create(CreateRequest { + workflow: WorkflowInput::DotSource { + source: dot.to_string(), base_dir: None, + workflow_slug: None, }, - ) + run_dir: Some(tempfile::tempdir().unwrap().path().join("run")), + ..Default::default() + }) .unwrap_err(); match err { @@ -432,41 +549,42 @@ mod tests { } #[test] - fn create_persists_normalized_config() { + fn create_persists_normalized_config_and_initial_state() { let dir = tempfile::tempdir().unwrap(); - let persisted = create( - MINIMAL_DOT, - RunCreateOptions { - settings: FabroSettings { - llm: Some(fabro_config::run::LlmSettings { - model: Some("sonnet".to_string()), - provider: None, - fallbacks: None, - }), - pull_request: Some(fabro_config::run::PullRequestSettings { - enabled: false, - ..Default::default() - }), - dry_run: Some(true), - ..Default::default() - }, - run_dir: Some(dir.path().join("run")), - run_id: Some("run-123".to_string()), - workflow_slug: Some("slug".to_string()), - labels: HashMap::from([("env".to_string(), "test".to_string())]), - base_branch: Some("main".to_string()), - working_directory: Some(dir.path().to_path_buf()), - host_repo_path: Some(dir.path().display().to_string()), - goal_override: Some("override goal".to_string()), + let created = create(CreateRequest { + workflow: WorkflowInput::DotSource { + source: MINIMAL_DOT.to_string(), base_dir: None, + workflow_slug: Some("slug".to_string()), }, - ) + settings: FabroSettings { + llm: Some(fabro_config::run::LlmSettings { + model: Some("sonnet".to_string()), + provider: None, + fallbacks: None, + }), + pull_request: Some(fabro_config::run::PullRequestSettings { + enabled: false, + ..Default::default() + }), + goal: Some("override goal".to_string()), + dry_run: Some(true), + labels: HashMap::from([("env".to_string(), "test".to_string())]), + ..Default::default() + }, + run_dir: Some(dir.path().join("run")), + run_id: Some("run-123".to_string()), + host_repo_path: Some(dir.path().display().to_string()), + base_branch: Some("main".to_string()), + ..Default::default() + }) .unwrap(); - assert_eq!(persisted.run_record().run_id, "run-123"); - assert_eq!(persisted.run_record().graph.goal(), "override goal"); + assert_eq!(created.run_id, "run-123"); + assert_eq!(created.persisted.run_record().graph.goal(), "override goal"); assert_eq!( - persisted + created + .persisted .run_record() .settings .llm @@ -475,7 +593,8 @@ mod tests { Some("claude-sonnet-4-6") ); assert_eq!( - persisted + created + .persisted .run_record() .settings .llm @@ -484,13 +603,54 @@ mod tests { Some("anthropic") ); assert_eq!( - persisted.run_record().settings.goal.as_deref(), + created.persisted.run_record().settings.goal.as_deref(), Some("override goal") ); - assert!(persisted.run_record().settings.pull_request.is_none()); + assert!(created + .persisted + .run_record() + .settings + .pull_request + .is_none()); assert_eq!( - persisted.run_record().workflow_slug.as_deref(), + created.persisted.run_record().workflow_slug.as_deref(), Some("slug") ); + assert_eq!( + crate::run_status::RunStatusRecord::load(&created.run_dir.join("status.json")) + .unwrap() + .status, + crate::run_status::RunStatus::Submitted + ); + assert!(!created.run_dir.join("id.txt").exists()); + } + + #[test] + fn create_copies_workflow_toml_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let workflow_dir = dir.path().join("workflow"); + std::fs::create_dir_all(&workflow_dir).unwrap(); + std::fs::write(workflow_dir.join("workflow.fabro"), MINIMAL_DOT).unwrap(); + std::fs::write( + workflow_dir.join("workflow.toml"), + "version = 1\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + + let created = create(CreateRequest { + workflow: WorkflowInput::Path(workflow_dir.join("workflow.toml")), + settings: FabroSettings { + storage_dir: Some(dir.path().join("storage")), + dry_run: Some(true), + ..Default::default() + }, + ..Default::default() + }) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(created.run_dir.join("workflow.toml")).unwrap(), + "version = 1\ngraph = \"workflow.fabro\"\n" + ); } } diff --git a/lib/crates/fabro-workflows/src/operations/mod.rs b/lib/crates/fabro-workflows/src/operations/mod.rs index 83ea14909..f01c4dae8 100644 --- a/lib/crates/fabro-workflows/src/operations/mod.rs +++ b/lib/crates/fabro-workflows/src/operations/mod.rs @@ -1,19 +1,21 @@ mod create; mod fork; mod rewind; +mod source; mod start; pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec, SandboxSpec}; pub use create::{ - create, create_from_file, default_run_dir, make_run_dir, validate, validate_from_file, - RunCreateOptions, ValidateOptions, + create, default_run_dir, make_run_dir, validate, validate_from_file, CreateRequest, CreatedRun, + ValidateOptions, }; pub use fork::fork; pub use rewind::{ build_timeline, find_run_id_by_prefix, load_parallel_map, parse_target, resolve_target, rewind, TimelineEntry, }; -pub use start::{ - resume, start, StartFinalizeOptions, StartOptions, StartPullRequestConfig, StartRetroOptions, - Started, +pub use source::{ + resolve_settings_for_path, resolve_workflow, resolve_workflow_path, ResolveWorkflowRequest, + ResolvedWorkflow, WorkflowInput, WorkflowPathResolution, }; +pub use start::{resume, start, StartServices, Started}; diff --git a/lib/crates/fabro-workflows/src/operations/rewind.rs b/lib/crates/fabro-workflows/src/operations/rewind.rs index 5652a6f62..08aaa8019 100644 --- a/lib/crates/fabro-workflows/src/operations/rewind.rs +++ b/lib/crates/fabro-workflows/src/operations/rewind.rs @@ -327,9 +327,12 @@ pub fn load_parallel_map(store: &Store, run_id: &str) -> HashMap } } - let graph_bytes = match bs.read_entry("graph.fabro") { + let graph_bytes = match bs.read_entry("workflow.fabro") { Ok(Some(bytes)) => bytes, - _ => return HashMap::new(), + _ => match bs.read_entry("graph.fabro") { + Ok(Some(bytes)) => bytes, + _ => return HashMap::new(), + }, }; let dot_source = String::from_utf8_lossy(&graph_bytes); let graph = match fabro_graphviz::parser::parse(&dot_source) { diff --git a/lib/crates/fabro-workflows/src/operations/source.rs b/lib/crates/fabro-workflows/src/operations/source.rs new file mode 100644 index 000000000..074674b7d --- /dev/null +++ b/lib/crates/fabro-workflows/src/operations/source.rs @@ -0,0 +1,235 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use fabro_config::{project as project_config, run as run_config, FabroConfig, FabroSettings}; + +const RUN_GRAPH_FILE: &str = "workflow.fabro"; +const LEGACY_RUN_GRAPH_FILE: &str = "graph.fabro"; + +#[derive(Clone, Debug)] +pub enum WorkflowInput { + Path(PathBuf), + DotSource { + source: String, + base_dir: Option, + workflow_slug: Option, + }, +} + +#[derive(Clone, Debug)] +pub struct WorkflowPathResolution { + pub resolved_workflow_path: PathBuf, + pub dot_path: PathBuf, + pub workflow_config: Option, + pub workflow_toml_path: Option, + pub workflow_slug: Option, +} + +#[derive(Clone, Debug)] +pub struct ResolveWorkflowRequest { + pub workflow: WorkflowInput, + pub settings: FabroSettings, +} + +#[derive(Clone, Debug)] +pub struct ResolvedWorkflow { + pub raw_source: String, + pub settings: FabroSettings, + pub workflow_slug: Option, + pub workflow_toml_path: Option, + pub dot_path: Option, + pub resolved_workflow_path: Option, + pub base_dir: Option, + pub goal_override: Option, + pub working_directory: PathBuf, +} + +fn resolve_goal_file( + goal_file: Option<&Path>, + working_directory: &Path, +) -> anyhow::Result> { + let Some(goal_file) = goal_file else { + return Ok(None); + }; + let expanded = fabro_util::path::expand_tilde(goal_file); + let goal_path = if expanded.is_absolute() { + expanded + } else { + working_directory.join(expanded) + }; + let content = std::fs::read_to_string(&goal_path) + .with_context(|| format!("failed to read goal file: {}", goal_path.display()))?; + tracing::debug!(path = %goal_path.display(), "Goal loaded from file"); + Ok(Some(content)) +} + +fn resolve_working_directory(settings: &FabroSettings, caller_cwd: &Path) -> PathBuf { + let Some(work_dir) = settings.work_dir.as_deref() else { + return caller_cwd.to_path_buf(); + }; + let path = PathBuf::from(work_dir); + if path.is_absolute() { + path + } else { + caller_cwd.join(path) + } +} + +pub fn workflow_slug_from_path(workflow_path: &Path) -> Option { + let file_name = workflow_path.file_name()?.to_string_lossy(); + if workflow_path.extension().is_none() { + return Some(file_name.into_owned()); + } + + let file_stem = workflow_path.file_stem()?.to_string_lossy(); + if file_stem == "workflow" { + return workflow_path + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .or_else(|| Some(file_stem.into_owned())); + } + + Some(file_stem.into_owned()) +} + +pub fn resolve_workflow_path(workflow_path: &Path) -> anyhow::Result { + let path = project_config::resolve_workflow_arg(workflow_path)?; + let workflow_slug = workflow_slug_from_path(&path); + if path.extension().is_some_and(|ext| ext == "toml") { + match run_config::load_run_config(&path) { + Ok(cfg) => { + let dot_path = run_config::resolve_graph_path( + &path, + cfg.graph.as_deref().unwrap_or(RUN_GRAPH_FILE), + ); + Ok(WorkflowPathResolution { + resolved_workflow_path: path.clone(), + dot_path, + workflow_config: Some(cfg), + workflow_toml_path: Some(path), + workflow_slug, + }) + } + Err(_) + if !path.exists() && path.starts_with(crate::run_lookup::default_runs_base()) => + { + let canonical = path.with_file_name(RUN_GRAPH_FILE); + let legacy = path.with_file_name(LEGACY_RUN_GRAPH_FILE); + let dot_path = if canonical.exists() || !legacy.exists() { + canonical + } else { + legacy + }; + Ok(WorkflowPathResolution { + resolved_workflow_path: path, + dot_path, + workflow_config: None, + workflow_toml_path: None, + workflow_slug, + }) + } + Err(err) => Err(err), + } + } else { + Ok(WorkflowPathResolution { + resolved_workflow_path: path.clone(), + dot_path: path, + workflow_config: None, + workflow_toml_path: None, + workflow_slug, + }) + } +} + +pub fn resolve_settings_for_path( + workflow_path: &Path, + defaults: FabroConfig, + overrides: FabroConfig, + apply_project_config: bool, +) -> anyhow::Result { + let resolution = resolve_workflow_path(workflow_path)?; + if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() { + anyhow::bail!( + "Workflow not found: {}", + resolution.resolved_workflow_path.display() + ); + } + + let project_config = if apply_project_config { + project_config::discover_project_config( + resolution + .resolved_workflow_path + .parent() + .unwrap_or_else(|| Path::new(".")), + )? + .map(|(_, config)| config) + .unwrap_or_default() + } else { + FabroConfig::default() + }; + + overrides + .combine(resolution.workflow_config.unwrap_or_default()) + .combine(project_config) + .combine(defaults) + .try_into() +} + +pub fn resolve_workflow(request: ResolveWorkflowRequest) -> anyhow::Result { + let caller_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + match request.workflow { + WorkflowInput::Path(workflow_path) => { + let resolution = resolve_workflow_path(&workflow_path)?; + let settings = request.settings; + let working_directory = resolve_working_directory(&settings, &caller_cwd); + let raw_source = std::fs::read_to_string(&resolution.dot_path) + .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; + let goal_override = settings.goal.clone().or(resolve_goal_file( + settings.goal_file.as_deref(), + &working_directory, + )?); + + Ok(ResolvedWorkflow { + raw_source, + settings, + workflow_slug: resolution.workflow_slug, + workflow_toml_path: resolution.workflow_toml_path, + dot_path: Some(resolution.dot_path.clone()), + resolved_workflow_path: Some(resolution.resolved_workflow_path), + base_dir: Some( + resolution + .dot_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(), + ), + goal_override, + working_directory, + }) + } + WorkflowInput::DotSource { + source, + base_dir, + workflow_slug, + } => { + let settings = request.settings; + let working_directory = resolve_working_directory(&settings, &caller_cwd); + let goal_override = settings.goal.clone().or(resolve_goal_file( + settings.goal_file.as_deref(), + &working_directory, + )?); + Ok(ResolvedWorkflow { + raw_source: source, + settings, + workflow_slug, + workflow_toml_path: None, + dot_path: None, + resolved_workflow_path: None, + base_dir, + goal_override, + working_directory, + }) + } + } +} diff --git a/lib/crates/fabro-workflows/src/operations/start.rs b/lib/crates/fabro-workflows/src/operations/start.rs index 7507a136b..1833ebe09 100644 --- a/lib/crates/fabro-workflows/src/operations/start.rs +++ b/lib/crates/fabro-workflows/src/operations/start.rs @@ -1,59 +1,74 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use chrono::Utc; +use fabro_config::sandbox::WorktreeMode; +use fabro_config::FabroSettings; +use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; +use fabro_interview::{AutoApproveInterviewer, Interviewer}; +use fabro_model::{Catalog, FallbackTarget, Provider}; +use fabro_sandbox::SandboxProvider; +use serde::Serialize; + use crate::context::Context; use crate::error::FabroError; -use crate::event::{EventEmitter, ProgressLogger, WorkflowRunEvent}; -use crate::outcome::StageStatus; +use crate::event::{EventEmitter, ProgressLogger, RunNoticeLevel, WorkflowRunEvent}; +use crate::outcome::{Outcome, StageStatus}; use crate::pipeline::{ - self, DevcontainerSpec, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, - PullRequestOptions, RetroOptions, SandboxEnvSpec, SandboxSpec, + self, build_conclusion, classify_engine_result, persist_terminal_outcome, DevcontainerSpec, + FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, PullRequestOptions, RetroOptions, + SandboxEnvSpec, SandboxSpec, }; use crate::records::{Checkpoint, Conclusion}; use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; -use fabro_config::sandbox::WorktreeMode; -use fabro_interview::Interviewer; +use crate::run_status::{self, RunStatus, StatusReason}; -pub struct StartRetroOptions { - pub enabled: bool, +struct StartRetroOptions { + enabled: bool, } -pub struct StartFinalizeOptions { - pub preserve_sandbox: bool, +struct StartFinalizeOptions { + preserve_sandbox: bool, } -pub struct StartPullRequestConfig { - pub pr_config: Option, - pub github_app: Option, - pub origin_url: Option, - pub model: String, +struct StartPullRequestConfig { + pr_config: Option, + github_app: Option, + origin_url: Option, + model: String, } -/// Options for `start()` and `resume()`. -/// -/// Fields that are derivable from `RunRecord` (run_id, labels, base_branch, -/// host_repo_path, settings, workflow_slug) are read from disk by `run_engine()`. -/// Callers only provide truly external values. -pub struct StartOptions { - // Truly external (not derivable from RunRecord) - pub cancel_token: Option>, +struct InternalStartOptions { + cancel_token: Option>, + emitter: Arc, + sandbox: SandboxSpec, + llm: LlmSpec, + interviewer: Arc, + lifecycle: LifecycleOptions, + hooks: fabro_hooks::HookConfig, + sandbox_env: SandboxEnvSpec, + devcontainer: Option, + seed_context: Option, + git_author: crate::git::GitAuthor, + git: Option, + github_app: Option, + worktree_mode: Option, + registry_override: Option>, + retro: StartRetroOptions, + finalize: StartFinalizeOptions, + pull_request: StartPullRequestConfig, +} + +pub struct StartServices { + pub cancel_token: Option>, pub emitter: Arc, - pub sandbox: SandboxSpec, - pub llm: LlmSpec, pub interviewer: Arc, - pub lifecycle: LifecycleOptions, - pub hooks: fabro_hooks::HookConfig, - pub sandbox_env: SandboxEnvSpec, - pub devcontainer: Option, - pub seed_context: Option, pub git_author: crate::git::GitAuthor, - pub git: Option, pub github_app: Option, - pub worktree_mode: Option, pub registry_override: Option>, - pub retro: StartRetroOptions, - pub finalize: StartFinalizeOptions, - pub pull_request: StartPullRequestConfig, } pub struct Started { @@ -63,26 +78,29 @@ pub struct Started { } /// Start a fresh workflow run. Errors if a checkpoint already exists (use `resume()` instead). -pub async fn start( - run_dir: &std::path::Path, - options: StartOptions, -) -> Result { +pub async fn start(run_dir: &Path, services: StartServices) -> Result { if run_dir.join("checkpoint.json").exists() { return Err(FabroError::Precondition( "checkpoint.json exists in run directory — did you mean to resume?".to_string(), )); } - let persisted = Persisted::load(run_dir)?; - run_engine(persisted, None, options).await + + if let Ok(record) = run_status::RunStatusRecord::load(&run_dir.join("status.json")) { + if !matches!(record.status, RunStatus::Submitted | RunStatus::Starting) { + return Err(FabroError::Precondition(format!( + "cannot start run: status is {:?}, expected submitted", + record.status + ))); + } + } + + execute_persisted_run(run_dir, None, services).await } /// Resume a workflow run from its checkpoint. Errors if no checkpoint is found. -pub async fn resume( - run_dir: &std::path::Path, - options: StartOptions, -) -> Result { - if let Ok(record) = crate::run_status::RunStatusRecord::load(&run_dir.join("status.json")) { - if record.status == crate::run_status::RunStatus::Succeeded { +pub async fn resume(run_dir: &Path, services: StartServices) -> Result { + if let Ok(record) = run_status::RunStatusRecord::load(&run_dir.join("status.json")) { + if record.status == RunStatus::Succeeded { return Err(FabroError::Precondition( "run already finished successfully — nothing to resume".to_string(), )); @@ -98,22 +116,358 @@ pub async fn resume( )); } } + let cp_path = run_dir.join("checkpoint.json"); let checkpoint = Checkpoint::load(&cp_path) .map_err(|e| FabroError::Precondition(format!("no checkpoint to resume from: {e}")))?; - let persisted = Persisted::load(run_dir)?; - run_engine(persisted, Some(checkpoint), options).await + + cleanup_resume_artifacts(run_dir); + run_status::write_run_status(run_dir, RunStatus::Submitted, None); + + execute_persisted_run(run_dir, Some(checkpoint), services).await +} + +async fn execute_persisted_run( + run_dir: &Path, + checkpoint: Option, + services: StartServices, +) -> Result { + let mut bootstrap_guard = DetachedRunBootstrapGuard::arm(run_dir)?; + + let persisted = match Persisted::load(run_dir) { + Ok(persisted) => persisted, + Err(err) => { + let _ = + persist_detached_failure(run_dir, "bootstrap", StatusReason::BootstrapFailed, &err); + bootstrap_guard.defuse(); + return Err(err); + } + }; + + let original_cwd = std::env::current_dir().ok(); + if let Err(err) = std::env::set_current_dir(&persisted.run_record().working_directory) { + let err = FabroError::Io(format!( + "Failed to set working directory to {}: {err}", + persisted.run_record().working_directory.display() + )); + let _ = persist_detached_failure(run_dir, "bootstrap", StatusReason::BootstrapFailed, &err); + bootstrap_guard.defuse(); + return Err(err); + } + let _cwd_guard = scopeguard::guard(original_cwd, |cwd| { + if let Some(cwd) = cwd { + let _ = std::env::set_current_dir(cwd); + } + }); + + let options = match derive_start_options(&persisted, services) { + Ok(options) => options, + Err(err) => { + let _ = + persist_detached_failure(run_dir, "bootstrap", StatusReason::BootstrapFailed, &err); + bootstrap_guard.defuse(); + return Err(err); + } + }; + + bootstrap_guard.defuse(); + let mut completion_guard = DetachedRunCompletionGuard::arm(run_dir); + let run_start = Instant::now(); + let started = run_engine(persisted, checkpoint, options).await; + + match started { + Ok(started) => { + completion_guard.defuse(); + Ok(started) + } + Err(err) => { + persist_terminal_engine_failure(run_dir, &err, run_start.elapsed()); + completion_guard.defuse(); + Err(err) + } + } +} + +fn persist_terminal_engine_failure(run_dir: &Path, error: &FabroError, duration: Duration) { + let engine_result: Result = Err(error.clone()); + let (final_status, failure_reason, run_status, status_reason) = + classify_engine_result(&engine_result); + let conclusion = build_conclusion( + run_dir, + final_status, + failure_reason, + duration.as_millis() as u64, + None, + ); + persist_terminal_outcome(run_dir, &conclusion, run_status, status_reason); +} + +fn cleanup_resume_artifacts(run_dir: &Path) { + for name in [ + "conclusion.json", + "pull_request.json", + "detached_failure.json", + "interview_request.json", + "interview_response.json", + "interview_request.claim", + "progress.jsonl", + ] { + let _ = std::fs::remove_file(run_dir.join(name)); + } +} + +fn derive_start_options( + persisted: &Persisted, + services: StartServices, +) -> Result { + let record = persisted.run_record(); + let mut settings = record.settings.clone(); + let working_directory = record.working_directory.clone(); + + if let Some(env) = settings + .sandbox + .as_mut() + .and_then(|sandbox| sandbox.env.as_mut()) + { + run_config::resolve_env_refs(env) + .map_err(|err| FabroError::Precondition(err.to_string()))?; + } + + let (origin_url, detected_base_branch) = + fabro_sandbox::daytona::detect_repo_info(&working_directory) + .map(|(url, branch)| (Some(url), branch)) + .unwrap_or((None, None)); + + let sandbox_provider = resolve_sandbox_provider(&settings)?; + let sandbox_provider = + if settings.dry_run_enabled() && !matches!(sandbox_provider, SandboxProvider::Local) { + SandboxProvider::Local + } else { + sandbox_provider + }; + let model = settings + .llm + .as_ref() + .and_then(|llm| llm.model.clone()) + .unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone()); + let provider = settings + .llm + .as_ref() + .and_then(|llm| llm.provider.clone()) + .filter(|value| !value.is_empty()); + + let provider_enum: Provider = provider + .as_deref() + .map(|value| value.parse::()) + .transpose() + .map_err(|err| FabroError::Precondition(err.to_string()))? + .unwrap_or_else(Provider::default_from_env); + + let fallback_chain = resolve_fallback_chain(provider_enum, &model, &settings); + let mcp_servers = settings + .mcp_server_entries() + .clone() + .into_iter() + .map(|(name, entry)| entry.into_config(name)) + .collect(); + + let sandbox = match sandbox_provider { + SandboxProvider::Local => SandboxSpec::Local { + working_directory: working_directory.clone(), + }, + SandboxProvider::Docker => SandboxSpec::Docker { + config: fabro_agent::DockerSandboxConfig { + host_working_directory: working_directory.to_string_lossy().to_string(), + ..Default::default() + }, + }, + SandboxProvider::Daytona => SandboxSpec::Daytona { + config: resolve_daytona_config(&settings).unwrap_or_default(), + github_app: services.github_app.clone(), + run_id: Some(record.run_id.clone()), + clone_branch: detected_base_branch.or_else(|| record.base_branch.clone()), + }, + #[cfg(feature = "exedev")] + SandboxProvider::Exe => SandboxSpec::Exe { + config: resolve_exe_config(&settings).unwrap_or_default(), + clone_params: resolve_exe_clone_params(&working_directory), + run_id: Some(record.run_id.clone()), + github_app: services.github_app.clone(), + mgmt_destination: "exe.dev".to_string(), + }, + #[cfg(not(feature = "exedev"))] + SandboxProvider::Exe => { + return Err(FabroError::Precondition( + "exe sandbox requires the exedev feature".to_string(), + )); + } + SandboxProvider::Ssh => SandboxSpec::Ssh { + config: resolve_ssh_config(&settings).ok_or_else(|| { + FabroError::Precondition("--sandbox ssh requires [sandbox.ssh] config".to_string()) + })?, + clone_params: resolve_ssh_clone_params(&working_directory), + run_id: Some(record.run_id.clone()), + github_app: services.github_app.clone(), + }, + }; + + let sandbox_env = SandboxEnvSpec { + devcontainer_env: HashMap::new(), + toml_env: settings + .sandbox_settings() + .and_then(|sandbox| sandbox.env.clone()) + .unwrap_or_default(), + github_permissions: settings.github_permissions().cloned(), + origin_url: origin_url.clone(), + }; + + let devcontainer = settings + .sandbox_settings() + .and_then(|sandbox| sandbox.devcontainer) + .unwrap_or(false) + .then(|| DevcontainerSpec { + enabled: true, + resolve_dir: working_directory.clone(), + }); + + let interviewer: Arc = if settings.auto_approve_enabled() { + Arc::new(AutoApproveInterviewer) + } else { + services.interviewer + }; + + Ok(InternalStartOptions { + cancel_token: services.cancel_token, + emitter: services.emitter, + sandbox, + llm: LlmSpec { + model: model.clone(), + provider: provider_enum, + fallback_chain, + mcp_servers, + dry_run: settings.dry_run_enabled(), + }, + interviewer, + lifecycle: LifecycleOptions { + setup_commands: settings.setup_commands().to_vec(), + setup_command_timeout_ms: settings.setup_timeout_ms().unwrap_or(300_000), + devcontainer_phases: Vec::new(), + }, + hooks: fabro_hooks::HookConfig { + hooks: settings.hooks.clone(), + }, + sandbox_env, + devcontainer, + seed_context: None, + git_author: services.git_author, + git: None, + github_app: services.github_app.clone(), + worktree_mode: Some(resolve_worktree_mode(&settings)), + registry_override: services.registry_override, + retro: StartRetroOptions { + enabled: !settings.no_retro_enabled() && project_config::is_retro_enabled(), + }, + finalize: StartFinalizeOptions { + preserve_sandbox: resolve_preserve_sandbox(&settings), + }, + pull_request: StartPullRequestConfig { + pr_config: settings.pull_request.clone(), + github_app: services.github_app, + origin_url, + model, + }, + }) +} + +fn resolve_sandbox_provider(settings: &FabroSettings) -> Result { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.provider.as_deref()) + .map(|provider| provider.parse::()) + .transpose() + .map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))? + .map_or_else(|| Ok(SandboxProvider::default()), Ok) +} + +fn resolve_preserve_sandbox(settings: &FabroSettings) -> bool { + settings.preserve_sandbox_enabled() +} + +fn resolve_worktree_mode(settings: &FabroSettings) -> sandbox_config::WorktreeMode { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.local.as_ref()) + .map(|local| local.worktree_mode) + .unwrap_or_default() +} + +fn resolve_daytona_config( + settings: &FabroSettings, +) -> Option { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.daytona.clone()) +} + +#[cfg(feature = "exedev")] +fn resolve_exe_config(settings: &FabroSettings) -> Option { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.exe.clone()) +} + +#[cfg(feature = "exedev")] +fn resolve_exe_clone_params(cwd: &Path) -> Option { + let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) { + Ok(info) => info, + Err(err) => { + tracing::warn!("No git repo detected for exe.dev clone: {err}"); + return None; + } + }; + let url = fabro_github::ssh_url_to_https(&detected_url); + Some(fabro_sandbox::exe::GitCloneParams { url, branch }) +} + +fn resolve_ssh_config(settings: &FabroSettings) -> Option { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.ssh.clone()) +} + +fn resolve_ssh_clone_params(cwd: &Path) -> Option { + let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) { + Ok(info) => info, + Err(err) => { + tracing::warn!("No git repo detected for SSH clone: {err}"); + return None; + } + }; + let url = fabro_github::ssh_url_to_https(&detected_url); + Some(fabro_sandbox::ssh::GitCloneParams { url, branch }) +} + +fn resolve_fallback_chain( + provider: Provider, + model: &str, + settings: &FabroSettings, +) -> Vec { + let fallbacks = settings.llm.as_ref().and_then(|llm| llm.fallbacks.as_ref()); + + match fallbacks { + Some(map) => Catalog::builtin().build_fallback_chain(provider, model, map), + None => Vec::new(), + } } /// Shared engine: initialize, execute, retro, finalize, pull_request. async fn run_engine( persisted: Persisted, checkpoint: Option, - options: StartOptions, + options: InternalStartOptions, ) -> Result { let preserve_sandbox = options.finalize.preserve_sandbox; - // Build RunOptions from the persisted RunRecord + external caller options. let record = persisted.run_record(); let run_options = RunOptions { settings: record.settings.clone(), @@ -124,10 +478,7 @@ async fn run_engine( git_author: options.git_author, workflow_slug: record.workflow_slug.clone(), github_app: options.github_app.clone(), - host_repo_path: record - .host_repo_path - .as_deref() - .map(std::path::PathBuf::from), + host_repo_path: record.host_repo_path.as_deref().map(PathBuf::from), base_branch: record.base_branch.clone(), display_base_sha: None, git: options.git.clone(), @@ -241,13 +592,188 @@ async fn run_engine( }) } +struct DetachedRunBootstrapGuard { + run_dir: PathBuf, + active: bool, +} + +impl DetachedRunBootstrapGuard { + fn arm(run_dir: &Path) -> Result { + run_status::write_run_status( + run_dir, + RunStatus::Starting, + Some(StatusReason::SandboxInitializing), + ); + Ok(Self { + run_dir: run_dir.to_path_buf(), + active: true, + }) + } + + fn defuse(&mut self) { + self.active = false; + } +} + +impl Drop for DetachedRunBootstrapGuard { + fn drop(&mut self) { + if self.active { + run_status::write_run_status( + &self.run_dir, + RunStatus::Failed, + Some(StatusReason::SandboxInitFailed), + ); + } + } +} + +const POSTRUN_ABORTED_MESSAGE: &str = "Run aborted before post-run finalization completed."; + +struct DetachedRunCompletionGuard { + run_dir: PathBuf, + active: bool, +} + +impl DetachedRunCompletionGuard { + fn arm(run_dir: &Path) -> Self { + Self { + run_dir: run_dir.to_path_buf(), + active: true, + } + } + + fn defuse(&mut self) { + self.active = false; + } +} + +impl Drop for DetachedRunCompletionGuard { + fn drop(&mut self) { + if !self.active { + return; + } + + run_status::write_run_status( + &self.run_dir, + RunStatus::Failed, + Some(StatusReason::WorkflowError), + ); + if !self.run_dir.join("conclusion.json").exists() { + let _ = write_failure_conclusion( + &self.run_dir, + POSTRUN_ABORTED_MESSAGE, + Some(StatusReason::WorkflowError), + ); + } + if let Some(run_id) = load_run_id(&self.run_dir) { + let _ = crate::event::append_progress_event( + &self.run_dir, + &run_id, + &WorkflowRunEvent::RunNotice { + level: RunNoticeLevel::Error, + code: "postrun_aborted".to_string(), + message: POSTRUN_ABORTED_MESSAGE.to_string(), + }, + ); + } + } +} + +fn load_run_id(run_dir: &Path) -> Option { + crate::records::RunRecord::load(run_dir) + .ok() + .map(|record| record.run_id) + .filter(|run_id| !run_id.trim().is_empty()) + .or_else(|| { + std::fs::read_to_string(run_dir.join("id.txt")) + .ok() + .map(|run_id| run_id.trim().to_string()) + .filter(|run_id| !run_id.is_empty()) + }) +} + +fn persist_detached_failure( + run_dir: &Path, + phase: &'static str, + reason: StatusReason, + error: &FabroError, +) -> Result<(), FabroError> { + #[derive(Serialize)] + struct DetachedFailureRecord<'a> { + timestamp: chrono::DateTime, + phase: &'a str, + reason: StatusReason, + error: String, + } + + let message = error.to_string(); + let record = DetachedFailureRecord { + timestamp: Utc::now(), + phase, + reason, + error: message.clone(), + }; + + std::fs::write( + run_dir.join("detached_failure.json"), + serde_json::to_string_pretty(&record).map_err(|err| FabroError::Io(err.to_string()))?, + ) + .map_err(|err| FabroError::Io(err.to_string()))?; + + write_failure_conclusion(run_dir, &message, Some(reason))?; + run_status::write_run_status(run_dir, RunStatus::Failed, Some(reason)); + + if let Some(run_id) = load_run_id(run_dir) { + crate::event::append_progress_event( + run_dir, + &run_id, + &WorkflowRunEvent::RunNotice { + level: RunNoticeLevel::Error, + code: format!("{phase}_failed"), + message, + }, + ) + .map_err(|err| FabroError::Io(err.to_string()))?; + } + + Ok(()) +} + +fn write_failure_conclusion( + run_dir: &Path, + message: &str, + _reason: Option, +) -> Result<(), FabroError> { + if run_dir.join("conclusion.json").exists() { + return Ok(()); + } + + let conclusion = Conclusion { + timestamp: Utc::now(), + status: StageStatus::Fail, + duration_ms: 0, + failure_reason: Some(message.to_string()), + final_git_commit_sha: None, + stages: vec![], + total_cost: None, + total_retries: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_read_tokens: 0, + total_cache_write_tokens: 0, + total_reasoning_tokens: 0, + has_pricing: false, + }; + conclusion.save(&run_dir.join("conclusion.json"))?; + Ok(()) +} + #[cfg(test)] mod tests { use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use chrono::Utc; - use fabro_agent::{LocalSandbox, Sandbox}; use fabro_config::FabroSettings; use super::*; @@ -256,8 +782,6 @@ mod tests { use crate::handler::exit::ExitHandler; use crate::handler::start::StartHandler; use crate::handler::HandlerRegistry; - use crate::pipeline::{LlmSpec, SandboxEnvSpec, SandboxSpec}; - use crate::run_options::LifecycleOptions; const MINIMAL_DOT: &str = r#"digraph Test { graph [goal="Build feature"] @@ -266,23 +790,25 @@ mod tests { start -> exit }"#; - fn persisted_workflow(dot: &str, run_dir: &std::path::Path) -> Persisted { - crate::operations::create( - dot, - crate::operations::RunCreateOptions { - settings: FabroSettings::default(), - run_dir: Some(run_dir.to_path_buf()), - run_id: Some("run-test".to_string()), - workflow_slug: Some("test".to_string()), - labels: std::collections::HashMap::new(), - base_branch: Some("main".to_string()), - working_directory: Some(std::env::current_dir().unwrap()), - host_repo_path: Some(std::env::current_dir().unwrap().display().to_string()), - goal_override: None, + fn persisted_workflow(dot: &str, run_dir: &Path) -> Persisted { + crate::operations::create(crate::operations::CreateRequest { + workflow: crate::operations::WorkflowInput::DotSource { + source: dot.to_string(), base_dir: None, + workflow_slug: Some("test".to_string()), }, - ) + settings: FabroSettings { + dry_run: Some(true), + ..Default::default() + }, + run_dir: Some(run_dir.to_path_buf()), + run_id: Some("run-test".to_string()), + host_repo_path: Some(std::env::current_dir().unwrap().display().to_string()), + base_branch: Some("main".to_string()), + ..Default::default() + }) .unwrap() + .persisted } fn test_registry() -> HandlerRegistry { @@ -292,92 +818,26 @@ mod tests { registry } - fn test_start_options( - _run_dir: &std::path::Path, - _sandbox: Arc, + fn test_start_services( emitter: Arc, registry: Arc, - lifecycle: LifecycleOptions, - preserve_sandbox: bool, - ) -> StartOptions { - StartOptions { + ) -> StartServices { + StartServices { cancel_token: None, emitter, - sandbox: SandboxSpec::Local { - working_directory: std::env::current_dir().unwrap(), - }, - llm: LlmSpec { - model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - fallback_chain: Vec::new(), - mcp_servers: Vec::new(), - dry_run: true, - }, interviewer: Arc::new(fabro_interview::AutoApproveInterviewer), - lifecycle, - hooks: fabro_hooks::HookConfig { hooks: vec![] }, - sandbox_env: SandboxEnvSpec { - devcontainer_env: HashMap::new(), - toml_env: HashMap::new(), - github_permissions: None, - origin_url: None, - }, - devcontainer: None, - seed_context: None, git_author: crate::git::GitAuthor::default(), - git: None, github_app: None, - worktree_mode: None, registry_override: Some(registry), - retro: StartRetroOptions { enabled: false }, - finalize: StartFinalizeOptions { preserve_sandbox }, - pull_request: StartPullRequestConfig { - pr_config: None, - github_app: None, - origin_url: None, - model: "test-model".to_string(), - }, } } - #[tokio::test] - async fn start_cleans_up_sandbox_when_initialize_fails() { - let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); - let emitter = Arc::new(EventEmitter::new()); - let registry = Arc::new(test_registry()); - let sandbox: Arc = - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); - - persisted_workflow(MINIMAL_DOT, &run_dir); - let result = start( - &run_dir, - test_start_options( - &run_dir, - sandbox, - emitter, - registry, - LifecycleOptions { - setup_commands: vec!["false".to_string()], - setup_command_timeout_ms: 1_000, - devcontainer_phases: vec![], - }, - false, - ), - ) - .await; - - assert!(result.is_err()); - } - #[tokio::test] async fn start_captures_checkpoint_git_sha_in_conclusion() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); let emitter = Arc::new(EventEmitter::new()); let registry = Arc::new(test_registry()); - let sandbox: Arc = - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); let injected = Arc::new(AtomicBool::new(false)); { @@ -401,23 +861,9 @@ mod tests { } persisted_workflow(MINIMAL_DOT, &run_dir); - let started = start( - &run_dir, - test_start_options( - &run_dir, - sandbox, - emitter, - registry, - LifecycleOptions { - setup_commands: vec![], - setup_command_timeout_ms: 1_000, - devcontainer_phases: vec![], - }, - true, - ), - ) - .await - .unwrap(); + let started = start(&run_dir, test_start_services(emitter, registry)) + .await + .unwrap(); assert_eq!( started.finalized.conclusion.final_git_commit_sha.as_deref(), @@ -433,28 +879,12 @@ mod tests { let run_dir = temp.path().join("run"); let emitter = Arc::new(EventEmitter::new()); let registry = Arc::new(test_registry()); - let sandbox: Arc = - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); persisted_workflow(MINIMAL_DOT, &run_dir); - let started = start( - &run_dir, - test_start_options( - &run_dir, - sandbox, - emitter, - registry, - LifecycleOptions { - setup_commands: vec![], - setup_command_timeout_ms: 1_000, - devcontainer_phases: vec![], - }, - true, - ), - ) - .await - .unwrap(); + let started = start(&run_dir, test_start_services(emitter, registry)) + .await + .unwrap(); assert_eq!(started.finalized.conclusion.status, StageStatus::Success); assert!(run_dir.join("conclusion.json").exists()); @@ -466,29 +896,11 @@ mod tests { let run_dir = temp.path().join("run"); let emitter = Arc::new(EventEmitter::new()); let registry = Arc::new(test_registry()); - let sandbox: Arc = - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); persisted_workflow(MINIMAL_DOT, &run_dir); - // Create a fake checkpoint file std::fs::write(run_dir.join("checkpoint.json"), "{}").unwrap(); - let result = start( - &run_dir, - test_start_options( - &run_dir, - sandbox, - emitter, - registry, - LifecycleOptions { - setup_commands: vec![], - setup_command_timeout_ms: 1_000, - devcontainer_phases: vec![], - }, - false, - ), - ) - .await; + let result = start(&run_dir, test_start_services(emitter, registry)).await; assert!( matches!(&result, Err(crate::error::FabroError::Precondition(_))), @@ -503,27 +915,10 @@ mod tests { let run_dir = temp.path().join("run"); let emitter = Arc::new(EventEmitter::new()); let registry = Arc::new(test_registry()); - let sandbox: Arc = - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); persisted_workflow(MINIMAL_DOT, &run_dir); - let result = resume( - &run_dir, - test_start_options( - &run_dir, - sandbox, - emitter, - registry, - LifecycleOptions { - setup_commands: vec![], - setup_command_timeout_ms: 1_000, - devcontainer_phases: vec![], - }, - false, - ), - ) - .await; + let result = resume(&run_dir, test_start_services(emitter, registry)).await; assert!( matches!(&result, Err(crate::error::FabroError::Precondition(_))), @@ -538,8 +933,6 @@ mod tests { let run_dir = temp.path().join("run"); let emitter = Arc::new(EventEmitter::new()); let registry = Arc::new(test_registry()); - let sandbox: Arc = - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); persisted_workflow(MINIMAL_DOT, &run_dir); @@ -575,22 +968,7 @@ mod tests { .save(&run_dir.join("conclusion.json")) .unwrap(); - let result = resume( - &run_dir, - test_start_options( - &run_dir, - sandbox, - emitter, - registry, - LifecycleOptions { - setup_commands: vec![], - setup_command_timeout_ms: 1_000, - devcontainer_phases: vec![], - }, - false, - ), - ) - .await; + let result = resume(&run_dir, test_start_services(emitter, registry)).await; assert!( matches!(&result, Err(crate::error::FabroError::Precondition(_))), diff --git a/lib/crates/fabro-workflows/src/pipeline/persist.rs b/lib/crates/fabro-workflows/src/pipeline/persist.rs index 7e5370d5d..d2e50c5db 100644 --- a/lib/crates/fabro-workflows/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflows/src/pipeline/persist.rs @@ -4,9 +4,10 @@ use crate::error::FabroError; use super::types::{PersistOptions, Persisted, Validated}; -const GRAPH_FILE_NAME: &str = "graph.fabro"; +const GRAPH_FILE_NAME: &str = "workflow.fabro"; +const LEGACY_GRAPH_FILE_NAME: &str = "graph.fabro"; -/// PERSIST phase: create run directory, write graph.fabro and run.json to disk. +/// PERSIST phase: create run directory, write workflow.fabro and run.json to disk. /// /// Overwrites `run_record.graph` with the validated graph before saving. pub fn persist(validated: Validated, mut options: PersistOptions) -> Result { @@ -30,14 +31,20 @@ pub fn persist(validated: Validated, mut options: PersistOptions) -> Result Result { let run_record = crate::records::RunRecord::load(run_dir)?; let graph = run_record.graph.clone(); let source = match std::fs::read_to_string(run_dir.join(GRAPH_FILE_NAME)) { Ok(source) => source, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + match std::fs::read_to_string(run_dir.join(LEGACY_GRAPH_FILE_NAME)) { + Ok(source) => source, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err.into()), + } + } Err(err) => return Err(err.into()), }; diff --git a/lib/crates/fabro-workflows/src/pipeline/types.rs b/lib/crates/fabro-workflows/src/pipeline/types.rs index e07a2a8f1..5ada5c586 100644 --- a/lib/crates/fabro-workflows/src/pipeline/types.rs +++ b/lib/crates/fabro-workflows/src/pipeline/types.rs @@ -108,7 +108,7 @@ pub struct PersistOptions { pub run_record: RunRecord, } -/// Output of the PERSIST phase. Run directory created, run.json and graph.fabro written. +/// Output of the PERSIST phase. Run directory created, run.json and workflow.fabro written. #[derive(Debug)] #[non_exhaustive] pub struct Persisted {