From a176bea0821ce000924ecd0eb7d257b4efcf57e9 Mon Sep 17 00:00:00 2001 From: "brynary-fabro[bot]" <265161896+brynary-fabro[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:48:38 -0400 Subject: [PATCH] `fabro wait` subcommand (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
Ran 9 stages in 12m 13s for $2.81 | 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** |
Ran ImplementAndSimplify.fabro (12 nodes and 15 edges) ```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 } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- lib/crates/fabro-cli/src/commands/mod.rs | 1 + lib/crates/fabro-cli/src/commands/wait.rs | 242 ++++++++++++++++++++++ lib/crates/fabro-cli/src/main.rs | 7 + 3 files changed, 250 insertions(+) create mode 100644 lib/crates/fabro-cli/src/commands/wait.rs diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index d7cf9c682..6c4535f57 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -17,4 +17,5 @@ pub mod secret; pub(crate) mod shared; pub mod ssh; pub mod validate; +pub mod wait; pub mod workflow; diff --git a/lib/crates/fabro-cli/src/commands/wait.rs b/lib/crates/fabro-cli/src/commands/wait.rs new file mode 100644 index 000000000..a6054f1b2 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/wait.rs @@ -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, + + /// 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); + } +} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 91659c72b..f43707989 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -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)?;