fabro/lib/crates/fabro-cli/tests/it/cmd/wait.rs
fabro-sh-0530[bot] 54bc67017e
feat: Replace duration/elapsed fields with wall_time_ms and StageTiming (#343)
## Summary

Replaces the ambiguous `runtime_secs`, `elapsed_secs`, and `duration_ms`
timing fields on run/stage public API surfaces with explicit
`wall_time_ms` (elapsed clock time) and a `StageTiming` value object
that also carries `inference_time_ms`, `tool_time_ms`, and
`active_time_ms`.

This is a greenfield breaking change — no compatibility shims are
preserved.

### What changed

**API shape**
- `RunBillingStage.runtime_secs` → `RunBillingStage.timing: StageTiming`
- `RunBillingTotals.runtime_secs` → `RunBillingTotals.timing:
StageTiming`
- `RunSummary.timestamps.duration_ms` / `elapsed_secs` removed; a
top-level `timing: StageTiming | null` field added
- Stage list item `duration_secs` → `wall_time_ms`

**Web app (`apps/fabro-web`)**
- `run-billing.tsx`: `liveRuntimeSecs` → `liveWallTimeMs`; live ticking
now returns milliseconds and the footer total sums `wallTimeMs` across
rows
- `stage-sidebar.ts`: `duration_secs` → `wall_time_ms` for the per-stage
duration display
- `runs.ts`: `elapsed_secs` lookup replaced with `timing.wall_time_ms`
- `formatElapsedSecs` / `formatDurationSecs` call sites replaced with
`formatDurationMs`

**Lockfile / tooling**
- `@openapitools/openapi-generator-cli@2.20.2` added as a dev dependency
to `@qltysh/fabro-api-client` to support regenerating the TypeScript
client after schema edits; several transitive deps pulled in alongside
it.

### Design notes

- **Units are now consistent**: every timing value on run/stage surfaces
is in milliseconds; the old API mixed seconds (`runtime_secs`,
`elapsed_secs`) with milliseconds (`duration_ms`).
- **Live ticking** still works correctly: the in-flight billing row
computes `now - startedAt` in ms and sums across rows for the footer,
avoiding a server round-trip during a running stage.
- **`StageTiming.active_time_ms = inference_time_ms + tool_time_ms`** —
parallel work is summed, so run active time can exceed wall time.
- Subsystem-internal `duration_ms` fields (sandbox setup, devcontainer
lifecycle, hooks) are intentionally left unchanged; only public
run/stage timing surfaces are affected.


### Fabro Details

<details>
<summary>Ran 9 stages in 115m 53s for $108.50</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 18s | – | 0 |
| implement | 80m 53s | $101.97 | 0 |
| simplify_opus | 21m 35s | $4.09 | 0 |
| simplify_gpt | 5m 1s | $2.44 | 0 |
| verify | 3m 11s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **115m 53s** | **$108.50** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    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 +nightly-2026-04-14 clippy -q --workspace --all-targets -- -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-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 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, test failures, and generated docs errors.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=succeeded"]
    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>
2026-05-21 21:06:40 -04:00

225 lines
6.5 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use fabro_test::{fabro_snapshot, test_context};
use httpmock::MockServer;
use serde_json::json;
use super::support::{
remote_run_summary_json, setup_seeded_completed_dry_run, setup_seeded_created_dry_run,
};
use crate::support::{run_projection_json, unique_run_id};
fn remote_run_summary(run_id: &str, status: &serde_json::Value) -> serde_json::Value {
remote_run_summary_json(
run_id,
"Blocked Remote Workflow",
"blocked-remote-workflow",
"Wait for approval",
status,
"2026-04-19T12:00:00Z",
)
}
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["wait", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Block until a workflow run completes
Usage: fabro wait [OPTIONS] <RUN>
Arguments:
<RUN> Run ID prefix or workflow name (most recent run)
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--timeout <SECONDS> Maximum time to wait in seconds
--interval <MS> Poll interval in milliseconds [default: 1000]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn wait_completed_run_prints_success_summary() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let mut filters = context.filters();
filters.push((
r"\b\d+(\.\d+)?(ms|s)\b".to_string(),
"[DURATION]".to_string(),
));
let mut cmd = context.command();
cmd.args(["wait", &run.run_id]);
fabro_snapshot!(filters, cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Succeeded [ULID] [DURATION]
");
}
#[test]
fn wait_completed_run_reads_store_without_status_or_conclusion_files() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let mut filters = context.filters();
filters.push((
r"\b\d+(\.\d+)?(ms|s)\b".to_string(),
"[DURATION]".to_string(),
));
let mut cmd = context.command();
cmd.args(["wait", &run.run_id]);
fabro_snapshot!(filters, cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Succeeded [ULID] [DURATION]
");
}
#[test]
fn wait_completed_run_json_outputs_status_and_duration() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let mut filters = context.filters();
filters.push((
r#""wall_time_ms":\s*\d+"#.to_string(),
r#""wall_time_ms": [WALL_TIME_MS]"#.to_string(),
));
filters.push((
r#""inference_time_ms":\s*\d+"#.to_string(),
r#""inference_time_ms": [INFERENCE_TIME_MS]"#.to_string(),
));
filters.push((
r#""tool_time_ms":\s*\d+"#.to_string(),
r#""tool_time_ms": [TOOL_TIME_MS]"#.to_string(),
));
filters.push((
r#""active_time_ms":\s*\d+"#.to_string(),
r#""active_time_ms": [ACTIVE_TIME_MS]"#.to_string(),
));
let mut cmd = context.command();
cmd.args(["wait", "--json", &run.run_id]);
fabro_snapshot!(filters, cmd, @r###"
success: true
exit_code: 0
----- stdout -----
{
"run_id": "[ULID]",
"status": "succeeded",
"timing": {
"wall_time_ms": [WALL_TIME_MS],
"inference_time_ms": [INFERENCE_TIME_MS],
"tool_time_ms": [TOOL_TIME_MS],
"active_time_ms": [ACTIVE_TIME_MS]
}
}
----- stderr -----
"###);
}
#[test]
fn wait_submitted_run_times_out() {
let context = test_context!();
let run = setup_seeded_created_dry_run(&context);
let mut cmd = context.command();
cmd.args(["wait", "--timeout", "0", "--interval", "10", &run.run_id]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Timed out after 0s waiting for run '[ULID]'
");
}
#[test]
fn wait_blocked_run_times_out_without_treating_it_as_terminal() {
let context = test_context!();
let run_id = unique_run_id();
let server = MockServer::start();
let summary = remote_run_summary(
run_id.as_str(),
&json!({
"kind": "blocked",
"blocked_reason": "human_input_required"
}),
);
let resolve_run = server.mock(|when, then| {
when.method("GET")
.path("/api/v1/runs/resolve")
.query_param("selector", run_id.as_str());
then.status(200)
.header("content-type", "application/json")
.body(summary.clone().to_string());
});
let retrieve_run = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{}", run_id.as_str()));
then.status(200)
.header("content-type", "application/json")
.body(summary.to_string());
});
let run_state = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{}/state", run_id.as_str()));
then.status(200)
.header("content-type", "application/json")
.body(
run_projection_json(
run_id.as_str(),
&json!({
"kind": "blocked",
"blocked_reason": "human_input_required"
}),
)
.to_string(),
);
});
let mut cmd = context.command();
cmd.args([
"wait",
"--server",
&format!("{}/api/v1", server.base_url()),
"--timeout",
"0",
"--interval",
"10",
run_id.as_str(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Timed out after 0s waiting for run '[ULID]'
");
resolve_run.assert();
assert!(
retrieve_run.calls() > 0,
"wait should keep polling the blocked run summary until timeout"
);
assert_eq!(
run_state.calls(),
0,
"wait should not fetch run state when the run never becomes terminal"
);
}