mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-24 00:51:19 +00:00
This PR decomposes `fabro run` into three composable primitives —
`create`, `start`, and `attach` — following the Docker-style lifecycle
model. Previously, `fabro run` performed everything in a single
monolithic function, and `--detach` was implemented by reconstructing
CLI argv to spawn a child process, which was brittle and hard to extend.
The new architecture cleanly separates concerns: `fabro create`
allocates the run directory and persists a `RunSpec` struct to
`spec.json`; `fabro start` spawns a detached `_run_engine` process (a
hidden internal command that reads `spec.json`) via `setsid`; and `fabro
attach` tails `progress.jsonl` with live rendering and handles
file-based interview IPC. `fabro run` is now a composition of these
three primitives, and `fabro run --detach` simply skips the attach step.
The main rendering work lives in a new `handle_json_line()` method on
`ProgressUI` that parses JSONL envelopes and dispatches to the same
internal rendering methods already used by the in-process event handler.
This preserves 100% rendering fidelity without duplicating
spinner/stage/tool-call logic — the attach loop just feeds file lines
into the same code paths. File-based interview IPC is handled in the
attach loop itself: it watches for `interview_request.json`, prompts the
user via `ConsoleInterviewer`, and writes `interview_response.json` back
for the engine to consume. The `hide_bars`/`show_bars` methods
previously private to `ProgressAwareInterviewer` are promoted to public
methods on `ProgressUI` and reused in both the attach loop and the
existing in-process interviewer.
The old `detach_run()` function in `main.rs`, which reconstructed argv
by string-scanning `std::env::args()`, is deleted entirely and replaced
by the `create` + `start` composition. New tests cover the
`handle_json_line` dispatch paths (stage started/completed, tool calls,
retro events, invalid input) and the CLI argument parsing for the new
command variants.
### Fabro Details
<details>
<summary>Ran 9 stages in 30m 55s for $8.55</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 11s | – | 0 |
| preflight_lint | 12s | – | 0 |
| implement | 18m 15s | $5.28 | 0 |
| simplify_opus | 10m 31s | $3.27 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 17s | – | 0 |
| fmt | 1s | – | 0 |
| **Total** | **30m 55s** | **$8.55** | **0** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (12 nodes and 15
edges)</summary>
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; 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>
127 lines
3.7 KiB
Rust
127 lines
3.7 KiB
Rust
/// Convert a Duration's milliseconds to u64, saturating on overflow.
|
|
pub(crate) fn millis_u64(d: std::time::Duration) -> u64 {
|
|
u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
|
|
}
|
|
|
|
/// Save a value as pretty-printed JSON to a file.
|
|
pub(crate) fn save_json<T: serde::Serialize>(
|
|
value: &T,
|
|
path: &std::path::Path,
|
|
label: &str,
|
|
) -> error::Result<()> {
|
|
let json = serde_json::to_string_pretty(value)
|
|
.map_err(|e| error::FabroError::Checkpoint(format!("{label} serialize failed: {e}")))?;
|
|
std::fs::write(path, json)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Load a value from a JSON file.
|
|
pub(crate) fn load_json<T: serde::de::DeserializeOwned>(
|
|
path: &std::path::Path,
|
|
label: &str,
|
|
) -> error::Result<T> {
|
|
let data = std::fs::read_to_string(path)?;
|
|
serde_json::from_str(&data)
|
|
.map_err(|e| error::FabroError::Checkpoint(format!("{label} deserialize failed: {e}")))
|
|
}
|
|
|
|
/// Build `Vec<CompletedStage>` from a `Checkpoint`, mapping workflow-engine
|
|
/// types into the flat struct expected by `fabro_retro::retro::derive_retro`.
|
|
pub fn build_completed_stages(
|
|
cp: &checkpoint::Checkpoint,
|
|
run_failed: bool,
|
|
) -> Vec<fabro_retro::retro::CompletedStage> {
|
|
use outcome::StageStatus;
|
|
|
|
let mut stages = Vec::new();
|
|
let mut any_stage_failed = false;
|
|
|
|
for node_id in &cp.completed_nodes {
|
|
let outcome = cp.node_outcomes.get(node_id);
|
|
let retries = cp
|
|
.node_retries
|
|
.get(node_id)
|
|
.copied()
|
|
.unwrap_or(1)
|
|
.saturating_sub(1);
|
|
|
|
let status = outcome
|
|
.map(|o| o.status.to_string())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
|
|
let succeeded = matches!(
|
|
outcome.map(|o| &o.status),
|
|
Some(StageStatus::Success | StageStatus::PartialSuccess)
|
|
);
|
|
let failed = matches!(outcome.map(|o| &o.status), Some(StageStatus::Fail));
|
|
if failed {
|
|
any_stage_failed = true;
|
|
}
|
|
|
|
stages.push(fabro_retro::retro::CompletedStage {
|
|
node_id: node_id.clone(),
|
|
status,
|
|
succeeded,
|
|
failed,
|
|
retries,
|
|
cost: outcome.and_then(|o| o.usage.as_ref()).and_then(|u| u.cost),
|
|
notes: outcome.and_then(|o| o.notes.clone()),
|
|
failure_reason: outcome.and_then(|o| o.failure_reason().map(String::from)),
|
|
files_touched: outcome.map(|o| o.files_touched.clone()).unwrap_or_default(),
|
|
});
|
|
}
|
|
|
|
// If run failed with an error not captured in stages, mark the last stage
|
|
if run_failed && !any_stage_failed {
|
|
if let Some(last) = stages.last_mut() {
|
|
last.failed = true;
|
|
} else {
|
|
stages.push(fabro_retro::retro::CompletedStage {
|
|
node_id: "unknown".to_string(),
|
|
status: "fail".to_string(),
|
|
succeeded: false,
|
|
failed: true,
|
|
retries: 0,
|
|
cost: None,
|
|
notes: None,
|
|
failure_reason: None,
|
|
files_touched: vec![],
|
|
});
|
|
}
|
|
}
|
|
|
|
stages
|
|
}
|
|
|
|
pub mod artifact;
|
|
pub mod asset_snapshot;
|
|
pub mod assets;
|
|
pub mod backend;
|
|
pub mod checkpoint;
|
|
pub mod conclusion;
|
|
pub mod condition;
|
|
pub mod context;
|
|
pub mod cost;
|
|
pub mod devcontainer_bridge;
|
|
pub mod engine;
|
|
pub mod error;
|
|
pub mod event;
|
|
pub mod git;
|
|
pub mod graph_render;
|
|
pub mod handler;
|
|
pub mod manifest;
|
|
pub mod outcome;
|
|
pub mod preamble;
|
|
pub mod pull_request;
|
|
pub mod run_fork;
|
|
pub mod run_lookup;
|
|
pub mod run_rewind;
|
|
pub mod run_spec;
|
|
pub mod run_status;
|
|
pub mod sandbox_provider;
|
|
pub mod sandbox_reconnect;
|
|
pub mod sandbox_record;
|
|
pub mod stylesheet;
|
|
pub mod transform;
|
|
pub mod vars;
|
|
pub mod workflow;
|