fabro wait subcommand (#112)

This PR adds a `fabro wait` subcommand that blocks until a workflow run
reaches a terminal state and exits with a code reflecting the outcome —
analogous to `docker wait`. The command accepts a run ID prefix or
workflow name, polls `status.json` at a configurable interval
(defaulting to 1 second), and exits 0 on success or 1 on failure/dead.
An optional `--timeout` flag causes the command to bail with an error
message if the deadline is exceeded before the run completes.

The implementation reuses existing infrastructure throughout:
`resolve_run()` for run ID/name resolution, `RunStatusRecord::load()`
and `RunStatus::is_terminal()` for polling, `Conclusion::load()` for
retrieving duration and cost after completion, and `Styles` for colored
terminal output. Human-readable status is written to stderr (preserving
stdout for data), while `--json` mode writes structured conclusion data
to stdout. Missing status files are treated as `Dead` to handle orphaned
runs gracefully. No new dependencies were required.

The change is registered in all three necessary locations:
`commands/mod.rs`, the `Command` enum in `main.rs`, the command name
mapping, and the dispatch match arm. Unit tests cover JSON output across
all terminal states (with and without conclusion data), the
human-readable output path, immediate-terminal poll behavior, and the
missing-file fallback to `Dead`.

### Fabro Details

<details>
<summary>Ran 9 stages in 12m 13s for $2.81</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 12s | – | 0 |
| preflight_lint | 13s | – | 0 |
| implement | 4m 23s | $1.47 | 0 |
| simplify_opus | 4m 22s | $1.34 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 1m 21s | – | 0 |
| fmt | 1s | – | 0 |
| **Total** | **12m 13s** | **$2.81** | **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>
This commit is contained in:
brynary-fabro[bot] 2026-03-19 22:48:38 -04:00 • committed by GitHub
parent 4b0a4f773e
commit a176bea082
3 changed files with 250 additions and 0 deletions

View file

@ -17,4 +17,5 @@ pub mod secret;
pub(crate) mod shared;
pub mod ssh;
pub mod validate;
pub mod wait;
pub mod workflow;

View file

@ -0,0 +1,242 @@
use std::io::Write;
use anyhow::{bail, Result};
use clap::Args;
use fabro_util::terminal::Styles;
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
use tracing::info;
use super::shared::format_duration_ms;
#[derive(Args)]
pub struct WaitArgs {
/// Run ID prefix or workflow name (most recent run)
pub run: String,
/// Maximum time to wait in seconds
#[arg(long, value_name = "SECONDS")]
pub timeout: Option<u64>,
/// Poll interval in milliseconds
#[arg(long, value_name = "MS", default_value = "1000")]
pub interval: u64,
/// Output conclusion as JSON
#[arg(long)]
pub json: bool,
}
pub fn run(args: WaitArgs, styles: &Styles) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
let run_info = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?;
info!(run_id = %run_info.run_id, "Waiting for run to complete");
let status_path = run_info.path.join("status.json");
let deadline = args
.timeout
.map(|secs| std::time::Instant::now() + std::time::Duration::from_secs(secs));
let interval = std::time::Duration::from_millis(args.interval);
let final_status = loop {
let status = match RunStatusRecord::load(&status_path) {
Ok(record) => record.status,
Err(_) => RunStatus::Dead,
};
if status.is_terminal() {
break status;
}
if let Some(dl) = deadline {
let now = std::time::Instant::now();
if now >= dl {
bail!(
"Timed out after {}s waiting for run '{}'",
args.timeout.unwrap(),
run_info.run_id
);
}
std::thread::sleep(interval.min(dl - now));
} else {
std::thread::sleep(interval);
}
};
let conclusion_path = run_info.path.join("conclusion.json");
let conclusion = fabro_workflows::conclusion::Conclusion::load(&conclusion_path).ok();
if args.json {
let json_value = build_json_output(final_status, &run_info.run_id, conclusion.as_ref());
let mut out = std::io::stdout().lock();
serde_json::to_writer_pretty(&mut out, &json_value)?;
writeln!(out)?;
} else {
print_human_output(final_status, &run_info.run_id, conclusion.as_ref(), styles);
}
if final_status == RunStatus::Succeeded {
Ok(())
} else {
std::process::exit(1);
}
}
fn build_json_output(
status: RunStatus,
run_id: &str,
conclusion: Option<&fabro_workflows::conclusion::Conclusion>,
) -> serde_json::Value {
let mut value = serde_json::json!({
"run_id": run_id,
"status": status.to_string(),
});
if let Some(c) = conclusion {
value["duration_ms"] = c.duration_ms.into();
if let Some(cost) = c.total_cost {
value["total_cost"] = cost.into();
}
}
value
}
fn print_human_output(
status: RunStatus,
run_id: &str,
conclusion: Option<&fabro_workflows::conclusion::Conclusion>,
styles: &Styles,
) {
let (style, label) = match status {
RunStatus::Succeeded => (&styles.bold_green, "Succeeded"),
RunStatus::Failed => (&styles.bold_red, "Failed"),
RunStatus::Dead => (&styles.bold_red, "Dead"),
// Poll loop only breaks on is_terminal() which is Succeeded | Failed | Dead
_ => unreachable!(),
};
let status_display = style.apply_to(label);
let details = match conclusion {
Some(c) => {
let duration = format_duration_ms(c.duration_ms);
let cost = c
.total_cost
.map(|v| format!(" ${v:.2}"))
.unwrap_or_default();
format!(" {duration}{cost}")
}
None => String::new(),
};
eprintln!(
"{} {}{details}",
status_display,
styles.dim.apply_to(run_id),
);
}
#[cfg(test)]
mod tests {
use super::*;
use fabro_workflows::conclusion::Conclusion;
use fabro_workflows::outcome::StageStatus;
fn no_color_styles() -> Styles {
Styles::new(false)
}
#[test]
fn json_output_succeeded_with_conclusion() {
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageStatus::Success,
duration_ms: 12345,
failure_reason: None,
final_git_commit_sha: None,
stages: vec![],
total_cost: Some(0.42),
total_retries: 0,
};
let json = build_json_output(RunStatus::Succeeded, "ABC123", Some(&conclusion));
assert_eq!(json["run_id"], "ABC123");
assert_eq!(json["status"], "succeeded");
assert_eq!(json["duration_ms"], 12345);
assert!((json["total_cost"].as_f64().unwrap() - 0.42).abs() < f64::EPSILON);
}
#[test]
fn json_output_failed_without_conclusion() {
let json = build_json_output(RunStatus::Failed, "DEF456", None);
assert_eq!(json["run_id"], "DEF456");
assert_eq!(json["status"], "failed");
assert!(json.get("duration_ms").is_none());
assert!(json.get("total_cost").is_none());
}
#[test]
fn json_output_dead_status() {
let json = build_json_output(RunStatus::Dead, "GHI789", None);
assert_eq!(json["status"], "dead");
}
#[test]
fn json_output_no_cost_when_none() {
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageStatus::Fail,
duration_ms: 500,
failure_reason: Some("error".into()),
final_git_commit_sha: None,
stages: vec![],
total_cost: None,
total_retries: 0,
};
let json = build_json_output(RunStatus::Failed, "JKL012", Some(&conclusion));
assert!(json.get("total_cost").is_none());
assert_eq!(json["duration_ms"], 500);
}
#[test]
fn human_output_succeeded() {
let styles = no_color_styles();
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageStatus::Success,
duration_ms: 8000,
failure_reason: None,
final_git_commit_sha: None,
stages: vec![],
total_cost: Some(0.15),
total_retries: 0,
};
// Just verify no panic; actual stderr output is hard to capture
print_human_output(RunStatus::Succeeded, "ABC123", Some(&conclusion), &styles);
}
#[test]
fn human_output_failed_no_conclusion() {
let styles = no_color_styles();
print_human_output(RunStatus::Failed, "DEF456", None, &styles);
}
#[test]
fn poll_terminal_immediately() {
let dir = tempfile::tempdir().unwrap();
let status_path = dir.path().join("status.json");
let record = RunStatusRecord::new(RunStatus::Succeeded, None);
record.save(&status_path).unwrap();
// Simulate what the poll loop does
let status = RunStatusRecord::load(&status_path).unwrap().status;
assert!(status.is_terminal());
assert_eq!(status, RunStatus::Succeeded);
}
#[test]
fn missing_status_treated_as_dead() {
let status = match RunStatusRecord::load(std::path::Path::new("/nonexistent/status.json")) {
Ok(record) => record.status,
Err(_) => RunStatus::Dead,
};
assert_eq!(status, RunStatus::Dead);
}
}

View file

@ -149,6 +149,8 @@ enum Command {
Rewind(commands::rewind::RewindArgs),
/// Fork a workflow run from an earlier checkpoint into a new run
Fork(commands::fork::ForkArgs),
/// Block until a workflow run completes
Wait(commands::wait::WaitArgs),
/// Workflow operations
Workflow {
#[command(subcommand)]
@ -496,6 +498,7 @@ async fn main_inner() -> (String, Result<()>) {
},
Command::Rewind(_) => "rewind",
Command::Fork(_) => "fork",
Command::Wait(_) => "wait",
Command::Workflow { command } => match command {
WorkflowCommand::List(_) => "workflow list",
WorkflowCommand::Create(_) => "workflow create",
@ -886,6 +889,10 @@ async fn main_inner() -> (String, Result<()>) {
let styles = fabro_util::terminal::Styles::detect_stderr();
commands::fork::run(&args, &styles)?;
}
Command::Wait(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
commands::wait::run(args, &styles)?;
}
Command::Workflow { command } => match command {
WorkflowCommand::List(args) => {
commands::workflow::list_command(&args)?;