From d36f3a421c073fc54f4cae5b1ac26c7b2e242982 Mon Sep 17 00:00:00 2001 From: Thormatt Date: Fri, 12 Jun 2026 12:37:23 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20orc=20propose=20=E2=80=94=20the=20?= =?UTF-8?q?approval=20queue's=20front=20door?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run.propose() was Python-only, so nothing outside a skill could stage an action: the approval queue had a human review surface but no external producer surface. orc propose routes a CLI-originated action through the exact guarded path skills use (executor existence, params schema, workspace allow-list — all checked before anything is enqueued) inside a traced run, and prints the human next steps or machine-readable JSON. Denials come back actionable: unknown executors list the known ids, allow-list rejections print the exact config.toml snippet, schema violations print the executor's schema. approve list gains --json so scripts can check the queue without scraping the rich table. Co-Authored-By: Claude Fable 5 --- README.md | 23 ++- src/orc/cli.py | 2 + src/orc/cli_commands/approve.py | 26 +++- src/orc/cli_commands/propose.py | 119 ++++++++++++++ tests/unit/test_approval_queue.py | 25 +++ tests/unit/test_propose_cli.py | 247 ++++++++++++++++++++++++++++++ 6 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 src/orc/cli_commands/propose.py create mode 100644 tests/unit/test_propose_cli.py diff --git a/README.md b/README.md index 6ea3db0..e577e94 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,33 @@ orc research "" [-w ] corpus-grounded synthesis with citations orc trace show full trace JSON orc trace list [-w ] recent runs orc replay [--live] re-execute a recorded run -orc approve list [-w ] list pending approval items +orc propose --params stage an action for human approval +orc approve list [-w ] [--json] list pending approval items orc approve accept [--note] accept a pending recommendation orc approve reject [--note] reject one orc mcp serve start the MCP stdio server ``` +### Propose from the CLI + +Anything that would touch the outside world goes through the approval queue — +including from the command line: + +```bash +# one-time: enable the executor for the workspace (deny-by-default) +cat >> ~/.orc/config.toml <<'TOML' +[workspace.research.effects] +allowed = ["fs.write_file"] +TOML + +orc propose fs.write_file \ + --params '{"path": "summary.md", "content": "..."}' \ + --summary "publish verified summary" -w research +# -> approval pending; then: +orc approve accept -w research +orc execute -w research # lands in ~/.orc/workspaces/research/out/ +``` + ## Architecture ``` diff --git a/src/orc/cli.py b/src/orc/cli.py index 5066458..e21657c 100644 --- a/src/orc/cli.py +++ b/src/orc/cli.py @@ -6,6 +6,7 @@ from orc.cli_commands import execute as execute_cmd from orc.cli_commands import ingest as ingest_cmd from orc.cli_commands import mcp as mcp_cmd +from orc.cli_commands import propose as propose_cmd from orc.cli_commands import replay as replay_cmd from orc.cli_commands import research as research_cmd from orc.cli_commands import search as search_cmd @@ -29,6 +30,7 @@ def main() -> None: main.add_command(trace_cmd.trace_group) main.add_command(replay_cmd.replay_command) main.add_command(approve_cmd.approve_group) +main.add_command(propose_cmd.propose_command) main.add_command(execute_cmd.execute_command) main.add_command(worker_cmd.worker_command) main.add_command(audit_cmd.audit_group) diff --git a/src/orc/cli_commands/approve.py b/src/orc/cli_commands/approve.py index 8f76ae0..c548bd7 100644 --- a/src/orc/cli_commands/approve.py +++ b/src/orc/cli_commands/approve.py @@ -41,7 +41,8 @@ def approve_group() -> None: help="Filter by status (default: pending)", ) @click.option("--limit", type=int, default=20) -def list_command(workspace: str | None, status: str, limit: int) -> None: +@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output") +def list_command(workspace: str | None, status: str, limit: int, as_json: bool) -> None: """List approvals.""" try: ws = ws_module.resolve(workspace) @@ -50,6 +51,29 @@ def list_command(workspace: str | None, status: str, limit: int) -> None: items = approval_module.list_approvals( ws.name, status=None if status == "all" else status, limit=limit ) + if as_json: + # Plain echo, never rich: scripts (and baton) parse this. + click.echo( + json_lib.dumps( + [ + { + "approval_id": a.approval_id, + "status": a.status, + "approvers_required": a.approvers_required, + "accept_count": a.accept_count, + "reject_count": a.reject_count, + "directive": a.directive, + "skill": a.skill, + "summary": a.summary, + "source_run_id": a.source_run_id, + "created_at": a.created_at, + } + for a in items + ], + indent=2, + ) + ) + return if not items: console.print(f"[dim]No approvals with status={status} in {ws.name}[/dim]") return diff --git a/src/orc/cli_commands/propose.py b/src/orc/cli_commands/propose.py new file mode 100644 index 0000000..c854048 --- /dev/null +++ b/src/orc/cli_commands/propose.py @@ -0,0 +1,119 @@ +"""`orc propose ` — the approval queue's front door. + +Stages an action from the command line through the same guarded path skills use +(`Run.propose`): executor existence, params schema, and the workspace allow-list +are all checked *before* anything is enqueued. The CLI never executes — it only +proposes; a human decision plus `orc execute`/`orc worker` carries it out. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import click + +from orc import effects +from orc.errors import WorkspaceNotFoundError +from orc.paths import config_path +from orc.runs import open_run +from orc.storage import workspace as ws_module + + +def _load_params(raw: str) -> dict[str, Any]: + # curl-style convention: @ means "read the JSON from this file". + if raw.startswith("@"): + path = Path(raw[1:]) + if not path.is_file(): + raise click.ClickException(f"Params file not found: {path}") + raw = path.read_text() + try: + params = json.loads(raw) + except json.JSONDecodeError as exc: + raise click.ClickException(f"--params is not valid JSON: {exc}") from exc + if not isinstance(params, dict): + raise click.ClickException("--params must be a JSON object, not a list or scalar") + return params + + +@click.command("propose") +@click.argument("executor_id") +@click.option("--params", "params_raw", required=True, help="JSON object, or @/path/to/file.json") +@click.option("--summary", required=True, help="One-line summary shown to approvers") +@click.option("--workspace", "-w", default=None, help="Workspace name (env: ORC_DEFAULT_WORKSPACE)") +@click.option("--approvers", type=int, default=1, show_default=True, help="Accepts required") +@click.option("--idempotency-key", default=None, help="Dedupe key for execution") +@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output") +def propose_command( + executor_id: str, + params_raw: str, + summary: str, + workspace: str | None, + approvers: int, + idempotency_key: str | None, + as_json: bool, +) -> None: + """Propose an action for human approval. + + Note: `orc replay` of a propose-run is unsupported — the "effects" directive + is not in the directive registry, and replaying a proposal is semantically + wrong (it would silently stage a duplicate effect). + """ + params = _load_params(params_raw) + try: + ws = ws_module.resolve(workspace) + except WorkspaceNotFoundError as exc: + raise click.ClickException(str(exc)) from exc + + with open_run( + ws, + directive="effects", + skill="cli.propose", + inputs={"executor": executor_id, "summary": summary, "params": params}, + ) as run: + try: + approval_id = run.propose( + executor=executor_id, + params=params, + summary=summary, + idempotency_key=idempotency_key, + approvers_required=approvers, + ) + except effects.ExecutorNotFoundError as exc: + known = ", ".join(sorted(e.id for e in effects.all_executors())) + raise click.ClickException(f"{exc}. Known executors: {known}") from exc + except effects.ExecutorNotAllowedError as exc: + raise click.ClickException( + f"{exc}\n" + f"Enable it by adding to {config_path()}:\n" + f"[workspace.{ws.name}.effects]\n" + f'allowed = ["{executor_id}"]' + ) from exc + except effects.ActionValidationError as exc: + schema = json.dumps(effects.get(executor_id).params_schema, indent=2) + raise click.ClickException( + f"Invalid params for {executor_id}: {exc}\nSchema:\n{schema}" + ) from exc + run.close(output={"approval_id": approval_id}) + run_id = run.run_id + + if as_json: + click.echo( + json.dumps( + { + "approval_id": approval_id, + "run_id": run_id, + "workspace": ws.name, + "executor": executor_id, + "status": "pending", + }, + indent=2, + ) + ) + else: + click.echo(f"Proposed {executor_id}: approval {approval_id} pending") + click.echo("Next steps:") + click.echo(f" orc approve show {approval_id} -w {ws.name}") + click.echo(f" orc approve accept {approval_id} -w {ws.name}") + click.echo(f" orc execute {approval_id} -w {ws.name}") diff --git a/tests/unit/test_approval_queue.py b/tests/unit/test_approval_queue.py index eeea085..c15c227 100644 --- a/tests/unit/test_approval_queue.py +++ b/tests/unit/test_approval_queue.py @@ -353,3 +353,28 @@ def test_backward_compat_default_single_approver(orc_home: Path) -> None: decided = approval_module.accept(name, aid, decided_by="alice") assert decided.status == "approved" assert decided.progress == "1/1" + + +def test_approve_list_json_emits_machine_readable_array(orc_home: Path) -> None: + """Scripts (and baton) need a parseable pending check, not a rich table.""" + import json as json_lib + + name = _seed_workspace(orc_home) + approval_module.enqueue( + name, + directive="research", + skill="t", + source_run_id="01HXYZ123", + summary="machine readable", + payload={}, + ) + + result = CliRunner().invoke(main, ["approve", "list", "-w", name, "--json"]) + assert result.exit_code == 0, result.output + items = json_lib.loads(result.output) + assert isinstance(items, list) and len(items) == 1 + item = items[0] + assert item["status"] == "pending" + assert item["summary"] == "machine readable" + assert {"approval_id", "approvers_required", "accept_count", "reject_count", + "directive", "skill", "source_run_id", "created_at"} <= set(item) diff --git a/tests/unit/test_propose_cli.py b/tests/unit/test_propose_cli.py new file mode 100644 index 0000000..2b3c36b --- /dev/null +++ b/tests/unit/test_propose_cli.py @@ -0,0 +1,247 @@ +"""`orc propose` — the approval queue's front door. + +Stages a validated, allow-listed action from the command line so a human (or a +script) can drive the propose -> approve -> execute loop without writing a skill. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from orc.cli import main +from orc.queue import approval as q +from orc.storage import workspace as ws_module + + +def _allow(orc_home: Path, workspace: str, executor_ids: list[str]) -> None: + quoted = ", ".join(f'"{e}"' for e in executor_ids) + (orc_home / "config.toml").write_text( + f"[workspace.{workspace}.effects]\nallowed = [{quoted}]\n" + ) + + +def test_propose_json_emits_pending_approval(orc_home: Path) -> None: + _allow(orc_home, "research", ["fs.write_file"]) + ws_module.create("research") + + result = CliRunner().invoke( + main, + [ + "propose", + "fs.write_file", + "--params", + '{"path": "out.txt", "content": "hi"}', + "--summary", + "write out.txt", + "-w", + "research", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "pending" + assert payload["workspace"] == "research" + assert payload["executor"] == "fs.write_file" + + appr = q.get("research", payload["approval_id"]) + assert appr.status == "pending" + assert appr.proposed_action["executor"] == "fs.write_file" + assert appr.proposed_action["params"] == {"path": "out.txt", "content": "hi"} + assert appr.source_run_id == payload["run_id"] + + +def test_propose_reads_params_from_file(orc_home: Path, tmp_path: Path) -> None: + _allow(orc_home, "research", ["fs.write_file"]) + ws_module.create("research") + params_file = tmp_path / "params.json" + params_file.write_text('{"path": "from-file.txt", "content": "filed"}') + + result = CliRunner().invoke( + main, + [ + "propose", + "fs.write_file", + "--params", + f"@{params_file}", + "--summary", + "write from file", + "-w", + "research", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + appr = q.get("research", payload["approval_id"]) + assert appr.proposed_action["params"] == {"path": "from-file.txt", "content": "filed"} + + +def test_propose_errors_on_missing_params_file(orc_home: Path, tmp_path: Path) -> None: + _allow(orc_home, "research", ["fs.write_file"]) + ws_module.create("research") + + result = CliRunner().invoke( + main, + [ + "propose", + "fs.write_file", + "--params", + f"@{tmp_path / 'nope.json'}", + "--summary", + "x", + "-w", + "research", + ], + ) + + assert result.exit_code != 0 + assert "nope.json" in result.output + + +def test_propose_human_output_shows_next_steps(orc_home: Path) -> None: + _allow(orc_home, "research", ["fs.write_file"]) + ws_module.create("research") + + result = CliRunner().invoke( + main, + [ + "propose", + "fs.write_file", + "--params", + '{"path": "out.txt", "content": "hi"}', + "--summary", + "write out.txt", + "-w", + "research", + ], + ) + + assert result.exit_code == 0, result.output + [appr] = q.list_approvals("research") + assert appr.approval_id in result.output + assert f"orc approve show {appr.approval_id} -w research" in result.output + assert f"orc approve accept {appr.approval_id} -w research" in result.output + assert f"orc execute {appr.approval_id} -w research" in result.output + + +def test_propose_unknown_executor_lists_known_ids(orc_home: Path) -> None: + ws_module.create("research") + + result = CliRunner().invoke( + main, + ["propose", "no.such", "--params", "{}", "--summary", "x", "-w", "research"], + ) + + assert result.exit_code != 0 + assert "no.such" in result.output + assert "Known executors:" in result.output + assert "fs.write_file" in result.output + + +def test_propose_not_allowed_shows_config_snippet(orc_home: Path) -> None: + # fs.write_file exists but is not enabled for this workspace -> deny, + # with copy-pasteable instructions to enable it. + ws_module.create("research") + + result = CliRunner().invoke( + main, + [ + "propose", + "fs.write_file", + "--params", + '{"path": "p", "content": "c"}', + "--summary", + "x", + "-w", + "research", + ], + ) + + assert result.exit_code != 0 + assert "config.toml" in result.output + assert "[workspace.research.effects]" in result.output + assert 'allowed = ["fs.write_file"]' in result.output + + +def test_propose_invalid_params_mentions_missing_field(orc_home: Path) -> None: + _allow(orc_home, "research", ["fs.write_file"]) + ws_module.create("research") + + result = CliRunner().invoke( + main, + [ + "propose", + "fs.write_file", + "--params", + '{"path": "p"}', + "--summary", + "x", + "-w", + "research", + ], + ) + + assert result.exit_code != 0 + assert "Invalid params for fs.write_file" in result.output + assert "content" in result.output # the missing required field + assert '"required"' in result.output # the schema is shown + + +def test_propose_rejects_malformed_params_json(orc_home: Path) -> None: + _allow(orc_home, "research", ["fs.write_file"]) + ws_module.create("research") + result = CliRunner().invoke( + main, + ["propose", "fs.write_file", "--params", "{not json", "--summary", "s", "-w", "research"], + ) + assert result.exit_code != 0 + assert "JSON" in result.output + assert q.list_approvals("research") == [] + + +def test_propose_rejects_non_object_params(orc_home: Path) -> None: + _allow(orc_home, "research", ["fs.write_file"]) + ws_module.create("research") + result = CliRunner().invoke( + main, + ["propose", "fs.write_file", "--params", "[1, 2]", "--summary", "s", "-w", "research"], + ) + assert result.exit_code != 0 + assert "JSON object" in result.output + + +def test_propose_unknown_workspace_fails_cleanly(orc_home: Path) -> None: + result = CliRunner().invoke( + main, + ["propose", "fs.write_file", "--params", "{}", "--summary", "s", "-w", "ghost"], + ) + assert result.exit_code != 0 + assert "ghost" in result.output + + +def test_propose_passes_idempotency_key_and_approvers(orc_home: Path) -> None: + _allow(orc_home, "research", ["fs.write_file"]) + ws_module.create("research") + result = CliRunner().invoke( + main, + [ + "propose", "fs.write_file", + "--params", '{"path": "r.md", "content": "hi"}', + "--summary", "ship", + "-w", "research", + "--idempotency-key", "key-1", + "--approvers", "2", + "--json", + ], + ) + assert result.exit_code == 0, result.output + approval_id = json.loads(result.output)["approval_id"] + item = q.get("research", approval_id) + assert item.proposed_action["idempotency_key"] == "key-1" + assert item.approvers_required == 2