mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-20 00:11:34 +00:00
## 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>
358 lines
11 KiB
Rust
358 lines
11 KiB
Rust
#![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"
|
||
}
|