From f5cdd6f5f2a9bc994fb5b8875deed2eea785aa30 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 12:17:11 -0700 Subject: [PATCH] feat: add `cueapi executions replay / verify / verification-pending` (rebase v6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 new subcommands. verify tri-state. --reason capped at 500 chars. Tests: 12 new (109+12=121 total). Rebased against main 2026-05-04 (post-#26, #27, #28, #30, #33). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/cli.py | 125 ++++++++++++++++++++++++++ tests/test_cli.py | 218 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) diff --git a/cueapi/cli.py b/cueapi/cli.py index 150141e..e0a5767 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1075,6 +1075,131 @@ def executions_report_outcome( click.echo(str(e)) +@executions.command(name="replay") +@click.argument("execution_id") +@click.pass_context +def executions_replay(ctx: click.Context, execution_id: str) -> None: + """Replay a terminal execution. + + Creates a fresh execution against the same cue with the original + payload_override carried forward. Only valid for terminal states + (success / failed / missed / outcome_timeout); 409 if the execution + is still in flight. + """ + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + resp = client.post(f"/executions/{execution_id}/replay", json={}) + if resp.status_code == 200: + data = resp.json() + click.echo() + echo_success(f"Replayed: {execution_id}") + if data.get("execution_id"): + echo_info("New execution:", data["execution_id"]) + if data.get("scheduled_for"): + echo_info("Scheduled:", data["scheduled_for"]) + echo_info("Status:", data.get("status", "?")) + if data.get("triggered_by"): + echo_info("Triggered by:", data["triggered_by"]) + click.echo() + elif resp.status_code == 404: + echo_error(f"Execution not found: {execution_id}") + elif resp.status_code == 409: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", "Cannot replay an execution still in flight")) + else: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", f"Failed (HTTP {resp.status_code})")) + except click.ClickException as e: + click.echo(str(e)) + + +@executions.command(name="verification-pending") +@click.argument("execution_id") +@click.pass_context +def executions_verification_pending(ctx: click.Context, execution_id: str) -> None: + """Mark an execution's outcome verification as pending.""" + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + resp = client.post(f"/executions/{execution_id}/verification-pending", json={}) + if resp.status_code == 200: + data = resp.json() + click.echo() + echo_success(f"Marked verification-pending: {execution_id}") + if data.get("outcome_state"): + echo_info("Outcome state:", data["outcome_state"]) + click.echo() + elif resp.status_code == 404: + echo_error(f"Execution not found: {execution_id}") + elif resp.status_code == 409: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", "Cannot transition from current outcome_state")) + else: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", f"Failed (HTTP {resp.status_code})")) + except click.ClickException as e: + click.echo(str(e)) + + +@executions.command(name="verify") +@click.argument("execution_id") +@click.option( + "--valid/--invalid", + "valid", + default=None, + help=( + "Mark verification result. --valid (default behavior, transitions to " + "verified_success) or --invalid (transitions to verification_failed). " + "Omitting either flag uses the server default (valid=true)." + ), +) +@click.option( + "--reason", + default=None, + help=( + "Optional human-readable reason (max 500 chars). Most useful with " + "--invalid to record why verification failed." + ), +) +@click.pass_context +def executions_verify( + ctx: click.Context, + execution_id: str, + valid: Optional[bool], + reason: Optional[str], +) -> None: + """Verify or invalidate an execution's evidence.""" + if reason is not None and len(reason) > 500: + raise click.UsageError("--reason must be ≤500 characters") + body: dict = {} + if valid is not None: + body["valid"] = valid + if reason: + body["reason"] = reason + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + resp = client.post(f"/executions/{execution_id}/verify", json=body) + if resp.status_code == 200: + data = resp.json() + click.echo() + if valid is False: + echo_success(f"Marked verification-failed: {execution_id}") + else: + echo_success(f"Verified: {execution_id}") + if data.get("outcome_state"): + echo_info("Outcome state:", data["outcome_state"]) + click.echo() + elif resp.status_code == 404: + echo_error(f"Execution not found: {execution_id}") + elif resp.status_code == 409: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", "Cannot transition from current outcome_state")) + else: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", f"Failed (HTTP {resp.status_code})")) + except click.ClickException as e: + click.echo(str(e)) + + main.add_command(executions) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4ed1b69..8501f36 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1533,3 +1533,221 @@ def test_executions_list_combines_all_filters(monkeypatch): assert p["has_evidence"] == "true" assert p["triggered_by"] == "scheduled" assert p["limit"] == 50 + + +# --- executions: replay / verification-pending / verify --- + + +class _FakeResp: + def __init__(self, status_code: int, payload: Any): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + +class _ExecClient: + def __init__(self, responses: Optional[dict] = None): + self.calls: list = [] + self._responses = responses or {} + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def _resolve(self, method: str, path: str): + for (m, p), factory in sorted(self._responses.items(), key=lambda kv: -len(kv[0][1])): + if m == method and path.startswith(p): + return factory() + return _FakeResp(200, {}) + + def post(self, path, json=None, **_): + self.calls.append(("POST", path, json)) + return self._resolve("POST", path) + + def get(self, path, params=None, **_): + self.calls.append(("GET", path, params)) + return self._resolve("GET", path) + + +def _patch_exec_client(monkeypatch, holder, responses=None): + import cueapi.cli as cli_mod + + def fake_factory(*_, **__): + holder["client"] = _ExecClient(responses=responses) + return holder["client"] + + monkeypatch.setattr(cli_mod, "CueAPIClient", fake_factory) + + +def test_executions_replay_help(): + result = runner.invoke(main, ["executions", "replay", "--help"]) + assert result.exit_code == 0 + assert "execution_id" in result.output.lower() + + +def test_executions_verification_pending_help(): + result = runner.invoke(main, ["executions", "verification-pending", "--help"]) + assert result.exit_code == 0 + assert "execution_id" in result.output.lower() + + +def test_executions_verify_help_lists_flags(): + result = runner.invoke(main, ["executions", "verify", "--help"]) + assert result.exit_code == 0 + assert "--valid" in result.output + assert "--invalid" in result.output + assert "--reason" in result.output + + +def test_executions_replay_posts_empty_body(monkeypatch): + holder: dict = {} + _patch_exec_client( + monkeypatch, + holder, + responses={ + ("POST", "/executions/exec_x/replay"): lambda: _FakeResp( + 200, + { + "execution_id": "exec_new", + "scheduled_for": "2026-05-04T17:30:00Z", + "status": "pending", + "triggered_by": "replay", + }, + ) + }, + ) + result = runner.invoke(main, ["executions", "replay", "exec_x"]) + assert result.exit_code == 0, result.output + method, path, body = holder["client"].calls[-1] + assert method == "POST" + assert path == "/executions/exec_x/replay" + assert body == {} + assert "exec_new" in result.output + + +def test_executions_replay_409_inflight_helpful_error(monkeypatch): + holder: dict = {} + _patch_exec_client( + monkeypatch, + holder, + responses={ + ("POST", "/executions/exec_x/replay"): lambda: _FakeResp( + 409, + {"detail": {"error": {"code": "execution_in_flight", "message": "still in progress", "status": 409}}}, + ) + }, + ) + result = runner.invoke(main, ["executions", "replay", "exec_x"]) + assert "in flight" in result.output.lower() or "in progress" in result.output.lower() + + +def test_executions_verification_pending_posts_empty_body(monkeypatch): + holder: dict = {} + _patch_exec_client( + monkeypatch, + holder, + responses={ + ("POST", "/executions/exec_x/verification-pending"): lambda: _FakeResp( + 200, {"outcome_state": "verification_pending"} + ) + }, + ) + result = runner.invoke(main, ["executions", "verification-pending", "exec_x"]) + assert result.exit_code == 0, result.output + method, path, body = holder["client"].calls[-1] + assert method == "POST" + assert path == "/executions/exec_x/verification-pending" + assert body == {} + assert "verification_pending" in result.output + + +def test_executions_verify_default_omits_valid_field(monkeypatch): + # No --valid / --invalid flag → body should be empty so the server's + # legacy default (valid=true) applies. Pinned so a refactor can't + # silently start always-sending the field. + holder: dict = {} + _patch_exec_client( + monkeypatch, + holder, + responses={ + ("POST", "/executions/exec_x/verify"): lambda: _FakeResp( + 200, {"outcome_state": "verified_success"} + ) + }, + ) + result = runner.invoke(main, ["executions", "verify", "exec_x"]) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body == {} + + +def test_executions_verify_invalid_sends_false(monkeypatch): + holder: dict = {} + _patch_exec_client( + monkeypatch, + holder, + responses={ + ("POST", "/executions/exec_x/verify"): lambda: _FakeResp( + 200, {"outcome_state": "verification_failed"} + ) + }, + ) + result = runner.invoke( + main, + ["executions", "verify", "exec_x", "--invalid", "--reason", "evidence missing"], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body == {"valid": False, "reason": "evidence missing"} + assert "verification_failed" in result.output or "verification-failed" in result.output + + +def test_executions_verify_explicit_valid_sends_true(monkeypatch): + holder: dict = {} + _patch_exec_client( + monkeypatch, + holder, + responses={ + ("POST", "/executions/exec_x/verify"): lambda: _FakeResp( + 200, {"outcome_state": "verified_success"} + ) + }, + ) + result = runner.invoke(main, ["executions", "verify", "exec_x", "--valid"]) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body == {"valid": True} + + +def test_executions_verify_reason_too_long_rejected_client_side(): + long_reason = "x" * 501 + result = runner.invoke( + main, + ["executions", "verify", "exec_x", "--reason", long_reason], + ) + assert result.exit_code != 0 + assert "500" in result.output or "characters" in result.output.lower() + + +def test_executions_verify_404(monkeypatch): + holder: dict = {} + _patch_exec_client( + monkeypatch, + holder, + responses={ + ("POST", "/executions/missing/verify"): lambda: _FakeResp(404, {}) + }, + ) + result = runner.invoke(main, ["executions", "verify", "missing"]) + assert "not found" in result.output.lower() or "missing" in result.output + + +def test_executions_group_help_includes_new_subcommands(): + result = runner.invoke(main, ["executions", "--help"]) + assert result.exit_code == 0 + for sub in ("replay", "verification-pending", "verify"): + assert sub in result.output, f"executions subcommand {sub} missing"