diff --git a/.ai/prompts/code-review-deep-1.md b/.ai/prompts/code-review-deep-1.md new file mode 100644 index 000000000..1d837cfe7 --- /dev/null +++ b/.ai/prompts/code-review-deep-1.md @@ -0,0 +1,35 @@ +Provide a code review for this branch relative to the base branch for bugs and defects. + +To do this, follow these steps precisely: + +1. Use Git to retrieve a list of modified files in this branch. +2. Use a Haiku agent to give you a list of file paths to (but not the contents of) any relevant CLAUDE.md files from the codebase: the root CLAUDE.md file (if one exists), as well as any CLAUDE.md files in the directories whose files the pull request modified +3. Use a Haiku agent to view the branch's diff, and ask the agent to return a summary of the change +4. Then, launch 5 parallel Opus agents to independently code review the change for production bugs and vulnerabilities. + a. Agent #1: Read the git blame and history of the code modified, to identify any bugs in light of that historical context + b. Agent #2: Read code comments in the modified files, and make sure the changes in the pull request comply with any guidance in the comments. + c. Agent #3-5: Read the file changes in this branch, then do a scan for potential bugs. Focus on bugs with production / end-user impact, and avoid small issues and nitpicks. + +Output a report with all the bugs using this format: + + + + title of bug + brief description of bug + + lib/crates/fabro-cli/src/commands/resume.rs + 115 + 115 + + critical/high/medium/low + + ... + + +Write the report to: `.ai/tmp/candidate_bugs.xml` + +Notes: + +- Do not check build signal or attempt to build or typecheck the app. These will run separately, and are not relevant to your code review. +- Include all potential bugs of all severity (critical/high/medium/low) that have production / end-user impact. (We will analyze them separately later.) +- Make a todo list first \ No newline at end of file diff --git a/.ai/prompts/code-review-deep-2.md b/.ai/prompts/code-review-deep-2.md new file mode 100644 index 000000000..f904ed5cb --- /dev/null +++ b/.ai/prompts/code-review-deep-2.md @@ -0,0 +1,110 @@ +Provide a code review for this branch relative to the base branch for bugs and defects. + +We have a report of candidate bugs which you need to analyze. + +To do this, follow these steps precisely: + +1. Use Git to retrieve a list of modified files in this branch. +2. View the branch's diff and understand the changes +3. Then, launch 5 parallel Opus agents to independently assess the candidate bugs. For each bug, investigate it thoroughly in order to produce the report in the format below. If the candidate bug is not valid, then discard it. + +Input: Read from `.ai/tmp/candidate_bugs.xml` + +Output a report with all the bugs using this format: + +```xml + + + up to 3 sentences + important OR nit + yes OR no + + lib/crates/fabro-cli/src/commands/resume.rs + 115 + 115 + + + ... + ... + ... + ... + ... + + first step + second step + ... + bug + + + + ... + +``` + +Here is a real-world example: + +```xml + + + `prepare_from_checkpoint` unconditionally creates a `LocalSandbox` via `local_sandbox_with_callback`, completely ignoring the `--sandbox` flag and TOML config. A user running `fabro resume --checkpoint logs/checkpoint.json --workflow w.fabro --sandbox docker` will silently get a local sandbox instead of Docker; to fix this, call `resolve_sandbox_provider(args.sandbox.map(Into::into), None, run_defaults)` just as `prepare_from_branch` does. + + important + no + + lib/crates/fabro-cli/src/commands/resume.rs + 208 + 208 + + + + `prepare_from_checkpoint` (resume.rs, around line 196) always wires up a `LocalSandbox` regardless of what sandbox the caller requested: + + ```rust + let sandbox: Arc = local_sandbox_with_callback(original_cwd, Arc::clone(&emitter)); + let sandbox: Arc = Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox)); + ``` + + The `args.sandbox` field (a `Option`) is populated by clap but never read inside this function. No error is raised and no warning is printed. + + + When a user invokes `fabro resume --checkpoint path/to/checkpoint.json --workflow w.fabro --sandbox docker`, `resume_command` sees `args.checkpoint.is_some()` and dispatches to `prepare_from_checkpoint`. That function builds the `ResumeContext` with a `LocalSandbox` and returns. The `--sandbox docker` value stored in `args.sandbox` is forwarded to `run_resumed` but by then the sandbox is already constructed and the field is never consulted. + + + `prepare_from_branch` — the sibling function for the run-ID path — correctly calls `resolve_sandbox_provider(args.sandbox.map(Into::into), None, run_defaults)` and dispatches through a `match sandbox_provider { ... }` that handles `Local`, `Docker`, `Ssh`, `Exe`, and `Daytona`. The checkpoint-file path was clearly authored separately and the sandbox resolution step was simply omitted. Additionally, the old `fabro run --resume checkpoint.json --sandbox docker` path ran through `run_command`, which performed sandbox resolution before the checkpoint branch — so this is a genuine regression of a previously-working feature. + + + Any user relying on `--sandbox docker` (for reproducibility, filesystem isolation, or container-specific tooling), `--sandbox ssh` (remote host execution), or `--sandbox exe` when resuming from a checkpoint file will silently run against the local filesystem instead. There is no error, no warning, and the job may produce different results or corrupt local state. The flag is prominently documented in both `docs/reference/cli.mdx` and the `--help` output, so users have every reason to expect it to work. + + + Replace the hardcoded `local_sandbox_with_callback` call in `prepare_from_checkpoint` with the same sandbox-resolution logic used by `prepare_from_branch`: + + ```rust + let sandbox_provider = if args.dry_run { + SandboxProvider::Local + } else { + resolve_sandbox_provider(args.sandbox.map(Into::into), None, run_defaults)? + }; + // then match sandbox_provider { ... } as prepare_from_branch does + ``` + + Note that `run_defaults` must also be threaded into `prepare_from_checkpoint` (currently it is not passed to this function), matching the signature of `prepare_from_branch`. + + + User runs: `fabro resume --checkpoint ~/.fabro/runs/20260321-01ABC.../checkpoint.json --workflow deploy.fabro --sandbox docker` + `resume_command` evaluates `args.checkpoint.is_some()` → `true` → calls `prepare_from_checkpoint(&args, ...)`. + Inside `prepare_from_checkpoint`, `args.sandbox` holds `Some(CliSandboxProvider::Docker)` but is never read. + Line ~196: `let sandbox = local_sandbox_with_callback(original_cwd, Arc::clone(&emitter));` — a `LocalSandbox` is constructed unconditionally. + `ResumeContext { sandbox, ... }` is returned with the local sandbox. + `run_resumed` receives this context and runs the entire workflow inside the local sandbox. + Docker is never launched; no diagnostic message is emitted. + + + +``` + +Write the output to `.ai/tmp/analyzed_bugs.xml` + +Notes: + +- Do not check build signal or attempt to build or typecheck the app. These will run separately, and are not relevant to your code review. +- Make a todo list first \ No newline at end of file diff --git a/.ai/prompts/code-review-deep-3.md b/.ai/prompts/code-review-deep-3.md new file mode 100644 index 000000000..b0b4a272b --- /dev/null +++ b/.ai/prompts/code-review-deep-3.md @@ -0,0 +1,33 @@ +Filter the bugs identified by code review to the bugs worth fixing. + +We have a report of analyzed bugs which you need to filter. + +To do this, follow these steps precisely: + +1. Use Git to retrieve a list of modified files in this branch. +2. Use a Haiku agent to view the branch's diff, and ask the agent to return a summary of the change +3. For each bug assess if it is a false positive based on the criteria below. + +Input: Read from `.ai/tmp/analyzed_bugs.xml` + +Filter out the false positives. Examples of false positives: + +- Nits +- Something that looks like a bug but is not actually a bug +- Pedantic issues that a senior engineer wouldn't call out +- Issues that a linter, typechecker, or compiler would catch (eg. missing or incorrect imports, type errors, broken tests, formatting issues, pedantic style issues like newlines). No need to run these build steps yourself -- it is safe to assume that they will be run separately as part of CI. +- General code quality issues (eg. lack of test coverage, general security issues, poor documentation) +- Maintainability, code smells, etc. +- Changes in functionality that are likely intentional or are directly related to the broader change +- Real issues, but are not related to the changes in the branch + +Ouput: + +1. Write to `.ai/tmp/valid_bugs.xml` in the same XML format with the false positives filtered out. +2. Write to `.ai/tmp/false_positives.md` a summary of the false positives you filtered out and why. + +Notes: + +- Do not check build signal or attempt to build or typecheck the app. These will run separately, and are not relevant to your code review. +- Make a todo list first +- It is OK to keep bugs which are pre-existing, if and only if they are both A) important and B) relevant to the changes being made. diff --git a/.ai/prompts/code-review-fast.md b/.ai/prompts/code-review-fast.md new file mode 100644 index 000000000..b26ba8b64 --- /dev/null +++ b/.ai/prompts/code-review-fast.md @@ -0,0 +1,92 @@ +--- +allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*), Bash(gh pr list:*) +description: Code review a pull request +disable-model-invocation: false +--- + +Provide a code review for the given pull request. + +To do this, follow these steps precisely: + +1. Use a Haiku agent to check if the pull request (a) is closed, (b) is a draft, (c) does not need a code review (eg. because it is an automated pull request, or is very simple and obviously ok), or (d) already has a code review from you from earlier. If so, do not proceed. +2. Use another Haiku agent to give you a list of file paths to (but not the contents of) any relevant CLAUDE.md files from the codebase: the root CLAUDE.md file (if one exists), as well as any CLAUDE.md files in the directories whose files the pull request modified +3. Use a Haiku agent to view the pull request, and ask the agent to return a summary of the change +4. Then, launch 5 parallel Sonnet agents to independently code review the change. The agents should do the following, then return a list of issues and the reason each issue was flagged (eg. CLAUDE.md adherence, bug, historical git context, etc.): + a. Agent #1: Audit the changes to make sure they compily with the CLAUDE.md. Note that CLAUDE.md is guidance for Claude as it writes code, so not all instructions will be applicable during code review. + b. Agent #2: Read the file changes in the pull request, then do a shallow scan for obvious bugs. Avoid reading extra context beyond the changes, focusing just on the changes themselves. Focus on large bugs, and avoid small issues and nitpicks. Ignore likely false positives. + c. Agent #3: Read the git blame and history of the code modified, to identify any bugs in light of that historical context + d. Agent #4: Read previous pull requests that touched these files, and check for any comments on those pull requests that may also apply to the current pull request. + e. Agent #5: Read code comments in the modified files, and make sure the changes in the pull request comply with any guidance in the comments. +5. For each issue found in #4, launch a parallel Haiku agent that takes the PR, issue description, and list of CLAUDE.md files (from step 2), and returns a score to indicate the agent's level of confidence for whether the issue is real or false positive. To do that, the agent should score each issue on a scale from 0-100, indicating its level of confidence. For issues that were flagged due to CLAUDE.md instructions, the agent should double check that the CLAUDE.md actually calls out that issue specifically. The scale is (give this rubric to the agent verbatim): + a. 0: Not confident at all. This is a false positive that doesn't stand up to light scrutiny, or is a pre-existing issue. + b. 25: Somewhat confident. This might be a real issue, but may also be a false positive. The agent wasn't able to verify that it's a real issue. If the issue is stylistic, it is one that was not explicitly called out in the relevant CLAUDE.md. + c. 50: Moderately confident. The agent was able to verify this is a real issue, but it might be a nitpick or not happen very often in practice. Relative to the rest of the PR, it's not very important. + d. 75: Highly confident. The agent double checked the issue, and verified that it is very likely it is a real issue that will be hit in practice. The existing approach in the PR is insufficient. The issue is very important and will directly impact the code's functionality, or it is an issue that is directly mentioned in the relevant CLAUDE.md. + e. 100: Absolutely certain. The agent double checked the issue, and confirmed that it is definitely a real issue, that will happen frequently in practice. The evidence directly confirms this. +6. Filter out any issues with a score less than 80. If there are no issues that meet this criteria, do not proceed. +7. Use a Haiku agent to repeat the eligibility check from #1, to make sure that the pull request is still eligible for code review. +8. Finally, use the gh bash command to comment back on the pull request with the result. When writing your comment, keep in mind to: + a. Keep your output brief + b. Avoid emojis + c. Link and cite relevant code, files, and URLs + +Examples of false positives, for steps 4 and 5: + +- Pre-existing issues +- Something that looks like a bug but is not actually a bug +- Pedantic nitpicks that a senior engineer wouldn't call out +- Issues that a linter, typechecker, or compiler would catch (eg. missing or incorrect imports, type errors, broken tests, formatting issues, pedantic style issues like newlines). No need to run these build steps yourself -- it is safe to assume that they will be run separately as part of CI. +- General code quality issues (eg. lack of test coverage, general security issues, poor documentation), unless explicitly required in CLAUDE.md +- Issues that are called out in CLAUDE.md, but explicitly silenced in the code (eg. due to a lint ignore comment) +- Changes in functionality that are likely intentional or are directly related to the broader change +- Real issues, but on lines that the user did not modify in their pull request + +Notes: + +- Do not check build signal or attempt to build or typecheck the app. These will run separately, and are not relevant to your code review. +- Use `gh` to interact with Github (eg. to fetch a pull request, or to create inline comments), rather than web fetch +- Make a todo list first +- You must cite and link each bug (eg. if referring to a CLAUDE.md, you must link it) +- For your final comment, follow the following format precisely (assuming for this example that you found 3 issues): + +--- + +### Code review + +Found 3 issues: + +1. (CLAUDE.md says "<...>") + + + +2. (some/other/CLAUDE.md says "<...>") + + + +3. (bug due to ) + + + +🤖 Generated with [Claude Code](https://claude.ai/code) + +- If this code review was useful, please react with 👍. Otherwise, react with 👎. + +--- + +- Or, if you found no issues: + +--- + +### Code review + +No issues found. Checked for bugs and CLAUDE.md compliance. + +🤖 Generated with [Claude Code](https://claude.ai/code) + +- When linking to code, follow the following format precisely, otherwise the Markdown preview won't render correctly: https://github.com/anthropics/claude-cli-internal/blob/c21d3c10bc8e898b7ac1a2d745bdc9bc4e423afe/package.json#L10-L15 + - Requires full git sha + - You must provide the full sha. Commands like `https://github.com/owner/repo/blob/$(git rev-parse HEAD)/foo/bar` will not work, since your comment will be directly rendered in Markdown. + - Repo name must match the repo you're code reviewing + - # sign after the file name + - Line range format is L[start]-L[end] + - Provide at least 1 line of context before and after, centered on the line you are commenting about (eg. if you are commenting about lines 5-6, you should link to `L4-7`) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6d18574f1..4fc27f7b3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ evals/swe-bench/results/ evals/swe-bench/dockerfiles/ __pycache__ .ai/plans +.ai/comments +.ai/tmp diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 3a013f14f..0706638c2 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -92,6 +92,7 @@ fabro resume --checkpoint path/to/checkpoint.json --workflow workflow.fabro | `--goal-file ` | Read the goal from a file | | `--no-retro` | Skip retro generation after the run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes | +| `--label ` | Attach a label to this run (repeatable) | ## `fabro ps` diff --git a/lib/crates/fabro-cli/src/commands/resume.rs b/lib/crates/fabro-cli/src/commands/resume.rs index bc3835ef0..f1698e58a 100644 --- a/lib/crates/fabro-cli/src/commands/resume.rs +++ b/lib/crates/fabro-cli/src/commands/resume.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::io::IsTerminal; use std::path::PathBuf; use std::sync::Arc; @@ -7,7 +6,7 @@ use std::time::{Duration, Instant}; use anyhow::{bail, Context}; use clap::Args; use fabro_agent::{DockerSandbox, DockerSandboxConfig, Sandbox, WorktreeConfig, WorktreeSandbox}; -use fabro_config::run::RunDefaults; +use fabro_config::run::{RunDefaults, WorkflowRunConfig}; use fabro_graphviz::graph::Graph; use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer}; use fabro_model::Provider; @@ -16,19 +15,25 @@ use fabro_workflows::backend::{AgentApiBackend, AgentCliBackend, BackendRouter}; use fabro_workflows::checkpoint::Checkpoint; use fabro_workflows::engine::RunConfig; use fabro_workflows::event::EventEmitter; +use fabro_workflows::manifest::Manifest; use fabro_workflows::outcome::StageStatus; use fabro_workflows::sandbox_provider::SandboxProvider; use indicatif::HumanDuration; use super::run::{ - apply_goal_override, build_event_envelope, default_run_dir, generate_retro, - local_sandbox_with_callback, print_assets, print_final_output, resolve_cli_goal, - resolve_daytona_config, resolve_model_provider, resolve_sandbox_provider, - resolve_ssh_clone_params, resolve_ssh_config, write_finalize_commit, CliSandboxProvider, + build_event_envelope, default_run_dir, generate_retro, local_sandbox_with_callback, + mint_github_token, prepare_workflow_with_project_config, print_assets, print_final_output, + resolve_daytona_config, resolve_fallback_chain, resolve_model_provider, + resolve_sandbox_provider, resolve_ssh_clone_params, resolve_ssh_config, write_finalize_commit, + write_run_config_snapshot, CliSandboxProvider, RunArgs, }; use crate::commands::shared::{print_diagnostics, tilde_path}; use fabro_config::project as project_config; +use fabro_config::run as run_config; use fabro_validate::Severity; +use fabro_workflows::devcontainer_bridge; +use std::collections::HashMap; +use tracing::debug; #[derive(Debug, Args)] pub struct ResumeArgs { @@ -56,14 +61,6 @@ pub struct ResumeArgs { #[arg(long)] pub auto_approve: bool, - /// Override the workflow goal (exposed as $goal in prompts) - #[arg(long)] - pub goal: Option, - - /// Read the workflow goal from a file - #[arg(long, conflicts_with = "goal")] - pub goal_file: Option, - /// Override default LLM model #[arg(long)] pub model: Option, @@ -87,6 +84,10 @@ pub struct ResumeArgs { /// Keep the sandbox alive after the run finishes (for debugging) #[arg(long)] pub preserve_sandbox: bool, + + /// Attach a label to this run (repeatable, format: KEY=VALUE) + #[arg(long = "label", value_name = "KEY=VALUE")] + pub label: Vec, } /// Intermediate state produced by the two resolution paths (checkpoint-file vs. git-branch). @@ -95,14 +96,93 @@ struct ResumeContext { graph: Graph, run_id: String, run_dir: PathBuf, + run_cfg: Option, sandbox: Arc, /// Kept as Arc so the sandbox event callbacks can emit through it. Listeners /// that need to be added later (e.g. ProgressUI) are registered separately. emitter: Arc, config: RunConfig, setup_commands: Vec, + /// Devcontainer lifecycle phases (on_create, post_create, post_start) resolved from config. + devcontainer_phases: Vec<(String, Vec)>, + /// Devcontainer remoteEnv values to layer under sandbox_env. + devcontainer_env: HashMap, /// Original cwd to restore after engine run (git-branch path changes cwd to worktree). original_cwd: Option, + origin_url: Option, + sandbox_provider: SandboxProvider, + ssh_data_host: Option, + github_app: Option, + status_guard: ResumeRunStatusGuard, +} + +struct ResumeRunStatusGuard { + run_dir: PathBuf, + active: bool, +} + +impl ResumeRunStatusGuard { + fn arm(run_dir: &std::path::Path) -> anyhow::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()))?; + fabro_workflows::run_status::write_run_status( + run_dir, + fabro_workflows::run_status::RunStatus::Starting, + Some(fabro_workflows::run_status::StatusReason::SandboxInitializing), + ); + Ok(Self { + run_dir: run_dir.to_path_buf(), + active: true, + }) + } + + fn defuse(&mut self) { + self.active = false; + } +} + +impl Drop for ResumeRunStatusGuard { + fn drop(&mut self) { + if self.active { + fabro_workflows::run_status::write_run_status( + &self.run_dir, + fabro_workflows::run_status::RunStatus::Failed, + Some(fabro_workflows::run_status::StatusReason::SandboxInitFailed), + ); + } + } +} + +fn resume_as_run_args(args: &ResumeArgs, workflow: PathBuf) -> RunArgs { + RunArgs { + workflow: Some(workflow), + run_dir: None, + dry_run: args.dry_run, + preflight: false, + auto_approve: args.auto_approve, + goal: None, + goal_file: None, + model: args.model.clone(), + provider: args.provider.clone(), + verbose: args.verbose, + sandbox: args.sandbox, + label: Vec::new(), + no_retro: args.no_retro, + preserve_sandbox: args.preserve_sandbox, + detach: false, + run_id: None, + } +} + +fn preferred_resume_repo_path( + original_cwd: &std::path::Path, + manifest: Option<&Manifest>, +) -> PathBuf { + manifest + .and_then(|m| m.host_repo_path.as_deref()) + .map(PathBuf::from) + .filter(|path| path.exists()) + .unwrap_or_else(|| original_cwd.to_path_buf()) } /// Resume an interrupted workflow run. @@ -113,11 +193,19 @@ struct ResumeContext { /// or the workflow cannot be resumed. pub async fn resume_command( args: ResumeArgs, - run_defaults: RunDefaults, + mut run_defaults: RunDefaults, styles: &'static Styles, github_app: Option, git_author: fabro_workflows::git::GitAuthor, ) -> anyhow::Result<()> { + // Apply project-level config overrides (fabro.toml) on top of CLI defaults (mirrors run_command). + if let Ok(Some((_config_path, project_config))) = + project_config::discover_project_config(&std::env::current_dir().unwrap_or_default()) + { + tracing::debug!("Applying run defaults from fabro.toml"); + run_defaults.merge_overlay(project_config.into_run_defaults()); + } + let ctx = if args.checkpoint.is_some() { prepare_from_checkpoint(&args, &run_defaults, styles, &github_app, git_author).await? } else { @@ -142,10 +230,17 @@ async fn prepare_from_checkpoint( .ok_or_else(|| anyhow::anyhow!("--workflow is required when using --checkpoint"))?; let checkpoint = Checkpoint::load(checkpoint_path)?; - let (mut graph, diagnostics) = fabro_workflows::workflow::prepare_from_file(workflow_path)?; - - let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?; - apply_goal_override(&mut graph, cli_goal.as_deref(), None); + let prepared = prepare_workflow_with_project_config( + &resume_as_run_args(args, workflow_path.clone()), + run_defaults.clone(), + styles, + true, + false, + )?; + let source = prepared.source; + let graph = prepared.graph; + let run_cfg = prepared.run_cfg; + let sandbox_provider = prepared.sandbox_provider; eprintln!( "{} {} from checkpoint {}", @@ -154,11 +249,6 @@ async fn prepare_from_checkpoint( styles.dim.apply_to(checkpoint_path.display()), ); - print_diagnostics(&diagnostics, styles); - if diagnostics.iter().any(|d| d.severity == Severity::Error) { - bail!("Validation failed"); - } - let run_id = ulid::Ulid::new().to_string(); let run_dir = args .run_dir @@ -167,17 +257,112 @@ async fn prepare_from_checkpoint( 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 status_guard = ResumeRunStatusGuard::arm(&run_dir)?; + tokio::fs::write(run_dir.join("graph.fabro"), &source).await?; + let mut run_cfg = run_cfg; + write_run_config_snapshot(&run_dir, run_cfg.as_mut()).await?; let original_cwd = std::env::current_dir()?; let emitter = Arc::new(EventEmitter::new()); - // Resolve sandbox provider from CLI flag / config / defaults - let sandbox_provider = if args.dry_run { - SandboxProvider::Local + // Resolve devcontainer BEFORE sandbox creation (mirrors run_command) so that + // the Daytona snapshot config can be overridden with the devcontainer Dockerfile. + let mut daytona_config = resolve_daytona_config(run_cfg.as_ref(), run_defaults); + let devcontainer_config = if run_cfg + .as_ref() + .and_then(|c| c.sandbox.as_ref()) + .or(run_defaults.sandbox.as_ref()) + .and_then(|s| s.devcontainer) + .unwrap_or(false) + { + match fabro_devcontainer::DevcontainerResolver::resolve(&original_cwd).await { + Ok(dc) => { + let lifecycle_command_count = dc.on_create_commands.len() + + dc.post_create_commands.len() + + dc.post_start_commands.len(); + emitter.emit( + &fabro_workflows::event::WorkflowRunEvent::DevcontainerResolved { + dockerfile_lines: dc.dockerfile.lines().count(), + environment_count: dc.environment.len(), + lifecycle_command_count, + workspace_folder: dc.workspace_folder.clone(), + }, + ); + + // Override daytona_config with devcontainer dockerfile + let snapshot = devcontainer_bridge::devcontainer_to_snapshot_config(&dc); + let mut cfg = daytona_config.unwrap_or_default(); + cfg.snapshot = Some(snapshot); + daytona_config = Some(cfg); + + // Run initialize_commands on host (mirrors run_command) + let timeout = std::time::Duration::from_millis(300_000); + for cmd in &dc.initialize_commands { + let shell_cmds = match cmd { + fabro_devcontainer::Command::Shell(s) => vec![s.clone()], + fabro_devcontainer::Command::Args(args) => { + vec![args + .iter() + .map(|a| { + shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string() + }) + .collect::>() + .join(" ")] + } + fabro_devcontainer::Command::Parallel(map) => { + map.values().cloned().collect() + } + }; + for shell_cmd in &shell_cmds { + let fut = tokio::process::Command::new("sh") + .arg("-c") + .arg(shell_cmd) + .current_dir(&original_cwd) + .output(); + let output = tokio::time::timeout(timeout, fut) + .await + .with_context(|| { + format!("Devcontainer initializeCommand timed out: {shell_cmd}") + })? + .with_context(|| { + format!( + "Failed to execute devcontainer initializeCommand: {shell_cmd}" + ) + })?; + if !output.status.success() { + let code = output + .status + .code() + .map_or("unknown".to_string(), |c| c.to_string()); + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "Devcontainer initializeCommand failed (exit code {code}): {shell_cmd}\n{stderr}" + ); + } + } + } + + Some(dc) + } + Err(e) => { + bail!("Failed to resolve devcontainer: {e}"); + } + } } else { - resolve_sandbox_provider(args.sandbox.map(Into::into), None, run_defaults)? + None }; + let devcontainer_phases = if let Some(ref dc) = devcontainer_config { + vec![ + ("on_create".to_string(), dc.on_create_commands.clone()), + ("post_create".to_string(), dc.post_create_commands.clone()), + ("post_start".to_string(), dc.post_start_commands.clone()), + ] + } else { + Vec::new() + }; + + let mut ssh_data_host: Option = None; let sandbox: Arc = match sandbox_provider { SandboxProvider::Local => { local_sandbox_with_callback(original_cwd.clone(), Arc::clone(&emitter)) @@ -197,7 +382,7 @@ async fn prepare_from_checkpoint( } #[cfg(feature = "exedev")] SandboxProvider::Exe => { - let exe_config = super::run::resolve_exe_config(None, run_defaults); + let exe_config = super::run::resolve_exe_config(run_cfg.as_ref(), run_defaults); let clone_params = super::run::resolve_exe_clone_params(&original_cwd); let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev") .await @@ -217,8 +402,9 @@ async fn prepare_from_checkpoint( Arc::new(env) } SandboxProvider::Ssh => { - let config = resolve_ssh_config(None, run_defaults) + let config = resolve_ssh_config(run_cfg.as_ref(), run_defaults) .ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?; + ssh_data_host = Some(config.destination.clone()); let clone_params = resolve_ssh_clone_params(&original_cwd); let mut env = fabro_sandbox::ssh::SshSandbox::new( config, @@ -233,7 +419,7 @@ async fn prepare_from_checkpoint( Arc::new(env) } SandboxProvider::Daytona => { - let config = resolve_daytona_config(None, run_defaults).unwrap_or_default(); + let config = daytona_config.unwrap_or_default(); let mut env = fabro_sandbox::daytona::DaytonaSandbox::new( config, github_app.clone(), @@ -261,26 +447,65 @@ async fn prepare_from_checkpoint( base_sha: None, run_branch: None, meta_branch: None, - labels: HashMap::new(), - checkpoint_exclude_globs: Vec::new(), + labels: args + .label + .iter() + .filter_map(|s| s.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + checkpoint_exclude_globs: run_cfg + .as_ref() + .map(|cfg| cfg.checkpoint.exclude_globs.clone()) + .unwrap_or_else(|| run_defaults.checkpoint.exclude_globs.clone()), github_app: github_app.clone(), git_author, base_branch: None, - pull_request: None, - asset_globs: Vec::new(), + pull_request: run_cfg + .as_ref() + .and_then(|cfg| cfg.pull_request.as_ref()) + .or(run_defaults.pull_request.as_ref()) + .filter(|p| p.enabled) + .cloned(), + asset_globs: run_cfg + .as_ref() + .and_then(|cfg| cfg.assets.as_ref()) + .or(run_defaults.assets.as_ref()) + .map(|a| a.include.clone()) + .unwrap_or_default(), workflow_slug: None, }; + let devcontainer_env = devcontainer_config + .as_ref() + .map(|dc| dc.environment.clone()) + .unwrap_or_default(); + let setup_commands = run_cfg + .as_ref() + .and_then(|cfg| cfg.setup.as_ref()) + .or(run_defaults.setup.as_ref()) + .map(|s| s.commands.clone()) + .unwrap_or_default(); + Ok(ResumeContext { checkpoint, graph, run_id, run_dir, + run_cfg, sandbox, emitter, config, - setup_commands: Vec::new(), + setup_commands, + devcontainer_phases, + devcontainer_env, original_cwd: None, + origin_url: fabro_sandbox::daytona::detect_repo_info(&original_cwd) + .ok() + .map(|(url, _)| url), + sandbox_provider, + ssh_data_host, + github_app: github_app.clone(), + status_guard, }) } @@ -305,27 +530,62 @@ async fn prepare_from_branch( }; let original_cwd = std::env::current_dir()?; - - // Read checkpoint from metadata branch - let checkpoint = fabro_workflows::git::MetadataStore::read_checkpoint(&original_cwd, &run_id)? - .ok_or_else(|| { - anyhow::anyhow!("no checkpoint found on metadata branch for run {run_id}") - })?; - - // Read graph DOT from metadata branch - let source = fabro_workflows::git::MetadataStore::read_graph_dot(&original_cwd, &run_id)? - .ok_or_else(|| { - anyhow::anyhow!("no graph.fabro found on metadata branch for run {run_id}") - })?; - - // If --workflow was also provided, use it instead (allows overriding) - let (mut graph, diagnostics) = if let Some(ref workflow_path) = args.workflow { - fabro_workflows::workflow::prepare_from_file(workflow_path)? + let manifest_hint = fabro_workflows::git::MetadataStore::read_manifest(&original_cwd, &run_id)?; + let resume_repo_path = preferred_resume_repo_path(&original_cwd, manifest_hint.as_ref()); + let manifest = if resume_repo_path == original_cwd { + manifest_hint } else { - fabro_workflows::workflow::WorkflowBuilder::new().prepare(&source)? + fabro_workflows::git::MetadataStore::read_manifest(&resume_repo_path, &run_id)? + .or(manifest_hint) }; - let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?; - apply_goal_override(&mut graph, cli_goal.as_deref(), None); + let checkpoint = + fabro_workflows::git::MetadataStore::read_checkpoint(&resume_repo_path, &run_id)? + .ok_or_else(|| { + anyhow::anyhow!("no checkpoint found on metadata branch for run {run_id}") + })?; + let source = fabro_workflows::git::MetadataStore::read_graph_dot(&resume_repo_path, &run_id)? + .ok_or_else(|| { + anyhow::anyhow!("no graph.fabro found on metadata branch for run {run_id}") + })?; + + let repo_info = fabro_sandbox::daytona::detect_repo_info(&resume_repo_path).ok(); + let origin_url = repo_info.as_ref().map(|(url, _)| url.clone()); + let detected_base_branch = manifest + .as_ref() + .and_then(|m| m.base_branch.clone()) + .or_else(|| repo_info.as_ref().and_then(|(_, branch)| branch.clone())); + let base_sha = manifest.as_ref().and_then(|m| m.base_sha.clone()); + + let (graph, graph_source, run_cfg, mut sandbox_provider) = + if let Some(ref workflow_path) = args.workflow { + let prepared = prepare_workflow_with_project_config( + &resume_as_run_args(args, workflow_path.clone()), + run_defaults.clone(), + styles, + true, + false, + )?; + ( + prepared.graph, + prepared.source, + prepared.run_cfg, + prepared.sandbox_provider, + ) + } else { + let (graph, diagnostics) = + fabro_workflows::workflow::WorkflowBuilder::new().prepare(&source)?; + print_diagnostics(&diagnostics, styles); + if diagnostics.iter().any(|d| d.severity == Severity::Error) { + bail!("Validation failed"); + } + + let sandbox_provider = if args.dry_run { + SandboxProvider::Local + } else { + resolve_sandbox_provider(args.sandbox.map(Into::into), None, run_defaults)? + }; + (graph, source.clone(), None, sandbox_provider) + }; eprintln!( "{} {} from branch {} ({})", @@ -335,48 +595,161 @@ async fn prepare_from_branch( run_id, ); - print_diagnostics(&diagnostics, styles); - if diagnostics.iter().any(|d| d.severity == Severity::Error) { - bail!("Validation failed"); - } - - // Set up logs directory - let run_dir = args - .run_dir - .clone() - .unwrap_or_else(|| default_run_dir(&run_id, args.dry_run)); + // Set up logs directory — reuse existing run dir for this run_id to avoid + // "ambiguous prefix" errors when the resume happens on a different day. + // Skip reuse when dry-running to avoid corrupting real run data. + let run_dir = if let Some(ref dir) = args.run_dir { + dir.clone() + } else if args.dry_run { + default_run_dir(&run_id, true) + } else { + find_existing_run_dir(&run_id).unwrap_or_else(|| default_run_dir(&run_id, false)) + }; tokio::fs::create_dir_all(&run_dir).await?; + let run_dir = tokio::fs::canonicalize(&run_dir).await.unwrap_or(run_dir); fabro_util::run_log::activate(&run_dir.join("cli.log")) .context("Failed to activate per-run log")?; - tokio::fs::write(run_dir.join("graph.fabro"), &source).await?; - - let base_sha = fabro_workflows::git::MetadataStore::read_manifest(&original_cwd, &run_id)? - .and_then(|m| m.base_sha); - - // Resolve sandbox provider - let sandbox_provider = if args.dry_run { - SandboxProvider::Local - } else { - resolve_sandbox_provider(args.sandbox.map(Into::into), None, run_defaults)? - }; + let status_guard = ResumeRunStatusGuard::arm(&run_dir)?; + tokio::fs::write(run_dir.join("graph.fabro"), &graph_source).await?; + let mut run_cfg = run_cfg; + write_run_config_snapshot(&run_dir, run_cfg.as_mut()).await?; let emitter = Arc::new(EventEmitter::new()); + + // Resolve devcontainer BEFORE sandbox creation (mirrors run_command) so that + // the Daytona snapshot config can be overridden with the devcontainer Dockerfile. + let mut daytona_config = resolve_daytona_config(run_cfg.as_ref(), run_defaults); + let devcontainer_config = if run_cfg + .as_ref() + .and_then(|cfg| cfg.sandbox.as_ref()) + .or(run_defaults.sandbox.as_ref()) + .and_then(|s| s.devcontainer) + .unwrap_or(false) + { + match fabro_devcontainer::DevcontainerResolver::resolve(&resume_repo_path).await { + Ok(dc) => { + let lifecycle_command_count = dc.on_create_commands.len() + + dc.post_create_commands.len() + + dc.post_start_commands.len(); + emitter.emit( + &fabro_workflows::event::WorkflowRunEvent::DevcontainerResolved { + dockerfile_lines: dc.dockerfile.lines().count(), + environment_count: dc.environment.len(), + lifecycle_command_count, + workspace_folder: dc.workspace_folder.clone(), + }, + ); + + // Override daytona_config with devcontainer dockerfile + let snapshot = devcontainer_bridge::devcontainer_to_snapshot_config(&dc); + let mut cfg = daytona_config.unwrap_or_default(); + cfg.snapshot = Some(snapshot); + daytona_config = Some(cfg); + + // Run initialize_commands on host (mirrors run_command) + let timeout = std::time::Duration::from_millis(300_000); + for cmd in &dc.initialize_commands { + let shell_cmds = match cmd { + fabro_devcontainer::Command::Shell(s) => vec![s.clone()], + fabro_devcontainer::Command::Args(args) => { + vec![args + .iter() + .map(|a| { + shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string() + }) + .collect::>() + .join(" ")] + } + fabro_devcontainer::Command::Parallel(map) => { + map.values().cloned().collect() + } + }; + for shell_cmd in &shell_cmds { + let fut = tokio::process::Command::new("sh") + .arg("-c") + .arg(shell_cmd) + .current_dir(&resume_repo_path) + .output(); + let output = tokio::time::timeout(timeout, fut) + .await + .with_context(|| { + format!("Devcontainer initializeCommand timed out: {shell_cmd}") + })? + .with_context(|| { + format!( + "Failed to execute devcontainer initializeCommand: {shell_cmd}" + ) + })?; + if !output.status.success() { + let code = output + .status + .code() + .map_or("unknown".to_string(), |c| c.to_string()); + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "Devcontainer initializeCommand failed (exit code {code}): {shell_cmd}\n{stderr}" + ); + } + } + } + + Some(dc) + } + Err(e) => { + bail!("Failed to resolve devcontainer: {e}"); + } + } + } else { + None + }; + + let devcontainer_phases = if let Some(ref dc) = devcontainer_config { + vec![ + ("on_create".to_string(), dc.on_create_commands.clone()), + ("post_create".to_string(), dc.post_create_commands.clone()), + ("post_start".to_string(), dc.post_start_commands.clone()), + ] + } else { + Vec::new() + }; + + let setup_worktree_sandbox = |emitter: &Arc| -> (WorktreeSandbox, PathBuf) { + let wt = run_dir.join("worktree"); + let wt_str = wt.to_string_lossy().into_owned(); + + let inner = local_sandbox_with_callback(resume_repo_path.clone(), Arc::clone(emitter)); + let wt_config = WorktreeConfig { + branch_name: run_branch.clone(), + base_sha: base_sha.clone().unwrap_or_default(), + worktree_path: wt_str.clone(), + skip_branch_creation: true, // branch already exists on resume + }; + let mut wt_sandbox = WorktreeSandbox::new(inner, wt_config); + wt_sandbox.set_event_callback(Arc::clone(emitter).worktree_callback()); + (wt_sandbox, wt) + }; + + let mut ssh_data_host: Option = None; let (sandbox, _worktree_path): (Arc, Option) = match sandbox_provider { - SandboxProvider::Local | SandboxProvider::Docker => { - // Re-attach worktree to the existing run branch via WorktreeSandbox. - let wt = run_dir.join("worktree"); - let wt_str = wt.to_string_lossy().into_owned(); - - let inner = local_sandbox_with_callback(original_cwd.clone(), Arc::clone(&emitter)); - let wt_config = WorktreeConfig { - branch_name: run_branch.clone(), - base_sha: base_sha.clone().unwrap_or_default(), - worktree_path: wt_str.clone(), - skip_branch_creation: true, // branch already exists on resume - }; - let mut wt_sandbox = WorktreeSandbox::new(inner, wt_config); - wt_sandbox.set_event_callback(Arc::clone(&emitter).worktree_callback()); - + SandboxProvider::Local => { + let (wt_sandbox, wt) = setup_worktree_sandbox(&emitter); + wt_sandbox + .initialize() + .await + .map_err(|e| anyhow::anyhow!("failed to attach worktree to {run_branch}: {e}"))?; + std::env::set_current_dir(&wt)?; + (Arc::new(wt_sandbox) as Arc, Some(wt)) + } + SandboxProvider::Docker => { + tracing::warn!( + "--sandbox docker is not supported for branch resume; falling back to local worktree sandbox" + ); + eprintln!( + "{} --sandbox docker is not supported for branch resume; falling back to local worktree sandbox.", + styles.yellow.apply_to("Warning:"), + ); + sandbox_provider = SandboxProvider::Local; + let (wt_sandbox, wt) = setup_worktree_sandbox(&emitter); wt_sandbox .initialize() .await @@ -386,8 +759,8 @@ async fn prepare_from_branch( } #[cfg(feature = "exedev")] SandboxProvider::Exe => { - let exe_config = super::run::resolve_exe_config(None, run_defaults); - let clone_params = super::run::resolve_exe_clone_params(&original_cwd); + let exe_config = super::run::resolve_exe_config(run_cfg.as_ref(), run_defaults); + let clone_params = super::run::resolve_exe_clone_params(&resume_repo_path); let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev") .await .map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?; @@ -406,9 +779,10 @@ async fn prepare_from_branch( (Arc::new(env), None) } SandboxProvider::Ssh => { - let config = resolve_ssh_config(None, run_defaults) + let config = resolve_ssh_config(run_cfg.as_ref(), run_defaults) .ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?; - let clone_params = resolve_ssh_clone_params(&original_cwd); + ssh_data_host = Some(config.destination.clone()); + let clone_params = resolve_ssh_clone_params(&resume_repo_path); let mut env = fabro_sandbox::ssh::SshSandbox::new( config, clone_params, @@ -422,7 +796,7 @@ async fn prepare_from_branch( (Arc::new(env), None) } SandboxProvider::Daytona => { - let config = resolve_daytona_config(None, run_defaults).unwrap_or_default(); + let config = daytona_config.unwrap_or_default(); let mut env = fabro_sandbox::daytona::DaytonaSandbox::new( config, github_app.clone(), @@ -442,8 +816,14 @@ async fn prepare_from_branch( // Wrap with ReadBeforeWriteSandbox to enforce read-before-write guard let sandbox: Arc = Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox)); - // Let the sandbox provide any commands needed to resume on the existing run branch - let setup_commands: Vec = sandbox.resume_setup_commands(&run_branch); + // User-configured setup commands first, then sandbox-specific resume commands + let mut setup_commands: Vec = run_cfg + .as_ref() + .and_then(|cfg| cfg.setup.as_ref()) + .or(run_defaults.setup.as_ref()) + .map(|s| s.commands.clone()) + .unwrap_or_default(); + setup_commands.extend(sandbox.resume_setup_commands(&run_branch)); let meta_branch = Some(fabro_workflows::git::MetadataStore::branch_name(&run_id)); let config = RunConfig { @@ -452,30 +832,61 @@ async fn prepare_from_branch( dry_run: args.dry_run, run_id: run_id.clone(), git_checkpoint_enabled: true, - host_repo_path: Some(original_cwd.clone()), + host_repo_path: Some(resume_repo_path.clone()), base_sha, run_branch: Some(run_branch), meta_branch, - labels: HashMap::new(), - checkpoint_exclude_globs: Vec::new(), + labels: args + .label + .iter() + .filter_map(|s| s.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + checkpoint_exclude_globs: run_cfg + .as_ref() + .map(|cfg| cfg.checkpoint.exclude_globs.clone()) + .unwrap_or_else(|| run_defaults.checkpoint.exclude_globs.clone()), github_app: github_app.clone(), git_author, - base_branch: None, - pull_request: None, - asset_globs: Vec::new(), + base_branch: detected_base_branch, + pull_request: run_cfg + .as_ref() + .and_then(|cfg| cfg.pull_request.as_ref()) + .or(run_defaults.pull_request.as_ref()) + .filter(|p| p.enabled) + .cloned(), + asset_globs: run_cfg + .as_ref() + .and_then(|cfg| cfg.assets.as_ref()) + .or(run_defaults.assets.as_ref()) + .map(|a| a.include.clone()) + .unwrap_or_default(), workflow_slug: None, }; + let devcontainer_env = devcontainer_config + .as_ref() + .map(|dc| dc.environment.clone()) + .unwrap_or_default(); + Ok(ResumeContext { checkpoint, graph, run_id, run_dir, + run_cfg, sandbox, emitter, config, setup_commands, + devcontainer_phases, + devcontainer_env, original_cwd: Some(original_cwd), + origin_url, + sandbox_provider, + ssh_data_host, + github_app: github_app.clone(), + status_guard, }) } @@ -491,19 +902,49 @@ async fn run_resumed( graph, run_id, run_dir, + mut run_cfg, sandbox, emitter, mut config, setup_commands, + devcontainer_phases, + devcontainer_env, original_cwd, + origin_url, + sandbox_provider, + ssh_data_host, + github_app, + mut status_guard, } = ctx; + // Track the last git commit SHA from CheckpointCompleted events + let last_git_sha: Arc>> = Arc::new(std::sync::Mutex::new(None)); + { + let sha_clone = Arc::clone(&last_git_sha); + emitter.on_event(move |event| { + if let fabro_workflows::event::WorkflowRunEvent::CheckpointCompleted { + git_commit_sha: Some(sha), + .. + } = event + { + *sha_clone.lock().unwrap() = Some(sha.clone()); + } + }); + } + // Create progress UI (verbose mode shows detailed turn/tool counts and token usage) let is_tty = std::io::stderr().is_terminal(); let progress_ui = Arc::new(std::sync::Mutex::new(super::run_progress::ProgressUI::new( is_tty, args.verbose, ))); + { + let mut ui = progress_ui.lock().expect("progress lock poisoned"); + ui.show_version(); + ui.show_run_id(&run_id); + ui.show_time(&chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()); + ui.show_run_dir(&run_dir); + } { let p = Arc::clone(&progress_ui); emitter.on_event(move |event| { @@ -512,6 +953,91 @@ async fn run_resumed( }); } + // Cost accumulator (mirrors run_command) + let accumulator = Arc::new(std::sync::Mutex::new(super::run::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) = fabro_workflows::cost::compute_stage_cost(u) { + acc.total_cost += cost; + acc.has_pricing = true; + } + } + }); + } + + // Write sandbox.json when sandbox is initialized (mirrors run_command) + { + let run_dir_for_listener = run_dir.clone(); + let progress_for_listener = Arc::clone(&progress_ui); + let cwd_for_listener = match &original_cwd { + Some(p) => p.to_string_lossy().to_string(), + // original_cwd is None only for checkpoint path where cwd hasn't changed + None => std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .to_string_lossy() + .to_string(), + }; + let sandbox_for_listener = Arc::clone(&sandbox); + let provider = sandbox_provider; + let ssh_host = ssh_data_host.clone(); + emitter.on_event(move |event| { + if let fabro_workflows::event::WorkflowRunEvent::SandboxInitialized { + working_directory, + } = event + { + progress_for_listener + .lock() + .expect("progress lock poisoned") + .set_working_directory(working_directory.clone()); + + let sandbox_info_opt = { + let info = sandbox_for_listener.sandbox_info(); + if info.is_empty() { + None + } else { + Some(info) + } + }; + + let is_docker = provider == SandboxProvider::Docker; + let record = fabro_workflows::sandbox_record::SandboxRecord { + provider: provider.to_string(), + working_directory: working_directory.clone(), + identifier: sandbox_info_opt, + host_working_directory: if is_docker { + Some(cwd_for_listener.clone()) + } else { + None + }, + container_mount_point: if is_docker { + Some(working_directory.clone()) + } else { + None + }, + data_host: if provider == SandboxProvider::Ssh { + ssh_host.clone() + } else { + None + }, + }; + if let Err(e) = record.save(&run_dir_for_listener.join("sandbox.json")) { + tracing::warn!(error = %e, "Failed to save sandbox record"); + } + } + }); + } + // JSONL progress log + live.json snapshot (mirrors run_command) { let jsonl_path = run_dir.join("progress.jsonl"); @@ -548,20 +1074,43 @@ async fn run_resumed( let interviewer: Arc = if args.auto_approve { Arc::new(AutoApproveInterviewer) } else { - Arc::new(ConsoleInterviewer::new(styles)) + Arc::new(super::run_progress::ProgressAwareInterviewer::new( + ConsoleInterviewer::new(styles), + Arc::clone(&progress_ui), + )) }; - let dry_run_mode = args.dry_run - || fabro_llm::client::Client::from_env() - .await - .map(|c| c.provider_names().is_empty()) - .unwrap_or(true); + let dry_run_mode = if args.dry_run { + true + } else { + match fabro_llm::client::Client::from_env().await { + Ok(c) if c.provider_names().is_empty() => { + eprintln!( + "{} No LLM providers configured. Running in dry-run mode.", + styles.yellow.apply_to("Warning:"), + ); + true + } + Ok(_) => false, + Err(e) => { + eprintln!( + "{} Failed to initialize LLM client: {e}. Running in dry-run mode.", + styles.yellow.apply_to("Warning:"), + ); + true + } + } + }; config.dry_run = dry_run_mode; + if let Some(ref mut cfg) = run_cfg { + run_config::resolve_sandbox_env(cfg)?; + } + let (model, provider) = resolve_model_provider( args.model.as_deref(), args.provider.as_deref(), - None, + run_cfg.as_ref(), &run_defaults, &graph, ); @@ -572,15 +1121,83 @@ async fn run_resumed( .map_err(|e| anyhow::anyhow!("{e}"))? .unwrap_or_else(Provider::default_from_env); - let fallback_chain = Vec::new(); + let fallback_chain = if run_cfg.is_some() { + resolve_fallback_chain(provider_enum, &model, run_cfg.as_ref()) + } else { + match run_defaults.llm.as_ref().and_then(|l| l.fallbacks.as_ref()) { + Some(map) => fabro_model::build_fallback_chain(provider_enum.as_str(), &model, map), + None => Vec::new(), + } + }; - let registry = fabro_workflows::handler::default_registry(interviewer.clone(), || { - if dry_run_mode { - None - } else { - let api = AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone()); - let cli = AgentCliBackend::new(model.clone(), provider_enum); - Some(Box::new(BackendRouter::new(Box::new(api), cli))) + // Build sandbox env: devcontainer env layered underneath TOML env (TOML wins on conflict, mirrors run_command) + let sandbox_env: HashMap = { + let mut env = devcontainer_env; + if let Some(mut toml_env) = run_cfg + .as_ref() + .and_then(|cfg| cfg.sandbox.as_ref()) + .and_then(|s| s.env.clone()) + .or_else(|| run_defaults.sandbox.as_ref().and_then(|s| s.env.clone())) + { + run_config::resolve_env_refs(&mut toml_env)?; + env.extend(toml_env); + } + env + }; + + // Mint a GitHub App IAT and inject as GITHUB_TOKEN if [github] permissions are declared + let mut sandbox_env = sandbox_env; + let github_permissions = run_cfg + .as_ref() + .and_then(|cfg| cfg.github.as_ref()) + .or(run_defaults.github.as_ref()); + if let Some(gh_cfg) = github_permissions { + if !gh_cfg.permissions.is_empty() { + if let (Some(ref creds), Some(ref url)) = (&github_app, &origin_url) { + match mint_github_token(creds, url, &gh_cfg.permissions).await { + Ok(token) => { + debug!("Minted GitHub IAT for sandbox GITHUB_TOKEN"); + sandbox_env.insert("GITHUB_TOKEN".to_string(), token); + } + Err(e) => { + eprintln!( + "{} Failed to mint GitHub token: {e}", + styles.yellow.apply_to("Warning:"), + ); + } + } + } else { + debug!("Skipping GitHub token: no GitHub App credentials or origin URL"); + } + } + } + + // Resolve MCP servers from run defaults + let mcp_servers: Vec = run_cfg + .as_ref() + .map(|cfg| cfg.mcp_servers.clone()) + .unwrap_or_else(|| run_defaults.mcp_servers.clone()) + .clone() + .into_iter() + .map(|(name, entry)| entry.into_config(name)) + .collect(); + + let registry = fabro_workflows::handler::default_registry(interviewer.clone(), { + let sandbox_env = sandbox_env.clone(); + let model = model.clone(); + let mcp_servers = mcp_servers.clone(); + move || { + if dry_run_mode { + None + } else { + let api = + AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone()) + .with_env(sandbox_env.clone()) + .with_mcp_servers(mcp_servers.clone()); + let cli = AgentCliBackend::new(model.clone(), provider_enum) + .with_env(sandbox_env.clone()); + Some(Box::new(BackendRouter::new(Box::new(api), cli))) + } } }); let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer( @@ -589,30 +1206,148 @@ async fn run_resumed( interviewer, Arc::clone(&sandbox), ); + if !sandbox_env.is_empty() { + engine.set_env(sandbox_env); + } if dry_run_mode { engine.set_dry_run(true); } + // Wire up hook runner from run defaults (mirrors run_command) + { + let hooks = run_cfg + .as_ref() + .map(|cfg| &cfg.hooks) + .unwrap_or(&run_defaults.hooks); + if !hooks.is_empty() { + let hook_config = fabro_hooks::HookConfig { + hooks: hooks.clone(), + }; + let runner = fabro_hooks::HookRunner::new(hook_config); + engine.set_hook_runner(Arc::new(runner)); + } + } let lifecycle = fabro_workflows::engine::LifecycleConfig { setup_commands, - setup_command_timeout_ms: 60_000, - devcontainer_phases: Vec::new(), + setup_command_timeout_ms: 300_000, + devcontainer_phases, }; + // Defuse the status guard — engine.run() will write "running" and conclusion handles "concluded" + status_guard.defuse(); + + // Safety net: if we panic or return early, best-effort cleanup via spawn (mirrors run_command). + let preserve = super::run::resolve_preserve_sandbox( + args.preserve_sandbox, + run_cfg.as_ref(), + &run_defaults, + ); + let sandbox_for_cleanup = Arc::clone(&sandbox); + let cleanup_guard = scopeguard::guard((), move |()| { + if preserve { + return; + } + let rt = tokio::runtime::Handle::try_current(); + if let Ok(handle) = rt { + handle.spawn(async move { + let _ = sandbox_for_cleanup.cleanup().await; + }); + } + }); + let run_start = Instant::now(); let engine_result = engine .run_with_lifecycle(&graph, &mut config, lifecycle, Some(&checkpoint)) .await; let run_duration_ms = run_start.elapsed().as_millis() as u64; - // Finish progress bars before retro - progress_ui.lock().expect("progress lock poisoned").finish(); - // Restore cwd if we changed it (worktree is kept for `fabro cp` access; pruned separately) if let Some(ref cwd) = original_cwd { let _ = std::env::set_current_dir(cwd); } + // Build and save conclusion.json + final status (mirrors run_command) + { + let (status, failure_reason) = match &engine_result { + Ok(ref o) => (o.status.clone(), o.failure_reason().map(String::from)), + Err(e) => (StageStatus::Fail, Some(e.to_string())), + }; + + let (run_status, status_reason) = match &engine_result { + Ok(ref o) => match o.status { + StageStatus::Success | StageStatus::Skipped => ( + fabro_workflows::run_status::RunStatus::Succeeded, + Some(fabro_workflows::run_status::StatusReason::Completed), + ), + StageStatus::PartialSuccess => ( + fabro_workflows::run_status::RunStatus::Succeeded, + Some(fabro_workflows::run_status::StatusReason::PartialSuccess), + ), + StageStatus::Fail | StageStatus::Retry => ( + fabro_workflows::run_status::RunStatus::Failed, + Some(fabro_workflows::run_status::StatusReason::WorkflowError), + ), + }, + Err(fabro_workflows::error::FabroError::Cancelled) => ( + fabro_workflows::run_status::RunStatus::Failed, + Some(fabro_workflows::run_status::StatusReason::Cancelled), + ), + Err(_) => ( + fabro_workflows::run_status::RunStatus::Failed, + Some(fabro_workflows::run_status::StatusReason::WorkflowError), + ), + }; + + let checkpoint_loaded = Checkpoint::load(&run_dir.join("checkpoint.json")).ok(); + let stage_durations = fabro_retro::retro::extract_stage_durations(&run_dir); + + let (stages, total_cost, total_retries) = if let Some(ref cp) = checkpoint_loaded { + let mut stages = Vec::new(); + let mut cost_sum: Option = None; + let mut retries_sum: u32 = 0; + + for node_id in &cp.completed_nodes { + let outcome = cp.node_outcomes.get(node_id); + let retries = cp + .node_retries + .get(node_id) + .copied() + .unwrap_or(1) + .saturating_sub(1); + retries_sum += retries; + + let cost = outcome.and_then(|o| o.usage.as_ref()).and_then(|u| u.cost); + if let Some(c) = cost { + *cost_sum.get_or_insert(0.0) += c; + } + + stages.push(fabro_workflows::conclusion::StageSummary { + stage_id: node_id.clone(), + stage_label: node_id.clone(), + duration_ms: stage_durations.get(node_id).copied().unwrap_or(0), + cost, + retries, + }); + } + (stages, cost_sum, retries_sum) + } else { + (vec![], None, 0) + }; + + let conclusion = fabro_workflows::conclusion::Conclusion { + timestamp: chrono::Utc::now(), + status, + duration_ms: run_duration_ms, + failure_reason, + final_git_commit_sha: last_git_sha.lock().unwrap().clone(), + stages, + total_cost, + total_retries, + }; + let _ = conclusion.save(&run_dir.join("conclusion.json")); + fabro_workflows::run_status::write_run_status(&run_dir, run_status, status_reason); + } + // Auto-derive retro if !args.no_retro && project_config::is_retro_enabled() { let failed = match &engine_result { @@ -644,15 +1379,126 @@ async fn run_resumed( .await; } + // Finish progress bars after retro (retro stage uses the same ProgressUI) + progress_ui.lock().expect("progress lock poisoned").finish(); + // Write finalize commit with retro.json + final node files (captures last diff.patch) write_finalize_commit(&config, &run_dir).await; + // Auto-create PR on successful completion (mirrors run_command) + let mut pushed_branch: Option = None; + let mut pr_url: Option = None; + if let Some(ref pr_cfg) = config.pull_request { + if config.dry_run { + debug!("Skipping PR creation: dry-run mode"); + } else if let Err(ref e) = engine_result { + debug!(error = %e, "Skipping PR creation: engine returned an error"); + } else if let Ok(ref outcome) = engine_result { + if !matches!( + outcome.status, + StageStatus::Success | StageStatus::PartialSuccess + ) { + debug!(status = ?outcome.status, "Skipping PR creation: run status is not success"); + } else { + let diff = tokio::fs::read_to_string(run_dir.join("final.patch")) + .await + .unwrap_or_default(); + if let ( + Some(ref base_branch), + Some(ref run_branch), + Some(ref creds), + Some(ref origin), + ) = ( + &config.base_branch, + &config.run_branch, + &github_app, + &origin_url, + ) { + if config.git_checkpoint_enabled { + pushed_branch = Some(run_branch.clone()); + } + + let auto_merge = if pr_cfg.auto_merge { + Some(fabro_workflows::pull_request::AutoMergeConfig { + merge_strategy: pr_cfg.merge_strategy, + }) + } else { + None + }; + + match fabro_workflows::pull_request::maybe_open_pull_request( + creds, + origin, + base_branch, + run_branch, + graph.goal(), + &diff, + &model, + pr_cfg.draft, + auto_merge, + &run_dir, + ) + .await + { + Ok(Some(record)) => { + emitter.emit( + &fabro_workflows::event::WorkflowRunEvent::PullRequestCreated { + pr_url: record.html_url.clone(), + pr_number: record.number, + draft: pr_cfg.draft, + }, + ); + pr_url = Some(record.html_url.clone()); + if let Err(e) = record.save(&run_dir.join("pull_request.json")) { + tracing::warn!(error = %e, "Failed to save pull_request.json"); + } + } + Ok(None) => {} // empty diff, logged at DEBUG + Err(e) => { + emitter.emit( + &fabro_workflows::event::WorkflowRunEvent::PullRequestFailed { + error: e.to_string(), + }, + ); + eprintln!( + "{} PR creation failed: {e}", + styles.yellow.apply_to("Warning:") + ); + } + } + } + } + } + } else { + debug!("Skipping PR creation: pull_request not enabled in config"); + } + + // Defuse the cleanup guard — we are about to do explicit cleanup + scopeguard::ScopeGuard::into_inner(cleanup_guard); + // Cleanup sandbox via engine (fires SandboxCleanup hook) - use super::run::resolve_preserve_sandbox; - let preserve = resolve_preserve_sandbox(args.preserve_sandbox, None, &run_defaults); - let _ = engine + // Before cleanup, print preserve banner (mirrors run_command) + if preserve { + let info = sandbox.sandbox_info(); + if !info.is_empty() { + eprintln!( + "\n{} sandbox preserved: {info}", + styles.bold.apply_to("Info:") + ); + } else { + eprintln!("\n{} sandbox preserved", styles.bold.apply_to("Info:")); + } + } + if let Err(e) = engine .cleanup_sandbox(&config.run_id, &graph.name, preserve) - .await; + .await + { + tracing::warn!(error = %e, "Sandbox cleanup failed"); + eprintln!( + "\n{} sandbox cleanup failed: {e}", + styles.yellow.apply_to("Warning:") + ); + } let outcome = engine_result?; @@ -668,6 +1514,52 @@ async fn run_resumed( "Duration: {}", HumanDuration(Duration::from_millis(run_duration_ms)) ); + + { + use crate::commands::shared::format_tokens_human; + use fabro_workflows::cost::format_cost; + let acc = accumulator.lock().unwrap(); + let total_tokens = acc.total_input_tokens + acc.total_output_tokens; + if total_tokens > 0 { + if acc.has_pricing { + eprintln!( + "{}", + styles.dim.apply_to(format!( + "Cost: {} ({} toks)", + format_cost(acc.total_cost), + format_tokens_human(total_tokens) + )) + ); + } else { + eprintln!( + "{}", + styles + .dim + .apply_to(format!("Toks: {}", format_tokens_human(total_tokens))) + ); + } + if acc.total_cache_read_tokens > 0 { + eprintln!( + "{}", + styles.dim.apply_to(format!( + "Cache: {} read, {} write", + format_tokens_human(acc.total_cache_read_tokens), + format_tokens_human(acc.total_cache_write_tokens), + )), + ); + } + if acc.total_reasoning_tokens > 0 { + eprintln!( + "{}", + styles.dim.apply_to(format!( + "Reasoning: {} tokens", + format_tokens_human(acc.total_reasoning_tokens), + )), + ); + } + } + } + eprintln!( "{}", styles @@ -675,6 +1567,20 @@ async fn run_resumed( .apply_to(format!("Run: {}", tilde_path(&run_dir))) ); + if let Some(failure) = outcome.failure_reason() { + eprintln!("Failure: {}", styles.red.apply_to(failure)); + } + + if pushed_branch.is_some() || pr_url.is_some() { + eprintln!(); + if let Some(ref branch) = pushed_branch { + eprintln!("{} {branch}", styles.bold.apply_to("Pushed branch:")); + } + if let Some(ref url) = pr_url { + eprintln!("{} {url}", styles.bold.apply_to("Pull request:")); + } + } + print_final_output(&run_dir, styles); print_assets(&run_dir, styles); @@ -684,3 +1590,92 @@ async fn run_resumed( _ => std::process::exit(1), } } + +/// Scan `~/.fabro/runs/` for an existing directory whose name ends with `-{run_id}`. +fn find_existing_run_dir(run_id: &str) -> Option { + let base = dirs::home_dir()?.join(".fabro").join("runs"); + let suffix = format!("-{run_id}"); + let entries = std::fs::read_dir(&base).ok()?; + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().ends_with(&suffix) && entry.path().is_dir() { + return Some(entry.path()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use fabro_workflows::run_status::{RunStatus, RunStatusRecord, StatusReason}; + + fn sample_manifest() -> Manifest { + Manifest { + run_id: "run-1".to_string(), + workflow_name: "resume".to_string(), + goal: "fix bug".to_string(), + start_time: Utc::now(), + node_count: 1, + edge_count: 0, + run_branch: Some("fabro/run/run-1".to_string()), + base_sha: Some("abc123".to_string()), + labels: HashMap::new(), + base_branch: Some("main".to_string()), + workflow_slug: None, + host_repo_path: None, + } + } + + #[test] + fn preferred_resume_repo_path_uses_manifest_host_repo_path_when_present() { + let cwd = tempfile::tempdir().unwrap(); + let host_repo = tempfile::tempdir().unwrap(); + let mut manifest = sample_manifest(); + manifest.host_repo_path = Some(host_repo.path().to_string_lossy().to_string()); + + let selected = preferred_resume_repo_path(cwd.path(), Some(&manifest)); + assert_eq!(selected, host_repo.path()); + } + + #[test] + fn preferred_resume_repo_path_falls_back_when_manifest_path_is_missing() { + let cwd = tempfile::tempdir().unwrap(); + let mut manifest = sample_manifest(); + manifest.host_repo_path = Some(cwd.path().join("missing-repo").display().to_string()); + + let selected = preferred_resume_repo_path(cwd.path(), Some(&manifest)); + assert_eq!(selected, cwd.path()); + } + + #[test] + fn resume_run_status_guard_marks_failed_on_drop() { + let dir = tempfile::tempdir().unwrap(); + + { + let guard = ResumeRunStatusGuard::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)); + drop(guard); + } + + let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap(); + assert_eq!(record.status, RunStatus::Failed); + assert_eq!(record.reason, Some(StatusReason::SandboxInitFailed)); + assert!(dir.path().join("run.pid").exists()); + } + + #[test] + fn resume_run_status_guard_does_not_overwrite_after_defuse() { + let dir = tempfile::tempdir().unwrap(); + let mut guard = ResumeRunStatusGuard::arm(dir.path()).unwrap(); + guard.defuse(); + drop(guard); + + let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap(); + assert_eq!(record.status, RunStatus::Starting); + assert_eq!(record.reason, Some(StatusReason::SandboxInitializing)); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs index c2fffccdb..a71e1e64d 100644 --- a/lib/crates/fabro-cli/src/commands/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -373,7 +373,7 @@ pub(crate) fn resolve_ssh_clone_params( /// /// `apply_defaults` must be called on `run_cfg` before this — it merges /// `run_defaults.llm.fallbacks` into `run_cfg.llm.fallbacks` already. -fn resolve_fallback_chain( +pub(crate) fn resolve_fallback_chain( provider: Provider, model: &str, run_cfg: Option<&WorkflowRunConfig>, @@ -392,7 +392,7 @@ fn resolve_fallback_chain( /// /// Signs a JWT, resolves `owner/repo` from `origin_url`, and requests a /// scoped token. Returns the token string on success. -async fn mint_github_token( +pub(crate) async fn mint_github_token( creds: &fabro_github::GitHubAppCredentials, origin_url: &str, permissions: &HashMap, @@ -430,14 +430,14 @@ enum WorkdirStrategy { /// Accumulates token usage and cost across all workflow stages. #[derive(Default)] -struct CostAccumulator { - total_input_tokens: i64, - total_output_tokens: i64, - total_cache_read_tokens: i64, - total_cache_write_tokens: i64, - total_reasoning_tokens: i64, - total_cost: f64, - has_pricing: bool, +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, } /// Create a [`LocalSandbox`] wired to emit [`WorkflowRunEvent::Sandbox`] events. @@ -479,7 +479,7 @@ pub(crate) async fn write_run_config_snapshot( Ok(()) } -fn resolve_workflow_source( +pub(crate) fn resolve_workflow_source( workflow_path: &Path, ) -> anyhow::Result<(PathBuf, Option)> { let path = project_config::resolve_workflow_arg(workflow_path)?; @@ -521,22 +521,34 @@ pub(crate) struct PreparedWorkflow { /// Shared between `create_run` (which only persists the spec) and /// `run_command` (which goes on to execute the workflow). pub(crate) fn prepare_workflow( + args: &RunArgs, + run_defaults: RunDefaults, + styles: &Styles, + quiet: bool, +) -> anyhow::Result { + prepare_workflow_with_project_config(args, run_defaults, styles, quiet, true) +} + +pub(crate) fn prepare_workflow_with_project_config( args: &RunArgs, mut run_defaults: RunDefaults, styles: &Styles, quiet: bool, + apply_project_config: bool, ) -> anyhow::Result { let workflow_path = args .workflow .as_ref() .ok_or_else(|| anyhow::anyhow!("--workflow is required"))?; - // Apply project-level config overrides (fabro.toml) on top of CLI defaults. - if let Ok(Some((_config_path, project_config))) = - project_config::discover_project_config(&std::env::current_dir().unwrap_or_default()) - { - tracing::debug!("Applying run defaults from fabro.toml"); - run_defaults.merge_overlay(project_config.into_run_defaults()); + if apply_project_config { + // Apply project-level config overrides (fabro.toml) on top of CLI defaults. + if let Ok(Some((_config_path, project_config))) = + project_config::discover_project_config(&std::env::current_dir().unwrap_or_default()) + { + tracing::debug!("Applying run defaults from fabro.toml"); + run_defaults.merge_overlay(project_config.into_run_defaults()); + } } // Resolve workflow arg, load run config if TOML, apply defaults @@ -2616,6 +2628,109 @@ mod tests { assert!(result.is_err()); } + #[test] + fn prepare_workflow_with_project_config_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 args = RunArgs { + workflow: Some(dir.path().join("workflow.toml")), + run_dir: None, + dry_run: false, + preflight: false, + auto_approve: false, + goal: None, + goal_file: None, + model: None, + provider: None, + verbose: false, + sandbox: None, + label: Vec::new(), + no_retro: false, + preserve_sandbox: false, + detach: false, + run_id: None, + }; + + let styles = Styles::new(false); + let prepared = prepare_workflow_with_project_config( + &args, + RunDefaults::default(), + &styles, + true, + false, + ) + .unwrap(); + + assert_eq!(prepared.graph.name, "smoke"); + assert_eq!(prepared.graph.goal(), "toml goal"); + assert_eq!(prepared.sandbox_provider, SandboxProvider::Docker); + assert_eq!(prepared.model, "gpt-5.2"); + assert_eq!(prepared.provider.as_deref(), Some("openai")); + + let run_cfg = prepared + .run_cfg + .as_ref() + .expect("run config should be loaded"); + assert_eq!( + run_cfg + .setup + .as_ref() + .expect("setup config should be preserved") + .commands, + vec!["echo from toml".to_string()] + ); + assert!( + run_cfg + .pull_request + .as_ref() + .expect("pull request config should be preserved") + .enabled + ); + assert_eq!( + run_cfg + .assets + .as_ref() + .expect("assets config should be preserved") + .include, + vec!["*.md".to_string()] + ); + } + #[test] fn apply_goal_override_cli_wins_over_toml() { use fabro_graphviz::graph::{AttrValue, Graph}; diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 6bd7eba3d..37db7faf9 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -937,10 +937,11 @@ async fn main_inner() -> (String, Result<()>) { commands::secret::set_command(&args)?; } }, - Command::Resume(args) => { + Command::Resume(mut args) => { let styles: &'static fabro_util::terminal::Styles = Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); let cli_config = cli_config::load_cli_config(None)?; + args.verbose = args.verbose || cli_config.verbose; #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = fabro_beastie::guard(cli_config.prevent_idle_sleep); let github_app = build_github_app_credentials(cli_config.app_id());