mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Extract fabro resume subcommand (#137)
This PR extracts the `fabro resume` subcommand from `fabro run`,
replacing the `--resume` and `--run-branch` flags with a dedicated, more
ergonomic interface. Users can now run `fabro resume <RUN_ID>` instead
of constructing `fabro run --run-branch fabro/run/<RUN_ID>` manually,
and the command also accepts run ID prefixes (matching the pattern
established by `fabro rewind` and `fabro fork`). Checkpoint-file-based
resumption is also supported via `fabro resume --checkpoint
path/to/checkpoint.json --workflow workflow.fabro`.
The implementation moves the ~315-line `run_from_branch()` function out
of `run.rs` and into a new `commands/resume.rs` module, splitting it
into two preparation paths (`prepare_from_checkpoint` and
`prepare_from_branch`) that converge on a shared `run_resumed()` tail.
Several previously private helpers in `run.rs` are widened to
`pub(crate)` to allow sharing: `local_sandbox_with_callback`,
`resolve_ssh_config`, `resolve_ssh_clone_params`,
`resolve_preserve_sandbox`, `generate_retro`, `write_finalize_commit`,
`print_final_output`, `print_assets`, and the new `default_run_dir`
helper extracted from duplicated inline logic. The `RunArgs` struct
loses its `resume` and `run_branch` fields along with their
`conflicts_with` annotations, and `RunSpec` drops the corresponding
fields with `#[serde(default)]` for backward compatibility.
Documentation across `docs/reference/cli.mdx`,
`docs/execution/checkpoints.mdx`, and
`docs/core-concepts/how-fabro-works.mdx` is updated to reflect the new
interface, and the `rewind`/`fork` commands now hint `fabro resume
<short-prefix>` instead of the full branch name.
### Fabro Details
<details>
<summary>Ran 9 stages in 30m 25s for $6.70</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 10s | – | 0 |
| preflight_lint | 13s | – | 0 |
| implement | 18m 30s | $3.95 | 0 |
| simplify_opus | 9m 38s | $2.75 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 19s | – | 0 |
| fmt | 1s | – | 0 |
| **Total** | **30m 25s** | **$6.70** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-6; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=success"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd7f0b1415
commit
165e2495bf
32 changed files with 2313 additions and 586 deletions
35
.ai/prompts/code-review-deep-1.md
Normal file
35
.ai/prompts/code-review-deep-1.md
Normal file
|
|
@ -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:
|
||||
|
||||
<code_review>
|
||||
<bug>
|
||||
<title>title of bug</title>
|
||||
<description>brief description of bug</description>
|
||||
<location>
|
||||
<file>lib/crates/fabro-cli/src/commands/resume.rs</file>
|
||||
<start_line>115</start_line>
|
||||
<end_line>115</end_line>
|
||||
</location>
|
||||
<severity>critical/high/medium/low</severity>
|
||||
</bug>
|
||||
<bug>...</bug>
|
||||
</code_review>
|
||||
|
||||
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
|
||||
110
.ai/prompts/code-review-deep-2.md
Normal file
110
.ai/prompts/code-review-deep-2.md
Normal file
|
|
@ -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
|
||||
<code_review>
|
||||
<bug>
|
||||
<summary>up to 3 sentences</summary>
|
||||
<severity>important OR nit</severity>
|
||||
<pre_exiting>yes OR no</pre_existing>
|
||||
<location>
|
||||
<file>lib/crates/fabro-cli/src/commands/resume.rs</file>
|
||||
<start_line>115</start_line>
|
||||
<end_line>115</end_line>
|
||||
</location>
|
||||
<extended_reasoning>
|
||||
<what_the_bug_is>...</what_the_bug_is>
|
||||
<the_specific_code_path_that_triggers_it>...</the_specific_code_path_that_triggers_it>
|
||||
<why_existing_code_does_not_prevent_it>...</why_existing_code_does_not_prevent_it>
|
||||
<impact>...</impact>
|
||||
<how_to_fix_it>...</how_to_fix_it>
|
||||
<step_by_step_proof>
|
||||
<step>first step</step>
|
||||
<step>second step</step>
|
||||
<step>...</step>
|
||||
<step>bug</step>
|
||||
</step_by_step_proof>
|
||||
</extended_reasoning>
|
||||
</bug>
|
||||
<bug>...</bug>
|
||||
</code_review>
|
||||
```
|
||||
|
||||
Here is a real-world example:
|
||||
|
||||
```xml
|
||||
<bug>
|
||||
<summary>
|
||||
`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.
|
||||
</summary>
|
||||
<severity>important</severity>
|
||||
<pre_exiting>no</pre_existing>
|
||||
<location>
|
||||
<file>lib/crates/fabro-cli/src/commands/resume.rs</file>
|
||||
<start_line>208</start_line>
|
||||
<end_line>208</end_line>
|
||||
</location>
|
||||
<extended_reasoning>
|
||||
<what_the_bug_is>
|
||||
`prepare_from_checkpoint` (resume.rs, around line 196) always wires up a `LocalSandbox` regardless of what sandbox the caller requested:
|
||||
|
||||
```rust
|
||||
let sandbox: Arc<dyn Sandbox> = local_sandbox_with_callback(original_cwd, Arc::clone(&emitter));
|
||||
let sandbox: Arc<dyn Sandbox> = Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox));
|
||||
```
|
||||
|
||||
The `args.sandbox` field (a `Option<CliSandboxProvider>`) is populated by clap but never read inside this function. No error is raised and no warning is printed.
|
||||
</what_the_bug_is>
|
||||
<the_specific_code_path_that_triggers_it>
|
||||
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.
|
||||
</the_specific_code_path_that_triggers_it>
|
||||
<why_existing_code_does_not_prevent_it>
|
||||
`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.
|
||||
</why_existing_code_does_not_prevent_it>
|
||||
<impact>
|
||||
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.
|
||||
</impact>
|
||||
<how_to_fix_it>
|
||||
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`.
|
||||
</how_to_fix_it>
|
||||
<step_by_step_proof>
|
||||
<step>User runs: `fabro resume --checkpoint ~/.fabro/runs/20260321-01ABC.../checkpoint.json --workflow deploy.fabro --sandbox docker`</step>
|
||||
<step>`resume_command` evaluates `args.checkpoint.is_some()` → `true` → calls `prepare_from_checkpoint(&args, ...)`.</step>
|
||||
<step>Inside `prepare_from_checkpoint`, `args.sandbox` holds `Some(CliSandboxProvider::Docker)` but is never read.</step>
|
||||
<step>Line ~196: `let sandbox = local_sandbox_with_callback(original_cwd, Arc::clone(&emitter));` — a `LocalSandbox` is constructed unconditionally.</step>
|
||||
<step>`ResumeContext { sandbox, ... }` is returned with the local sandbox.</step>
|
||||
<step>`run_resumed` receives this context and runs the entire workflow inside the local sandbox.</step>
|
||||
<step>Docker is never launched; no diagnostic message is emitted.</step>
|
||||
</step_by_step_proof>
|
||||
</extended_reasoning>
|
||||
</bug>
|
||||
```
|
||||
|
||||
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
|
||||
33
.ai/prompts/code-review-deep-3.md
Normal file
33
.ai/prompts/code-review-deep-3.md
Normal file
|
|
@ -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.
|
||||
92
.ai/prompts/code-review-fast.md
Normal file
92
.ai/prompts/code-review-fast.md
Normal file
|
|
@ -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. <brief description of bug> (CLAUDE.md says "<...>")
|
||||
|
||||
<link to file and line with full sha1 + line range for context, note that you MUST provide the full sha and not use bash here, eg. https://github.com/anthropics/claude-code/blob/1d54823877c4de72b2316a64032a54afc404e619/README.md#L13-L17>
|
||||
|
||||
2. <brief description of bug> (some/other/CLAUDE.md says "<...>")
|
||||
|
||||
<link to file and line with full sha1 + line range for context>
|
||||
|
||||
3. <brief description of bug> (bug due to <file and code snippet>)
|
||||
|
||||
<link to file and line with full sha1 + line range for context>
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
<sub>- If this code review was useful, please react with 👍. Otherwise, react with 👎.</sub>
|
||||
|
||||
---
|
||||
|
||||
- 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`)
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@ evals/swe-bench/results/
|
|||
evals/swe-bench/dockerfiles/
|
||||
__pycache__
|
||||
.ai/plans
|
||||
.ai/comments
|
||||
.ai/tmp
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ Previously, starting too many runs at once could overwhelm the machine. Now exce
|
|||
|
||||
## SSH access to running sandboxes
|
||||
|
||||
Use `--ssh` to get SSH access into running Daytona sandboxes for live debugging while the workflow executes. When something goes wrong mid-run, you can drop into the sandbox, inspect the filesystem, and understand the problem without waiting for the run to finish.
|
||||
|
||||
```bash
|
||||
fabro run start --ssh my-workflow.fabro
|
||||
```
|
||||
Use `fabro ssh <run-id>` to get SSH access into running Daytona sandboxes for live debugging while the workflow executes. When something goes wrong mid-run, you can drop into the sandbox, inspect the filesystem, and understand the problem without waiting for the run to finish.
|
||||
|
||||
Use `--preserve-sandbox` to keep sandboxes alive after a run completes for post-mortem inspection.
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ image = "my-custom-image:latest"
|
|||
```
|
||||
|
||||
```bash
|
||||
fabro run --ssh my-workflow.fabro
|
||||
fabro ssh <run-id>
|
||||
```
|
||||
|
||||
## `fabro cp` — copy files to and from sandboxes
|
||||
|
|
|
|||
|
|
@ -95,13 +95,14 @@ 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -170,10 +170,10 @@ When using server defaults, labels are merged — run config labels override def
|
|||
Connect to a running Daytona sandbox via SSH for live debugging:
|
||||
|
||||
```bash
|
||||
fabro run workflow.fabro --sandbox daytona --ssh
|
||||
fabro ssh <run-id>
|
||||
```
|
||||
|
||||
This creates temporary SSH credentials (valid for 60 minutes) and prints the connection command.
|
||||
This creates temporary SSH credentials (valid for 60 minutes) and connects directly.
|
||||
|
||||
### Preserving the sandbox
|
||||
|
||||
|
|
@ -306,7 +306,7 @@ image = "my-custom-image:latest"
|
|||
Connect to a running exe.dev sandbox via SSH for live debugging:
|
||||
|
||||
```bash
|
||||
fabro run workflow.fabro --sandbox exe --ssh
|
||||
fabro ssh <run-id>
|
||||
```
|
||||
|
||||
This prints the SSH connection command so you can connect to the VM while the workflow runs.
|
||||
|
|
|
|||
|
|
@ -24,29 +24,12 @@ fabro ssh <run-id> --print
|
|||
fabro ssh <run-id> --ttl 120
|
||||
```
|
||||
|
||||
## Enabling SSH access during `fabro run`
|
||||
|
||||
Pass the `--ssh` flag to `fabro run` to create SSH credentials at the start of the run:
|
||||
|
||||
```bash
|
||||
fabro run workflow.fabro --sandbox daytona --ssh
|
||||
```
|
||||
|
||||
After the sandbox is created, Fabro generates temporary SSH credentials (valid for 60 minutes) and prints the connection command:
|
||||
|
||||
```
|
||||
Sandbox: daytona (fabro-20260307-143022-a3f2)
|
||||
ssh daytona@fabro-20260307-143022-a3f2.ssh.daytona.io
|
||||
```
|
||||
|
||||
Copy and run the `ssh` command in a separate terminal to connect.
|
||||
|
||||
## Keeping the sandbox alive
|
||||
|
||||
By default, Daytona sandboxes are destroyed when the workflow finishes. To keep the sandbox running after the workflow completes — so you can continue debugging — combine `--ssh` with `--preserve-sandbox`:
|
||||
By default, Daytona sandboxes are destroyed when the workflow finishes. To keep the sandbox running after the workflow completes — so you can continue debugging — pass `--preserve-sandbox`:
|
||||
|
||||
```bash
|
||||
fabro run workflow.fabro --sandbox daytona --ssh --preserve-sandbox
|
||||
fabro run workflow.fabro --sandbox daytona --preserve-sandbox
|
||||
```
|
||||
|
||||
Without `--preserve-sandbox`, the SSH session is terminated when the run ends and the sandbox is cleaned up.
|
||||
|
|
@ -73,9 +56,9 @@ Once connected, you have a full shell inside the sandbox VM:
|
|||
|
||||
## Credential lifetime
|
||||
|
||||
SSH credentials are temporary and expire after **60 minutes** by default. With `fabro ssh`, you can set a custom TTL with `--ttl <MINUTES>`. If your session expires, run `fabro ssh` again or start a new run with `--ssh` to get fresh credentials.
|
||||
SSH credentials are temporary and expire after **60 minutes** by default. With `fabro ssh`, you can set a custom TTL with `--ttl <MINUTES>`. If your session expires, run `fabro ssh` again to get fresh credentials.
|
||||
|
||||
## Limitations
|
||||
|
||||
- SSH access is **Daytona-only**. Passing `--ssh` with other sandbox providers prints a warning and is ignored.
|
||||
- SSH access is **Daytona-only**.
|
||||
- SSH access is currently available only from the **CLI**. The API server and web UI do not yet expose an SSH endpoint.
|
||||
|
|
|
|||
|
|
@ -16,17 +16,16 @@ VS Code remote access requires [SSH access](/human-tools/ssh-access), which is o
|
|||
|
||||
## Connecting to a sandbox
|
||||
|
||||
1. Start a workflow with SSH access and a preserved sandbox:
|
||||
1. Start a workflow with a preserved Daytona sandbox:
|
||||
|
||||
```bash
|
||||
fabro run workflow.fabro --sandbox daytona --ssh --preserve-sandbox
|
||||
fabro run workflow.fabro --sandbox daytona --preserve-sandbox
|
||||
```
|
||||
|
||||
2. Fabro prints the SSH connection command:
|
||||
2. Use `fabro ssh` to get the connection command:
|
||||
|
||||
```
|
||||
Sandbox: daytona (fabro-20260307-143022-a3f2)
|
||||
ssh daytona@fabro-20260307-143022-a3f2.ssh.daytona.io
|
||||
```bash
|
||||
fabro ssh <run-id> --print
|
||||
```
|
||||
|
||||
3. In VS Code, open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) and run **Remote-SSH: Connect to Host...**
|
||||
|
|
@ -51,4 +50,4 @@ Once connected, VS Code operates as if the sandbox filesystem were local:
|
|||
|
||||
- **Use `--preserve-sandbox`** — Without it, the sandbox is destroyed when the workflow finishes and your VS Code session disconnects. Combine with `auto_stop_interval` in your [run config](/execution/run-configuration) to control idle timeout.
|
||||
- **Pair with human gates** — When a workflow pauses at a [human gate](/workflows/human-in-the-loop), connect via VS Code to review the agent's changes before approving.
|
||||
- **SSH credential lifetime** — Daytona SSH credentials expire after 60 minutes. If your VS Code session disconnects, you'll need to start a new run with `--ssh` to get fresh credentials.
|
||||
- **SSH credential lifetime** — Daytona SSH credentials expire after 60 minutes by default. If your VS Code session disconnects, run `fabro ssh <run-id>` again to get fresh credentials (use `--ttl` to set a custom expiry).
|
||||
|
|
|
|||
|
|
@ -114,17 +114,13 @@ for your organization.
|
|||
Connect to a running Daytona sandbox via SSH for live debugging:
|
||||
|
||||
```bash
|
||||
fabro run workflow.fabro --sandbox daytona --ssh
|
||||
fabro ssh <run-id>
|
||||
```
|
||||
|
||||
This creates temporary SSH credentials (valid for 60 minutes) and prints the connection command:
|
||||
|
||||
```
|
||||
SSH access ready: ssh daytona@fabro-20260307-143022-a3f2.ssh.daytona.io
|
||||
```
|
||||
This creates temporary SSH credentials (valid for 60 minutes) and connects directly. Use `--print` to print the SSH command instead of connecting, or `--ttl` to set the credential expiry.
|
||||
|
||||
<Note>
|
||||
SSH credentials cannot be refreshed during a run. To keep the sandbox alive after the run completes, combine `--ssh` with `--preserve-sandbox`.
|
||||
To keep the sandbox alive after the run completes, pass `--preserve-sandbox` to `fabro run`.
|
||||
</Note>
|
||||
|
||||
## Sandbox lifecycle
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ image = "my-custom-image:latest"
|
|||
Connect to a running exe.dev sandbox via SSH for live debugging:
|
||||
|
||||
```bash
|
||||
fabro run workflow.fabro --sandbox exe --ssh
|
||||
fabro ssh <run-id>
|
||||
```
|
||||
|
||||
This prints the SSH connection command so you can connect to the VM while the workflow runs.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -62,14 +59,41 @@ fabro run --run-branch fabro/run/abc123
|
|||
| `--goal <GOAL>` | Override the workflow goal (exposed as `$goal` in prompts) |
|
||||
| `--goal-file <FILE>` | Read the goal from a file instead of inline text |
|
||||
| `--no-retro` | Skip retro generation after the run |
|
||||
| `--ssh` | Create SSH access to the sandbox (Daytona or exe.dev) and print the connection command |
|
||||
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
|
||||
| `-d, --detach` | Fork the workflow as a background process and print the run ID. Reconnect later with `fabro logs -f`. |
|
||||
|
||||
<Note>
|
||||
`--preflight` conflicts with `--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 |
|
||||
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes |
|
||||
| `--label <KEY=VALUE>` | Attach a label to this run (repeatable) |
|
||||
|
||||
## `fabro ps`
|
||||
|
||||
List workflow runs. By default, shows only active (running) runs — similar to `docker ps`. Use `-a` to include completed runs.
|
||||
|
|
@ -399,7 +423,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 +448,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 +472,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.
|
||||
|
|
@ -787,3 +811,4 @@ Open the Fabro Discord community invite in your default browser.
|
|||
```bash
|
||||
fabro discord
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -553,7 +553,7 @@ async fn execute_run(state: Arc<AppState>, run_id: String) {
|
|||
runs.get(&run_id).and_then(|r| r.event_tx.clone())
|
||||
};
|
||||
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
if let Some(tx_clone) = event_tx {
|
||||
emitter.on_event(move |event| {
|
||||
let _ = tx_clone.send(event.clone());
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Local;
|
||||
use fabro_config::run::RunDefaults;
|
||||
use fabro_workflows::run_spec::RunSpec;
|
||||
|
||||
use super::run::{cached_graph_path, prepare_workflow, write_run_config_snapshot, RunArgs};
|
||||
use super::run::{
|
||||
cached_graph_path, default_run_dir, prepare_workflow, write_run_config_snapshot, RunArgs,
|
||||
};
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
/// Create a workflow run: allocate run directory, persist spec, return (run_id, run_dir).
|
||||
|
|
@ -27,17 +28,10 @@ pub async fn create_run(
|
|||
|
||||
// Create run directory
|
||||
let run_id = ulid::Ulid::new().to_string();
|
||||
let run_dir = args.run_dir.clone().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!("{}-{}", Local::now().format("%Y%m%d"), run_id))
|
||||
}
|
||||
});
|
||||
let run_dir = args
|
||||
.run_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_run_dir(&run_id, args.dry_run));
|
||||
tokio::fs::create_dir_all(&run_dir).await?;
|
||||
|
||||
// Write essential files
|
||||
|
|
@ -76,12 +70,9 @@ pub async fn create_run(
|
|||
.collect(),
|
||||
verbose: args.verbose,
|
||||
no_retro: args.no_retro,
|
||||
ssh: args.ssh,
|
||||
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;
|
||||
|
|
|
|||
1681
lib/crates/fabro-cli/src/commands/resume.rs
Normal file
1681
lib/crates/fabro-cli/src/commands/resume.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,8 @@ 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
|
||||
#[arg(required = true)]
|
||||
pub workflow: Option<PathBuf>,
|
||||
|
||||
/// Run output directory
|
||||
|
|
@ -87,21 +87,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>,
|
||||
|
|
@ -134,16 +126,12 @@ pub struct RunArgs {
|
|||
#[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,
|
||||
|
||||
/// 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)
|
||||
|
|
@ -186,6 +174,19 @@ pub(crate) fn apply_goal_override(
|
|||
}
|
||||
}
|
||||
|
||||
/// Compute the default run directory when `--run-dir` is not provided.
|
||||
pub(crate) fn default_run_dir(run_id: &str, dry_run: bool) -> PathBuf {
|
||||
if 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!("{}-{}", Local::now().format("%Y%m%d"), run_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve model and provider through the full precedence chain:
|
||||
/// CLI flag > TOML config > run defaults > DOT graph attrs > provider-specific defaults.
|
||||
/// Then resolve through the catalog for alias expansion.
|
||||
|
|
@ -258,7 +259,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,
|
||||
|
|
@ -293,7 +294,7 @@ fn resolve_worktree_mode(
|
|||
}
|
||||
|
||||
/// Resolve daytona config: TOML config > run defaults.
|
||||
fn resolve_daytona_config(
|
||||
pub(crate) fn resolve_daytona_config(
|
||||
run_cfg: Option<&WorkflowRunConfig>,
|
||||
run_defaults: &RunDefaults,
|
||||
) -> Option<fabro_sandbox::daytona::DaytonaConfig> {
|
||||
|
|
@ -310,7 +311,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 +326,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 +341,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 +355,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) => {
|
||||
|
|
@ -368,7 +373,7 @@ fn resolve_ssh_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::ssh:
|
|||
///
|
||||
/// `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>,
|
||||
|
|
@ -387,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<String, String>,
|
||||
|
|
@ -425,18 +430,21 @@ 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.
|
||||
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 });
|
||||
|
|
@ -471,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<WorkflowRunConfig>)> {
|
||||
let path = project_config::resolve_workflow_arg(workflow_path)?;
|
||||
|
|
@ -513,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<PreparedWorkflow> {
|
||||
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<PreparedWorkflow> {
|
||||
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
|
||||
|
|
@ -655,11 +675,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,
|
||||
|
|
@ -719,17 +734,9 @@ pub async fn run_command(
|
|||
|
||||
// 3. Create logs directory
|
||||
let run_id = args.run_id.unwrap_or_else(|| 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!("{}-{}", Local::now().format("%Y%m%d"), run_id))
|
||||
}
|
||||
});
|
||||
let run_dir = args
|
||||
.run_dir
|
||||
.unwrap_or_else(|| default_run_dir(&run_id, args.dry_run));
|
||||
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")?;
|
||||
|
|
@ -784,7 +791,7 @@ pub async fn run_command(
|
|||
}
|
||||
|
||||
// 3. Build event emitter
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
|
||||
// Track the last git commit SHA from CheckpointCompleted events
|
||||
let last_git_sha: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
|
|
@ -854,7 +861,7 @@ pub async fn run_command(
|
|||
});
|
||||
}
|
||||
|
||||
run_progress::ProgressUI::register(&progress_ui, &mut emitter);
|
||||
run_progress::ProgressUI::register(&progress_ui, &emitter);
|
||||
|
||||
// 4. Build interviewer
|
||||
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
|
||||
|
|
@ -1174,34 +1181,6 @@ pub async fn run_command(
|
|||
});
|
||||
}
|
||||
|
||||
// Register SSH access listener
|
||||
if args.ssh {
|
||||
let deferred_sb_ssh = Arc::clone(&deferred_sandbox);
|
||||
emitter.on_event(move |event| {
|
||||
if let fabro_workflows::event::WorkflowRunEvent::SandboxInitialized { .. } = event {
|
||||
if let Ok(rt) = tokio::runtime::Handle::try_current() {
|
||||
let sb_lock = deferred_sb_ssh.lock().unwrap();
|
||||
if let Some(ref sb) = *sb_lock {
|
||||
let sb = Arc::clone(sb);
|
||||
rt.spawn(async move {
|
||||
match sb.ssh_access_command().await {
|
||||
Ok(Some(ssh_command)) => {
|
||||
// Note: we can't emit from here since emitter is shared;
|
||||
// SSH access info is logged via tracing.
|
||||
tracing::info!(ssh_command, "SSH access ready");
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Failed to create SSH access");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wrap emitter in Arc so we can share it with exec env callbacks
|
||||
let emitter = Arc::new(emitter);
|
||||
|
||||
|
|
@ -1541,16 +1520,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)
|
||||
|
|
@ -1880,332 +1852,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 {} found on metadata branch for run {run_id}",
|
||||
RUN_GRAPH_FILE
|
||||
)
|
||||
})?;
|
||||
|
||||
// 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(cached_graph_path(&run_dir), &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;
|
||||
};
|
||||
|
|
@ -2226,7 +1874,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;
|
||||
|
|
@ -2600,7 +2248,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 {
|
||||
|
|
@ -2637,7 +2285,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,
|
||||
|
|
@ -2834,7 +2482,7 @@ async fn generate_retro(
|
|||
}
|
||||
}
|
||||
|
||||
fn build_event_envelope(
|
||||
pub(crate) fn build_event_envelope(
|
||||
event: &fabro_workflows::event::WorkflowRunEvent,
|
||||
run_id: &str,
|
||||
) -> serde_json::Value {
|
||||
|
|
@ -2916,6 +2564,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};
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ impl ProgressUI {
|
|||
}
|
||||
|
||||
/// Register event handlers on the emitter.
|
||||
pub fn register(progress: &Arc<Mutex<Self>>, emitter: &mut EventEmitter) {
|
||||
pub fn register(progress: &Arc<Mutex<Self>>, emitter: &EventEmitter) {
|
||||
let p = Arc::clone(progress);
|
||||
emitter.on_event(move |event| {
|
||||
let mut ui = p.lock().expect("progress lock poisoned");
|
||||
|
|
@ -309,7 +309,7 @@ impl ProgressUI {
|
|||
|
||||
// ── Event dispatch ──────────────────────────────────────────────────
|
||||
|
||||
fn handle_event(&mut self, event: &WorkflowRunEvent) {
|
||||
pub(crate) fn handle_event(&mut self, event: &WorkflowRunEvent) {
|
||||
match event {
|
||||
WorkflowRunEvent::Sandbox {
|
||||
event: sandbox_event,
|
||||
|
|
|
|||
|
|
@ -82,12 +82,9 @@ mod tests {
|
|||
labels: HashMap::new(),
|
||||
verbose: false,
|
||||
no_retro: true,
|
||||
ssh: false,
|
||||
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",
|
||||
|
|
@ -734,8 +737,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),
|
||||
|
|
@ -752,7 +753,6 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect(),
|
||||
no_retro: spec.no_retro,
|
||||
ssh: spec.ssh,
|
||||
preserve_sandbox: spec.preserve_sandbox,
|
||||
detach: false,
|
||||
run_id: Some(spec.run_id),
|
||||
|
|
@ -916,6 +916,27 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
commands::secret::set_command(&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());
|
||||
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 ============================
|
||||
|
|
@ -569,12 +581,10 @@ fn setup_run_dir(
|
|||
"labels": {},
|
||||
"verbose": false,
|
||||
"no_retro": true,
|
||||
"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 {
|
||||
|
|
@ -622,12 +632,10 @@ digraph G {
|
|||
"labels": {},
|
||||
"verbose": false,
|
||||
"no_retro": true,
|
||||
"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"),
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn emits_started_and_completed_events() {
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let events_clone = Arc::clone(&events);
|
||||
emitter.on_event(move |event| {
|
||||
|
|
@ -410,7 +410,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn failed_command_emits_failed_and_returns_error() {
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let events_clone = Arc::clone(&events);
|
||||
emitter.on_event(move |event| {
|
||||
|
|
@ -430,7 +430,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn empty_commands_is_noop() {
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let events_clone = Arc::clone(&events);
|
||||
emitter.on_event(move |event| {
|
||||
|
|
|
|||
|
|
@ -3228,7 +3228,7 @@ mod tests {
|
|||
|
||||
let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let events_clone = events.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(format!("{event:?}"));
|
||||
});
|
||||
|
|
@ -5514,7 +5514,7 @@ mod tests {
|
|||
|
||||
let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::<WorkflowRunEvent>::new()));
|
||||
let events_clone = events.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
|
@ -5604,7 +5604,7 @@ mod tests {
|
|||
|
||||
let events = Arc::new(std::sync::Mutex::new(Vec::<WorkflowRunEvent>::new()));
|
||||
let events_clone = events.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
|
@ -5635,7 +5635,7 @@ mod tests {
|
|||
|
||||
let events = Arc::new(std::sync::Mutex::new(Vec::<WorkflowRunEvent>::new()));
|
||||
let events_clone = events.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
|
@ -5757,7 +5757,7 @@ mod tests {
|
|||
|
||||
let events = Arc::new(std::sync::Mutex::new(Vec::<WorkflowRunEvent>::new()));
|
||||
let events_clone = events.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
|
@ -5800,7 +5800,7 @@ mod tests {
|
|||
|
||||
let event_names = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let names_clone = event_names.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
let name = match event {
|
||||
WorkflowRunEvent::SandboxInitialized { .. } => "SandboxInitialized",
|
||||
|
|
|
|||
|
|
@ -1016,19 +1016,20 @@ fn epoch_millis() -> i64 {
|
|||
}
|
||||
|
||||
/// Listener callback type for workflow run events.
|
||||
type EventListener = Box<dyn Fn(&WorkflowRunEvent) + Send + Sync>;
|
||||
type EventListener = Arc<dyn Fn(&WorkflowRunEvent) + Send + Sync>;
|
||||
|
||||
/// Callback-based event emitter for workflow run events.
|
||||
pub struct EventEmitter {
|
||||
listeners: Vec<EventListener>,
|
||||
listeners: std::sync::Mutex<Vec<EventListener>>,
|
||||
/// Epoch milliseconds of the last `emit()` or `touch()` call. 0 until first event.
|
||||
last_event_at: AtomicI64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EventEmitter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let count = self.listeners.lock().map(|l| l.len()).unwrap_or(0);
|
||||
f.debug_struct("EventEmitter")
|
||||
.field("listener_count", &self.listeners.len())
|
||||
.field("listener_count", &count)
|
||||
.field("last_event_at", &self.last_event_at.load(Ordering::Relaxed))
|
||||
.finish()
|
||||
}
|
||||
|
|
@ -1044,19 +1045,30 @@ impl EventEmitter {
|
|||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
listeners: Vec::new(),
|
||||
listeners: std::sync::Mutex::new(Vec::new()),
|
||||
last_event_at: AtomicI64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_event(&mut self, listener: impl Fn(&WorkflowRunEvent) + Send + Sync + 'static) {
|
||||
self.listeners.push(Box::new(listener));
|
||||
pub fn on_event(&self, listener: impl Fn(&WorkflowRunEvent) + Send + Sync + 'static) {
|
||||
self.listeners
|
||||
.lock()
|
||||
.expect("listeners lock poisoned")
|
||||
.push(Arc::new(listener));
|
||||
}
|
||||
|
||||
pub fn emit(&self, event: &WorkflowRunEvent) {
|
||||
self.last_event_at.store(epoch_millis(), Ordering::Relaxed);
|
||||
event.trace();
|
||||
for listener in &self.listeners {
|
||||
// Clone the listener list so we don't hold the lock during dispatch.
|
||||
// This prevents deadlocks if a listener calls emit() reentrantly.
|
||||
// Note: listeners added during this emit() won't receive the current event.
|
||||
let snapshot: Vec<EventListener> = self
|
||||
.listeners
|
||||
.lock()
|
||||
.expect("listeners lock poisoned")
|
||||
.clone();
|
||||
for listener in &snapshot {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
|
@ -1098,12 +1110,12 @@ mod tests {
|
|||
#[test]
|
||||
fn event_emitter_new_has_no_listeners() {
|
||||
let emitter = EventEmitter::new();
|
||||
assert_eq!(emitter.listeners.len(), 0);
|
||||
assert_eq!(emitter.listeners.lock().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_emitter_calls_listener() {
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
let received = Arc::new(Mutex::new(Vec::new()));
|
||||
let received_clone = Arc::clone(&received);
|
||||
emitter.on_event(move |event| {
|
||||
|
|
@ -1161,7 +1173,7 @@ mod tests {
|
|||
#[test]
|
||||
fn event_emitter_default() {
|
||||
let emitter = EventEmitter::default();
|
||||
assert_eq!(emitter.listeners.len(), 0);
|
||||
assert_eq!(emitter.listeners.lock().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2510,7 +2522,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn emitter_captures_retro_events() {
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
let received = Arc::new(Mutex::new(Vec::new()));
|
||||
let r = Arc::clone(&received);
|
||||
emitter.on_event(move |event| {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RunSpec {
|
||||
pub run_id: String,
|
||||
pub workflow_path: PathBuf,
|
||||
|
|
@ -16,12 +17,9 @@ pub struct RunSpec {
|
|||
pub labels: HashMap<String, String>,
|
||||
pub verbose: bool,
|
||||
pub no_retro: bool,
|
||||
pub ssh: bool,
|
||||
pub preserve_sandbox: bool,
|
||||
pub dry_run: bool,
|
||||
pub auto_approve: bool,
|
||||
pub resume: Option<PathBuf>,
|
||||
pub run_branch: Option<String>,
|
||||
}
|
||||
|
||||
impl RunSpec {
|
||||
|
|
@ -61,12 +59,9 @@ mod tests {
|
|||
labels,
|
||||
verbose: true,
|
||||
no_retro: false,
|
||||
ssh: true,
|
||||
preserve_sandbox: false,
|
||||
dry_run: false,
|
||||
auto_approve: true,
|
||||
resume: Some(PathBuf::from("/tmp/checkpoint")),
|
||||
run_branch: Some("fabro/run/abc123".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -569,7 +569,7 @@ async fn daytona_git_checkpoint_remote_emits_events() {
|
|||
|
||||
// Set up event collection
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
{
|
||||
let events_clone = Arc::clone(&events);
|
||||
|
|
@ -754,7 +754,7 @@ async fn daytona_parallel_git_branching_e2e() {
|
|||
graph.edges.push(Edge::new("fan_in", "exit"));
|
||||
|
||||
let run_tmp = tempfile::tempdir().unwrap();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
{
|
||||
let events_clone = Arc::clone(&events);
|
||||
|
|
|
|||
|
|
@ -1300,7 +1300,7 @@ impl Handler for ContextSetterHandler {
|
|||
}
|
||||
}
|
||||
|
||||
fn collect_events(emitter: &mut EventEmitter) -> Arc<std::sync::Mutex<Vec<WorkflowRunEvent>>> {
|
||||
fn collect_events(emitter: &EventEmitter) -> Arc<std::sync::Mutex<Vec<WorkflowRunEvent>>> {
|
||||
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let events_clone = Arc::clone(&events);
|
||||
emitter.on_event(move |event| {
|
||||
|
|
@ -1857,8 +1857,8 @@ async fn event_streaming_lifecycle() {
|
|||
}"#;
|
||||
let graph = parse(input).expect("parse");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
let engine = WorkflowRunEngine::new(make_linear_registry(), Arc::new(emitter), local_env());
|
||||
let config = RunConfig {
|
||||
run_dir: dir.path().to_path_buf(),
|
||||
|
|
@ -2376,8 +2376,8 @@ async fn scenario_ship_a_feature() {
|
|||
|
||||
let interviewer = Arc::new(AutoApproveInterviewer);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
let engine = WorkflowRunEngine::new(
|
||||
make_full_registry(interviewer),
|
||||
Arc::new(emitter),
|
||||
|
|
@ -3500,8 +3500,8 @@ async fn integration_smoke_plan_implement_review_done() {
|
|||
// Run pipeline
|
||||
let interviewer = Arc::new(AutoApproveInterviewer);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
let engine = WorkflowRunEngine::new(
|
||||
make_full_registry(interviewer),
|
||||
Arc::new(emitter),
|
||||
|
|
@ -7428,8 +7428,8 @@ fn engine_with_hooks_and_events(
|
|||
Arc<std::sync::Mutex<Vec<WorkflowRunEvent>>>,
|
||||
) {
|
||||
let registry = make_linear_registry();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
let sandbox = local_env();
|
||||
let mut engine = WorkflowRunEngine::new(registry, Arc::new(emitter), sandbox);
|
||||
if !hooks.is_empty() {
|
||||
|
|
@ -8886,8 +8886,8 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
|
|||
registry.register("start", Box::new(StartHandler));
|
||||
registry.register("exit", Box::new(ExitHandler));
|
||||
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), local_env());
|
||||
let config = RunConfig {
|
||||
run_dir: dir.path().to_path_buf(),
|
||||
|
|
@ -10641,8 +10641,8 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() {
|
|||
|
||||
// 4. Set up event collection and engine
|
||||
let run_dir = tempfile::tempdir().unwrap();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
|
||||
let env: Arc<dyn fabro_agent::Sandbox> =
|
||||
Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone()));
|
||||
|
|
@ -11026,8 +11026,8 @@ async fn parallel_git_branching_host_e2e() {
|
|||
|
||||
// 4. Set up engine with FileWriterHandler for branches
|
||||
let run_dir = tempfile::tempdir().unwrap();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
|
||||
let env: Arc<dyn fabro_agent::Sandbox> =
|
||||
Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone()));
|
||||
|
|
@ -11298,8 +11298,8 @@ async fn git_checkpoint_host_skips_empty_diff_patch() {
|
|||
graph.edges.push(Edge::new("work", "exit"));
|
||||
|
||||
let run_dir = tempfile::tempdir().unwrap();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let _events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let _events = collect_events(&emitter);
|
||||
|
||||
let env: Arc<dyn fabro_agent::Sandbox> =
|
||||
Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone()));
|
||||
|
|
@ -12211,8 +12211,8 @@ async fn e2e_circuit_breaker_emits_events_before_abort() {
|
|||
let dir = tempfile::tempdir().unwrap();
|
||||
let graph = circuit_breaker_self_loop_graph(Some(3));
|
||||
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
|
||||
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
|
||||
registry.register("start", Box::new(StartHandler));
|
||||
|
|
@ -12833,7 +12833,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() {
|
|||
|
||||
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let events_clone = events.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
let emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(format!("{event:?}"));
|
||||
});
|
||||
|
|
@ -13124,8 +13124,8 @@ async fn asset_collection_local_sandbox_success() {
|
|||
registry.register("start", Box::new(StartHandler));
|
||||
registry.register("exit", Box::new(ExitHandler));
|
||||
|
||||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
let emitter = EventEmitter::new();
|
||||
let events = collect_events(&emitter);
|
||||
|
||||
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), sandbox.clone());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue