mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
fabro(01KM8C0SVZW77C5W018CYEVE4Y): implement (success)
Fabro-Run: 01KM8C0SVZW77C5W018CYEVE4Y
Fabro-Completed: 5
Fabro-Checkpoint: 7b249487c8
⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
3295828378
commit
52a46a218b
13 changed files with 742 additions and 407 deletions
|
|
@ -95,13 +95,13 @@ See [Observability](/execution/observability) for more on querying run data.
|
|||
Because Fabro checkpoints after every stage, interrupted runs can be resumed from where they left off:
|
||||
|
||||
```bash
|
||||
fabro run --resume path/to/checkpoint.json
|
||||
fabro resume <RUN_ID>
|
||||
```
|
||||
|
||||
Or resume from a git run branch:
|
||||
Or resume from a checkpoint file:
|
||||
|
||||
```bash
|
||||
fabro run --run-branch fabro/runs/abc123
|
||||
fabro resume --checkpoint path/to/checkpoint.json --workflow workflow.fabro
|
||||
```
|
||||
|
||||
The engine restores the full context, node visit counts, and retry state, then continues execution from the next node.
|
||||
The engine restores the full context, node visit counts, and retry state, then continues execution from the next node.
|
||||
|
|
@ -98,7 +98,7 @@ There are two ways to resume an interrupted run:
|
|||
Resume from a `checkpoint.json` saved in the run directory:
|
||||
|
||||
```bash
|
||||
fabro run workflow.fabro --resume path/to/logs/checkpoint.json
|
||||
fabro resume --checkpoint path/to/logs/checkpoint.json --workflow workflow.fabro
|
||||
```
|
||||
|
||||
Fabro loads the checkpoint, restores the context and execution state, and continues from the next node after the checkpoint.
|
||||
|
|
@ -108,7 +108,7 @@ Fabro loads the checkpoint, restores the context and execution state, and contin
|
|||
Resume from the Git branches created during a previous run:
|
||||
|
||||
```bash
|
||||
fabro run --run-branch fabro/run/01JKXYZ...
|
||||
fabro resume 01JKXYZ
|
||||
```
|
||||
|
||||
This reads the checkpoint, manifest, and Graphviz graph from the metadata branch (`fabro/meta/01JKXYZ...`), re-attaches a worktree to the existing run branch, and resumes execution. No workflow file argument is needed — everything is recovered from Git.
|
||||
|
|
@ -163,7 +163,7 @@ fabro rewind <RUN_ID> --list
|
|||
fabro rewind <RUN_ID> plan@2
|
||||
|
||||
# Resume from the rewound point
|
||||
fabro run --run-branch fabro/run/<RUN_ID>
|
||||
fabro resume <RUN_ID>
|
||||
```
|
||||
|
||||
See [`fabro rewind`](/reference/cli#fabro-rewind) for the full command reference.
|
||||
|
|
@ -180,7 +180,7 @@ fabro fork <RUN_ID> --list
|
|||
fabro fork <RUN_ID> plan@2
|
||||
|
||||
# Resume the forked run
|
||||
fabro run --run-branch fabro/run/<NEW_RUN_ID>
|
||||
fabro resume <NEW_RUN_ID>
|
||||
```
|
||||
|
||||
Use **rewind** when you want to redo a run from an earlier point (destructive — resets the original). Use **fork** when you want to try a different approach while keeping the original run as a reference.
|
||||
|
|
|
|||
|
|
@ -42,18 +42,15 @@ Launch a workflow from a `.fabro` workflow file or `.toml` task config.
|
|||
```bash
|
||||
fabro run <WORKFLOW>
|
||||
fabro run run.toml
|
||||
fabro run --run-branch fabro/run/abc123
|
||||
```
|
||||
|
||||
| Argument / Flag | Description |
|
||||
|---|---|
|
||||
| `<WORKFLOW>` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name (resolved from `fabro/workflows/` in the project, then `~/.fabro/workflows/`). Not required when using `--run-branch`. |
|
||||
| `<WORKFLOW>` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name (resolved from `fabro/workflows/` in the project, then `~/.fabro/workflows/`). |
|
||||
| `--run-dir <DIR>` | Run output directory |
|
||||
| `--dry-run` | Execute with a simulated LLM backend |
|
||||
| `--preflight` | Validate run configuration without executing |
|
||||
| `--auto-approve` | Auto-approve all human gates |
|
||||
| `--resume <FILE>` | Resume from a checkpoint file |
|
||||
| `--run-branch <BRANCH>` | Resume from a git run branch (reads checkpoint and graph from metadata branch) |
|
||||
| `--model <MODEL>` | Override default LLM model |
|
||||
| `--provider <PROVIDER>` | Override default LLM provider |
|
||||
| `-v, --verbose` | Enable verbose output |
|
||||
|
|
@ -67,9 +64,37 @@ fabro run --run-branch fabro/run/abc123
|
|||
| `-d, --detach` | Fork the workflow as a background process and print the run ID. Reconnect later with `fabro logs -f`. |
|
||||
|
||||
<Note>
|
||||
`--preflight` conflicts with `--resume`, `--run-branch`, `--dry-run`, and `--detach`. `--run-branch` conflicts with `--resume`.
|
||||
`--preflight` conflicts with `--dry-run` and `--detach`.
|
||||
</Note>
|
||||
|
||||
## `fabro resume`
|
||||
|
||||
Resume an interrupted workflow run from its last checkpoint.
|
||||
|
||||
```bash
|
||||
fabro resume <RUN_ID>
|
||||
fabro resume <RUN_ID> --workflow updated.fabro
|
||||
fabro resume --checkpoint path/to/checkpoint.json --workflow workflow.fabro
|
||||
```
|
||||
|
||||
| Argument / Flag | Description |
|
||||
|---|---|
|
||||
| `<RUN_ID>` | Run ID, prefix, or branch (`fabro/run/...`). Not required when using `--checkpoint`. |
|
||||
| `--checkpoint <FILE>` | Resume from a checkpoint file (requires `--workflow`) |
|
||||
| `--workflow <FILE>` | Override workflow graph (required with `--checkpoint`) |
|
||||
| `--run-dir <DIR>` | Run output directory |
|
||||
| `--dry-run` | Execute with a simulated LLM backend |
|
||||
| `--auto-approve` | Auto-approve all human gates |
|
||||
| `--model <MODEL>` | Override default LLM model |
|
||||
| `--provider <PROVIDER>` | Override default LLM provider |
|
||||
| `-v, --verbose` | Enable verbose output |
|
||||
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` |
|
||||
| `--goal <GOAL>` | Override the workflow goal |
|
||||
| `--goal-file <FILE>` | Read the goal from a file |
|
||||
| `--no-retro` | Skip retro generation after the run |
|
||||
| `--ssh` | Create SSH access to the sandbox |
|
||||
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes |
|
||||
|
||||
## `fabro ps`
|
||||
|
||||
List workflow runs. By default, shows only active (running) runs — similar to `docker ps`. Use `-a` to include completed runs.
|
||||
|
|
@ -399,7 +424,7 @@ fabro skill install --for user --dir claude
|
|||
|
||||
## `fabro rewind`
|
||||
|
||||
Rewind a workflow run to an earlier checkpoint. This resets both the run branch and metadata branch refs so that `fabro run --run-branch` resumes from the target checkpoint.
|
||||
Rewind a workflow run to an earlier checkpoint. This resets both the run branch and metadata branch refs so that `fabro resume` continues from the target checkpoint.
|
||||
|
||||
```bash
|
||||
fabro rewind <RUN_ID> [TARGET]
|
||||
|
|
@ -424,7 +449,7 @@ Target formats:
|
|||
After rewinding, resume from the earlier point:
|
||||
|
||||
```bash
|
||||
fabro run --run-branch fabro/run/<RUN_ID>
|
||||
fabro resume <RUN_ID>
|
||||
```
|
||||
|
||||
See [Checkpoints](/execution/checkpoints#rewinding-to-an-earlier-checkpoint) for background on how checkpointing works.
|
||||
|
|
@ -448,7 +473,7 @@ fabro fork <RUN_ID> --list
|
|||
Target formats are the same as [`fabro rewind`](#fabro-rewind). After forking, resume the new run:
|
||||
|
||||
```bash
|
||||
fabro run --run-branch fabro/run/<NEW_RUN_ID>
|
||||
fabro resume <NEW_RUN_ID>
|
||||
```
|
||||
|
||||
See [Checkpoints — Forking a run](/execution/checkpoints#forking-a-run) for when to use fork vs. rewind.
|
||||
|
|
@ -786,4 +811,4 @@ Open the Fabro Discord community invite in your default browser.
|
|||
|
||||
```bash
|
||||
fabro discord
|
||||
```
|
||||
```
|
||||
|
|
@ -83,8 +83,6 @@ pub async fn create_run(
|
|||
preserve_sandbox: args.preserve_sandbox,
|
||||
dry_run: args.dry_run,
|
||||
auto_approve: args.auto_approve,
|
||||
resume: args.resume.clone(),
|
||||
run_branch: args.run_branch.clone(),
|
||||
};
|
||||
spec.save(&run_dir)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -54,9 +54,8 @@ pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
|
|||
&new_run_id[..8.min(new_run_id.len())]
|
||||
);
|
||||
eprintln!(
|
||||
"To resume: fabro run --run-branch {}{}",
|
||||
fabro_workflows::git::RUN_BRANCH_PREFIX,
|
||||
new_run_id
|
||||
"To resume: fabro resume {}",
|
||||
&new_run_id[..8.min(new_run_id.len())]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod parse;
|
|||
pub mod pr;
|
||||
pub mod preview;
|
||||
pub mod provider;
|
||||
pub mod resume;
|
||||
pub mod rewind;
|
||||
pub mod run;
|
||||
pub(crate) mod run_progress;
|
||||
|
|
|
|||
604
lib/crates/fabro-cli/src/commands/resume.rs
Normal file
604
lib/crates/fabro-cli/src/commands/resume.rs
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use clap::Args;
|
||||
use fabro_agent::{Sandbox, WorktreeConfig, WorktreeSandbox};
|
||||
use fabro_config::run::RunDefaults;
|
||||
use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer};
|
||||
use fabro_model::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
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::outcome::StageStatus;
|
||||
use fabro_workflows::sandbox_provider::SandboxProvider;
|
||||
use indicatif::HumanDuration;
|
||||
|
||||
use super::run::{
|
||||
apply_goal_override, generate_retro, local_sandbox_with_callback, print_assets,
|
||||
print_final_output, resolve_cli_goal, resolve_sandbox_provider, resolve_ssh_clone_params,
|
||||
resolve_ssh_config, write_finalize_commit, CliSandboxProvider,
|
||||
};
|
||||
use crate::commands::shared::{print_diagnostics, tilde_path};
|
||||
use fabro_config::project as project_config;
|
||||
use fabro_validate::Severity;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ResumeArgs {
|
||||
/// Run ID, prefix, or branch (fabro/run/...)
|
||||
#[arg(required_unless_present = "checkpoint")]
|
||||
pub run: Option<String>,
|
||||
|
||||
/// Resume from a checkpoint file (requires --workflow)
|
||||
#[arg(long)]
|
||||
pub checkpoint: Option<PathBuf>,
|
||||
|
||||
/// Override workflow graph (required with --checkpoint)
|
||||
#[arg(long)]
|
||||
pub workflow: Option<PathBuf>,
|
||||
|
||||
/// Run output directory
|
||||
#[arg(long)]
|
||||
pub run_dir: Option<PathBuf>,
|
||||
|
||||
/// Execute with simulated LLM backend
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// Auto-approve all human gates
|
||||
#[arg(long)]
|
||||
pub auto_approve: bool,
|
||||
|
||||
/// Override the workflow goal (exposed as $goal in prompts)
|
||||
#[arg(long)]
|
||||
pub goal: Option<String>,
|
||||
|
||||
/// Read the workflow goal from a file
|
||||
#[arg(long, conflicts_with = "goal")]
|
||||
pub goal_file: Option<PathBuf>,
|
||||
|
||||
/// Override default LLM model
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Override default LLM provider
|
||||
#[arg(long)]
|
||||
pub provider: Option<String>,
|
||||
|
||||
/// Enable verbose output
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Sandbox for agent tools
|
||||
#[arg(long, value_enum)]
|
||||
pub sandbox: Option<CliSandboxProvider>,
|
||||
|
||||
/// Skip retro generation after the run
|
||||
#[arg(long)]
|
||||
pub no_retro: bool,
|
||||
|
||||
/// Create SSH access to the Daytona sandbox and print the connection command
|
||||
#[arg(long)]
|
||||
pub ssh: bool,
|
||||
|
||||
/// Keep the sandbox alive after the run finishes (for debugging)
|
||||
#[arg(long)]
|
||||
pub preserve_sandbox: bool,
|
||||
}
|
||||
|
||||
/// Resume an interrupted workflow run.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the run cannot be found, the checkpoint cannot be loaded,
|
||||
/// or the workflow cannot be resumed.
|
||||
pub async fn resume_command(
|
||||
args: ResumeArgs,
|
||||
run_defaults: RunDefaults,
|
||||
styles: &'static Styles,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
git_author: fabro_workflows::git::GitAuthor,
|
||||
) -> anyhow::Result<()> {
|
||||
// Checkpoint-file path: load checkpoint and graph from files
|
||||
if let Some(ref checkpoint_path) = args.checkpoint {
|
||||
let workflow_path = args
|
||||
.workflow
|
||||
.as_ref()
|
||||
.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);
|
||||
|
||||
eprintln!(
|
||||
"{} {} from checkpoint {}",
|
||||
styles.bold.apply_to("Resuming workflow:"),
|
||||
graph.name,
|
||||
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.unwrap_or_else(|| {
|
||||
if args.dry_run {
|
||||
std::env::temp_dir().join("fabro-dry-run").join(&run_id)
|
||||
} else {
|
||||
let base = dirs::home_dir()
|
||||
.expect("could not determine home directory")
|
||||
.join(".fabro")
|
||||
.join("runs");
|
||||
base.join(format!(
|
||||
"{}-{}",
|
||||
chrono::Local::now().format("%Y%m%d"),
|
||||
run_id
|
||||
))
|
||||
}
|
||||
});
|
||||
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 original_cwd = std::env::current_dir()?;
|
||||
let emitter = Arc::new(EventEmitter::new());
|
||||
|
||||
let sandbox: Arc<dyn Sandbox> =
|
||||
local_sandbox_with_callback(original_cwd.clone(), Arc::clone(&emitter));
|
||||
let sandbox: Arc<dyn Sandbox> = Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox));
|
||||
|
||||
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
|
||||
Arc::new(AutoApproveInterviewer)
|
||||
} else {
|
||||
Arc::new(ConsoleInterviewer::new(styles))
|
||||
};
|
||||
|
||||
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 model = args
|
||||
.model
|
||||
.unwrap_or_else(|| fabro_model::default_model_from_env().id);
|
||||
let provider_enum = args
|
||||
.provider
|
||||
.as_deref()
|
||||
.map(|s| s.parse::<Provider>())
|
||||
.transpose()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
.unwrap_or_else(Provider::default_from_env);
|
||||
|
||||
let fallback_chain = 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)))
|
||||
}
|
||||
});
|
||||
let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer(
|
||||
registry,
|
||||
Arc::clone(&emitter),
|
||||
interviewer,
|
||||
Arc::clone(&sandbox),
|
||||
);
|
||||
if dry_run_mode {
|
||||
engine.set_dry_run(true);
|
||||
}
|
||||
|
||||
let mut config = RunConfig {
|
||||
run_dir: run_dir.clone(),
|
||||
cancel_token: None,
|
||||
dry_run: dry_run_mode,
|
||||
run_id: run_id.clone(),
|
||||
git_checkpoint_enabled: false,
|
||||
host_repo_path: None,
|
||||
base_sha: None,
|
||||
run_branch: None,
|
||||
meta_branch: None,
|
||||
labels: HashMap::new(),
|
||||
checkpoint_exclude_globs: Vec::new(),
|
||||
github_app: github_app.clone(),
|
||||
git_author,
|
||||
base_branch: None,
|
||||
pull_request: None,
|
||||
asset_globs: Vec::new(),
|
||||
workflow_slug: None,
|
||||
};
|
||||
|
||||
let lifecycle = fabro_workflows::engine::LifecycleConfig {
|
||||
setup_commands: Vec::new(),
|
||||
setup_command_timeout_ms: 60_000,
|
||||
devcontainer_phases: Vec::new(),
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
if !args.no_retro && project_config::is_retro_enabled() {
|
||||
let failed = match &engine_result {
|
||||
Ok(ref o) => o.status == StageStatus::Fail,
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let llm_client = if dry_run_mode {
|
||||
None
|
||||
} else {
|
||||
fabro_llm::client::Client::from_env().await.ok()
|
||||
};
|
||||
|
||||
generate_retro(
|
||||
&config.run_id,
|
||||
&graph.name,
|
||||
graph.goal(),
|
||||
&run_dir,
|
||||
failed,
|
||||
run_duration_ms,
|
||||
dry_run_mode,
|
||||
llm_client.as_ref(),
|
||||
&sandbox,
|
||||
provider_enum,
|
||||
&model,
|
||||
styles,
|
||||
Some(Arc::clone(&emitter)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = engine
|
||||
.cleanup_sandbox(&config.run_id, &graph.name, false)
|
||||
.await;
|
||||
|
||||
let outcome = engine_result?;
|
||||
|
||||
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="));
|
||||
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
|
||||
let status_str = outcome.status.to_string().to_uppercase();
|
||||
let status_color = match outcome.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(run_duration_ms))
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("Run: {}", tilde_path(&run_dir)))
|
||||
);
|
||||
|
||||
print_final_output(&run_dir, styles);
|
||||
print_assets(&run_dir, styles);
|
||||
|
||||
fabro_util::run_log::deactivate();
|
||||
match outcome.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => return Ok(()),
|
||||
_ => std::process::exit(1),
|
||||
}
|
||||
}
|
||||
|
||||
// Run-ID path: resolve run_id and resume from git metadata
|
||||
let run_arg = args.run.as_deref().expect("run is required");
|
||||
|
||||
let (run_id, run_branch) =
|
||||
if let Some(stripped) = run_arg.strip_prefix(fabro_workflows::git::RUN_BRANCH_PREFIX) {
|
||||
let id = stripped.to_string();
|
||||
let branch = run_arg.to_string();
|
||||
(id, branch)
|
||||
} else {
|
||||
let repo = git2::Repository::discover(".").context("not in a git repository")?;
|
||||
let id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, run_arg)?;
|
||||
let branch = format!("{}{}", fabro_workflows::git::RUN_BRANCH_PREFIX, id);
|
||||
(id, 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)?
|
||||
} else {
|
||||
fabro_workflows::workflow::WorkflowBuilder::new().prepare(&source)?
|
||||
};
|
||||
let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?;
|
||||
apply_goal_override(&mut graph, cli_goal.as_deref(), None);
|
||||
|
||||
eprintln!(
|
||||
"{} {} from branch {} ({})",
|
||||
styles.bold.apply_to("Resuming workflow:"),
|
||||
graph.name,
|
||||
styles.dim.apply_to(&run_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.unwrap_or_else(|| {
|
||||
if args.dry_run {
|
||||
std::env::temp_dir().join("fabro-dry-run").join(&run_id)
|
||||
} else {
|
||||
let base = dirs::home_dir()
|
||||
.expect("could not determine home directory")
|
||||
.join(".fabro")
|
||||
.join("runs");
|
||||
base.join(format!(
|
||||
"{}-{}",
|
||||
chrono::Local::now().format("%Y%m%d"),
|
||||
run_id
|
||||
))
|
||||
}
|
||||
});
|
||||
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")?;
|
||||
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 emitter = Arc::new(EventEmitter::new());
|
||||
let (sandbox, _worktree_path): (Arc<dyn Sandbox>, Option<PathBuf>) = 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());
|
||||
|
||||
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<dyn Sandbox>, Some(wt))
|
||||
}
|
||||
#[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 mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev")
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?;
|
||||
let config = exe_config.unwrap_or_default();
|
||||
let mut env = fabro_sandbox::exe::ExeSandbox::new(
|
||||
Box::new(mgmt_ssh),
|
||||
config,
|
||||
clone_params,
|
||||
Some(run_id.clone()),
|
||||
github_app.clone(),
|
||||
);
|
||||
let emitter_cb = Arc::clone(&emitter);
|
||||
env.set_event_callback(Arc::new(move |event| {
|
||||
emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
|
||||
}));
|
||||
(Arc::new(env), None)
|
||||
}
|
||||
SandboxProvider::Ssh => {
|
||||
let config = resolve_ssh_config(None, &run_defaults)
|
||||
.ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?;
|
||||
let clone_params = resolve_ssh_clone_params(&original_cwd);
|
||||
let mut env = fabro_sandbox::ssh::SshSandbox::new(
|
||||
config,
|
||||
clone_params,
|
||||
Some(run_id.clone()),
|
||||
github_app.clone(),
|
||||
);
|
||||
let emitter_cb = Arc::clone(&emitter);
|
||||
env.set_event_callback(Arc::new(move |event| {
|
||||
emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
|
||||
}));
|
||||
(Arc::new(env), None)
|
||||
}
|
||||
SandboxProvider::Daytona => {
|
||||
bail!("resume is not yet supported with --sandbox daytona");
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap with ReadBeforeWriteSandbox to enforce read-before-write guard
|
||||
let sandbox: Arc<dyn Sandbox> = Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox));
|
||||
|
||||
// Let the sandbox provide any commands needed to resume on the existing run branch
|
||||
let resume_setup_commands: Vec<String> = sandbox.resume_setup_commands(&run_branch);
|
||||
|
||||
// Build interviewer
|
||||
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
|
||||
Arc::new(AutoApproveInterviewer)
|
||||
} else {
|
||||
Arc::new(ConsoleInterviewer::new(styles))
|
||||
};
|
||||
|
||||
// Build engine with a backend
|
||||
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 model = args
|
||||
.model
|
||||
.unwrap_or_else(|| fabro_model::default_model_from_env().id);
|
||||
let provider_enum = args
|
||||
.provider
|
||||
.as_deref()
|
||||
.map(|s| s.parse::<Provider>())
|
||||
.transpose()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
.unwrap_or_else(Provider::default_from_env);
|
||||
|
||||
// No fallback config available for branch resume; use empty chain.
|
||||
let fallback_chain = 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)))
|
||||
}
|
||||
});
|
||||
let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer(
|
||||
registry,
|
||||
Arc::clone(&emitter),
|
||||
interviewer,
|
||||
Arc::clone(&sandbox),
|
||||
);
|
||||
if dry_run_mode {
|
||||
engine.set_dry_run(true);
|
||||
}
|
||||
|
||||
let meta_branch = Some(fabro_workflows::git::MetadataStore::branch_name(&run_id));
|
||||
let mut config = RunConfig {
|
||||
run_dir: run_dir.clone(),
|
||||
cancel_token: None,
|
||||
dry_run: dry_run_mode,
|
||||
run_id: run_id.clone(),
|
||||
git_checkpoint_enabled: true, // always true for resume (worktree or sandbox git is set up)
|
||||
host_repo_path: Some(original_cwd.clone()),
|
||||
base_sha,
|
||||
run_branch: Some(run_branch.to_string()),
|
||||
meta_branch,
|
||||
labels: HashMap::new(),
|
||||
checkpoint_exclude_globs: Vec::new(),
|
||||
github_app: github_app.clone(),
|
||||
git_author,
|
||||
base_branch: None,
|
||||
pull_request: None,
|
||||
asset_globs: Vec::new(),
|
||||
workflow_slug: None,
|
||||
};
|
||||
|
||||
let lifecycle = fabro_workflows::engine::LifecycleConfig {
|
||||
setup_commands: resume_setup_commands,
|
||||
setup_command_timeout_ms: 60_000,
|
||||
devcontainer_phases: Vec::new(),
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// Restore cwd (worktree is kept for `fabro cp` access; pruned separately)
|
||||
let _ = std::env::set_current_dir(&original_cwd);
|
||||
|
||||
// Auto-derive retro
|
||||
if !args.no_retro && project_config::is_retro_enabled() {
|
||||
let failed = match &engine_result {
|
||||
Ok(ref o) => o.status == StageStatus::Fail,
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let llm_client = if dry_run_mode {
|
||||
None
|
||||
} else {
|
||||
fabro_llm::client::Client::from_env().await.ok()
|
||||
};
|
||||
|
||||
generate_retro(
|
||||
&config.run_id,
|
||||
&graph.name,
|
||||
graph.goal(),
|
||||
&run_dir,
|
||||
failed,
|
||||
run_duration_ms,
|
||||
dry_run_mode,
|
||||
llm_client.as_ref(),
|
||||
&sandbox,
|
||||
provider_enum,
|
||||
&model,
|
||||
styles,
|
||||
Some(Arc::clone(&emitter)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Write finalize commit with retro.json + final node files (captures last diff.patch)
|
||||
write_finalize_commit(&config, &run_dir).await;
|
||||
|
||||
// Cleanup sandbox via engine (fires SandboxCleanup hook)
|
||||
let _ = engine
|
||||
.cleanup_sandbox(&config.run_id, &graph.name, false)
|
||||
.await;
|
||||
|
||||
let outcome = engine_result?;
|
||||
|
||||
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="));
|
||||
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
|
||||
let status_str = outcome.status.to_string().to_uppercase();
|
||||
let status_color = match outcome.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(run_duration_ms))
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("Run: {}", tilde_path(&run_dir)))
|
||||
);
|
||||
|
||||
print_final_output(&run_dir, styles);
|
||||
print_assets(&run_dir, styles);
|
||||
|
||||
fabro_util::run_log::deactivate();
|
||||
match outcome.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => Ok(()),
|
||||
_ => std::process::exit(1),
|
||||
}
|
||||
}
|
||||
|
|
@ -46,9 +46,8 @@ pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
|
|||
fabro_workflows::run_rewind::execute_rewind(&store, &run_id, entry, !args.no_push)?;
|
||||
|
||||
eprintln!(
|
||||
"\nTo resume: fabro run --run-branch {}{}",
|
||||
fabro_workflows::git::RUN_BRANCH_PREFIX,
|
||||
run_id
|
||||
"\nTo resume: fabro resume {}",
|
||||
&run_id[..8.min(run_id.len())]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -74,8 +74,7 @@ impl From<SandboxProvider> for CliSandboxProvider {
|
|||
|
||||
#[derive(Args)]
|
||||
pub struct RunArgs {
|
||||
/// Path to a .fabro workflow file or .toml task config (not required with --run-branch)
|
||||
#[arg(required_unless_present = "run_branch")]
|
||||
/// Path to a .fabro workflow file or .toml task config
|
||||
pub workflow: Option<PathBuf>,
|
||||
|
||||
/// Run output directory
|
||||
|
|
@ -87,21 +86,13 @@ pub struct RunArgs {
|
|||
pub dry_run: bool,
|
||||
|
||||
/// Validate run configuration without executing
|
||||
#[arg(long, conflicts_with_all = ["resume", "run_branch", "dry_run"])]
|
||||
#[arg(long, conflicts_with = "dry_run")]
|
||||
pub preflight: bool,
|
||||
|
||||
/// Auto-approve all human gates
|
||||
#[arg(long)]
|
||||
pub auto_approve: bool,
|
||||
|
||||
/// Resume from a checkpoint file
|
||||
#[arg(long)]
|
||||
pub resume: Option<PathBuf>,
|
||||
|
||||
/// Resume from a git run branch (reads checkpoint and graph from metadata branch)
|
||||
#[arg(long, conflicts_with = "resume")]
|
||||
pub run_branch: Option<String>,
|
||||
|
||||
/// Override the workflow goal (exposed as $goal in prompts)
|
||||
#[arg(long)]
|
||||
pub goal: Option<String>,
|
||||
|
|
@ -143,7 +134,7 @@ pub struct RunArgs {
|
|||
pub preserve_sandbox: bool,
|
||||
|
||||
/// Run the workflow in the background and print the run ID
|
||||
#[arg(short = 'd', long, conflicts_with_all = ["resume", "run_branch", "preflight"])]
|
||||
#[arg(short = 'd', long, conflicts_with = "preflight")]
|
||||
pub detach: bool,
|
||||
|
||||
/// Pre-generated run ID (used internally by --detach)
|
||||
|
|
@ -258,7 +249,7 @@ pub(crate) fn resolve_sandbox_provider(
|
|||
}
|
||||
|
||||
/// Resolve preserve-sandbox: CLI flag > TOML config > run defaults > false.
|
||||
fn resolve_preserve_sandbox(
|
||||
pub(crate) fn resolve_preserve_sandbox(
|
||||
cli: bool,
|
||||
run_cfg: Option<&WorkflowRunConfig>,
|
||||
run_defaults: &RunDefaults,
|
||||
|
|
@ -310,7 +301,7 @@ fn resolve_daytona_config(
|
|||
|
||||
#[cfg(feature = "exedev")]
|
||||
/// Resolve exe.dev config: TOML config > run defaults.
|
||||
fn resolve_exe_config(
|
||||
pub(crate) fn resolve_exe_config(
|
||||
run_cfg: Option<&WorkflowRunConfig>,
|
||||
run_defaults: &RunDefaults,
|
||||
) -> Option<fabro_sandbox::exe::ExeConfig> {
|
||||
|
|
@ -325,7 +316,9 @@ fn resolve_exe_config(
|
|||
///
|
||||
/// Returns `None` if no git repo is detected. Credential resolution is
|
||||
/// handled by ExeSandbox itself via its `github_app` field.
|
||||
fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::exe::GitCloneParams> {
|
||||
pub(crate) fn resolve_exe_clone_params(
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<fabro_sandbox::exe::GitCloneParams> {
|
||||
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
|
|
@ -338,7 +331,7 @@ fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::exe:
|
|||
}
|
||||
|
||||
/// Resolve SSH sandbox config: TOML config > run defaults.
|
||||
fn resolve_ssh_config(
|
||||
pub(crate) fn resolve_ssh_config(
|
||||
run_cfg: Option<&WorkflowRunConfig>,
|
||||
run_defaults: &RunDefaults,
|
||||
) -> Option<fabro_sandbox::ssh::SshConfig> {
|
||||
|
|
@ -352,7 +345,9 @@ fn resolve_ssh_config(
|
|||
///
|
||||
/// Returns `None` if no git repo is detected. Credential resolution is
|
||||
/// handled by SshSandbox itself via its `github_app` field.
|
||||
fn resolve_ssh_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::ssh::GitCloneParams> {
|
||||
pub(crate) fn resolve_ssh_clone_params(
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<fabro_sandbox::ssh::GitCloneParams> {
|
||||
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
|
|
@ -436,7 +431,10 @@ struct CostAccumulator {
|
|||
}
|
||||
|
||||
/// Create a [`LocalSandbox`] wired to emit [`WorkflowRunEvent::Sandbox`] events.
|
||||
fn local_sandbox_with_callback(cwd: PathBuf, emitter: Arc<EventEmitter>) -> Arc<dyn Sandbox> {
|
||||
pub(crate) fn local_sandbox_with_callback(
|
||||
cwd: PathBuf,
|
||||
emitter: Arc<EventEmitter>,
|
||||
) -> Arc<dyn Sandbox> {
|
||||
let mut env = LocalSandbox::new(cwd);
|
||||
env.set_event_callback(Arc::new(move |event| {
|
||||
emitter.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
|
||||
|
|
@ -599,11 +597,6 @@ pub async fn run_command(
|
|||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
git_author: fabro_workflows::git::GitAuthor,
|
||||
) -> anyhow::Result<()> {
|
||||
// Handle --run-branch resume: read everything from git metadata
|
||||
if let Some(branch) = args.run_branch.clone() {
|
||||
return run_from_branch(args, &branch, styles, git_author, run_defaults, github_app).await;
|
||||
}
|
||||
|
||||
let PreparedWorkflow {
|
||||
source,
|
||||
graph,
|
||||
|
|
@ -1464,16 +1457,9 @@ pub async fn run_command(
|
|||
});
|
||||
|
||||
let run_start = Instant::now();
|
||||
let engine_result = if let Some(ref checkpoint_path) = args.resume {
|
||||
let checkpoint = Checkpoint::load(checkpoint_path)?;
|
||||
engine
|
||||
.run_with_lifecycle(&graph, &mut config, lifecycle, Some(&checkpoint))
|
||||
.await
|
||||
} else {
|
||||
engine
|
||||
.run_with_lifecycle(&graph, &mut config, lifecycle, None)
|
||||
.await
|
||||
};
|
||||
let engine_result = engine
|
||||
.run_with_lifecycle(&graph, &mut config, lifecycle, None)
|
||||
.await;
|
||||
let run_duration_ms = run_start.elapsed().as_millis() as u64;
|
||||
|
||||
// Restore cwd (worktree is kept for `fabro cp` access; pruned separately)
|
||||
|
|
@ -1803,329 +1789,8 @@ pub async fn run_command(
|
|||
}
|
||||
}
|
||||
|
||||
/// Resume a workflow run from a git run branch.
|
||||
///
|
||||
/// Reads the checkpoint, manifest, and graph DOT from the metadata branch
|
||||
/// (`fabro/meta/{run_id}`), re-attaches a worktree to the existing run branch,
|
||||
/// and resumes execution via `run_from_checkpoint()`.
|
||||
async fn run_from_branch(
|
||||
args: RunArgs,
|
||||
run_branch: &str,
|
||||
styles: &'static Styles,
|
||||
git_author: fabro_workflows::git::GitAuthor,
|
||||
run_defaults: RunDefaults,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Extract run_id from branch name: "fabro/run/{run_id}" -> "{run_id}"
|
||||
let run_id = run_branch
|
||||
.strip_prefix(fabro_workflows::git::RUN_BRANCH_PREFIX)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"invalid run branch format: expected '{}<run_id>', got '{run_branch}'",
|
||||
fabro_workflows::git::RUN_BRANCH_PREFIX,
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
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 --pipeline 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)?
|
||||
} else {
|
||||
fabro_workflows::workflow::WorkflowBuilder::new().prepare(&source)?
|
||||
};
|
||||
let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?;
|
||||
apply_goal_override(&mut graph, cli_goal.as_deref(), None);
|
||||
|
||||
eprintln!(
|
||||
"{} {} from branch {} ({})",
|
||||
styles.bold.apply_to("Resuming workflow:"),
|
||||
graph.name,
|
||||
styles.dim.apply_to(run_branch),
|
||||
run_id,
|
||||
);
|
||||
|
||||
print_diagnostics(&diagnostics, styles);
|
||||
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
|
||||
anyhow::bail!("Validation failed");
|
||||
}
|
||||
|
||||
// Set up logs directory
|
||||
let run_dir = args.run_dir.unwrap_or_else(|| {
|
||||
if args.dry_run {
|
||||
std::env::temp_dir().join("fabro-dry-run").join(&run_id)
|
||||
} else {
|
||||
let base = dirs::home_dir()
|
||||
.expect("could not determine home directory")
|
||||
.join(".fabro")
|
||||
.join("runs");
|
||||
base.join(format!(
|
||||
"{}-{}",
|
||||
chrono::Local::now().format("%Y%m%d"),
|
||||
run_id
|
||||
))
|
||||
}
|
||||
});
|
||||
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")?;
|
||||
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 emitter = Arc::new(EventEmitter::new());
|
||||
let (sandbox, _worktree_path): (Arc<dyn fabro_agent::Sandbox>, Option<PathBuf>) =
|
||||
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.to_string(),
|
||||
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.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<dyn fabro_agent::Sandbox>,
|
||||
Some(wt),
|
||||
)
|
||||
}
|
||||
#[cfg(feature = "exedev")]
|
||||
SandboxProvider::Exe => {
|
||||
let exe_config = resolve_exe_config(None, &run_defaults);
|
||||
let clone_params = resolve_exe_clone_params(&original_cwd);
|
||||
let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev")
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?;
|
||||
let config = exe_config.unwrap_or_default();
|
||||
let mut env = fabro_sandbox::exe::ExeSandbox::new(
|
||||
Box::new(mgmt_ssh),
|
||||
config,
|
||||
clone_params,
|
||||
Some(run_id.clone()),
|
||||
github_app.clone(),
|
||||
);
|
||||
let emitter_cb = Arc::clone(&emitter);
|
||||
env.set_event_callback(Arc::new(move |event| {
|
||||
emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
|
||||
}));
|
||||
(Arc::new(env), None)
|
||||
}
|
||||
SandboxProvider::Ssh => {
|
||||
let config = resolve_ssh_config(None, &run_defaults).ok_or_else(|| {
|
||||
anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config")
|
||||
})?;
|
||||
let clone_params = resolve_ssh_clone_params(&original_cwd);
|
||||
let mut env = fabro_sandbox::ssh::SshSandbox::new(
|
||||
config,
|
||||
clone_params,
|
||||
Some(run_id.clone()),
|
||||
github_app.clone(),
|
||||
);
|
||||
let emitter_cb = Arc::clone(&emitter);
|
||||
env.set_event_callback(Arc::new(move |event| {
|
||||
emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
|
||||
}));
|
||||
(Arc::new(env), None)
|
||||
}
|
||||
SandboxProvider::Daytona => {
|
||||
bail!("--run-branch resume is not yet supported with --sandbox daytona");
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap with ReadBeforeWriteSandbox to enforce read-before-write guard
|
||||
let sandbox: Arc<dyn fabro_agent::Sandbox> =
|
||||
Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox));
|
||||
|
||||
// Let the sandbox provide any commands needed to resume on the existing run branch
|
||||
let resume_setup_commands: Vec<String> = sandbox.resume_setup_commands(run_branch);
|
||||
|
||||
// Build interviewer
|
||||
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
|
||||
Arc::new(AutoApproveInterviewer)
|
||||
} else {
|
||||
Arc::new(ConsoleInterviewer::new(styles))
|
||||
};
|
||||
|
||||
// Build engine with a backend
|
||||
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 model = args
|
||||
.model
|
||||
.unwrap_or_else(|| fabro_model::default_model_from_env().id);
|
||||
let provider_enum = args
|
||||
.provider
|
||||
.as_deref()
|
||||
.map(|s| s.parse::<fabro_model::Provider>())
|
||||
.transpose()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
.unwrap_or_else(fabro_model::Provider::default_from_env);
|
||||
|
||||
// No fallback config available for branch resume; use empty chain.
|
||||
let fallback_chain = 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)))
|
||||
}
|
||||
});
|
||||
let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer(
|
||||
registry,
|
||||
Arc::clone(&emitter),
|
||||
interviewer,
|
||||
Arc::clone(&sandbox),
|
||||
);
|
||||
if dry_run_mode {
|
||||
engine.set_dry_run(true);
|
||||
}
|
||||
|
||||
let meta_branch = Some(fabro_workflows::git::MetadataStore::branch_name(&run_id));
|
||||
let mut config = RunConfig {
|
||||
run_dir: run_dir.clone(),
|
||||
cancel_token: None,
|
||||
dry_run: dry_run_mode,
|
||||
run_id: run_id.clone(),
|
||||
git_checkpoint_enabled: true, // always true for resume (worktree or sandbox git is set up)
|
||||
host_repo_path: Some(original_cwd.clone()),
|
||||
base_sha,
|
||||
run_branch: Some(run_branch.to_string()),
|
||||
meta_branch,
|
||||
labels: HashMap::new(),
|
||||
checkpoint_exclude_globs: Vec::new(),
|
||||
github_app: github_app.clone(),
|
||||
git_author,
|
||||
base_branch: None,
|
||||
pull_request: None,
|
||||
asset_globs: Vec::new(),
|
||||
workflow_slug: None,
|
||||
};
|
||||
|
||||
let lifecycle = fabro_workflows::engine::LifecycleConfig {
|
||||
setup_commands: resume_setup_commands,
|
||||
setup_command_timeout_ms: 60_000,
|
||||
devcontainer_phases: Vec::new(),
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// Restore cwd (worktree is kept for `fabro cp` access; pruned separately)
|
||||
let _ = std::env::set_current_dir(&original_cwd);
|
||||
|
||||
// Auto-derive retro
|
||||
if !args.no_retro && project_config::is_retro_enabled() {
|
||||
let failed = match &engine_result {
|
||||
Ok(ref o) => o.status == StageStatus::Fail,
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let llm_client = if dry_run_mode {
|
||||
None
|
||||
} else {
|
||||
fabro_llm::client::Client::from_env().await.ok()
|
||||
};
|
||||
|
||||
generate_retro(
|
||||
&config.run_id,
|
||||
&graph.name,
|
||||
graph.goal(),
|
||||
&run_dir,
|
||||
failed,
|
||||
run_duration_ms,
|
||||
dry_run_mode,
|
||||
llm_client.as_ref(),
|
||||
&sandbox,
|
||||
provider_enum,
|
||||
&model,
|
||||
styles,
|
||||
Some(Arc::clone(&emitter)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Write finalize commit with retro.json + final node files (captures last diff.patch)
|
||||
write_finalize_commit(&config, &run_dir).await;
|
||||
|
||||
// Cleanup sandbox via engine (fires SandboxCleanup hook)
|
||||
let _ = engine
|
||||
.cleanup_sandbox(&config.run_id, &graph.name, false)
|
||||
.await;
|
||||
|
||||
let outcome = engine_result?;
|
||||
|
||||
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="),);
|
||||
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
|
||||
let status_str = outcome.status.to_string().to_uppercase();
|
||||
let status_color = match outcome.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(run_duration_ms))
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("Run: {}", tilde_path(&run_dir)))
|
||||
);
|
||||
|
||||
print_final_output(&run_dir, styles);
|
||||
print_assets(&run_dir, styles);
|
||||
|
||||
fabro_util::run_log::deactivate();
|
||||
match outcome.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => Ok(()),
|
||||
_ => std::process::exit(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print the final stage output from the checkpoint, if available.
|
||||
fn print_final_output(run_dir: &std::path::Path, styles: &Styles) {
|
||||
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;
|
||||
};
|
||||
|
|
@ -2146,7 +1811,7 @@ fn print_final_output(run_dir: &std::path::Path, styles: &Styles) {
|
|||
}
|
||||
|
||||
/// Print collected asset paths, if any.
|
||||
fn print_assets(run_dir: &std::path::Path, styles: &Styles) {
|
||||
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;
|
||||
|
|
@ -2520,7 +2185,7 @@ async fn run_preflight(
|
|||
///
|
||||
/// This captures the last diff.patch (written after the final checkpoint) and retro.json.
|
||||
/// Best-effort: errors are logged as warnings.
|
||||
async fn write_finalize_commit(config: &RunConfig, run_dir: &std::path::Path) {
|
||||
pub(crate) async fn write_finalize_commit(config: &RunConfig, run_dir: &std::path::Path) {
|
||||
let (Some(ref meta_branch), Some(ref repo_path)) =
|
||||
(&config.meta_branch, &config.host_repo_path)
|
||||
else {
|
||||
|
|
@ -2557,7 +2222,7 @@ async fn write_finalize_commit(config: &RunConfig, run_dir: &std::path::Path) {
|
|||
/// Derives a basic retro from the checkpoint, then optionally runs the retro agent
|
||||
/// for a richer narrative. Errors are logged as warnings rather than propagated.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn generate_retro(
|
||||
pub(crate) async fn generate_retro(
|
||||
run_id: &str,
|
||||
workflow_name: &str,
|
||||
goal: &str,
|
||||
|
|
|
|||
|
|
@ -86,8 +86,6 @@ mod tests {
|
|||
preserve_sandbox: false,
|
||||
dry_run: false,
|
||||
auto_approve: true,
|
||||
resume: None,
|
||||
run_branch: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ enum Command {
|
|||
#[command(subcommand)]
|
||||
command: SecretCommand,
|
||||
},
|
||||
/// Resume an interrupted workflow run
|
||||
Resume(commands::resume::ResumeArgs),
|
||||
/// Rewind a workflow run to an earlier checkpoint
|
||||
Rewind(commands::rewind::RewindArgs),
|
||||
/// Fork a workflow run from an earlier checkpoint into a new run
|
||||
|
|
@ -435,6 +437,7 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
SecretCommand::Rm(_) => "secret rm",
|
||||
SecretCommand::Set(_) => "secret set",
|
||||
},
|
||||
Command::Resume(_) => "resume",
|
||||
Command::Rewind(_) => "rewind",
|
||||
Command::Fork(_) => "fork",
|
||||
Command::Wait(_) => "wait",
|
||||
|
|
@ -737,8 +740,6 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
dry_run: spec.dry_run,
|
||||
preflight: false,
|
||||
auto_approve: spec.auto_approve,
|
||||
resume: spec.resume,
|
||||
run_branch: spec.run_branch,
|
||||
goal: spec.goal,
|
||||
goal_file: None,
|
||||
model: Some(spec.model),
|
||||
|
|
@ -919,6 +920,24 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
commands::secret::set_command(&args)?;
|
||||
}
|
||||
},
|
||||
Command::Resume(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)?;
|
||||
let github_app = build_github_app_credentials(cli_config.app_id());
|
||||
let git_author = fabro_workflows::git::GitAuthor::from_options(
|
||||
cli_config.git_author().and_then(|a| a.name.clone()),
|
||||
cli_config.git_author().and_then(|a| a.email.clone()),
|
||||
);
|
||||
commands::resume::resume_command(
|
||||
args,
|
||||
cli_config.run_defaults,
|
||||
styles,
|
||||
github_app,
|
||||
git_author,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Command::Rewind(args) => {
|
||||
let styles = fabro_util::terminal::Styles::detect_stderr();
|
||||
commands::rewind::run(&args, &styles)?;
|
||||
|
|
|
|||
|
|
@ -513,19 +513,31 @@ fn detach_creates_run_dir_with_detach_log() {
|
|||
);
|
||||
}
|
||||
|
||||
// == Resume ===================================================================
|
||||
|
||||
#[test]
|
||||
fn detach_conflicts_with_resume() {
|
||||
fn resume_help_shows_expected_args() {
|
||||
arc()
|
||||
.args([
|
||||
"run",
|
||||
"--detach",
|
||||
"--resume",
|
||||
"/tmp/fake-checkpoint.json",
|
||||
"../../../test/simple.fabro",
|
||||
])
|
||||
.args(["resume", "--help"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("cannot be used with"));
|
||||
.success()
|
||||
.stdout(predicate::str::contains("--checkpoint"))
|
||||
.stdout(predicate::str::contains("--workflow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_requires_run_or_checkpoint() {
|
||||
arc().args(["resume"]).assert().failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_help_no_longer_shows_resume_or_run_branch() {
|
||||
arc()
|
||||
.args(["run", "--help"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("--resume").not())
|
||||
.stdout(predicate::str::contains("--run-branch").not());
|
||||
}
|
||||
|
||||
// == Bug regression: create/start/attach lifecycle ============================
|
||||
|
|
@ -572,9 +584,7 @@ fn setup_run_dir(
|
|||
"ssh": false,
|
||||
"preserve_sandbox": false,
|
||||
"dry_run": true,
|
||||
"auto_approve": true,
|
||||
"resume": null,
|
||||
"run_branch": null
|
||||
"auto_approve": true
|
||||
});
|
||||
if let (Some(base), Some(overrides)) = (spec.as_object_mut(), spec_overrides.as_object()) {
|
||||
for (k, v) in overrides {
|
||||
|
|
@ -625,9 +635,7 @@ digraph G {
|
|||
"ssh": false,
|
||||
"preserve_sandbox": false,
|
||||
"dry_run": true,
|
||||
"auto_approve": true,
|
||||
"resume": null,
|
||||
"run_branch": null
|
||||
"auto_approve": true
|
||||
});
|
||||
std::fs::write(
|
||||
run_dir.join("spec.json"),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RunSpec {
|
||||
pub run_id: String,
|
||||
pub workflow_path: PathBuf,
|
||||
|
|
@ -20,8 +21,28 @@ pub struct RunSpec {
|
|||
pub preserve_sandbox: bool,
|
||||
pub dry_run: bool,
|
||||
pub auto_approve: bool,
|
||||
pub resume: Option<PathBuf>,
|
||||
pub run_branch: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RunSpec {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
run_id: String::new(),
|
||||
workflow_path: PathBuf::new(),
|
||||
dot_source: String::new(),
|
||||
working_directory: PathBuf::new(),
|
||||
goal: None,
|
||||
model: String::new(),
|
||||
provider: None,
|
||||
sandbox_provider: String::new(),
|
||||
labels: HashMap::new(),
|
||||
verbose: false,
|
||||
no_retro: false,
|
||||
ssh: false,
|
||||
preserve_sandbox: false,
|
||||
dry_run: false,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RunSpec {
|
||||
|
|
@ -65,8 +86,6 @@ mod tests {
|
|||
preserve_sandbox: false,
|
||||
dry_run: false,
|
||||
auto_approve: true,
|
||||
resume: Some(PathBuf::from("/tmp/checkpoint")),
|
||||
run_branch: Some("fabro/run/abc123".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue