mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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:
parent
4b0a4f773e
commit
a176bea082
3 changed files with 250 additions and 0 deletions
|
|
@ -17,4 +17,5 @@ pub mod secret;
|
|||
pub(crate) mod shared;
|
||||
pub mod ssh;
|
||||
pub mod validate;
|
||||
pub mod wait;
|
||||
pub mod workflow;
|
||||
|
|
|
|||
242
lib/crates/fabro-cli/src/commands/wait.rs
Normal file
242
lib/crates/fabro-cli/src/commands/wait.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)?;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue