mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-05 02:41:45 +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>
356 lines
10 KiB
Rust
356 lines
10 KiB
Rust
#![allow(
|
|
clippy::absolute_paths,
|
|
reason = "This test module prefers explicit type paths over extra imports."
|
|
)]
|
|
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "Integration tests stage fixtures with sync std::fs calls."
|
|
)]
|
|
|
|
use fabro_test::{fabro_json_snapshot, test_context};
|
|
use serde_json::Value;
|
|
|
|
use super::{fixture, run_state, timeout_for};
|
|
use crate::support::unique_run_id;
|
|
|
|
#[fabro_macros::e2e_test()]
|
|
fn local_run_lifecycle() {
|
|
let context = test_context!();
|
|
|
|
let cmd = |args: &[&str]| -> assert_cmd::assert::Assert {
|
|
context
|
|
.command()
|
|
.args(args)
|
|
.timeout(timeout_for("local"))
|
|
.assert()
|
|
};
|
|
|
|
// 1. Run a workflow
|
|
context
|
|
.run_cmd()
|
|
.args([
|
|
"--auto-approve",
|
|
"--environment",
|
|
"local",
|
|
fixture("command_pipeline.fabro").to_str().unwrap(),
|
|
])
|
|
.timeout(timeout_for("local"))
|
|
.assert()
|
|
.success();
|
|
|
|
// 2. ps -a --json — should list exactly one run
|
|
let label = context.test_case_label();
|
|
let ps_out = cmd(&["ps", "-a", "--json", "--label", &label]).success();
|
|
let ps_stdout = String::from_utf8(ps_out.get_output().stdout.clone()).unwrap();
|
|
let runs: Vec<Value> =
|
|
serde_json::from_str(&ps_stdout).expect("ps --json should produce a JSON array");
|
|
assert_eq!(runs.len(), 1, "should have exactly one run: {ps_stdout}");
|
|
let run_id = runs[0]["run_id"]
|
|
.as_str()
|
|
.expect("run should have run_id")
|
|
.to_string();
|
|
assert_eq!(
|
|
runs[0]["workflow_name"].as_str(),
|
|
Some("CommandPipeline"),
|
|
"workflow_name should be CommandPipeline"
|
|
);
|
|
|
|
// 3. inspect <run_id> — JSON array with run_spec and conclusion
|
|
let inspect_out = cmd(&["inspect", &run_id]).success();
|
|
let inspect_stdout = String::from_utf8(inspect_out.get_output().stdout.clone()).unwrap();
|
|
let items: Vec<Value> =
|
|
serde_json::from_str(&inspect_stdout).expect("inspect should produce a JSON array");
|
|
assert!(!items.is_empty(), "inspect should return at least one item");
|
|
assert!(
|
|
items[0]["run_spec"].is_object(),
|
|
"inspect should include run_spec"
|
|
);
|
|
assert!(
|
|
items[0]["conclusion"].is_object(),
|
|
"inspect should include conclusion"
|
|
);
|
|
// 4. events <run_id> — non-empty, first line is valid JSONL with event field
|
|
let events_out = cmd(&["events", &run_id]).success();
|
|
let events_stdout = String::from_utf8(events_out.get_output().stdout.clone()).unwrap();
|
|
assert!(!events_stdout.is_empty(), "events should not be empty");
|
|
let first_line = events_stdout.lines().next().unwrap();
|
|
let log_entry: Value =
|
|
serde_json::from_str(first_line).expect("first event line should be valid JSON");
|
|
assert!(
|
|
log_entry["event"].is_string(),
|
|
"first log line should have an event field"
|
|
);
|
|
|
|
// 5. artifact list — no assets yet, should succeed with empty message
|
|
let artifact_list_out = cmd(&["artifact", "list", &run_id]).success();
|
|
let artifact_list_stdout =
|
|
String::from_utf8(artifact_list_out.get_output().stdout.clone()).unwrap();
|
|
assert!(
|
|
artifact_list_stdout.contains("No artifacts found"),
|
|
"artifact list should report no artifacts: {artifact_list_stdout}"
|
|
);
|
|
|
|
// 6. system df — mentions "Runs"
|
|
let df_out = cmd(&["system", "df"]).success();
|
|
let df_stdout = String::from_utf8(df_out.get_output().stdout.clone()).unwrap();
|
|
assert!(
|
|
df_stdout.contains("Runs"),
|
|
"system df should mention Runs: {df_stdout}"
|
|
);
|
|
|
|
// 7. rm <run_id> — remove the run
|
|
cmd(&["rm", &run_id]).success();
|
|
|
|
// 8. ps -a --json — should be empty
|
|
let ps_out2 = cmd(&["ps", "-a", "--json", "--label", &label]).success();
|
|
let ps_stdout2 = String::from_utf8(ps_out2.get_output().stdout.clone()).unwrap();
|
|
let runs2: Vec<Value> =
|
|
serde_json::from_str(&ps_stdout2).expect("ps --json should produce a JSON array");
|
|
assert!(
|
|
runs2.is_empty(),
|
|
"runs should be empty after rm: {ps_stdout2}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dry_run_create_start_attach_works_with_default_run_lookup() {
|
|
let context = test_context!();
|
|
context.ensure_home_server_auth_methods();
|
|
let run_id = unique_run_id();
|
|
let workflow = context.install_fixture("simple.fabro");
|
|
|
|
context
|
|
.command()
|
|
.args([
|
|
"create",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
workflow.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
context
|
|
.command()
|
|
.args(["start", &run_id])
|
|
.assert()
|
|
.success();
|
|
context
|
|
.command()
|
|
.args(["attach", &run_id])
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.assert()
|
|
.success();
|
|
|
|
let run_dir = context.find_run_dir(&run_id);
|
|
let state = run_state(&run_dir);
|
|
fabro_json_snapshot!(
|
|
context,
|
|
serde_json::json!({
|
|
"status": if state.archived_at.is_some() {
|
|
"archived"
|
|
} else {
|
|
match state.status {
|
|
fabro_types::RunStatus::Submitted => "submitted",
|
|
fabro_types::RunStatus::Pending { .. } => "pending",
|
|
fabro_types::RunStatus::Runnable => "runnable",
|
|
fabro_types::RunStatus::Starting => "starting",
|
|
fabro_types::RunStatus::Running => "running",
|
|
fabro_types::RunStatus::Blocked { .. } => "blocked",
|
|
fabro_types::RunStatus::Paused { .. } => "paused",
|
|
fabro_types::RunStatus::Removing => "removing",
|
|
fabro_types::RunStatus::Succeeded { .. } => "succeeded",
|
|
fabro_types::RunStatus::Failed { .. } => "failed",
|
|
fabro_types::RunStatus::Dead => "dead",
|
|
}
|
|
},
|
|
"has_conclusion": state.conclusion.is_some(),
|
|
}),
|
|
@r#"
|
|
{
|
|
"status": "succeeded",
|
|
"has_conclusion": true
|
|
}
|
|
"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dry_run_detach_attach_works_with_default_run_lookup() {
|
|
let context = test_context!();
|
|
context.ensure_home_server_auth_methods();
|
|
let run_id = unique_run_id();
|
|
let workflow = context.install_fixture("simple.fabro");
|
|
|
|
context
|
|
.command()
|
|
.args([
|
|
"run",
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
workflow.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
context
|
|
.command()
|
|
.args(["attach", &run_id])
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.assert()
|
|
.success();
|
|
|
|
let run_dir = context.find_run_dir(&run_id);
|
|
let state = run_state(&run_dir);
|
|
fabro_json_snapshot!(
|
|
context,
|
|
serde_json::json!({
|
|
"run_dir": run_dir,
|
|
"has_conclusion": state.conclusion.is_some(),
|
|
}),
|
|
@r#"
|
|
{
|
|
"run_dir": "[RUN_DIR]",
|
|
"has_conclusion": true
|
|
}
|
|
"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completed_run_can_be_attached_by_workflow_slug() {
|
|
let context = test_context!();
|
|
context.ensure_home_server_auth_methods();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let workflow_dir = project.path().join("workflows").join("sluggy");
|
|
let workflow_path = workflow_dir.join("workflow.fabro");
|
|
let run_id = unique_run_id();
|
|
|
|
std::fs::create_dir_all(&workflow_dir).unwrap();
|
|
std::fs::write(
|
|
&workflow_path,
|
|
"\
|
|
digraph BarBaz {
|
|
start [shape=Mdiamond, label=\"Start\"]
|
|
exit [shape=Msquare, label=\"Exit\"]
|
|
start -> exit
|
|
}
|
|
",
|
|
)
|
|
.unwrap();
|
|
|
|
context
|
|
.command()
|
|
.current_dir(project.path())
|
|
.args([
|
|
"create",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
workflow_path.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
context
|
|
.command()
|
|
.current_dir(project.path())
|
|
.args(["start", "sluggy"])
|
|
.assert()
|
|
.success();
|
|
context
|
|
.command()
|
|
.current_dir(project.path())
|
|
.args(["attach", &run_id])
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.assert()
|
|
.success();
|
|
context
|
|
.command()
|
|
.current_dir(project.path())
|
|
.args(["attach", "sluggy"])
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.assert()
|
|
.success();
|
|
|
|
let run_dir = context.find_run_dir(&run_id);
|
|
let state = run_state(&run_dir);
|
|
fabro_json_snapshot!(
|
|
context,
|
|
serde_json::json!({
|
|
"run_dir_exists": run_dir.exists(),
|
|
"has_conclusion": state.conclusion.is_some(),
|
|
}),
|
|
@r#"
|
|
{
|
|
"run_dir_exists": true,
|
|
"has_conclusion": true
|
|
}
|
|
"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completed_run_can_be_attached_by_file_stem() {
|
|
let context = test_context!();
|
|
context.ensure_home_server_auth_methods();
|
|
let workflow_dir = tempfile::tempdir().unwrap();
|
|
let workflow_path = workflow_dir.path().join("alpha.fabro");
|
|
let run_id = unique_run_id();
|
|
|
|
std::fs::write(
|
|
&workflow_path,
|
|
"\
|
|
digraph FooWorkflow {
|
|
start [shape=Mdiamond, label=\"Start\"]
|
|
exit [shape=Msquare, label=\"Exit\"]
|
|
start -> exit
|
|
}
|
|
",
|
|
)
|
|
.unwrap();
|
|
|
|
context
|
|
.command()
|
|
.args([
|
|
"create",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
workflow_path.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
context
|
|
.command()
|
|
.args(["start", "alpha"])
|
|
.assert()
|
|
.success();
|
|
context
|
|
.command()
|
|
.args(["attach", "alpha"])
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.assert()
|
|
.success();
|
|
|
|
let run_spec = run_state(&context.find_run_dir(&run_id)).spec;
|
|
fabro_json_snapshot!(
|
|
context,
|
|
serde_json::json!({
|
|
"graph_name": run_spec.graph.name,
|
|
"workflow_slug": run_spec.workflow_slug,
|
|
}),
|
|
@r#"
|
|
{
|
|
"graph_name": "FooWorkflow",
|
|
"workflow_slug": "alpha"
|
|
}
|
|
"#
|
|
);
|
|
}
|