fabro/lib/crates/fabro-workflows/src/lib.rs
brynary-fabro[bot] 33d697d93d Decompose fabro run into create / start / attach (#116)
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>
2026-03-20 14:08:54 -04:00

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;