Skip to content

Commit 4e9b73d

Browse files
committed
fix(executions): mark_verified actually sends valid+reason; add replay()
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)
1 parent ac957e2 commit 4e9b73d

2 files changed

Lines changed: 129 additions & 4 deletions

File tree

cueapi/resources/executions.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,27 @@ def heartbeat(self, execution_id: str) -> dict:
128128
"""Send heartbeat to extend claim lease."""
129129
return self._client._post(f"/v1/executions/{execution_id}/heartbeat", json={})
130130

131+
def replay(self, execution_id: str) -> dict:
132+
"""Replay a terminal execution.
133+
134+
Creates a fresh execution against the same cue with the original
135+
execution's ``payload_override`` carried forward. Server-side
136+
constraint: only valid for terminal states (``success`` /
137+
``failed`` / ``missed`` / ``outcome_timeout``); 409 if the
138+
execution is still in flight.
139+
140+
Args:
141+
execution_id: Execution UUID to replay.
142+
143+
Returns:
144+
Dict with ``execution_id`` (new), ``scheduled_for``,
145+
``status`` (``pending``), ``triggered_by`` (``replay``),
146+
``replayed_from`` (the original execution_id).
147+
"""
148+
return self._client._post(
149+
f"/v1/executions/{execution_id}/replay", json={}
150+
)
151+
131152
def mark_verification_pending(self, execution_id: str) -> dict:
132153
"""Mark execution outcome as pending verification."""
133154
return self._client._post(
@@ -141,5 +162,29 @@ def mark_verified(
141162
valid: bool = True,
142163
reason: Optional[str] = None,
143164
) -> dict:
144-
"""Mark execution outcome as verified or verification failed."""
145-
return self._client._post(f"/v1/executions/{execution_id}/verify", json={})
165+
"""Mark execution outcome as verified or verification failed.
166+
167+
Args:
168+
execution_id: Execution UUID.
169+
valid: ``True`` (default) transitions to ``verified_success``;
170+
``False`` transitions to ``verification_failed``.
171+
reason: Optional human-readable reason (max 500 chars).
172+
Appended to the execution's ``evidence_summary``. Most
173+
useful with ``valid=False`` to record why verification
174+
failed.
175+
176+
Returns:
177+
Dict with the new ``outcome_state`` and timestamp fields.
178+
"""
179+
# Bug fix: the prior implementation accepted ``valid`` and
180+
# ``reason`` kwargs but always sent ``json={}``. The server
181+
# treated absent body as ``valid=true``, so callers passing
182+
# ``valid=False`` or ``reason="..."`` got ``verified_success``
183+
# regardless of intent — silently dropping the kwargs. Pinned
184+
# by the corresponding regression test.
185+
body: Dict[str, Any] = {"valid": valid}
186+
if reason is not None:
187+
body["reason"] = reason
188+
return self._client._post(
189+
f"/v1/executions/{execution_id}/verify", json=body
190+
)

tests/test_executions_resource.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,93 @@ def test_mark_verification_pending(self):
154154
"/v1/executions/exec_123/verification-pending", json={},
155155
)
156156

157-
def test_mark_verified(self):
157+
def test_mark_verified_default_sends_valid_true(self):
158+
# Regression: prior implementation always sent ``json={}`` and
159+
# silently dropped both kwargs. The server treated absent body
160+
# as ``valid=true``, so the default-arg path produced the right
161+
# outcome by accident — but ``valid=False`` and ``reason="..."``
162+
# callers got ``verified_success`` instead of their intent.
163+
# Pinning that the default-arg path now sends ``{"valid": True}``
164+
# explicitly.
158165
mock_client = MagicMock()
159166
mock_client._post.return_value = {"outcome_state": "verified_success"}
160167
resource = ExecutionsResource(mock_client)
161168

162169
resource.mark_verified("exec_123")
163170

164171
mock_client._post.assert_called_once_with(
165-
"/v1/executions/exec_123/verify", json={},
172+
"/v1/executions/exec_123/verify", json={"valid": True},
166173
)
174+
175+
def test_mark_verified_with_invalid_sends_false(self):
176+
# The fix: ``valid=False`` MUST land in the body. Pre-fix this
177+
# was silently dropped.
178+
mock_client = MagicMock()
179+
mock_client._post.return_value = {"outcome_state": "verification_failed"}
180+
resource = ExecutionsResource(mock_client)
181+
182+
resource.mark_verified("exec_123", valid=False)
183+
184+
mock_client._post.assert_called_once_with(
185+
"/v1/executions/exec_123/verify", json={"valid": False},
186+
)
187+
188+
def test_mark_verified_with_reason(self):
189+
# The fix: ``reason`` MUST land in the body. Pre-fix this was
190+
# silently dropped, so any caller passing a reason saw it
191+
# disappear into the ether.
192+
mock_client = MagicMock()
193+
mock_client._post.return_value = {"outcome_state": "verification_failed"}
194+
resource = ExecutionsResource(mock_client)
195+
196+
resource.mark_verified("exec_123", valid=False, reason="evidence missing")
197+
198+
mock_client._post.assert_called_once_with(
199+
"/v1/executions/exec_123/verify",
200+
json={"valid": False, "reason": "evidence missing"},
201+
)
202+
203+
def test_mark_verified_omits_reason_when_none(self):
204+
# ``reason=None`` must NOT serialize as ``"reason": null``; it
205+
# must be omitted entirely. Pinning the omit-when-default
206+
# behavior.
207+
mock_client = MagicMock()
208+
mock_client._post.return_value = {"outcome_state": "verified_success"}
209+
resource = ExecutionsResource(mock_client)
210+
211+
resource.mark_verified("exec_123", valid=True, reason=None)
212+
213+
sent_body = mock_client._post.call_args.kwargs["json"]
214+
assert "reason" not in sent_body
215+
216+
217+
class TestReplay:
218+
def test_replay_posts_to_replay_endpoint(self):
219+
mock_client = MagicMock()
220+
mock_client._post.return_value = {
221+
"execution_id": "exec_new",
222+
"scheduled_for": "2026-05-04T17:30:00Z",
223+
"status": "pending",
224+
"triggered_by": "replay",
225+
"replayed_from": "exec_old",
226+
}
227+
resource = ExecutionsResource(mock_client)
228+
229+
result = resource.replay("exec_old")
230+
231+
mock_client._post.assert_called_once_with(
232+
"/v1/executions/exec_old/replay", json={},
233+
)
234+
assert result["execution_id"] == "exec_new"
235+
assert result["triggered_by"] == "replay"
236+
237+
def test_replay_returns_server_dict_unchanged(self):
238+
# SDK doesn't transform the response — caller gets the raw dict
239+
# the server returned. Pin so a future refactor can't silently
240+
# start munging fields.
241+
mock_client = MagicMock()
242+
mock_client._post.return_value = {"execution_id": "exec_x", "extra": "field"}
243+
resource = ExecutionsResource(mock_client)
244+
245+
result = resource.replay("exec_old")
246+
assert result == {"execution_id": "exec_x", "extra": "field"}

0 commit comments

Comments
 (0)