From 4e9b73d03f3ef655f0096cbf45cca5af5b6ad57d Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 10:35:09 -0700 Subject: [PATCH] fix(executions): mark_verified actually sends valid+reason; add replay() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, both in ExecutionsResource: (1) **Bug fix — mark_verified silently dropped both kwargs.** The prior implementation accepted ``valid`` and ``reason`` as keyword args but always sent ``json={}``. The server treats absent body as ``valid=true``, so the default-arg path produced the right outcome by accident — but every caller passing ``valid=False`` or ``reason="..."`` got ``verified_success`` instead of their intent, silently. The fix builds the body explicitly: body = {"valid": valid} if reason is not None: body["reason"] = reason Pinned by 4 regression tests: - default-arg sends ``{"valid": True}`` (not ``{}``) - ``valid=False`` lands in body - ``reason="..."`` lands in body - ``reason=None`` is omitted (not serialized as null) (2) **New: ``ExecutionsResource.replay(execution_id)``** — POST /v1/executions/{id}/replay. Closes one of the ``endpoints_missing`` entries from cueapi-python #24's parity manifest. Server-side already shipped on prod; this is pure SDK catch-up. Returns the server's response dict unchanged (new execution_id, scheduled_for, status, triggered_by="replay", replayed_from). Tests: 5 new (12 → 17 total). All pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/resources/executions.py | 49 +++++++++++++++++- tests/test_executions_resource.py | 84 ++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/cueapi/resources/executions.py b/cueapi/resources/executions.py index 713a28d..20fd7b2 100644 --- a/cueapi/resources/executions.py +++ b/cueapi/resources/executions.py @@ -128,6 +128,27 @@ def heartbeat(self, execution_id: str) -> dict: """Send heartbeat to extend claim lease.""" return self._client._post(f"/v1/executions/{execution_id}/heartbeat", json={}) + def replay(self, execution_id: str) -> dict: + """Replay a terminal execution. + + Creates a fresh execution against the same cue with the original + execution's ``payload_override`` carried forward. Server-side + constraint: only valid for terminal states (``success`` / + ``failed`` / ``missed`` / ``outcome_timeout``); 409 if the + execution is still in flight. + + Args: + execution_id: Execution UUID to replay. + + Returns: + Dict with ``execution_id`` (new), ``scheduled_for``, + ``status`` (``pending``), ``triggered_by`` (``replay``), + ``replayed_from`` (the original execution_id). + """ + return self._client._post( + f"/v1/executions/{execution_id}/replay", json={} + ) + def mark_verification_pending(self, execution_id: str) -> dict: """Mark execution outcome as pending verification.""" return self._client._post( @@ -141,5 +162,29 @@ def mark_verified( valid: bool = True, reason: Optional[str] = None, ) -> dict: - """Mark execution outcome as verified or verification failed.""" - return self._client._post(f"/v1/executions/{execution_id}/verify", json={}) + """Mark execution outcome as verified or verification failed. + + Args: + execution_id: Execution UUID. + valid: ``True`` (default) transitions to ``verified_success``; + ``False`` transitions to ``verification_failed``. + reason: Optional human-readable reason (max 500 chars). + Appended to the execution's ``evidence_summary``. Most + useful with ``valid=False`` to record why verification + failed. + + Returns: + Dict with the new ``outcome_state`` and timestamp fields. + """ + # Bug fix: the prior implementation accepted ``valid`` and + # ``reason`` kwargs but always sent ``json={}``. The server + # treated absent body as ``valid=true``, so callers passing + # ``valid=False`` or ``reason="..."`` got ``verified_success`` + # regardless of intent — silently dropping the kwargs. Pinned + # by the corresponding regression test. + body: Dict[str, Any] = {"valid": valid} + if reason is not None: + body["reason"] = reason + return self._client._post( + f"/v1/executions/{execution_id}/verify", json=body + ) diff --git a/tests/test_executions_resource.py b/tests/test_executions_resource.py index 9f6c7e1..cab3101 100644 --- a/tests/test_executions_resource.py +++ b/tests/test_executions_resource.py @@ -154,7 +154,14 @@ def test_mark_verification_pending(self): "/v1/executions/exec_123/verification-pending", json={}, ) - def test_mark_verified(self): + def test_mark_verified_default_sends_valid_true(self): + # Regression: prior implementation always sent ``json={}`` and + # silently dropped both kwargs. The server treated absent body + # as ``valid=true``, so the default-arg path produced the right + # outcome by accident — but ``valid=False`` and ``reason="..."`` + # callers got ``verified_success`` instead of their intent. + # Pinning that the default-arg path now sends ``{"valid": True}`` + # explicitly. mock_client = MagicMock() mock_client._post.return_value = {"outcome_state": "verified_success"} resource = ExecutionsResource(mock_client) @@ -162,5 +169,78 @@ def test_mark_verified(self): resource.mark_verified("exec_123") mock_client._post.assert_called_once_with( - "/v1/executions/exec_123/verify", json={}, + "/v1/executions/exec_123/verify", json={"valid": True}, ) + + def test_mark_verified_with_invalid_sends_false(self): + # The fix: ``valid=False`` MUST land in the body. Pre-fix this + # was silently dropped. + mock_client = MagicMock() + mock_client._post.return_value = {"outcome_state": "verification_failed"} + resource = ExecutionsResource(mock_client) + + resource.mark_verified("exec_123", valid=False) + + mock_client._post.assert_called_once_with( + "/v1/executions/exec_123/verify", json={"valid": False}, + ) + + def test_mark_verified_with_reason(self): + # The fix: ``reason`` MUST land in the body. Pre-fix this was + # silently dropped, so any caller passing a reason saw it + # disappear into the ether. + mock_client = MagicMock() + mock_client._post.return_value = {"outcome_state": "verification_failed"} + resource = ExecutionsResource(mock_client) + + resource.mark_verified("exec_123", valid=False, reason="evidence missing") + + mock_client._post.assert_called_once_with( + "/v1/executions/exec_123/verify", + json={"valid": False, "reason": "evidence missing"}, + ) + + def test_mark_verified_omits_reason_when_none(self): + # ``reason=None`` must NOT serialize as ``"reason": null``; it + # must be omitted entirely. Pinning the omit-when-default + # behavior. + mock_client = MagicMock() + mock_client._post.return_value = {"outcome_state": "verified_success"} + resource = ExecutionsResource(mock_client) + + resource.mark_verified("exec_123", valid=True, reason=None) + + sent_body = mock_client._post.call_args.kwargs["json"] + assert "reason" not in sent_body + + +class TestReplay: + def test_replay_posts_to_replay_endpoint(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "execution_id": "exec_new", + "scheduled_for": "2026-05-04T17:30:00Z", + "status": "pending", + "triggered_by": "replay", + "replayed_from": "exec_old", + } + resource = ExecutionsResource(mock_client) + + result = resource.replay("exec_old") + + mock_client._post.assert_called_once_with( + "/v1/executions/exec_old/replay", json={}, + ) + assert result["execution_id"] == "exec_new" + assert result["triggered_by"] == "replay" + + def test_replay_returns_server_dict_unchanged(self): + # SDK doesn't transform the response — caller gets the raw dict + # the server returned. Pin so a future refactor can't silently + # start munging fields. + mock_client = MagicMock() + mock_client._post.return_value = {"execution_id": "exec_x", "extra": "field"} + resource = ExecutionsResource(mock_client) + + result = resource.replay("exec_old") + assert result == {"execution_id": "exec_x", "extra": "field"}