mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-03 02:24:33 +00:00
## Summary
Exposes the existing `POST /api/v1/runs/{id}/approve` and `POST
/api/v1/runs/{id}/deny` REST endpoints through the `fabro_run_interact`
MCP tool and two new top-level CLI commands (`fabro approve`, `fabro
deny`). Workflow agents are explicitly blocked from using these actions
— approval remains a human/user operation.
## What changed
**Client & tool backend** (`fabro-client`, `fabro-tool`): Added
`approve_run` and `deny_run` to `Client` and the `FabroToolBackend`
trait, implemented in `ClientBackend`. `deny_run` passes a
`DenyRunRequest` body; absent, blank, or whitespace-only reasons are
normalised to `None`.
**`fabro_run_interact` MCP tool**: Added `Approve` and `Deny` variants
to `RunInteractAction` / `ValidatedInteractAction`, and an optional
`reason` parameter (only valid for `deny`; validated and trimmed on
input). Both actions return `{ "summary": … }` using the existing shape.
The tool description is updated to list the new actions.
**Workflow-agent guard** (`fabro-workflow`): Before dispatching
`fabro_run_interact`, the handler checks
`validated.action.requires_user()`. If the action is `approve` or
`deny`, it returns an immediate `ToolError` without ever reaching the
backend, keeping the guard explicit and independent of server auth.
**CLI** (`fabro-cli`): Extracted the archive/unarchive batch loop into a
shared `run_resolved_run_batch` helper in `commands/runs/mod.rs`, then
implemented `approval.rs` using the same helper. Both commands follow
the same batch contract as archive: attempt all runs, collect per-run
errors, exit non-zero if any fail, and emit `{ "approved"/"denied": […],
"errors": […] }` in JSON mode.
**Server auth regression** (`fabro-server`): Extended
`run_tools_worker_cannot_call_user_only_non_mcp_routes` to cover `POST
/runs/{id}/deny` alongside the existing `approve` and `timeline` checks.
**Docs** (`mcp.mdx`, `cli.mdx`): Updated the `fabro_run_interact` table
entry and added approve/deny examples, plus reference sections for the
two new CLI commands.
### Plan Summary
- Add `approve_run` / `deny_run` to `Client` and `FabroToolBackend`
- Extend `fabro_run_interact` with `approve`, `deny`, and optional
`reason`
- Block workflow-agent self-approval with an early `ToolError`
- Refactor archive batch loop into shared `run_resolved_run_batch`
helper
- Add `fabro approve` and `fabro deny` CLI commands reusing that helper
- Add integration tests for CLI commands, MCP tool, and server auth
guard
### Fabro Details
<details>
<summary>Ran 9 stages in 63m 57s for $42.33</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 17s | – | 0 |
| implement | 28m 16s | $32.57 | 0 |
| simplify_opus | 10m 59s | $4.24 | 0 |
| simplify_gpt | 6m 13s | $3.96 | 0 |
| verify | 10m 51s | – | 0 |
| fixup | 2m 29s | $1.55 | 0 |
| **Total** | **63m 57s** | **$42.33** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
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="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 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 format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
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 -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
262 lines
8.1 KiB
Rust
262 lines
8.1 KiB
Rust
use fabro_test::{fabro_snapshot, test_context};
|
|
use httpmock::MockServer;
|
|
use serde_json::Value;
|
|
|
|
use super::support::{
|
|
remote_run_summary_json, setup_seeded_completed_dry_run, setup_seeded_created_dry_run,
|
|
ulid_filter,
|
|
};
|
|
use crate::support::unique_run_id;
|
|
|
|
#[test]
|
|
fn help() {
|
|
let context = test_context!();
|
|
let mut cmd = context.command();
|
|
cmd.args(["archive", "--help"]);
|
|
fabro_snapshot!(context.filters(), cmd, @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings
|
|
|
|
Usage: fabro archive [OPTIONS] <RUNS>...
|
|
|
|
Arguments:
|
|
<RUNS>... Run IDs or workflow names to archive
|
|
|
|
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=]
|
|
--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 archive_requires_at_least_one_id() {
|
|
let context = test_context!();
|
|
let mut cmd = context.command();
|
|
cmd.args(["archive"]);
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: false
|
|
exit_code: 2
|
|
----- stdout -----
|
|
----- stderr -----
|
|
error: the following required arguments were not provided:
|
|
<RUNS>...
|
|
|
|
Usage: fabro archive --no-upgrade-check <RUNS>...
|
|
|
|
For more information, try '--help'.
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn archive_succeeded_run_hides_it_from_default_ps() {
|
|
let context = test_context!();
|
|
let run = setup_seeded_completed_dry_run(&context);
|
|
let mut filters = context.filters();
|
|
filters.push(ulid_filter());
|
|
|
|
let mut cmd = context.command();
|
|
cmd.args(["archive", &run.run_id]);
|
|
fabro_snapshot!(filters, cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
----- stderr -----
|
|
[ULID]
|
|
");
|
|
|
|
// Default `ps` filters out archived runs.
|
|
let mut ps = context.ps();
|
|
ps.args(["--json", "--label", &context.test_case_label()]);
|
|
fabro_snapshot!(context.filters(), ps, @r#"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
[]
|
|
----- stderr -----
|
|
"#);
|
|
|
|
// `ps -a` surfaces it with status `archived`.
|
|
let output = context
|
|
.ps()
|
|
.args(["-a", "--json", "--label", &context.test_case_label()])
|
|
.output()
|
|
.expect("ps -a should execute");
|
|
assert!(output.status.success());
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
|
assert_eq!(runs.len(), 1, "ps -a should show the archived run");
|
|
assert_eq!(runs[0]["status"]["kind"], "succeeded");
|
|
assert_eq!(runs[0]["status"]["reason"], "completed");
|
|
assert_eq!(runs[0]["run_id"], run.run_id);
|
|
}
|
|
|
|
#[test]
|
|
fn archive_running_run_rejects_with_must_be_terminal_message() {
|
|
// A `create`d run is in `submitted` — not yet terminal.
|
|
let context = test_context!();
|
|
let run = setup_seeded_created_dry_run(&context);
|
|
let output = context
|
|
.command()
|
|
.args(["archive", &run.run_id])
|
|
.output()
|
|
.expect("archive should execute");
|
|
assert!(!output.status.success(), "archive on submitted must fail");
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("must be terminal"),
|
|
"expected 'must be terminal' in stderr, got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archive_already_archived_is_idempotent() {
|
|
let context = test_context!();
|
|
let run = setup_seeded_completed_dry_run(&context);
|
|
|
|
let first = context
|
|
.command()
|
|
.args(["archive", &run.run_id])
|
|
.output()
|
|
.expect("archive should execute");
|
|
assert!(first.status.success(), "first archive should succeed");
|
|
|
|
let second = context
|
|
.command()
|
|
.args(["archive", &run.run_id])
|
|
.output()
|
|
.expect("archive should execute");
|
|
assert!(
|
|
second.status.success(),
|
|
"second archive on already-archived should succeed\nstderr:\n{}",
|
|
String::from_utf8_lossy(&second.stderr)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archive_unknown_id_renders_clean_error() {
|
|
let context = test_context!();
|
|
let fake_id = unique_run_id();
|
|
let output = context
|
|
.command()
|
|
.args(["archive", &fake_id])
|
|
.output()
|
|
.expect("archive should execute");
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains(&fake_id) || stderr.contains("No run found"),
|
|
"expected unknown-id error in stderr, got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archive_json_output_shape() {
|
|
let context = test_context!();
|
|
let run = setup_seeded_completed_dry_run(&context);
|
|
|
|
let output = context
|
|
.command()
|
|
.args(["--json", "archive", &run.run_id])
|
|
.output()
|
|
.expect("archive --json should execute");
|
|
assert!(output.status.success());
|
|
let value: Value = serde_json::from_slice(&output.stdout).expect("archive JSON should parse");
|
|
assert_eq!(
|
|
value["archived"],
|
|
Value::Array(vec![Value::String(run.run_id.clone())])
|
|
);
|
|
assert_eq!(value["errors"], Value::Array(vec![]));
|
|
}
|
|
|
|
#[test]
|
|
fn archive_mixed_batch_aggregates_errors() {
|
|
let context = test_context!();
|
|
let good = setup_seeded_completed_dry_run(&context);
|
|
let bad = unique_run_id();
|
|
|
|
let output = context
|
|
.command()
|
|
.args(["--json", "archive", &good.run_id, &bad])
|
|
.output()
|
|
.expect("archive should execute");
|
|
assert!(!output.status.success(), "mixed batch should exit non-zero");
|
|
let value: Value = serde_json::from_slice(&output.stdout).expect("archive JSON should parse");
|
|
assert_eq!(
|
|
value["archived"],
|
|
Value::Array(vec![Value::String(good.run_id.clone())])
|
|
);
|
|
let errors = value["errors"].as_array().expect("errors should be array");
|
|
assert_eq!(errors.len(), 1);
|
|
assert_eq!(errors[0]["identifier"], bad);
|
|
}
|
|
|
|
#[test]
|
|
fn archive_resolves_selector_via_server_endpoint() {
|
|
let context = test_context!();
|
|
let server = MockServer::start();
|
|
let run_id = unique_run_id();
|
|
let resolve_mock = server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path("/api/v1/runs/resolve")
|
|
.query_param("selector", "nightly-build");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
remote_run_summary_json(
|
|
&run_id,
|
|
"Nightly Build",
|
|
"nightly-build",
|
|
"Nightly run",
|
|
&serde_json::json!({
|
|
"kind": "succeeded",
|
|
"reason": "completed"
|
|
}),
|
|
"2026-04-05T12:00:00Z",
|
|
)
|
|
.to_string(),
|
|
);
|
|
});
|
|
let archive_mock = server.mock(|when, then| {
|
|
when.method("POST")
|
|
.path(format!("/api/v1/runs/{run_id}/archive"));
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
remote_run_summary_json(
|
|
&run_id,
|
|
"Nightly Build",
|
|
"nightly-build",
|
|
"Nightly run",
|
|
&serde_json::json!({
|
|
"kind": "succeeded",
|
|
"reason": "completed"
|
|
}),
|
|
"2026-04-05T12:00:00Z",
|
|
)
|
|
.to_string(),
|
|
);
|
|
});
|
|
context.set_http_target(&server.base_url());
|
|
|
|
let mut filters = context.filters();
|
|
filters.push(ulid_filter());
|
|
let mut cmd = context.command();
|
|
cmd.args(["archive", "nightly-build"]);
|
|
fabro_snapshot!(filters, cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
----- stderr -----
|
|
[ULID]
|
|
");
|
|
|
|
resolve_mock.assert();
|
|
archive_mock.assert();
|
|
}
|