Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions cueapi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
218 changes: 218 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"