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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion sprintctl/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
29 changes: 26 additions & 3 deletions sprintctl/served.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from __future__ import annotations

import asyncio
import uuid
from typing import Any

from . import reservation as _reservation
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions tests/test_served.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion verification/validate_release_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<digest>[0-9a-f]{64})$")
ADAPTER_PATH_RE = re.compile(
Expand Down
Loading