mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
2c851d5a41
25 changed files with 1434 additions and 190 deletions
94
Cargo.lock
generated
94
Cargo.lock
generated
|
|
@ -1583,7 +1583,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -1602,7 +1602,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1644,7 +1644,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -1665,7 +1665,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1689,11 +1689,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -1709,7 +1709,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -1810,7 +1810,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1839,7 +1839,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -1868,7 +1868,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -1883,7 +1883,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -1902,7 +1902,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-devcontainer"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-static",
|
||||
|
|
@ -1919,7 +1919,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1933,7 +1933,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1955,7 +1955,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -1969,7 +1969,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -1993,7 +1993,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http",
|
||||
|
|
@ -2003,7 +2003,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2019,7 +2019,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2034,7 +2034,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2069,7 +2069,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2080,7 +2080,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2098,7 +2098,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2118,7 +2118,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2145,7 +2145,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http",
|
||||
|
|
@ -2161,7 +2161,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2183,7 +2183,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2191,7 +2191,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2200,7 +2200,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2216,7 +2216,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2259,7 +2259,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2344,7 +2344,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -2366,18 +2366,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -2404,7 +2404,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2430,7 +2430,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2444,7 +2444,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"assert_cmd",
|
||||
"axum",
|
||||
|
|
@ -2467,7 +2467,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2488,7 +2488,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2502,7 +2502,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -2523,7 +2523,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -2543,7 +2543,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -2556,7 +2556,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-types",
|
||||
|
|
@ -2568,7 +2568,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -7362,7 +7362,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -7381,7 +7381,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.243.0-nightly.1"
|
||||
version = "0.244.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import TestRenderer, { act } from "react-test-renderer";
|
|||
import { MemoryRouter } from "react-router";
|
||||
|
||||
import {
|
||||
AgentToolCategory,
|
||||
AgentSkillActivationSource,
|
||||
PermissionLevel,
|
||||
AgentToolCategory,
|
||||
StageContextWindowCategory,
|
||||
StageContextWindowCountMethod,
|
||||
StageContextWindowStaleness,
|
||||
|
|
@ -148,17 +147,7 @@ describe("StageInsightsSidebar", () => {
|
|||
expect(dom).not.toContain("31%");
|
||||
});
|
||||
|
||||
test("renders permission badge for read-only", () => {
|
||||
const dom = render(makeStage({ permission_level: PermissionLevel.READ_ONLY }), null);
|
||||
expect(dom).toContain("Read-only");
|
||||
});
|
||||
|
||||
test("renders permission badge for full access", () => {
|
||||
const dom = render(makeStage({ permission_level: PermissionLevel.FULL }), null);
|
||||
expect(dom).toContain("Full access");
|
||||
});
|
||||
|
||||
test("renders projected agent tool names, descriptions, categories, and invoked state", () => {
|
||||
test("renders projected agent tool names and invoked state", () => {
|
||||
const dom = render(
|
||||
makeStage({
|
||||
agent_tools: [
|
||||
|
|
@ -177,29 +166,16 @@ describe("StageInsightsSidebar", () => {
|
|||
invoked: false,
|
||||
},
|
||||
],
|
||||
permission_level: PermissionLevel.FULL,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
expect(dom).toContain("1/2");
|
||||
expect(dom).toContain("apply_patch");
|
||||
expect(dom).toContain("Apply a unified diff patch");
|
||||
expect(dom).toContain("write");
|
||||
expect(dom).toContain("used");
|
||||
expect(dom).toContain("grep");
|
||||
// Tool description still appears as the row `title` tooltip.
|
||||
expect(dom).toContain("Apply a unified diff patch");
|
||||
expect(dom).toContain("Search file contents");
|
||||
expect(dom).toContain("read");
|
||||
expect(dom).toContain("available");
|
||||
// Permission remains secondary compatibility metadata, not the source of
|
||||
// the tool list.
|
||||
expect(dom).toContain("Full access");
|
||||
});
|
||||
|
||||
test("legacy stages without agent tools keep permission fallback only", () => {
|
||||
const dom = render(makeStage({ permission_level: PermissionLevel.READ_WRITE }), null);
|
||||
expect(dom).toContain("Read/write");
|
||||
expect(dom).not.toContain("apply_patch");
|
||||
});
|
||||
|
||||
test("renders mcp server used/total count, marks invoked servers as 'used'", () => {
|
||||
|
|
@ -266,6 +242,5 @@ describe("StageInsightsSidebar", () => {
|
|||
const dom = render(undefined, null);
|
||||
// sidebar still renders even with no data
|
||||
expect(dom).toContain("Agent");
|
||||
expect(dom).toContain("Unknown");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ Pass `--server` when the MCP client should connect to a specific Fabro server, o
|
|||
| `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. |
|
||||
| `fabro_run_search` | Search runs by ID, parent, workflow, labels, status, archive state, and creation time. |
|
||||
| `fabro_run_get` | Read-only inspection of a run: returns its summary, projection, and pending questions without mutating state. |
|
||||
| `fabro_run_interact` | Control a run: start, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. |
|
||||
| `fabro_run_interact` | Control a run: start, approve, deny, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. |
|
||||
| `fabro_run_gather` | Wait for runs to reach terminal states, returning current state on timeout. |
|
||||
| `fabro_run_pair` | Inspect, start, message, end, or read transcript for a live run pairing session. |
|
||||
| `fabro_run_events` | List, inspect, or search stored events for a run. |
|
||||
|
|
@ -65,6 +65,20 @@ Use `goal` for inline goal text or `goal_file` to read the run goal from a file.
|
|||
|
||||
Run summaries returned by the MCP server include parent metadata. Use `parent_id` on `fabro_run_create` to create a child run, `parent_id` on `fabro_run_search` to list direct children, and the `link_parent` or `unlink_parent` actions on `fabro_run_interact` to change an existing run's parent. See [Child Runs](/execution/child-runs) for the orchestration model.
|
||||
|
||||
Pending runs can be approved or denied through `fabro_run_interact`:
|
||||
|
||||
```json
|
||||
{ "run_id": "nightly", "action": "approve" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "nightly",
|
||||
"action": "deny",
|
||||
"reason": "Not approved for execution"
|
||||
}
|
||||
```
|
||||
|
||||
Use `fabro_run_pair` when an MCP client needs to pair with an active API-mode agent stage. The tool can inspect current pair status, start a pair session for a selected stage, send messages, end the session, and read transcript entries.
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ fabro [OPTIONS] [COMMAND]
|
|||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `fabro approve` | Approve pending workflow runs |
|
||||
| `fabro archive` | Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings |
|
||||
| `fabro artifact` | Inspect and copy run artifacts (screenshots, reports, traces) |
|
||||
| `fabro ask` | Ask Fabro a read-only question about a run |
|
||||
|
|
@ -70,6 +71,7 @@ fabro [OPTIONS] [COMMAND]
|
|||
| `fabro auth` | Manage CLI authentication state |
|
||||
| `fabro completion` | Generate shell completions |
|
||||
| `fabro create` | Create a workflow run (allocate run dir, persist spec) |
|
||||
| `fabro deny` | Deny pending workflow runs |
|
||||
| `fabro discord` | Open the Discord community in the browser |
|
||||
| `fabro docs` | Open the docs website in the browser |
|
||||
| `fabro doctor` | Check environment and integration health |
|
||||
|
|
@ -106,6 +108,26 @@ fabro [OPTIONS] [COMMAND]
|
|||
| `fabro wait` | Block until a workflow run completes |
|
||||
| `fabro workflow` | Workflow operations |
|
||||
|
||||
### `fabro approve`
|
||||
|
||||
Approve pending workflow runs
|
||||
|
||||
```bash
|
||||
fabro approve [OPTIONS] <RUNS>...
|
||||
```
|
||||
|
||||
#### Arguments
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `RUNS` | Run IDs or workflow names to approve |
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
|
||||
### `fabro archive`
|
||||
|
||||
Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings
|
||||
|
|
@ -338,6 +360,27 @@ fabro create [OPTIONS] <WORKFLOW>
|
|||
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
|
||||
| `-v, --verbose` | Enable verbose output |
|
||||
|
||||
### `fabro deny`
|
||||
|
||||
Deny pending workflow runs
|
||||
|
||||
```bash
|
||||
fabro deny [OPTIONS] <RUNS>...
|
||||
```
|
||||
|
||||
#### Arguments
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `RUNS` | Run IDs or workflow names to deny |
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--reason <reason>` | Reason for denying execution |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
|
||||
### `fabro discord`
|
||||
|
||||
Open the Discord community in the browser
|
||||
|
|
|
|||
|
|
@ -393,6 +393,30 @@ pub(crate) struct RunsUnarchiveArgs {
|
|||
pub(crate) runs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunsApproveArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Run IDs or workflow names to approve
|
||||
#[arg(required = true)]
|
||||
pub(crate) runs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunsDenyArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Reason for denying execution
|
||||
#[arg(long)]
|
||||
pub(crate) reason: Option<String>,
|
||||
|
||||
/// Run IDs or workflow names to deny
|
||||
#[arg(required = true)]
|
||||
pub(crate) runs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct EventsArgs {
|
||||
#[command(flatten)]
|
||||
|
|
@ -1151,6 +1175,10 @@ pub(crate) enum RunsCommands {
|
|||
Rm(RunsRemoveArgs),
|
||||
/// Show detailed information about a workflow run
|
||||
Inspect(InspectArgs),
|
||||
/// Approve pending workflow runs.
|
||||
Approve(RunsApproveArgs),
|
||||
/// Deny pending workflow runs.
|
||||
Deny(RunsDenyArgs),
|
||||
/// Mark terminal runs as archived (reviewed, no further action needed).
|
||||
/// Archived runs are hidden from default listings.
|
||||
Archive(RunsArchiveArgs),
|
||||
|
|
@ -1164,6 +1192,8 @@ impl RunsCommands {
|
|||
Self::Ps(_) => "ps",
|
||||
Self::Rm(_) => "rm",
|
||||
Self::Inspect(_) => "inspect",
|
||||
Self::Approve(_) => "approve",
|
||||
Self::Deny(_) => "deny",
|
||||
Self::Archive(_) => "archive",
|
||||
Self::Unarchive(_) => "unarchive",
|
||||
}
|
||||
|
|
|
|||
38
lib/crates/fabro-cli/src/commands/runs/approval.rs
Normal file
38
lib/crates/fabro-cli/src/commands/runs/approval.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use anyhow::Result;
|
||||
use futures::FutureExt as _;
|
||||
|
||||
use super::{RunBatchAction, run_resolved_run_batch};
|
||||
use crate::args::{RunsApproveArgs, RunsDenyArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) async fn approve_command(
|
||||
args: &RunsApproveArgs,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
run_resolved_run_batch(
|
||||
RunBatchAction {
|
||||
past: "approved",
|
||||
json_key: "approved",
|
||||
},
|
||||
&args.runs,
|
||||
&ctx,
|
||||
|client, run_id| client.approve_run(run_id).boxed(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn deny_command(args: &RunsDenyArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let reason = args.reason.clone();
|
||||
run_resolved_run_batch(
|
||||
RunBatchAction {
|
||||
past: "denied",
|
||||
json_key: "denied",
|
||||
},
|
||||
&args.runs,
|
||||
&ctx,
|
||||
move |client, run_id| client.deny_run(run_id, reason.clone()).boxed(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -1,16 +1,25 @@
|
|||
use anyhow::{Result, bail};
|
||||
use anyhow::Result;
|
||||
use futures::FutureExt as _;
|
||||
|
||||
use super::short_run_id;
|
||||
use super::{RunBatchAction, run_resolved_run_batch};
|
||||
use crate::args::{RunsArchiveArgs, RunsUnarchiveArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) async fn archive_command(
|
||||
args: &RunsArchiveArgs,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
run_bulk(Action::Archive, &args.runs, &ctx).await
|
||||
run_resolved_run_batch(
|
||||
RunBatchAction {
|
||||
past: "archived",
|
||||
json_key: "archived",
|
||||
},
|
||||
&args.runs,
|
||||
&ctx,
|
||||
|client, run_id| client.archive_run(run_id).boxed(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn unarchive_command(
|
||||
|
|
@ -18,88 +27,14 @@ pub(crate) async fn unarchive_command(
|
|||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
run_bulk(Action::Unarchive, &args.runs, &ctx).await
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Action {
|
||||
Archive,
|
||||
Unarchive,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn past(self) -> &'static str {
|
||||
match self {
|
||||
Self::Archive => "archived",
|
||||
Self::Unarchive => "unarchived",
|
||||
}
|
||||
}
|
||||
|
||||
fn json_key(self) -> &'static str {
|
||||
self.past()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_bulk(action: Action, identifiers: &[String], ctx: &CommandContext) -> Result<()> {
|
||||
let client = ctx.server().await?;
|
||||
let client = client.as_ref();
|
||||
let json = ctx.json_output();
|
||||
let printer = ctx.printer();
|
||||
let mut had_errors = false;
|
||||
let mut changed = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for identifier in identifiers {
|
||||
let run = match client.resolve_run(identifier).await {
|
||||
Ok(run) => run,
|
||||
Err(err) => {
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "error: {identifier}: {err}");
|
||||
}
|
||||
errors.push(serde_json::json!({
|
||||
"identifier": identifier,
|
||||
"error": err.to_string(),
|
||||
}));
|
||||
had_errors = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let run_id = run.id;
|
||||
let result = match action {
|
||||
Action::Archive => client.archive_run(&run_id).await,
|
||||
Action::Unarchive => client.unarchive_run(&run_id).await,
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let run_id_string = run_id.to_string();
|
||||
changed.push(run_id_string.clone());
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "{}", short_run_id(&run_id_string));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "error: {identifier}: {err}");
|
||||
}
|
||||
errors.push(serde_json::json!({
|
||||
"identifier": identifier,
|
||||
"error": err.to_string(),
|
||||
}));
|
||||
had_errors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert(action.json_key().to_string(), serde_json::json!(changed));
|
||||
body.insert("errors".to_string(), serde_json::json!(errors));
|
||||
print_json_pretty(&serde_json::Value::Object(body))?;
|
||||
}
|
||||
|
||||
if had_errors {
|
||||
bail!("some runs could not be {}", action.past());
|
||||
}
|
||||
Ok(())
|
||||
run_resolved_run_batch(
|
||||
RunBatchAction {
|
||||
past: "unarchived",
|
||||
json_key: "unarchived",
|
||||
},
|
||||
&args.runs,
|
||||
&ctx,
|
||||
|client, run_id| client.unarchive_run(run_id).boxed(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::{Result, bail};
|
||||
use fabro_client::Client;
|
||||
use fabro_types::{Run, RunId};
|
||||
use fabro_util::terminal::Styles;
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::args::RunsCommands;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) mod approval;
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod inspect;
|
||||
pub(crate) mod list;
|
||||
|
|
@ -17,6 +22,8 @@ pub(crate) async fn dispatch(cmd: RunsCommands, base_ctx: &CommandContext) -> Re
|
|||
}
|
||||
RunsCommands::Rm(args) => rm::remove_command(&args, base_ctx).await,
|
||||
RunsCommands::Inspect(args) => inspect::run(&args, base_ctx).await,
|
||||
RunsCommands::Approve(args) => approval::approve_command(&args, base_ctx).await,
|
||||
RunsCommands::Deny(args) => approval::deny_command(&args, base_ctx).await,
|
||||
RunsCommands::Archive(args) => archive::archive_command(&args, base_ctx).await,
|
||||
RunsCommands::Unarchive(args) => archive::unarchive_command(&args, base_ctx).await,
|
||||
}
|
||||
|
|
@ -26,6 +33,80 @@ pub(super) fn short_run_id(id: &str) -> &str {
|
|||
if id.len() > 12 { &id[..12] } else { id }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct RunBatchAction {
|
||||
pub(super) past: &'static str,
|
||||
pub(super) json_key: &'static str,
|
||||
}
|
||||
|
||||
pub(super) async fn run_resolved_run_batch<F>(
|
||||
action: RunBatchAction,
|
||||
identifiers: &[String],
|
||||
ctx: &CommandContext,
|
||||
mut apply: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: for<'a> FnMut(&'a Client, &'a RunId) -> BoxFuture<'a, Result<Run>>,
|
||||
{
|
||||
let client = ctx.server().await?;
|
||||
let client = client.as_ref();
|
||||
let json = ctx.json_output();
|
||||
let printer = ctx.printer();
|
||||
let mut had_errors = false;
|
||||
let mut changed = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for identifier in identifiers {
|
||||
let run = match client.resolve_run(identifier).await {
|
||||
Ok(run) => run,
|
||||
Err(err) => {
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "error: {identifier}: {err}");
|
||||
}
|
||||
errors.push(serde_json::json!({
|
||||
"identifier": identifier,
|
||||
"error": err.to_string(),
|
||||
}));
|
||||
had_errors = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let run_id = run.id;
|
||||
match apply(client, &run_id).await {
|
||||
Ok(_) => {
|
||||
let run_id_string = run_id.to_string();
|
||||
changed.push(run_id_string.clone());
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "{}", short_run_id(&run_id_string));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "error: {identifier}: {err}");
|
||||
}
|
||||
errors.push(serde_json::json!({
|
||||
"identifier": identifier,
|
||||
"error": err.to_string(),
|
||||
}));
|
||||
had_errors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert(action.json_key.to_string(), serde_json::json!(changed));
|
||||
body.insert("errors".to_string(), serde_json::json!(errors));
|
||||
print_json_pretty(&serde_json::Value::Object(body))?;
|
||||
}
|
||||
|
||||
if had_errors {
|
||||
bail!("some runs could not be {}", action.past);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::args::parse_duration;
|
||||
|
|
|
|||
242
lib/crates/fabro-cli/tests/it/cmd/approve.rs
Normal file
242
lib/crates/fabro-cli/tests/it/cmd/approve.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use httpmock::MockServer;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::support::{conflict_error_body, remote_run_summary_json, ulid_filter};
|
||||
use crate::support::unique_run_id;
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["approve", "--help"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Approve pending workflow runs
|
||||
|
||||
Usage: fabro approve [OPTIONS] <RUNS>...
|
||||
|
||||
Arguments:
|
||||
<RUNS>... Run IDs or workflow names to approve
|
||||
|
||||
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 approve_requires_at_least_one_run() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["approve"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
error: the following required arguments were not provided:
|
||||
<RUNS>...
|
||||
|
||||
Usage: fabro approve --no-upgrade-check <RUNS>...
|
||||
|
||||
For more information, try '--help'.
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_resolves_selector_posts_endpoint_and_prints_short_run_id() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let run_id = unique_run_id();
|
||||
let resolve = 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")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let approve = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{run_id}/approve"));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "runnable" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
context.set_http_target(&server.base_url());
|
||||
|
||||
let mut filters = context.filters();
|
||||
filters.push(ulid_filter());
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["approve", "nightly-build"]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
[ULID]
|
||||
");
|
||||
|
||||
resolve.assert();
|
||||
approve.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_json_success_shape() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let run_id = unique_run_id();
|
||||
let resolve = 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")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let approve = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{run_id}/approve"));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "runnable" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
context.set_http_target(&server.base_url());
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["--json", "approve", "nightly-build"])
|
||||
.output()
|
||||
.expect("approve --json should execute");
|
||||
|
||||
assert!(output.status.success());
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("approve JSON should parse");
|
||||
assert_eq!(
|
||||
value["approved"],
|
||||
Value::Array(vec![Value::String(run_id.clone())])
|
||||
);
|
||||
assert_eq!(value["errors"], Value::Array(vec![]));
|
||||
resolve.assert();
|
||||
approve.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_partial_error_attempts_remaining_runs_and_reports_json() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let stale_run = unique_run_id();
|
||||
let good_run = unique_run_id();
|
||||
let stale_resolve = server.mock(|when, then| {
|
||||
when.method("GET")
|
||||
.path("/api/v1/runs/resolve")
|
||||
.query_param("selector", "stale");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&stale_run,
|
||||
"Stale",
|
||||
"stale",
|
||||
"Stale run",
|
||||
&serde_json::json!({ "kind": "succeeded", "reason": "completed" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let good_resolve = server.mock(|when, then| {
|
||||
when.method("GET")
|
||||
.path("/api/v1/runs/resolve")
|
||||
.query_param("selector", "nightly");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&good_run,
|
||||
"Nightly",
|
||||
"nightly",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let stale_approve = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{stale_run}/approve"));
|
||||
then.status(409)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(conflict_error_body("Run is not pending approval."));
|
||||
});
|
||||
let good_approve = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{good_run}/approve"));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&good_run,
|
||||
"Nightly",
|
||||
"nightly",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "runnable" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
context.set_http_target(&server.base_url());
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["--json", "approve", "stale", "nightly"])
|
||||
.output()
|
||||
.expect("approve should execute");
|
||||
|
||||
assert!(!output.status.success(), "mixed batch should exit non-zero");
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("approve JSON should parse");
|
||||
assert_eq!(
|
||||
value["approved"],
|
||||
Value::Array(vec![Value::String(good_run.clone())])
|
||||
);
|
||||
assert_eq!(value["errors"][0]["identifier"], "stale");
|
||||
assert!(
|
||||
value["errors"][0]["error"]
|
||||
.as_str()
|
||||
.is_some_and(|error| error.contains("Run is not pending approval")),
|
||||
"{value}"
|
||||
);
|
||||
stale_resolve.assert();
|
||||
good_resolve.assert();
|
||||
stale_approve.assert();
|
||||
good_approve.assert();
|
||||
}
|
||||
|
|
@ -4,16 +4,10 @@ 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;
|
||||
|
||||
fn ulid_filter() -> (String, String) {
|
||||
(
|
||||
r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(),
|
||||
"[ULID]".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
307
lib/crates/fabro-cli/tests/it/cmd/deny.rs
Normal file
307
lib/crates/fabro-cli/tests/it/cmd/deny.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use httpmock::MockServer;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::support::{conflict_error_body, remote_run_summary_json, ulid_filter};
|
||||
use crate::support::unique_run_id;
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["deny", "--help"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Deny pending workflow runs
|
||||
|
||||
Usage: fabro deny [OPTIONS] <RUNS>...
|
||||
|
||||
Arguments:
|
||||
<RUNS>... Run IDs or workflow names to deny
|
||||
|
||||
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=]
|
||||
--reason <REASON> Reason for denying execution
|
||||
--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 deny_requires_at_least_one_run() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["deny"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
error: the following required arguments were not provided:
|
||||
<RUNS>...
|
||||
|
||||
Usage: fabro deny --no-upgrade-check <RUNS>...
|
||||
|
||||
For more information, try '--help'.
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_resolves_selector_posts_endpoint_and_prints_short_run_id() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let run_id = unique_run_id();
|
||||
let resolve = 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")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let deny = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{run_id}/deny"))
|
||||
.json_body(serde_json::json!({}));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "failed", "reason": "approval_denied" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
context.set_http_target(&server.base_url());
|
||||
|
||||
let mut filters = context.filters();
|
||||
filters.push(ulid_filter());
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["deny", "nightly-build"]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
[ULID]
|
||||
");
|
||||
|
||||
resolve.assert();
|
||||
deny.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_reason_sends_request_body() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let run_id = unique_run_id();
|
||||
let resolve = 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")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let deny = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{run_id}/deny"))
|
||||
.json_body(serde_json::json!({ "reason": "Needs review" }));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "failed", "reason": "approval_denied" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
context.set_http_target(&server.base_url());
|
||||
|
||||
let mut filters = context.filters();
|
||||
filters.push(ulid_filter());
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["deny", "--reason", "Needs review", "nightly-build"]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
[ULID]
|
||||
");
|
||||
|
||||
resolve.assert();
|
||||
deny.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_json_success_shape() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let run_id = unique_run_id();
|
||||
let resolve = 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")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let deny = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{run_id}/deny"))
|
||||
.json_body(serde_json::json!({}));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Nightly Build",
|
||||
"nightly-build",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "failed", "reason": "approval_denied" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
context.set_http_target(&server.base_url());
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["--json", "deny", "nightly-build"])
|
||||
.output()
|
||||
.expect("deny --json should execute");
|
||||
|
||||
assert!(output.status.success());
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("deny JSON should parse");
|
||||
assert_eq!(
|
||||
value["denied"],
|
||||
Value::Array(vec![Value::String(run_id.clone())])
|
||||
);
|
||||
assert_eq!(value["errors"], Value::Array(vec![]));
|
||||
resolve.assert();
|
||||
deny.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_partial_error_attempts_remaining_runs_and_reports_json() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let stale_run = unique_run_id();
|
||||
let good_run = unique_run_id();
|
||||
let stale_resolve = server.mock(|when, then| {
|
||||
when.method("GET")
|
||||
.path("/api/v1/runs/resolve")
|
||||
.query_param("selector", "stale");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&stale_run,
|
||||
"Stale",
|
||||
"stale",
|
||||
"Stale run",
|
||||
&serde_json::json!({ "kind": "succeeded", "reason": "completed" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let good_resolve = server.mock(|when, then| {
|
||||
when.method("GET")
|
||||
.path("/api/v1/runs/resolve")
|
||||
.query_param("selector", "nightly");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&good_run,
|
||||
"Nightly",
|
||||
"nightly",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let stale_deny = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{stale_run}/deny"))
|
||||
.json_body(serde_json::json!({ "reason": "Needs review" }));
|
||||
then.status(409)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(conflict_error_body("Run is not pending approval."));
|
||||
});
|
||||
let good_deny = server.mock(|when, then| {
|
||||
when.method("POST")
|
||||
.path(format!("/api/v1/runs/{good_run}/deny"))
|
||||
.json_body(serde_json::json!({ "reason": "Needs review" }));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&good_run,
|
||||
"Nightly",
|
||||
"nightly",
|
||||
"Nightly run",
|
||||
&serde_json::json!({ "kind": "failed", "reason": "approval_denied" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
context.set_http_target(&server.base_url());
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args([
|
||||
"--json",
|
||||
"deny",
|
||||
"--reason",
|
||||
"Needs review",
|
||||
"stale",
|
||||
"nightly",
|
||||
])
|
||||
.output()
|
||||
.expect("deny should execute");
|
||||
|
||||
assert!(!output.status.success(), "mixed batch should exit non-zero");
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("deny JSON should parse");
|
||||
assert_eq!(
|
||||
value["denied"],
|
||||
Value::Array(vec![Value::String(good_run.clone())])
|
||||
);
|
||||
assert_eq!(value["errors"][0]["identifier"], "stale");
|
||||
assert!(
|
||||
value["errors"][0]["error"]
|
||||
.as_str()
|
||||
.is_some_and(|error| error.contains("Run is not pending approval")),
|
||||
"{value}"
|
||||
);
|
||||
stale_resolve.assert();
|
||||
good_resolve.assert();
|
||||
stale_deny.assert();
|
||||
good_deny.assert();
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ fn help() {
|
|||
dump Export a run's durable state to a directory
|
||||
rm Remove one or more workflow runs
|
||||
inspect Show detailed information about a workflow run
|
||||
approve Approve pending workflow runs
|
||||
deny Deny pending workflow runs
|
||||
archive Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings
|
||||
unarchive Restore archived runs to their prior terminal status
|
||||
model List and test LLM models
|
||||
|
|
|
|||
|
|
@ -468,6 +468,19 @@ async fn stdio_server_initializes_and_lists_run_tools() {
|
|||
.is_some_and(serde_json::Value::is_object),
|
||||
"fabro_run_interact.answer should have an object JSON Schema: {interact_schema}"
|
||||
);
|
||||
assert!(
|
||||
interact_schema
|
||||
.pointer("/properties/reason")
|
||||
.is_some_and(serde_json::Value::is_object),
|
||||
"fabro_run_interact.reason should have an object JSON Schema: {interact_schema}"
|
||||
);
|
||||
let interact_schema_text = interact_schema.to_string();
|
||||
for action in ["approve", "deny"] {
|
||||
assert!(
|
||||
interact_schema_text.contains(&format!("\"{action}\"")),
|
||||
"fabro_run_interact schema should expose action {action}: {interact_schema}"
|
||||
);
|
||||
}
|
||||
let get_schema = tools
|
||||
.iter()
|
||||
.find(|(name, _, _)| name == "fabro_run_get")
|
||||
|
|
@ -1490,6 +1503,79 @@ async fn mcp_interact_actions_resolve_selector_and_call_expected_endpoints() {
|
|||
.expect("MCP client should shut down");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_interact_approval_actions_resolve_selector_and_call_expected_endpoints() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let target_url = format!("{}/api/v1", server.base_url());
|
||||
let target: fabro_client::ServerTarget = target_url.parse().unwrap();
|
||||
seed_dev_token_auth(&context.home_dir, &target, TEST_DEV_TOKEN);
|
||||
let run_id = unique_run_id();
|
||||
let selector = "nightly";
|
||||
let resolve = mock_resolved_run(&server, selector, &run_id);
|
||||
let approve = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path(format!("/api/v1/runs/{run_id}/approve"));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Simple",
|
||||
"simple",
|
||||
"Run tests",
|
||||
&serde_json::json!({ "kind": "runnable" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
let deny = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path(format!("/api/v1/runs/{run_id}/deny"))
|
||||
.json_body(serde_json::json!({
|
||||
"reason": "Not approved for execution"
|
||||
}));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(remote_run_summary_json(
|
||||
&run_id,
|
||||
"Simple",
|
||||
"simple",
|
||||
"Run tests",
|
||||
&serde_json::json!({ "kind": "failed", "reason": "approval_denied" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
));
|
||||
});
|
||||
|
||||
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
|
||||
let approved = call_tool_json(
|
||||
&client,
|
||||
"fabro_run_interact",
|
||||
serde_json::json!({ "run_id": selector, "action": "approve" }),
|
||||
)
|
||||
.await;
|
||||
let denied = call_tool_json(
|
||||
&client,
|
||||
"fabro_run_interact",
|
||||
serde_json::json!({
|
||||
"run_id": selector,
|
||||
"action": "deny",
|
||||
"reason": "Not approved for execution"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(approved["result"]["summary"]["run_id"], run_id);
|
||||
assert_eq!(approved["result"]["summary"]["status"], "runnable");
|
||||
assert_eq!(denied["result"]["summary"]["run_id"], run_id);
|
||||
assert_eq!(denied["result"]["summary"]["status"], "failed");
|
||||
resolve.assert_calls(2);
|
||||
approve.assert();
|
||||
deny.assert();
|
||||
client
|
||||
.shutdown()
|
||||
.await
|
||||
.expect("MCP client should shut down");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_get_resolves_selector_and_returns_summary_projection_and_questions() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
mod approve;
|
||||
mod archive;
|
||||
mod artifact_cp;
|
||||
mod artifact_list;
|
||||
|
|
@ -6,6 +7,7 @@ mod auth;
|
|||
mod cli_reference;
|
||||
mod config;
|
||||
mod create;
|
||||
mod deny;
|
||||
mod diff;
|
||||
mod discord;
|
||||
mod docs;
|
||||
|
|
|
|||
|
|
@ -134,6 +134,27 @@ pub(crate) fn mock_resolved_run<'a>(
|
|||
})
|
||||
}
|
||||
|
||||
/// Snapshot filter that scrubs short (12-char) ULID suffixes from output, used
|
||||
/// when the CLI prints abbreviated run IDs.
|
||||
pub(crate) fn ulid_filter() -> (String, String) {
|
||||
(
|
||||
r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(),
|
||||
"[ULID]".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// JSON 409 error body mirroring the server's batch-error shape, used by mock
|
||||
/// HTTP servers in CLI tests that exercise partial-failure code paths.
|
||||
pub(crate) fn conflict_error_body(detail: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"errors": [{
|
||||
"status": "409",
|
||||
"title": "Conflict",
|
||||
"detail": detail,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn remote_run_summary_json(
|
||||
run_id: &str,
|
||||
workflow_name: &str,
|
||||
|
|
|
|||
|
|
@ -4,16 +4,10 @@ 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;
|
||||
|
||||
fn ulid_filter() -> (String, String) {
|
||||
(
|
||||
r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(),
|
||||
"[ULID]".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -924,6 +924,33 @@ impl Client {
|
|||
convert_type(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn approve_run(&self, run_id: &RunId) -> Result<Run> {
|
||||
let response = self
|
||||
.send_api(
|
||||
|client| async move { client.approve_run().id(run_id.to_string()).send().await },
|
||||
)
|
||||
.await?;
|
||||
convert_type(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn deny_run(&self, run_id: &RunId, reason: Option<String>) -> Result<Run> {
|
||||
let body = types::DenyRunRequest { reason };
|
||||
let response = self
|
||||
.send_api(|client| {
|
||||
let body = body.clone();
|
||||
async move {
|
||||
client
|
||||
.deny_run()
|
||||
.id(run_id.to_string())
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
convert_type(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn interrupt_run(&self, run_id: &RunId) -> Result<()> {
|
||||
self.send_api(|client| async move {
|
||||
client.interrupt_run().id(run_id.to_string()).send().await
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ impl FabroMcpServer {
|
|||
|
||||
#[tool(
|
||||
name = "fabro_run_interact",
|
||||
description = "Control a Fabro run: start, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. Use fabro_run_get for read-only inspection."
|
||||
description = "Control a Fabro run: start, approve, deny, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. Use fabro_run_get for read-only inspection."
|
||||
)]
|
||||
async fn fabro_run_interact(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -8730,6 +8730,7 @@ async fn run_tools_worker_cannot_call_user_only_non_mcp_routes() {
|
|||
|
||||
for (method, path) in [
|
||||
(Method::POST, format!("/runs/{target_run_id}/approve")),
|
||||
(Method::POST, format!("/runs/{target_run_id}/deny")),
|
||||
(Method::GET, format!("/runs/{target_run_id}/timeline")),
|
||||
] {
|
||||
let response = app
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ pub trait FabroToolBackend: Send + Sync {
|
|||
async fn resolve_run(&self, selector: &str) -> anyhow::Result<Run>;
|
||||
async fn retrieve_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
|
||||
async fn start_run(&self, run_id: &RunId, resume: bool) -> anyhow::Result<Run>;
|
||||
async fn approve_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
|
||||
async fn deny_run(&self, run_id: &RunId, reason: Option<String>) -> anyhow::Result<Run>;
|
||||
async fn cancel_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
|
||||
async fn interrupt_run(&self, run_id: &RunId) -> anyhow::Result<()>;
|
||||
async fn steer_run(&self, run_id: &RunId, text: String, interrupt: bool) -> anyhow::Result<()>;
|
||||
|
|
@ -192,7 +194,7 @@ static TOOL_DEFINITIONS: LazyLock<Vec<ToolDefinition>> = LazyLock::new(|| {
|
|||
),
|
||||
tool_definition::<crate::FabroRunInteractParams>(
|
||||
FABRO_RUN_INTERACT_TOOL_NAME,
|
||||
"Control a Fabro run: start, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. Use fabro_run_get for read-only inspection.",
|
||||
"Control a Fabro run: start, approve, deny, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. Use fabro_run_get for read-only inspection.",
|
||||
),
|
||||
tool_definition::<crate::FabroRunGatherParams>(
|
||||
FABRO_RUN_GATHER_TOOL_NAME,
|
||||
|
|
@ -365,6 +367,34 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interact_tool_definition_exposes_approval_schema() {
|
||||
let definition = tool_definitions()
|
||||
.iter()
|
||||
.find(|definition| definition.name == FABRO_RUN_INTERACT_TOOL_NAME)
|
||||
.expect("interact tool should be in the shared catalog");
|
||||
let schema = &definition.parameters;
|
||||
let schema_text = schema.to_string();
|
||||
|
||||
assert!(
|
||||
definition.description.contains("approve") && definition.description.contains("deny"),
|
||||
"interact description should include approval actions: {}",
|
||||
definition.description
|
||||
);
|
||||
for field in ["action", "run_id", "reason"] {
|
||||
assert!(
|
||||
schema.pointer(&format!("/properties/{field}")).is_some(),
|
||||
"interact schema should expose {field}: {schema}"
|
||||
);
|
||||
}
|
||||
for action in ["approve", "deny"] {
|
||||
assert!(
|
||||
schema_text.contains(&format!("\"{action}\"")),
|
||||
"interact schema should expose action {action}: {schema}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_summary_result_includes_parent_metadata() {
|
||||
let parent_id = run_id("01KRBZW4DW0000000000000002");
|
||||
|
|
|
|||
|
|
@ -985,6 +985,14 @@ mod tests {
|
|||
))
|
||||
}
|
||||
|
||||
async fn approve_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn deny_run(&self, _run_id: &RunId, _reason: Option<String>) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn cancel_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,16 @@ impl FabroToolBackend for ClientBackend {
|
|||
self.client.start_run(run_id, resume).await
|
||||
}
|
||||
|
||||
async fn approve_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
|
||||
self.ensure_run_scope(run_id)?;
|
||||
self.client.approve_run(run_id).await
|
||||
}
|
||||
|
||||
async fn deny_run(&self, run_id: &RunId, reason: Option<String>) -> anyhow::Result<Run> {
|
||||
self.ensure_run_scope(run_id)?;
|
||||
self.client.deny_run(run_id, reason).await
|
||||
}
|
||||
|
||||
async fn cancel_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
|
||||
self.ensure_run_scope(run_id)?;
|
||||
self.client.cancel_run(run_id).await
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ use super::common::{FabroToolBackend, ToolError, ToolResult};
|
|||
pub enum RunInteractAction {
|
||||
Get,
|
||||
Start,
|
||||
Approve,
|
||||
Deny,
|
||||
Message,
|
||||
/// Cancel the active steerable agent's current round and park it
|
||||
/// waiting for a later `message`. The run sits idle until you follow up
|
||||
|
|
@ -35,6 +37,7 @@ pub struct FabroRunInteractParams {
|
|||
pub action: RunInteractAction,
|
||||
pub run_id: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub interrupt: Option<bool>,
|
||||
pub question_id: Option<String>,
|
||||
|
|
@ -114,6 +117,10 @@ pub struct ValidatedInteractRun {
|
|||
pub enum ValidatedInteractAction {
|
||||
Get,
|
||||
Start,
|
||||
Approve,
|
||||
Deny {
|
||||
reason: Option<String>,
|
||||
},
|
||||
Message {
|
||||
message: String,
|
||||
interrupt: bool,
|
||||
|
|
@ -134,10 +141,18 @@ pub enum ValidatedInteractAction {
|
|||
}
|
||||
|
||||
impl ValidatedInteractAction {
|
||||
/// Actions that may only be performed by a human user, never by a
|
||||
/// workflow-agent through its own `fabro_tools` MCP surface.
|
||||
pub fn requires_user(&self) -> bool {
|
||||
matches!(self, Self::Approve | Self::Deny { .. })
|
||||
}
|
||||
|
||||
fn action(&self) -> RunInteractAction {
|
||||
match self {
|
||||
Self::Get => RunInteractAction::Get,
|
||||
Self::Start => RunInteractAction::Start,
|
||||
Self::Approve => RunInteractAction::Approve,
|
||||
Self::Deny { .. } => RunInteractAction::Deny,
|
||||
Self::Message { .. } => RunInteractAction::Message,
|
||||
Self::Interrupt => RunInteractAction::Interrupt,
|
||||
Self::Cancel => RunInteractAction::Cancel,
|
||||
|
|
@ -158,9 +173,15 @@ impl TryFrom<FabroRunInteractParams> for ValidatedInteractRun {
|
|||
if params.run_id.trim().is_empty() {
|
||||
return Err(ToolError::message("run_id is required"));
|
||||
}
|
||||
let reason = normalize_optional_text(params.reason.as_deref());
|
||||
if !matches!(params.action, RunInteractAction::Deny) && reason.is_some() {
|
||||
return Err(ToolError::message("reason is only valid for action deny"));
|
||||
}
|
||||
let action = match params.action {
|
||||
RunInteractAction::Get => ValidatedInteractAction::Get,
|
||||
RunInteractAction::Start => ValidatedInteractAction::Start,
|
||||
RunInteractAction::Approve => ValidatedInteractAction::Approve,
|
||||
RunInteractAction::Deny => ValidatedInteractAction::Deny { reason },
|
||||
RunInteractAction::Message => {
|
||||
let Some(message) = params
|
||||
.message
|
||||
|
|
@ -249,6 +270,20 @@ pub async fn interact_run(
|
|||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::Approve => {
|
||||
let summary = backend
|
||||
.approve_run(&run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::Deny { reason } => {
|
||||
let summary = backend
|
||||
.deny_run(&run_id, reason)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::Message { message, interrupt } => {
|
||||
backend
|
||||
.steer_run(&run_id, message.clone(), interrupt)
|
||||
|
|
@ -401,12 +436,42 @@ fn text_answer_request(text: String) -> types::SubmitAnswerRequest {
|
|||
.into()
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_types::{
|
||||
EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunProjection,
|
||||
RunStatus, RunTimestamps, WorkflowRef,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn interact_params(action: RunInteractAction) -> FabroRunInteractParams {
|
||||
FabroRunInteractParams {
|
||||
action,
|
||||
run_id: "run_123".to_string(),
|
||||
parent_id: None,
|
||||
message: None,
|
||||
interrupt: None,
|
||||
question_id: None,
|
||||
answer: None,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_payloads_map_to_submit_answer_wire_json() {
|
||||
let cases = [
|
||||
|
|
@ -450,6 +515,7 @@ mod tests {
|
|||
interrupt: None,
|
||||
question_id: Some("question-1".to_string()),
|
||||
answer: Some(json!({ "value": "yes" }).into()),
|
||||
reason: None,
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
|
|
@ -467,6 +533,7 @@ mod tests {
|
|||
interrupt: None,
|
||||
question_id: None,
|
||||
answer: None,
|
||||
reason: None,
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
|
|
@ -489,6 +556,7 @@ mod tests {
|
|||
interrupt: None,
|
||||
question_id: None,
|
||||
answer: None,
|
||||
reason: None,
|
||||
})
|
||||
.expect("unlink_parent should not require parent_id");
|
||||
|
||||
|
|
@ -509,6 +577,7 @@ mod tests {
|
|||
interrupt: None,
|
||||
question_id: None,
|
||||
answer: None,
|
||||
reason: None,
|
||||
})
|
||||
.expect("interrupt should validate with only run_id");
|
||||
|
||||
|
|
@ -518,4 +587,279 @@ mod tests {
|
|||
ValidatedInteractAction::Interrupt
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_action_requires_only_run_id() {
|
||||
let validated = ValidatedInteractRun::try_from(interact_params(RunInteractAction::Approve))
|
||||
.expect("approve should validate with only run_id");
|
||||
|
||||
assert_eq!(validated.run_id, "run_123");
|
||||
assert!(matches!(validated.action, ValidatedInteractAction::Approve));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_action_normalizes_optional_reason() {
|
||||
for (raw_reason, expected) in [
|
||||
(None, None),
|
||||
(
|
||||
Some(" Needs review ".to_string()),
|
||||
Some("Needs review".to_string()),
|
||||
),
|
||||
(Some(" ".to_string()), None),
|
||||
] {
|
||||
let mut params = interact_params(RunInteractAction::Deny);
|
||||
params.reason = raw_reason;
|
||||
|
||||
let validated =
|
||||
ValidatedInteractRun::try_from(params).expect("deny should validate reason");
|
||||
|
||||
match validated.action {
|
||||
ValidatedInteractAction::Deny { reason } => assert_eq!(reason, expected),
|
||||
other => panic!("expected deny action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonblank_reason_is_rejected_for_non_deny_actions() {
|
||||
let mut params = interact_params(RunInteractAction::Approve);
|
||||
params.reason = Some("because".to_string());
|
||||
|
||||
let err = ValidatedInteractRun::try_from(params).unwrap_err();
|
||||
|
||||
assert!(err.as_str().contains("reason"));
|
||||
assert!(err.as_str().contains("deny"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_dispatches_to_backend_and_returns_summary() {
|
||||
let run_id = run_id("01KRBZW5C00000000000000001");
|
||||
let backend = Arc::new(MockInteractBackend::new(run_id));
|
||||
|
||||
let result = interact_run(backend.clone(), ValidatedInteractRun {
|
||||
run_id: "nightly".to_string(),
|
||||
action: ValidatedInteractAction::Approve,
|
||||
})
|
||||
.await
|
||||
.expect("approve should dispatch");
|
||||
|
||||
assert!(matches!(result.action, RunInteractAction::Approve));
|
||||
assert_eq!(result.result["summary"]["run_id"], run_id.to_string());
|
||||
assert_eq!(backend.approved.lock().unwrap().as_slice(), &[run_id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deny_dispatches_to_backend_with_reason_and_returns_summary() {
|
||||
let run_id = run_id("01KRBZW5C00000000000000001");
|
||||
let backend = Arc::new(MockInteractBackend::new(run_id));
|
||||
|
||||
let result = interact_run(backend.clone(), ValidatedInteractRun {
|
||||
run_id: "nightly".to_string(),
|
||||
action: ValidatedInteractAction::Deny {
|
||||
reason: Some("Needs review".to_string()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("deny should dispatch");
|
||||
|
||||
assert!(matches!(result.action, RunInteractAction::Deny));
|
||||
assert_eq!(result.result["summary"]["run_id"], run_id.to_string());
|
||||
assert_eq!(backend.denied.lock().unwrap().as_slice(), &[(
|
||||
run_id,
|
||||
Some("Needs review".to_string())
|
||||
)]);
|
||||
}
|
||||
|
||||
fn run_id(raw: &str) -> RunId {
|
||||
raw.parse().expect("test run id should parse")
|
||||
}
|
||||
|
||||
fn run_with_status(run_id: RunId, status: RunStatus) -> Run {
|
||||
Run {
|
||||
id: run_id,
|
||||
parent_id: None,
|
||||
children_count: 0,
|
||||
title: "Test run".to_string(),
|
||||
goal: "Test run".to_string(),
|
||||
workflow: WorkflowRef {
|
||||
slug: Some("simple".to_string()),
|
||||
name: Some("Simple".to_string()),
|
||||
graph_name: None,
|
||||
node_count: 0,
|
||||
edge_count: 0,
|
||||
},
|
||||
automation: None,
|
||||
repository: None,
|
||||
created_by: None,
|
||||
origin: RunOrigin::default(),
|
||||
labels: HashMap::new(),
|
||||
lifecycle: RunLifecycle {
|
||||
status,
|
||||
approval: None,
|
||||
pending_control: None,
|
||||
queue_position: None,
|
||||
error: None,
|
||||
archived: false,
|
||||
archived_at: None,
|
||||
},
|
||||
sandbox: None,
|
||||
models: Vec::new(),
|
||||
source_directory: None,
|
||||
timestamps: RunTimestamps {
|
||||
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
|
||||
started_at: None,
|
||||
last_event_at: None,
|
||||
completed_at: None,
|
||||
},
|
||||
timing: None,
|
||||
billing: None,
|
||||
size: fabro_types::RunSize::default(),
|
||||
ask_fabro: fabro_types::AskFabro::default(),
|
||||
diff: None,
|
||||
pull_request: None,
|
||||
current_question: None,
|
||||
superseded_by: None,
|
||||
retried_from: None,
|
||||
links: RunLinks { web: None },
|
||||
}
|
||||
}
|
||||
|
||||
struct MockInteractBackend {
|
||||
run_id: RunId,
|
||||
approved: Mutex<Vec<RunId>>,
|
||||
denied: Mutex<Vec<(RunId, Option<String>)>>,
|
||||
}
|
||||
|
||||
impl MockInteractBackend {
|
||||
fn new(run_id: RunId) -> Self {
|
||||
Self {
|
||||
run_id,
|
||||
approved: Mutex::new(Vec::new()),
|
||||
denied: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FabroToolBackend for MockInteractBackend {
|
||||
async fn create_run_from_spec(
|
||||
&self,
|
||||
_spec: &crate::ValidatedCreateRunSpec,
|
||||
_cwd: &Path,
|
||||
_user_settings_path: &Path,
|
||||
_parent_id: Option<RunId>,
|
||||
) -> anyhow::Result<RunId> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn resolve_run(&self, selector: &str) -> anyhow::Result<Run> {
|
||||
assert_eq!(selector, "nightly");
|
||||
Ok(run_with_status(self.run_id, RunStatus::Pending {
|
||||
reason: fabro_types::PendingReason::ApprovalRequired,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn retrieve_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn start_run(&self, _run_id: &RunId, _resume: bool) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn approve_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
|
||||
self.approved.lock().unwrap().push(*run_id);
|
||||
Ok(run_with_status(*run_id, RunStatus::Runnable))
|
||||
}
|
||||
|
||||
async fn deny_run(&self, run_id: &RunId, reason: Option<String>) -> anyhow::Result<Run> {
|
||||
self.denied.lock().unwrap().push((*run_id, reason));
|
||||
Ok(run_with_status(*run_id, RunStatus::Failed {
|
||||
reason: FailureReason::ApprovalDenied,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn cancel_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn interrupt_run(&self, _run_id: &RunId) -> anyhow::Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn steer_run(
|
||||
&self,
|
||||
_run_id: &RunId,
|
||||
_text: String,
|
||||
_interrupt: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn archive_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn unarchive_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_store_runs(&self) -> anyhow::Result<Vec<Run>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_store_runs_by_parent(&self, _parent_id: RunId) -> anyhow::Result<Vec<Run>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn link_run_parent(
|
||||
&self,
|
||||
_child_id: &RunId,
|
||||
_parent_id: &RunId,
|
||||
) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn unlink_run_parent(&self, _child_id: &RunId) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn get_run_state(&self, _run_id: &RunId) -> anyhow::Result<RunProjection> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_run_events(
|
||||
&self,
|
||||
_run_id: &RunId,
|
||||
_after: Option<u32>,
|
||||
_limit: Option<usize>,
|
||||
) -> anyhow::Result<Vec<EventEnvelope>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_run_events_until(
|
||||
&self,
|
||||
_run_id: &RunId,
|
||||
_after: Option<u32>,
|
||||
_limit: usize,
|
||||
) -> anyhow::Result<Vec<EventEnvelope>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_run_questions(
|
||||
&self,
|
||||
_run_id: &RunId,
|
||||
) -> anyhow::Result<Vec<types::ApiQuestion>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn submit_run_answer(
|
||||
&self,
|
||||
_run_id: &RunId,
|
||||
_question_id: &str,
|
||||
_body: types::SubmitAnswerRequest,
|
||||
) -> anyhow::Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,11 +287,13 @@ async fn execute_fabro_run_tool(
|
|||
}
|
||||
fabro_tool::FABRO_RUN_INTERACT_TOOL_NAME => {
|
||||
let params = parse_fabro_tool_args::<fabro_tool::FabroRunInteractParams>(name, args)?;
|
||||
let result = fabro_tool::interact_run(
|
||||
Arc::clone(&services.backend),
|
||||
fabro_tool::ValidatedInteractRun::try_from(params)?,
|
||||
)
|
||||
.await?;
|
||||
let validated = fabro_tool::ValidatedInteractRun::try_from(params)?;
|
||||
if validated.action.requires_user() {
|
||||
return Err(fabro_tool::ToolError::message(
|
||||
"Run approval must be performed by a user through the API, CLI, web UI, or human MCP server.",
|
||||
));
|
||||
}
|
||||
let result = fabro_tool::interact_run(Arc::clone(&services.backend), validated).await?;
|
||||
let summary = fabro_tool::interact_run_text(&result);
|
||||
render_fabro_tool_result(&summary, &result)
|
||||
}
|
||||
|
|
@ -1539,8 +1541,8 @@ mod tests {
|
|||
use fabro_llm::{Error as LlmError, ProviderErrorDetail, ProviderErrorKind};
|
||||
use fabro_tool::FabroToolBackend;
|
||||
use fabro_types::{
|
||||
EventEnvelope, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunPairStatusResponse,
|
||||
RunProjection, RunStatus, RunTimestamps, SuccessReason, WorkflowRef,
|
||||
EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin,
|
||||
RunPairStatusResponse, RunProjection, RunStatus, RunTimestamps, SuccessReason, WorkflowRef,
|
||||
};
|
||||
use fabro_vault::{SecretType, Vault};
|
||||
use futures::stream;
|
||||
|
|
@ -1947,6 +1949,38 @@ reasoning = false
|
|||
]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_run_interact_rejects_approval_actions_before_backend_dispatch() {
|
||||
for action in ["approve", "deny"] {
|
||||
let (services, backend) = fabro_run_tool_services();
|
||||
let mut registry = ToolRegistry::new();
|
||||
register_fabro_run_tools(&mut registry, &services);
|
||||
let tool = registry
|
||||
.get(fabro_tool::FABRO_RUN_INTERACT_TOOL_NAME)
|
||||
.expect("interact tool should be registered");
|
||||
|
||||
let err = (tool.executor)(
|
||||
serde_json::json!({
|
||||
"run_id": child_run_id().to_string(),
|
||||
"action": action
|
||||
}),
|
||||
tool_context(),
|
||||
)
|
||||
.await
|
||||
.expect_err("workflow agents must not approve or deny runs");
|
||||
|
||||
assert!(err.contains("must be performed by a user"), "{err}");
|
||||
assert!(
|
||||
backend.approved_run_ids.lock().unwrap().is_empty(),
|
||||
"approve backend should not be called for {action}"
|
||||
);
|
||||
assert!(
|
||||
backend.denied_run_ids.lock().unwrap().is_empty(),
|
||||
"deny backend should not be called for {action}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_run_pair_dispatches_to_shared_backend() {
|
||||
let (services, backend) = fabro_run_tool_services();
|
||||
|
|
@ -1978,6 +2012,8 @@ reasoning = false
|
|||
child_id: child_run_id(),
|
||||
created_parent_ids: Mutex::new(Vec::new()),
|
||||
started_run_ids: Mutex::new(Vec::new()),
|
||||
approved_run_ids: Mutex::new(Vec::new()),
|
||||
denied_run_ids: Mutex::new(Vec::new()),
|
||||
pair_status_run_ids: Mutex::new(Vec::new()),
|
||||
});
|
||||
let services = FabroRunToolServices {
|
||||
|
|
@ -2078,6 +2114,8 @@ reasoning = false
|
|||
child_id: RunId,
|
||||
created_parent_ids: Mutex<Vec<Option<RunId>>>,
|
||||
started_run_ids: Mutex<Vec<RunId>>,
|
||||
approved_run_ids: Mutex<Vec<RunId>>,
|
||||
denied_run_ids: Mutex<Vec<RunId>>,
|
||||
pair_status_run_ids: Mutex<Vec<RunId>>,
|
||||
}
|
||||
|
||||
|
|
@ -2118,6 +2156,28 @@ reasoning = false
|
|||
))
|
||||
}
|
||||
|
||||
async fn approve_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
|
||||
self.approved_run_ids.lock().unwrap().push(*run_id);
|
||||
Ok(run_with_status(
|
||||
*run_id,
|
||||
Some(current_run_id()),
|
||||
0,
|
||||
RunStatus::Runnable,
|
||||
))
|
||||
}
|
||||
|
||||
async fn deny_run(&self, run_id: &RunId, _reason: Option<String>) -> anyhow::Result<Run> {
|
||||
self.denied_run_ids.lock().unwrap().push(*run_id);
|
||||
Ok(run_with_status(
|
||||
*run_id,
|
||||
Some(current_run_id()),
|
||||
0,
|
||||
RunStatus::Failed {
|
||||
reason: FailureReason::ApprovalDenied,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn cancel_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
|
||||
unreachable!()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue