fabro/lib/crates/fabro-cli/tests/it/cmd/dump.rs
fabro-sh-0530[bot] f73f2a53f3
Replace queued with pending/runnable and add approval flow (web + API s… (#371)
## Summary

Replaces the single `queued` pre-execution state with explicit `pending`
and `runnable` states, and wires approve/deny actions for
parent-generated child runs that require human approval before they can
execute. This diff covers the web UI and OpenAPI spec layers of that
change.

## What changed

**Run status model**
- `queued` is removed from all TypeScript types, display maps, column
routing, and tests.
- `pending` (awaiting approval) and `runnable` (eligible for the
scheduler) replace it as distinct board columns and `RunStatus` variants
with their own labels and colors (`runnable` gets cyan; `pending` stays
muted).

**Approval actions**
- New `approveRun` / `denyRun` API calls in `run-actions.ts` invoke the
new `POST /runs/{id}/approve` and `POST /runs/{id}/deny` endpoints.
- `canApprove` predicate requires both `status.kind === "pending"` and
`lifecycle.approval?.state === "pending"` — a run whose status is
pending but has no approval record does not expose the action.
- `useApproveRun` / `useDenyRun` mutations in `mutations.ts` follow the
same pattern as `useCancelRun`.
- `ActionsMenu` in `run-detail.tsx` gains Approve (lifecycle group) and
Deny (destructive group) menu items.

**Board and event plumbing**
- `columnForStatus` now routes `pending → pending column` and `runnable
→ runnable column`; `submitted` stays in the pending column.
- `BOARD_STATUS_EVENTS` and `RUN_SUMMARY_EVENTS` replace `run.queued`
with `run.start_requested`, `run.pending`, `run.approved`, `run.denied`,
and `run.runnable`.
- The `pending` column is hidden when empty (same behaviour the old
`queued` column had).

**Waterfall phases (`run-phases.ts`)**
- `queued` phase is removed; `pending` and `runnable` phases are added
in order.
- The submitted phase closes at `run.start_requested` rather than
`run.queued`.
- Each phase derives its timestamps from its own event rather than a
single `firstTs` lookup, making multi-phase pre-execution timelines
accurate.

**OpenAPI spec**
- `POST /api/v1/runs/{id}/approve` and `POST /api/v1/runs/{id}/deny`
endpoints added with 200/404/409 responses.
- `startRun` description updated to describe the pending/runnable
branching behaviour.
- `cancelRun` description updated to reference `pending`/`runnable`
instead of `queued`.

### Plan Summary

- **Task 3** (OpenAPI schema additions for approve/deny endpoints) —
complete in this diff.
- **Task 6** (Web UI surfaces: board columns, run-detail actions,
waterfall phases, event subscriptions) — complete in this diff.
- **Task 7** (doc cleanup: references to `queued` replaced in plans,
brainstorms, and QA docs) — complete in this diff.


### Fabro Details

<details>
<summary>Ran 9 stages in 127m 37s for $104.98</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 2s | – | 0 |
| preflight_compile | 2m 15s | – | 0 |
| preflight_lint | 2m 29s | – | 0 |
| implement | 92m 10s | $91.53 | 0 |
| simplify_opus | 18m 35s | $10.65 | 0 |
| simplify_gpt | 7m 36s | $2.81 | 0 |
| verify | 3m 42s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **127m 37s** | **$104.98** | **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.", model="gpt-55", reasoning_effort="xhigh"]
    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: fabro <fabro@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-05-23 15:34:33 -04:00

358 lines
11 KiB
Rust
Raw 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.

#![expect(
clippy::disallowed_methods,
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use std::fs;
use std::time::Duration;
use fabro_client::ServerTarget;
use fabro_test::{fabro_snapshot, test_context};
use insta::assert_snapshot;
use super::support::{
local_dev_token, server_target, setup_completed_dry_run, setup_seeded_completed_dry_run,
setup_seeded_created_dry_run,
};
use crate::support::{LightweightCli, seed_dev_token_auth, unique_run_id};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["dump", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Export a run's durable state to a directory
Usage: fabro dump [OPTIONS] --output <OUTPUT> <RUN>
Arguments:
<RUN> Run ID prefix or workflow name
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=]
-o, --output <OUTPUT> Output directory (must not exist or be empty)
--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 dump_accepts_server_target_from_separate_home() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let cli = LightweightCli::new();
let output_dir = context.temp_dir.join("remote-export");
let server = server_target(&context.storage_dir);
if let Some(dev_token) = local_dev_token(&context.storage_dir) {
let target = server
.parse::<ServerTarget>()
.expect("server target should parse");
seed_dev_token_auth(cli.home(), &target, &dev_token);
}
let mut cmd = cli.command();
cmd.args([
"dump",
"--server",
&server,
"--output",
output_dir.to_str().unwrap(),
&run.run_id,
]);
let output = cmd.output().expect("dump should execute");
assert!(
output.status.success(),
"dump via remote server target failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(output_dir.join("run.json").is_file());
}
#[test]
fn dump_exports_large_command_output_backed_by_blob_refs() {
let context = test_context!();
let workflow = context.temp_dir.join("large-output.fabro");
fs::write(
&workflow,
r#"digraph LargeOutput {
graph [goal="Generate oversized command output"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
big [shape=parallelogram, label="Big", script="printf '%*s' 120000 '' | tr ' ' x"]
start -> big -> exit
}
"#,
)
.unwrap();
let run_id = unique_run_id();
let mut run_cmd = context.run_cmd();
run_cmd.current_dir(&context.temp_dir);
run_cmd.timeout(Duration::from_secs(30));
run_cmd.args(["--run-id", run_id.as_str(), "--environment", "local"]);
run_cmd.arg(&workflow);
let run_output = run_cmd.output().expect("command should execute");
assert!(
run_output.status.success(),
"workflow run failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&run_output.stdout),
String::from_utf8_lossy(&run_output.stderr)
);
let mut inspect_cmd = context.command();
inspect_cmd.args(["inspect", "--json", &run_id]);
let inspect_output = inspect_cmd.output().expect("inspect should execute");
assert!(
inspect_output.status.success(),
"inspect failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&inspect_output.stdout),
String::from_utf8_lossy(&inspect_output.stderr)
);
let inspect_json = String::from_utf8(inspect_output.stdout).unwrap();
assert!(
inspect_json.contains("blob://sha256/"),
"inspect output should contain blob refs to exercise hydration\n{inspect_json}"
);
let output_dir = context.temp_dir.join("export");
let mut dump_cmd = context.command();
dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]);
let dump_output = dump_cmd.output().expect("dump should execute");
assert!(
dump_output.status.success(),
"dump failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&dump_output.stdout),
String::from_utf8_lossy(&dump_output.stderr)
);
let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
assert!(
!run_json.contains("blob://sha256/"),
"run export should hydrate blob refs\n{run_json}"
);
}
#[test]
fn dump_exports_blob_refs_and_artifacts_together() {
let context = test_context!();
let workspace_dir = context.temp_dir.join("mixed-export");
fs::create_dir_all(&workspace_dir).unwrap();
fs::write(
workspace_dir.join("mixed-export.fabro"),
r#"digraph MixedExport {
graph [goal="Generate oversized command output and artifacts"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
big [shape=parallelogram, label="Big", script="mkdir -p assets/shared && printf exported > assets/shared/report.txt && printf '%*s' 120000 '' | tr ' ' x"]
start -> big -> exit
}
"#,
)
.unwrap();
fs::write(
workspace_dir.join("run.toml"),
r#"_version = 1
[workflow]
graph = "mixed-export.fabro"
[run]
goal = "Generate oversized command output and artifacts"
[run.environment]
id = "local"
[environments.local]
provider = "local"
[environments.local.lifecycle]
preserve = true
[run.artifacts]
include = ["assets/**"]
"#,
)
.unwrap();
let run_id = unique_run_id();
let mut run_cmd = context.run_cmd();
run_cmd.current_dir(&workspace_dir);
run_cmd.timeout(Duration::from_secs(30));
run_cmd.args([
"--run-id",
run_id.as_str(),
"--environment",
"local",
"run.toml",
]);
let run_output = run_cmd.output().expect("command should execute");
assert!(
run_output.status.success(),
"workflow run failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&run_output.stdout),
String::from_utf8_lossy(&run_output.stderr)
);
let mut inspect_cmd = context.command();
inspect_cmd.args(["inspect", "--json", &run_id]);
let inspect_output = inspect_cmd.output().expect("inspect should execute");
assert!(
inspect_output.status.success(),
"inspect failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&inspect_output.stdout),
String::from_utf8_lossy(&inspect_output.stderr)
);
let inspect_json = String::from_utf8(inspect_output.stdout).unwrap();
assert!(
inspect_json.contains("blob://sha256/"),
"inspect output should contain blob refs to exercise hydration\n{inspect_json}"
);
let output_dir = context.temp_dir.join("export-mixed");
let mut dump_cmd = context.command();
dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]);
let dump_output = dump_cmd.output().expect("dump should execute");
assert!(
dump_output.status.success(),
"dump failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&dump_output.stdout),
String::from_utf8_lossy(&dump_output.stderr)
);
let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
assert!(
!run_json.contains("blob://sha256/"),
"run export should hydrate blob refs\n{run_json}"
);
assert_eq!(
fs::read_to_string(
output_dir.join("artifacts/002-big@1/retry-0001/assets/shared/report.txt")
)
.unwrap(),
"exported"
);
}
#[test]
fn dump_exports_completed_run_snapshot() {
let context = test_context!();
let run = setup_completed_dry_run(&context);
let output_dir = context.temp_dir.join("export");
let mut cmd = context.command();
cmd.args([
"dump",
"--output",
output_dir.to_str().unwrap(),
&run.run_id,
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Exported 13 files for run [ULID] to [TEMP_DIR]/export
----- stderr -----
");
assert_snapshot!(dump_file_summary(&output_dir), @"
checkpoints/0014.json
checkpoints/0018.json
checkpoints/0022.json
events.jsonl
graph.fabro
run.json
run.log
stages/001-start@1/status.json
stages/002-run_tests@1/response.md
stages/002-run_tests@1/status.json
stages/003-report@1/response.md
stages/003-report@1/status.json
stages/004-exit@1/status.json
");
}
#[test]
fn dump_succeeds_when_run_log_is_missing() {
let context = test_context!();
let run = setup_seeded_created_dry_run(&context);
let output_dir = context.temp_dir.join("export-missing-log");
let mut cmd = context.command();
cmd.args([
"dump",
"--output",
output_dir.to_str().unwrap(),
&run.run_id,
]);
let output = cmd.output().expect("dump should execute");
assert!(
output.status.success(),
"dump failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
!output_dir.join("run.log").exists(),
"dump should skip run.log when the server has no run log"
);
}
#[test]
fn dump_rejects_non_empty_output_dir() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let output_dir = context.temp_dir.join("nonempty");
std::fs::create_dir_all(&output_dir).unwrap();
std::fs::write(output_dir.join("file.txt"), "x").unwrap();
let mut cmd = context.command();
cmd.args([
"dump",
"--output",
output_dir.to_str().unwrap(),
&run.run_id,
]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× output path [TEMP_DIR]/nonempty already exists and is not an empty directory; remove it first or choose a different path
");
}
fn dump_file_summary(output_dir: &std::path::Path) -> String {
let mut files: Vec<String> = walkdir::WalkDir::new(output_dir)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| {
entry
.path()
.strip_prefix(output_dir)
.expect("walked file should stay under the output directory")
.to_string_lossy()
.replace('\\', "/")
})
.collect();
files.sort();
files.join("\n") + "\n"
}