diff --git a/pyproject.toml b/pyproject.toml index b8a67e4..ce4eb2c 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sprintctl" -version = "0.3.1" +version = "0.3.2" requires-python = ">=3.11" dependencies = [ "click>=8.1", diff --git a/sprintctl/__init__.py b/sprintctl/__init__.py index 0a0a168..3f646bd 100755 --- a/sprintctl/__init__.py +++ b/sprintctl/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.3.1" +__version__ = "0.3.2" # Keep these identifiers stable: the doctor command compares the running # package with the capabilities declared by a checked-out source tree. diff --git a/sprintctl/served.py b/sprintctl/served.py index e438a44..d1f0e28 100644 --- a/sprintctl/served.py +++ b/sprintctl/served.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio +import uuid from typing import Any from . import reservation as _reservation @@ -275,10 +276,32 @@ def read_next_work( def reservation_operation( - served_profile: ServedProfile, operation: str, arguments: dict[str, Any], *, repo_id: str + served_profile: ServedProfile, + operation: str, + arguments: dict[str, Any], + *, + repo_id: str, + idempotency_key: str | None = None, ) -> dict[str, Any]: - """Invoke one v0.3 reservation operation through the served authority.""" - return asyncio.run(_invoke_operation(served_profile, operation, arguments, repo_id=repo_id)) + """Invoke one v0.3 reservation operation through the served authority. + + Every ``work.reservation.*`` operation is declared ``idempotency: required`` + by the adapter, so the authority rejects a call without a key + (``idempotency-key-required``) *after* the authority check has passed -- + which is how a correctly granted identity was first seen failing here. A + reservation is a coordination signal, and ``touch`` in particular must be + able to advance ``last_activity_at`` on every call, so the default key is + unique per invocation (the same convention as the per-event key used by + ``publish_events``) rather than derived from the arguments. Callers that + retry a single logical call pass their own key to deduplicate. Reads + (``work.read.reservation``) are never keyed. + """ + kwargs: dict[str, Any] = {"repo_id": repo_id} + if operation.startswith("work.reservation."): + # Only the mutations are keyed: ``work.read.reservation`` travels + # through here too and the authority rejects a key on a read. + kwargs["idempotency_key"] = idempotency_key or uuid.uuid4().hex + return asyncio.run(_invoke_operation(served_profile, operation, arguments, **kwargs)) def read_records( diff --git a/tests/test_served.py b/tests/test_served.py index f2b9b66..abbdec9 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -287,6 +287,25 @@ def test_reservation_operation_sends_credential_free_shape(fake_vuoro_client): assert "claim_token" not in args +def test_reservation_operation_always_sends_an_idempotency_key(fake_vuoro_client): + """Every work.reservation.* operation is ``idempotency: required`` on the + adapter, and the authority rejects a keyless call only *after* the + authority check -- so a missing key looked like a grant problem in the + field. Default keys are unique per call (touch must advance activity every + time); an explicit key is passed through for callers that retry.""" + profile = _profile() + args = {"item_id": 5, "actor": "worker", "session_id": "session-1"} + served.reservation_operation(profile, "work.reservation.reserve", dict(args), repo_id="repo-x") + served.reservation_operation(profile, "work.reservation.touch", dict(args), repo_id="repo-x") + served.reservation_operation( + profile, "work.reservation.release", dict(args), repo_id="repo-x", idempotency_key="retry-7" + ) + keys = [inv[2].get("idempotency_key") for client in fake_vuoro_client.instances for inv in client.invocations] + assert len(keys) == 3 and all(keys), keys + assert keys[0] != keys[1] + assert keys[2] == "retry-7" + + def test_item_note_sends_full_shape_and_never_an_actor_field(fake_vuoro_client): profile = _profile() result = served.item_note( diff --git a/uv.lock b/uv.lock index 2f7421b..3431894 100755 --- a/uv.lock +++ b/uv.lock @@ -517,7 +517,7 @@ wheels = [ [[package]] name = "sprintctl" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/verification/validate_release_contract.py b/verification/validate_release_contract.py index 673947a..664182d 100644 --- a/verification/validate_release_contract.py +++ b/verification/validate_release_contract.py @@ -14,7 +14,7 @@ ROOT = Path(__file__).resolve().parents[1] -RELEASE_VERSION = "0.3.1" +RELEASE_VERSION = "0.3.2" ADAPTER_NAME = "vuoro-adapter-kit" ADAPTER_DIGEST_RE = re.compile(r"^sha256=(?P[0-9a-f]{64})$") ADAPTER_PATH_RE = re.compile(