diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2d867fd..d3ed6f4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,16 +5,20 @@ on: tags: - "v*" +permissions: + contents: read + jobs: publish: + name: Publish to PyPI runs-on: ubuntu-latest permissions: id-token: write + contents: read steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.7 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Set up Python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.3.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -25,4 +29,6 @@ jobs: run: python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.12.3 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + attestations: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1420d84..f2d1d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to cueapi-sdk will be documented here. +## [0.2.0] - 2026-05-01 + +### Added +- `client.cues.fire(cue_id, payload_override=None, merge_strategy=None)` for ad-hoc one-shot triggers and for using cues as a messaging channel between agents. Wraps `POST /v1/cues/{id}/fire`. +- `client.executions.list_claimable(task=None, agent=None)` for unclaimed worker-transport executions ready for processing. Filters server-side via task / agent query params; required for single-purpose workers. +- `client.executions.claim(execution_id, worker_id=...)` for atomic claim of a specific execution. Returns 409 if already claimed. +- `client.executions.claim_next(worker_id=..., task=None)` for the next available execution. Without task the server picks the oldest pending; with task the SDK fans out internally (list_claimable filtered, pick oldest, claim by ID) since the server's claim endpoint does not accept a task filter today. + +### Changed +- `__version__` in `cueapi/__init__.py` had drifted to 0.1.2 while `pyproject.toml` was at 0.1.3. Both now aligned at 0.2.0. + +### Pending follow-up +- `client.executions.heartbeat(execution_id)` currently sends an empty body and does NOT include `worker_id` via the `X-Worker-Id` request header that the server reads from. Worker-id is what the server uses to enforce ownership on the heartbeat (returns 403 on mismatch); without it the race-protection check is silently bypassed. A signature change to add `worker_id` is held pending technical review of the deprecation cadence. + ## [0.1.0] - 2025-03-28 ### Added diff --git a/README.md b/README.md index b9eec10..f07d756 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -> **For AI agents:** This is the official Python SDK for CueAPI. Run `pip install cueapi-sdk`. Your API key is in `CUEAPI_API_KEY`. Create a client: `client = CueAPI(os.environ["CUEAPI_API_KEY"])`. Schedule a task: `client.cues.create(name="task-name", cron="0 9 * * *", callback="https://your-handler.com", payload={})`. For agents without a public URL: set `transport="worker"` and run `pip install cueapi-worker`, then define a handler with `@worker.handler("task-name")` returning `{"success": true}` or `{"success": false, "error": "reason"}`. List executions: `client.executions.list()`. Get one: `client.executions.get("exec_id")`. Do not retry on `AuthenticationError` or `CueLimitExceededError`. Wait `e.retry_after` seconds on `RateLimitError`. + +> **For AI agents:** This is the official Python SDK for CueAPI. Run `pip install cueapi-sdk`. Your API key is in `CUEAPI_API_KEY`. Create a client: `client = CueAPI(os.environ["CUEAPI_API_KEY"])`. Schedule a task: `client.cues.create(name="task-name", cron="0 9 * * *", callback="https://your-handler.com", payload={})`. For agents without a public URL: set `transport="worker"` and run `pip install cueapi-worker`, then define a handler with `@worker.handler("task-name")` returning `{"success": true}` or `{"success": false, "error": "reason"}`. Fire an existing cue immediately: `client.cues.fire("cue_id", payload_override={"task": "x"})`. List unclaimed worker executions: `client.executions.list_claimable(task="task-name")`. Claim one: `client.executions.claim("exec_id", worker_id="my-worker")`. List executions: `client.executions.list()`. Get one: `client.executions.get("exec_id")`. Do not retry on `AuthenticationError` or `CueLimitExceededError`. Wait `e.retry_after` seconds on `RateLimitError`. # cueapi-sdk @@ -267,6 +268,12 @@ See [`/examples`](examples/) for working code: --- +## Releases + +Releases are published to PyPI with PEP 740 attestations via GitHub Actions Trusted Publishing. + +--- + ## License MIT. See [LICENSE](LICENSE). diff --git a/cueapi/__init__.py b/cueapi/__init__.py index fc882f3..5d60b4d 100644 --- a/cueapi/__init__.py +++ b/cueapi/__init__.py @@ -11,15 +11,23 @@ RateLimitError, ) from cueapi.payload import CuePayload +from cueapi.resources.agents import AgentsResource from cueapi.resources.executions import ExecutionsResource +from cueapi.resources.messages import MessagesResource +from cueapi.resources.usage import UsageResource +from cueapi.resources.workers import WorkersResource from cueapi.webhook import verify_webhook -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ + "AgentsResource", "CueAPI", "CuePayload", "ExecutionsResource", + "MessagesResource", + "UsageResource", + "WorkersResource", "verify_webhook", "CueAPIError", "AuthenticationError", diff --git a/cueapi/client.py b/cueapi/client.py index 696d0fd..0fa1113 100644 --- a/cueapi/client.py +++ b/cueapi/client.py @@ -15,8 +15,12 @@ InvalidScheduleError, RateLimitError, ) +from cueapi.resources.agents import AgentsResource from cueapi.resources.cues import CuesResource from cueapi.resources.executions import ExecutionsResource +from cueapi.resources.messages import MessagesResource +from cueapi.resources.usage import UsageResource +from cueapi.resources.workers import WorkersResource DEFAULT_BASE_URL = "https://api.cueapi.ai" DEFAULT_TIMEOUT = 30.0 @@ -69,6 +73,10 @@ def __init__( # Resources self.cues = CuesResource(self) self.executions = ExecutionsResource(self) + self.workers = WorkersResource(self) + self.usage = UsageResource(self) + self.agents = AgentsResource(self) + self.messages = MessagesResource(self) def close(self) -> None: """Close the underlying HTTP client.""" @@ -89,9 +97,19 @@ def _request( *, json: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, ) -> Any: - """Make an HTTP request and handle errors.""" - response = self._http.request(method, path, json=json, params=params) + """Make an HTTP request and handle errors. + + ``headers`` extends (does not replace) the client's default + ``Authorization`` + ``Content-Type`` + ``User-Agent`` headers. + Used by per-call header semantics: messaging primitive's + ``X-Cueapi-From-Agent`` + ``Idempotency-Key``, and the + destructive-operation guard ``X-Confirm-Destructive``. + """ + response = self._http.request( + method, path, json=json, params=params, headers=headers + ) return self._handle_response(response) def _handle_response(self, response: httpx.Response) -> Any: diff --git a/cueapi/models/cue.py b/cueapi/models/cue.py index 0692d00..fb7d0fd 100644 --- a/cueapi/models/cue.py +++ b/cueapi/models/cue.py @@ -32,6 +32,38 @@ class OnFailure(BaseModel): pause: bool = False +class DeliveryConfig(BaseModel): + """Two-phase delivery configuration (Gap 5).""" + + timeout_seconds: int = 30 + outcome_deadline_seconds: int = 300 + + +class AlertConfig(BaseModel): + """Alert configuration (Gap 5). + + Surfaced as a passthrough dict via ``extra="allow"`` so callers see + every field the server returns even if the SDK hasn't been updated + for new alert kinds yet. Models that have grown additively benefit + from forward-compat. + """ + + model_config = {"extra": "allow"} + + +class VerificationConfig(BaseModel): + """Outcome verification policy. + + The ``mode`` field controls evidence requirements. The + ``required_assertions`` field (Gap 8) controls structural requirements + on the reported outcome. + """ + + mode: Optional[str] = None + required_assertions: Optional[List[str]] = None + model_config = {"extra": "allow"} + + class Cue(BaseModel): id: str name: str @@ -47,6 +79,24 @@ class Cue(BaseModel): run_count: int = 0 fired_count: int = 0 on_failure: Optional[OnFailure] = None + # Two-phase + alerts + catch-up + verification config (hosted Phase + # 18 / Gap 5 / Gap 8). All optional and forward-compat — server + # may grow these objects over time without breaking SDK callers. + delivery: Optional[DeliveryConfig] = None + alerts: Optional[AlertConfig] = None + catch_up: Optional[str] = None + verification: Optional[VerificationConfig] = None + # On-success chaining (Gap 1): cue ID to fire when an execution of + # this cue reaches a successful terminal state. Strictly 1:1. + on_success_fire: Optional[str] = None + # Per-cue payload_override enforcement on /fire (hosted PR #590). + # Default false (server's default) so old responses without these + # keys still parse cleanly. + require_payload_override: bool = False + required_payload_keys: Optional[List[str]] = None + # Cue-detail-response stats: 7d success rate, miss rate, totals. + # Returned only on GET /v1/cues/{id} detail; absent on list rows. + stats: Optional[Dict[str, Any]] = None warning: Optional[str] = None created_at: datetime updated_at: datetime diff --git a/cueapi/resources/agents.py b/cueapi/resources/agents.py new file mode 100644 index 0000000..d7c8933 --- /dev/null +++ b/cueapi/resources/agents.py @@ -0,0 +1,192 @@ +"""Agents resource — messaging primitive identity surface (Phase 12.1.5).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class AgentsResource: + """Agents API resource. + + Wraps the ``/v1/agents`` surface from the messaging primitive + (Phase 12.1.5). Covers identity CRUD, webhook-secret rotation, and + the inbox/sent message lists keyed by agent ref. + + The send/get/read/ack message lifecycle lives on a sibling + ``client.messages`` resource — this class only handles identity. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def create( + self, + *, + display_name: str, + slug: Optional[str] = None, + webhook_url: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: + """Create an agent. + + The ``webhook_secret`` field is populated in the 201 response + ONLY when ``webhook_url`` is supplied. Subsequent reads omit + the secret. Save it now or use ``webhook_secret_regenerate()`` + to mint a fresh one (which revokes the old one). + + Args: + display_name: Human-readable name (1-255 chars). + slug: Optional per-user unique slug. If omitted, the server + derives one from ``display_name``. + webhook_url: Push-delivery target. SSRF-validated. Omit for + poll-only. + metadata: Optional JSON metadata blob. + + Returns: + Dict matching the server's ``AgentResponse`` shape, including + ``webhook_secret`` ONCE on this response if ``webhook_url`` + was given. + """ + body: Dict[str, Any] = {"display_name": display_name} + if slug is not None: + body["slug"] = slug + if webhook_url is not None: + body["webhook_url"] = webhook_url + if metadata is not None: + body["metadata"] = metadata + return self._client._post("/v1/agents", json=body) + + def list( + self, + *, + status: Optional[str] = None, + include_deleted: bool = False, + limit: int = 50, + offset: int = 0, + ) -> dict: + """List your agents. + + Args: + status: Optional filter — ``online`` / ``offline`` / ``away``. + include_deleted: Whether to include soft-deleted agents. + Defaults to False; only sent on the wire when True + (omit-when-default keeps URLs clean and matches the + server's ``include_deleted=false`` default). + limit: Page size (default 50, max 100). + offset: Pagination offset. + """ + params: Dict[str, Any] = {"limit": limit, "offset": offset} + if status is not None: + params["status"] = status + if include_deleted: + params["include_deleted"] = "true" + return self._client._get("/v1/agents", params=params) + + def get( + self, + ref: str, + *, + include_deleted: bool = False, + ) -> dict: + """Get an agent by opaque ID or slug-form (``agent@user``).""" + params: Dict[str, Any] = {} + if include_deleted: + params["include_deleted"] = "true" + return self._client._get(f"/v1/agents/{ref}", params=params) + + def update( + self, + ref: str, + *, + display_name: Optional[str] = None, + webhook_url: Optional[str] = None, + clear_webhook_url: bool = False, + status: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: + """Update an agent (PATCH semantics). + + ``webhook_url`` and ``clear_webhook_url`` are mutually exclusive. + Pass ``clear_webhook_url=True`` to send literal JSON ``null`` and + revert the agent to poll-only — the server uses + ``model_fields_set`` to disambiguate "field omitted = no change" + from "field explicitly null = clear", so the SDK MUST send the + key with explicit None rather than omit. + """ + if webhook_url is not None and clear_webhook_url: + raise ValueError( + "webhook_url and clear_webhook_url are mutually exclusive" + ) + body: Dict[str, Any] = {} + if display_name is not None: + body["display_name"] = display_name + if webhook_url is not None: + body["webhook_url"] = webhook_url + elif clear_webhook_url: + body["webhook_url"] = None + if status is not None: + body["status"] = status + if metadata is not None: + body["metadata"] = metadata + return self._client._patch(f"/v1/agents/{ref}", json=body) + + def delete(self, ref: str) -> None: + """Soft-delete an agent. Returns ``None`` on success (204).""" + return self._client._delete(f"/v1/agents/{ref}") + + def webhook_secret_get(self, ref: str) -> dict: + """Reveal the agent's current webhook signing secret. + + 404 path commonly means the agent has no ``webhook_url`` set + (poll-only agents have no webhook secret). + """ + return self._client._get(f"/v1/agents/{ref}/webhook-secret") + + def webhook_secret_regenerate(self, ref: str) -> dict: + """Mint a fresh webhook secret. Old secret revoked immediately. + + Sends ``X-Confirm-Destructive: true`` header, which the server + requires for this destructive op. Returns the new secret one-time + in the response — save it now. + """ + return self._client._post( + f"/v1/agents/{ref}/webhook-secret/regenerate", + json={}, + headers={"X-Confirm-Destructive": "true"}, + ) + + def inbox( + self, + ref: str, + *, + state: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> dict: + """Poll the agent's inbox (incoming messages). + + Args: + ref: Agent opaque ID or slug-form. + state: Optional filter (e.g. ``queued`` / ``delivered`` / + ``read`` / ``acked`` / ``failed``). + limit: Page size (default 50). + offset: Pagination offset. + """ + params: Dict[str, Any] = {"limit": limit, "offset": offset} + if state is not None: + params["state"] = state + return self._client._get(f"/v1/agents/{ref}/inbox", params=params) + + def sent( + self, + ref: str, + *, + limit: int = 50, + offset: int = 0, + ) -> dict: + """List messages sent by this agent.""" + params: Dict[str, Any] = {"limit": limit, "offset": offset} + return self._client._get(f"/v1/agents/{ref}/sent", params=params) diff --git a/cueapi/resources/cues.py b/cueapi/resources/cues.py index 7a0e114..5ad2c00 100644 --- a/cueapi/resources/cues.py +++ b/cueapi/resources/cues.py @@ -218,3 +218,36 @@ def resume(self, cue_id: str) -> Cue: The updated Cue object. """ return self.update(cue_id, status="active") + + def fire( + self, + cue_id: str, + *, + payload_override: Optional[Dict[str, Any]] = None, + merge_strategy: Optional[str] = None, + ) -> Dict[str, Any]: + """Fire an existing cue immediately, optionally overriding its payload. + + For ad-hoc one-shot triggers and for using cues as a messaging channel + between agents (carry message/instruction/task/reply_cue_id in + payload_override). + + Args: + cue_id: The cue ID to fire. + payload_override: Override the cue's default payload for this fire + only. Persisted on the resulting execution row, never on the + cue itself. + merge_strategy: How payload_override combines with the cue's stored + payload. "merge" (server default) shallow-merges with override + wins on key collisions. "replace" uses override as the final + payload, ignoring cue.payload. + + Returns: + The execution dict (id, scheduled_for, status, etc.). + """ + body: Dict[str, Any] = {} + if payload_override is not None: + body["payload_override"] = payload_override + if merge_strategy is not None: + body["merge_strategy"] = merge_strategy + return self._client._post(f"/v1/cues/{cue_id}/fire", json=body) diff --git a/cueapi/resources/executions.py b/cueapi/resources/executions.py index 4a4d017..960c1ea 100644 --- a/cueapi/resources/executions.py +++ b/cueapi/resources/executions.py @@ -58,6 +58,7 @@ def report_outcome( metadata: Optional[dict] = None, external_id: Optional[str] = None, result_url: Optional[str] = None, + result_ref: Optional[str] = None, result_type: Optional[str] = None, summary: Optional[str] = None, artifacts: Optional[list] = None, @@ -74,6 +75,8 @@ def report_outcome( body["external_id"] = external_id if result_url is not None: body["result_url"] = result_url + if result_ref is not None: + body["result_ref"] = result_ref if result_type is not None: body["result_type"] = result_type if summary is not None: @@ -121,10 +124,121 @@ def get(self, execution_id: str) -> dict: """Get a single execution.""" return self._client._get(f"/v1/executions/{execution_id}") + def list_claimable( + self, + *, + task: Optional[str] = None, + agent: Optional[str] = None, + ) -> dict: + """List unclaimed worker-transport executions ready for processing. + + Filters server-side via task / agent query params (NOT client-side). + Required for single-purpose workers; without a filter, sibling tasks + ahead in the LIMIT 50 window starve your handler. + + Returns: + Dict with "executions" list, each item carrying execution_id, + cue_id, cue_name, task, scheduled_for, payload, attempt. + """ + params: Dict[str, Any] = {} + if task is not None: + params["task"] = task + if agent is not None: + params["agent"] = agent + return self._client._get("/v1/executions/claimable", params=params) + + def claim(self, execution_id: str, *, worker_id: str) -> dict: + """Atomically claim a specific worker-transport execution. + + Conditional UPDATE WHERE status IN ('pending', 'retry_ready'); returns + 409 if already claimed or not eligible. Response includes lease_seconds + (default 900s = 15 min); send heartbeat well before that to extend. + + Args: + execution_id: Execution UUID. + worker_id: Stable identifier for this worker. Caller-defined, not + session/process-scoped. Same value must be used across + claim, heartbeat, and outcome calls so the server can enforce + ownership. + + Returns: + Dict with claimed (bool), execution_id, lease_seconds. + """ + return self._client._post( + f"/v1/executions/{execution_id}/claim", + json={"worker_id": worker_id}, + ) + + def claim_next( + self, + *, + worker_id: str, + task: Optional[str] = None, + ) -> dict: + """Claim the next available worker-transport execution. + + Without task, the server picks the oldest pending across any of your + worker cues. With task, this method internally fans out (list_claimable + filtered, pick oldest, claim by ID) since the server's claim endpoint + does not accept a task filter today. Tiny race window between list and + claim is bounded by the atomic claim returning 409, in which case the + caller retries. + + Args: + worker_id: Stable caller-defined identifier (see claim()). + task: Optional task filter. + + Returns: + Dict with claimed (bool), execution_id, lease_seconds. When + task is set and no executions are claimable for that task, + returns {"claimed": False, "reason": "no_executions_for_task", + "task": }. + """ + if task is not None: + listing = self._client._get( + "/v1/executions/claimable", params={"task": task} + ) + execs = listing.get("executions") or [] + if not execs: + return { + "claimed": False, + "reason": "no_executions_for_task", + "task": task, + } + next_id = execs[0].get("execution_id") + return self._client._post( + f"/v1/executions/{next_id}/claim", + json={"worker_id": worker_id}, + ) + return self._client._post( + "/v1/executions/claim", json={"worker_id": worker_id} + ) + 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( @@ -138,5 +252,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/cueapi/resources/messages.py b/cueapi/resources/messages.py new file mode 100644 index 0000000..a7ea2e8 --- /dev/null +++ b/cueapi/resources/messages.py @@ -0,0 +1,120 @@ +"""Messages resource — messaging primitive lifecycle (Phase 12.1.5).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class MessagesResource: + """Messages API resource. + + Wraps the ``/v1/messages`` surface from the messaging primitive + (Phase 12.1.5). Covers send + per-message lifecycle (get / read / + ack). The agents identity surface lives on the sibling + ``client.agents`` resource — this class only handles messages. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def send( + self, + *, + from_agent: str, + to: str, + body: str, + subject: Optional[str] = None, + reply_to: Optional[str] = None, + priority: Optional[int] = None, + expects_reply: bool = False, + reply_to_agent: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> dict: + """Send a message. + + ``from_agent`` is sent as the ``X-Cueapi-From-Agent`` header, + NOT in the body. The server reads it from the header to + authenticate the sender against the calling key. Don't try to + pass it in the body — the server's ``MessageCreate`` schema is + ``extra="forbid"`` and will 400. + + ``idempotency_key`` is sent as the ``Idempotency-Key`` header. + Same key + same body within 24h returns the existing message + with HTTP 200 instead of 201. Same key + different body + returns HTTP 409 ``idempotency_key_conflict``. + + Args: + from_agent: Sender agent — opaque agent_id or slug-form + (``agent@user``). MUST be owned by the calling key. + to: Recipient — opaque agent_id or slug-form. + body: Message body (1-32768 chars). + subject: Optional subject line (max 255 chars). + reply_to: Previous message ID this is replying to + (``msg_<12 alphanumeric>``). thread_id inherits. + priority: 1-5 (server default 3). Receiver-pair limits may + downgrade priority>3 to 3; the server signals this via + the ``X-CueAPI-Priority-Downgraded: true`` response + header. Callers wanting to detect downgrade need to + inspect the response shape via the underlying + httpx.Response — not exposed in the SDK return value. + expects_reply: Mark this message as expecting a reply. + Default False; only sent when True. + reply_to_agent: Decoupled reply target. Defaults to + ``from`` (sender). Use when reply should route to a + different agent. + metadata: Optional JSON metadata blob. + idempotency_key: Optional ``Idempotency-Key`` header + (≤255 chars). + + Returns: + Dict matching the server's ``MessageResponse`` shape. + + Raises: + ValueError: If ``idempotency_key`` exceeds 255 chars + (matches the server's hard limit). + """ + if idempotency_key is not None and len(idempotency_key) > 255: + raise ValueError("idempotency_key must be ≤255 characters") + + payload: Dict[str, Any] = {"to": to, "body": body} + if subject is not None: + payload["subject"] = subject + if reply_to is not None: + payload["reply_to"] = reply_to + if priority is not None: + payload["priority"] = priority + # Boolean flag — only send when True. Server default is False; + # sending `false` is no-op + adds payload noise. Pinned in tests. + if expects_reply: + payload["expects_reply"] = True + if reply_to_agent is not None: + payload["reply_to_agent"] = reply_to_agent + if metadata is not None: + payload["metadata"] = metadata + + headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + + return self._client._post("/v1/messages", json=payload, headers=headers) + + def get(self, msg_id: str) -> dict: + """Get a single message by ID.""" + return self._client._get(f"/v1/messages/{msg_id}") + + def mark_read(self, msg_id: str) -> dict: + """Mark a message as read. + + Idempotent — calling on already-``read`` returns 200 unchanged. + Returns 409 (raised as ``CueAPIError``) if the message is in a + terminal state (``acked`` / ``expired``). + """ + return self._client._post(f"/v1/messages/{msg_id}/read", json={}) + + def ack(self, msg_id: str) -> dict: + """Acknowledge a message — terminal state, no further transitions.""" + return self._client._post(f"/v1/messages/{msg_id}/ack", json={}) diff --git a/cueapi/resources/usage.py b/cueapi/resources/usage.py new file mode 100644 index 0000000..230db14 --- /dev/null +++ b/cueapi/resources/usage.py @@ -0,0 +1,31 @@ +"""Usage resource — plan + cue + execution usage stats.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class UsageResource: + """Usage stats resource. + + Wraps ``GET /v1/usage`` for SDK callers who want plan + cue count + + execution count + rate-limit info without parsing the broader + ``/v1/auth/me`` response. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def get(self) -> dict: + """Get current usage stats. + + Returns: + Dict with ``plan`` (name + interval + period_end), + ``cues`` (active count + limit), + ``executions`` (used this period + limit + outcomes summary), + and ``rate_limit`` (requests/min limit). + """ + return self._client._get("/v1/usage") diff --git a/cueapi/resources/workers.py b/cueapi/resources/workers.py new file mode 100644 index 0000000..23e70f0 --- /dev/null +++ b/cueapi/resources/workers.py @@ -0,0 +1,51 @@ +"""Workers resource — fleet visibility for worker-transport users.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class WorkersResource: + """Workers API resource. + + Mirrors the hosted ``/v1/workers`` surface — list registered workers + with heartbeat status, and delete decommissioned workers. Worker + registration itself happens via cueapi-worker (which sends heartbeats); + the SDK doesn't expose ``POST /v1/worker/heartbeat`` because direct + SDK-driven registration is redundant with that package. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def list(self) -> dict: + """List all registered workers with heartbeat status. + + Returns: + Dict with ``workers`` (list of worker dicts) and ``total``. + Each worker carries ``worker_id``, ``handlers``, + ``last_heartbeat``, ``heartbeat_status`` + (``online`` / ``stale`` / ``dead`` based on seconds since + last heartbeat), and ``seconds_since_heartbeat``. + """ + return self._client._get("/v1/workers") + + def delete(self, worker_id: str) -> None: + """Delete a registered worker. + + Removes the worker row; in-flight executions claimed by this + worker will be picked up by the stale-recovery loop. Useful for + cleaning up workers that have been decommissioned. + + Returns ``None`` on success (HTTP 204). Raises + ``CueNotFoundError`` if the worker doesn't exist. + + Args: + worker_id: The caller-defined worker_id used during the + worker's heartbeats. Same value is what appears in + ``list()`` responses. + """ + return self._client._delete(f"/v1/workers/{worker_id}") diff --git a/parity-manifest.json b/parity-manifest.json new file mode 100644 index 0000000..67751cc --- /dev/null +++ b/parity-manifest.json @@ -0,0 +1,94 @@ +{ + "manifest_version": 1, + "description": "Parity tracking for cueapi-sdk against the hosted CueAPI API. Enumerates which API surfaces this SDK covers and which it doesn't, so drift becomes visible at audit time. Per the 3-layer parity discipline (PR template + this manifest + Backlog rows for each port).", + "private_source_repo": "https://github.com/cueapi/cueapi", + "private_source_paths": [ + "app/routers/", + "app/schemas/" + ], + "last_full_audit": "2026-05-04", + "sdk_version_at_audit": "0.1.3", + "audit_methodology": "Walk every endpoint in private cueapi/app/routers/ and check coverage in cueapi/resources/. For each covered endpoint, walk the corresponding schemas/*.py to verify field-level coverage in cueapi/models/. Drift goes in `missing_endpoints` or `missing_fields` keyed by endpoint.", + "audit_cadence": "Monthly full sweep. Per-PR diffs handled via the .github/pull_request_template.md `Parity Impact` section in the private repo.", + + "endpoints_covered": { + "POST /v1/auth/register": {"sdk": "client.register (utility, not on a resource)"}, + "POST /v1/cues": {"sdk": "client.cues.create"}, + "GET /v1/cues": {"sdk": "client.cues.list"}, + "GET /v1/cues/{id}": {"sdk": "client.cues.get"}, + "PATCH /v1/cues/{id}": {"sdk": "client.cues.update"}, + "DELETE /v1/cues/{id}": {"sdk": "client.cues.delete"}, + "POST /v1/cues/{id} (pause)": {"sdk": "client.cues.pause"}, + "POST /v1/cues/{id} (resume)": {"sdk": "client.cues.resume"}, + "POST /v1/executions/{id}/outcome": {"sdk": "client.executions.report_outcome"}, + "GET /v1/executions": {"sdk": "client.executions.list"}, + "GET /v1/executions/{id}": {"sdk": "client.executions.get"}, + "POST /v1/executions/{id}/heartbeat": {"sdk": "client.executions.heartbeat"}, + "POST /v1/executions/{id}/verification-pending": {"sdk": "client.executions.mark_verification_pending"}, + "POST /v1/executions/{id}/verify": {"sdk": "client.executions.mark_verified"} + }, + + "endpoints_missing": { + "POST /v1/cues/{id}/fire": { + "blocker": "Real ergonomic gap. Team-comm convention requires payload_override per fire; SDK users currently fall back to raw httpx.", + "tracking": "Backlog row: priority=now after the audit ships. Should pair with the PR #590 require_payload_override port since cue-fire is the primary surface for that enforcement." + }, + "POST /v1/executions/{id}/replay": {"blocker": "Used for retry-from-failure flows; not in any current SDK release."}, + "GET /v1/executions/claimable": {"blocker": "Worker-pull endpoint; some SDK users want to write Python workers directly without using cueapi-worker."}, + "POST /v1/executions/{id}/claim": {"blocker": "Same as above — worker-pull surface missing."}, + "POST /v1/worker/heartbeat": {"blocker": "Worker registration endpoint; same justification."}, + "GET /v1/workers": {"blocker": "List workers + heartbeat status; useful for fleet visibility."}, + "GET /v1/usage": {"blocker": "Plan, cue count, execution usage, rate limit info. Currently SDK users hit /v1/auth/me only."}, + "POST /v1/billing/checkout": {"blocker": "Hosted-only — wrap if/when hosted users need programmatic checkout."}, + "POST /v1/billing/portal": {"blocker": "Hosted-only."}, + "POST /v1/auth/key/regenerate": {"blocker": "Risky destructive op; intentionally not surfaced. Re-evaluate."}, + "GET /v1/auth/webhook-secret": {"blocker": "Webhook-secret retrieval — SDK users running their own webhook servers want this."}, + "POST /v1/auth/webhook-secret/regenerate": {"blocker": "Destructive; re-evaluate."}, + "Messaging primitive (all of /v1/agents, /v1/messages, /v1/agents/{id}/inbox)": { + "blocker": "Phase 12.1.5 messaging primitive is on prod but not yet exposed in the SDK. Significant new surface — agent identity, send_message, inbox poll, idempotency-keyed sends, reply threading.", + "tracking": "Major SDK extension. Should land before push delivery (v1.5) goes wide." + } + }, + + "model_drift": { + "Cue": { + "sdk_class": "cueapi.models.Cue", + "covered_fields": [ + "id", "name", "description", "status", "transport", "schedule", + "callback", "payload", "retry", "next_run", "last_run", "run_count", + "fired_count", "on_failure", "warning", "created_at", "updated_at" + ], + "missing_fields": [ + "delivery", "alerts", "catch_up", "verification", "on_success_fire", + "require_payload_override", "required_payload_keys", "stats" + ], + "missing_response_shape": "CueDetailResponse (cue + executions[] + execution_total/limit/offset)" + }, + "Execution": { + "sdk_class": "no dedicated model; SDK returns dicts from executions.get/list", + "needs_dedicated_class": true, + "missing_fields": [ + "payload (PR #589, just shipped)", + "outcome", + "outcome_state", + "triggered_by", + "evidence_external_id, evidence_result_url, evidence_result_type, evidence_summary, evidence_validation_state, evidence_assertions", + "claimed_by_worker, claimed_at, last_heartbeat_at", + "chain_parent_id, chain_depth" + ] + }, + "Worker": { + "sdk_class": "missing entirely", + "missing_fields": ["worker_id", "user_id", "handlers", "last_heartbeat", "heartbeat_status (active/stale/dead)"] + }, + "Agent (messaging)": {"sdk_class": "missing entirely (Phase 12.1.5)"}, + "Message (messaging)": {"sdk_class": "missing entirely (Phase 12.1.5)"} + }, + + "ported_pr_history": [ + "PR #589 (expose payload on GET /v1/executions): NOT YET PORTED — Backlog row 'Parity port: PR #589 → cueapi-python SDK' priority=now.", + "PR #590 (require_payload_override + required_payload_keys + cue.fire enforcement): NOT YET PORTED — Backlog row 'Parity port: PR #590 → cueapi-python SDK' priority=now." + ], + + "notes": "First seeded 2026-05-04 as Layer 2 of parity discipline (PR template + this manifest + Backlog rows). Schema may evolve based on what auditors actually need. The `endpoints_missing` and `model_drift` sections are deliberately verbose — they are the audit checklist for catching up the SDK to the hosted API." +} diff --git a/pyproject.toml b/pyproject.toml index 1a598ac..81757fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,24 +4,51 @@ build-backend = "hatchling.build" [project] name = "cueapi-sdk" -version = "0.1.0" -description = "The official Python SDK for CueAPI — scheduling infrastructure for agents" +version = "0.2.0" +description = "Official Python SDK for CueAPI — open-source execution accountability primitive for AI agents. Schedule agent work, require evidence-backed outcomes, and gate execution with write-once verification." readme = "README.md" -license = { text = "MIT" } +license = { text = "Apache-2.0" } requires-python = ">=3.9" -authors = [{ name = "CueAPI", email = "support@cueapi.ai" }] -keywords = ["cueapi", "scheduling", "ai-agents", "cron", "webhooks", "sdk"] +authors = [{ name = "Vector Apps", email = "support@cueapi.ai" }] +maintainers = [{ name = "Vector Apps", email = "support@cueapi.ai" }] +keywords = [ + "cueapi", + "ai-agents", + "agent-infrastructure", + "scheduling", + "cron", + "webhooks", + "sdk", + "llm", + "llm-observability", + "accountability", + "verification", + "outcome-reporting", + "claude", + "gpt", + "gemini", +] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "Intended Audience :: System Administrators", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Monitoring", + "Topic :: System :: Distributed Computing", + "Topic :: Internet :: WWW/HTTP", + "Typing :: Typed", ] dependencies = [ "httpx>=0.24.0", @@ -32,6 +59,10 @@ dependencies = [ Homepage = "https://cueapi.ai" Documentation = "https://docs.cueapi.ai" Repository = "https://github.com/cueapi/cueapi-python" +"Bug Tracker" = "https://github.com/cueapi/cueapi-python/issues" +Changelog = "https://github.com/cueapi/cueapi-python/blob/main/CHANGELOG.md" +"Source Code" = "https://github.com/cueapi/cueapi-python" +"Status Page" = "https://status.cueapi.ai" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_agents_resource.py b/tests/test_agents_resource.py new file mode 100644 index 0000000..58c6de1 --- /dev/null +++ b/tests/test_agents_resource.py @@ -0,0 +1,219 @@ +"""Tests for AgentsResource.""" + +import pytest +from unittest.mock import MagicMock + +from cueapi.resources.agents import AgentsResource + + +class TestCreate: + def test_minimal_only_display_name(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_x", "slug": "team-comm", "display_name": "Team Comm", + "status": "online", + } + r = AgentsResource(mock_client) + + r.create(display_name="Team Comm") + + mock_client._post.assert_called_once_with( + "/v1/agents", + json={"display_name": "Team Comm"}, + ) + + def test_with_all_optionals(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_x", "slug": "team-comm", "display_name": "Team Comm", + "status": "online", "webhook_url": "https://x.example", + "webhook_secret": "wsec_secretvalue", + } + r = AgentsResource(mock_client) + + r.create( + display_name="Team Comm", + slug="team-comm", + webhook_url="https://x.example/webhook", + metadata={"team": "platform"}, + ) + + mock_client._post.assert_called_once_with( + "/v1/agents", + json={ + "display_name": "Team Comm", + "slug": "team-comm", + "webhook_url": "https://x.example/webhook", + "metadata": {"team": "platform"}, + }, + ) + + +class TestList: + def test_defaults_omit_filters(self): + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list() + + params = mock_client._get.call_args.kwargs["params"] + assert params["limit"] == 50 + assert params["offset"] == 0 + assert "status" not in params + assert "include_deleted" not in params + + def test_include_deleted_only_sent_when_true(self): + # Same omit-when-default pattern as `executions list --has-evidence` + # in the CLI. Pinned so a refactor can't silently start sending + # `include_deleted=false` (which is no-op server-side but adds noise). + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list(include_deleted=True) + assert mock_client._get.call_args.kwargs["params"]["include_deleted"] == "true" + + # Reset, run with default — must omit. + mock_client.reset_mock() + r.list() + assert "include_deleted" not in mock_client._get.call_args.kwargs["params"] + + def test_status_filter_passed(self): + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list(status="online") + + assert mock_client._get.call_args.kwargs["params"]["status"] == "online" + + +class TestGet: + def test_get_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.get("agt_x") + + mock_client._get.assert_called_once_with("/v1/agents/agt_x", params={}) + + def test_get_with_include_deleted(self): + mock_client = MagicMock() + mock_client._get.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.get("agt_x", include_deleted=True) + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x", params={"include_deleted": "true"} + ) + + +class TestUpdate: + def test_partial_body(self): + mock_client = MagicMock() + mock_client._patch.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.update("agt_x", status="away") + + mock_client._patch.assert_called_once_with( + "/v1/agents/agt_x", json={"status": "away"} + ) + + def test_clear_webhook_url_sends_explicit_null(self): + # Mirror of cueapi-cli #28's --clear-webhook-url pin. Server uses + # model_fields_set to disambiguate "field omitted = no change" + # vs "field explicitly null = clear", so the SDK MUST send None + # (literal JSON null) rather than omit the key. + mock_client = MagicMock() + mock_client._patch.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.update("agt_x", clear_webhook_url=True) + + sent_body = mock_client._patch.call_args.kwargs["json"] + assert "webhook_url" in sent_body + assert sent_body["webhook_url"] is None + + def test_webhook_url_and_clear_mutually_exclusive(self): + mock_client = MagicMock() + r = AgentsResource(mock_client) + + with pytest.raises(ValueError, match="mutually exclusive"): + r.update("agt_x", webhook_url="https://x.example", clear_webhook_url=True) + + +class TestDelete: + def test_delete(self): + mock_client = MagicMock() + mock_client._delete.return_value = None + r = AgentsResource(mock_client) + + result = r.delete("agt_x") + + mock_client._delete.assert_called_once_with("/v1/agents/agt_x") + assert result is None + + +class TestWebhookSecret: + def test_get(self): + mock_client = MagicMock() + mock_client._get.return_value = {"webhook_secret": "wsec_revealed"} + r = AgentsResource(mock_client) + + r.webhook_secret_get("agt_x") + + mock_client._get.assert_called_once_with("/v1/agents/agt_x/webhook-secret") + + def test_regenerate_sends_destructive_header(self): + # Server requires X-Confirm-Destructive: true for this op. Pin + # the header so a refactor can't drop it (which would 400). + mock_client = MagicMock() + mock_client._post.return_value = {"webhook_secret": "wsec_new"} + r = AgentsResource(mock_client) + + r.webhook_secret_regenerate("agt_x") + + mock_client._post.assert_called_once_with( + "/v1/agents/agt_x/webhook-secret/regenerate", + json={}, + headers={"X-Confirm-Destructive": "true"}, + ) + + +class TestInbox: + def test_inbox_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.inbox("agt_x") + + params = mock_client._get.call_args.kwargs["params"] + assert params == {"limit": 50, "offset": 0} + + def test_inbox_with_state_filter(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.inbox("agt_x", state="queued") + + assert mock_client._get.call_args.kwargs["params"]["state"] == "queued" + + +class TestSent: + def test_sent_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.sent("agt_x") + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x/sent", + params={"limit": 50, "offset": 0}, + ) diff --git a/tests/test_cue_model.py b/tests/test_cue_model.py new file mode 100644 index 0000000..695e68d --- /dev/null +++ b/tests/test_cue_model.py @@ -0,0 +1,182 @@ +"""Unit tests for the Cue Pydantic model — drift-against-hosted-API coverage. + +These tests validate that the Cue model deserializes the full server +response shape, not just the subset the SDK had before the +2026-05-04 fix-up. Run against synthesized payloads that mirror what +the hosted ``app/schemas/cue.py CueResponse`` returns. +""" + +from datetime import datetime, timezone + +from cueapi.models.cue import ( + AlertConfig, + Cue, + CueList, + DeliveryConfig, + VerificationConfig, +) + + +def _base_cue_payload() -> dict: + return { + "id": "cue_test123", + "name": "test-cue", + "status": "active", + "transport": "webhook", + "schedule": {"type": "recurring", "cron": "0 9 * * *", "timezone": "UTC"}, + "callback": {"url": "https://example.com/webhook", "method": "POST"}, + "payload": {}, + "retry": {"max_attempts": 3, "backoff_minutes": [1, 5, 15]}, + "next_run": None, + "last_run": None, + "run_count": 0, + "fired_count": 0, + "warning": None, + "created_at": "2026-05-04T17:00:00Z", + "updated_at": "2026-05-04T17:00:00Z", + } + + +class TestNewFields: + def test_old_response_still_parses(self): + # Older server responses without the new fields must still + # deserialize cleanly. Pinning so a future required-field + # addition doesn't break SDK callers reading legacy data. + cue = Cue.model_validate(_base_cue_payload()) + assert cue.delivery is None + assert cue.alerts is None + assert cue.catch_up is None + assert cue.verification is None + assert cue.on_success_fire is None + assert cue.require_payload_override is False + assert cue.required_payload_keys is None + assert cue.stats is None + + def test_delivery_config_parses(self): + payload = _base_cue_payload() + payload["delivery"] = {"timeout_seconds": 60, "outcome_deadline_seconds": 600} + cue = Cue.model_validate(payload) + assert isinstance(cue.delivery, DeliveryConfig) + assert cue.delivery.timeout_seconds == 60 + assert cue.delivery.outcome_deadline_seconds == 600 + + def test_alerts_config_forward_compat(self): + # AlertConfig has extra="allow" so server can grow the object + # without the SDK breaking. Pin the forward-compat behavior. + payload = _base_cue_payload() + payload["alerts"] = { + "channels": ["email", "slack"], + "future_field_we_dont_know_about_yet": "value", + } + cue = Cue.model_validate(payload) + assert isinstance(cue.alerts, AlertConfig) + assert cue.alerts.model_extra["channels"] == ["email", "slack"] + assert cue.alerts.model_extra["future_field_we_dont_know_about_yet"] == "value" + + def test_catch_up_passthrough(self): + for v in ("run_once_if_missed", "skip_missed", "replay_all"): + payload = _base_cue_payload() + payload["catch_up"] = v + cue = Cue.model_validate(payload) + assert cue.catch_up == v + + def test_verification_config_with_assertions(self): + payload = _base_cue_payload() + payload["verification"] = { + "mode": "evidence_required", + "required_assertions": ["external_id", "result_url"], + } + cue = Cue.model_validate(payload) + assert isinstance(cue.verification, VerificationConfig) + assert cue.verification.mode == "evidence_required" + assert cue.verification.required_assertions == ["external_id", "result_url"] + + def test_verification_config_forward_compat(self): + payload = _base_cue_payload() + payload["verification"] = { + "mode": "manual", + "future_assertion_subkey": {"nested": True}, + } + cue = Cue.model_validate(payload) + assert cue.verification.mode == "manual" + assert cue.verification.model_extra["future_assertion_subkey"] == {"nested": True} + + def test_on_success_fire(self): + payload = _base_cue_payload() + payload["on_success_fire"] = "cue_chained123" + cue = Cue.model_validate(payload) + assert cue.on_success_fire == "cue_chained123" + + def test_require_payload_override_explicitly_true(self): + payload = _base_cue_payload() + payload["require_payload_override"] = True + payload["required_payload_keys"] = ["task", "message"] + cue = Cue.model_validate(payload) + assert cue.require_payload_override is True + assert cue.required_payload_keys == ["task", "message"] + + def test_stats_blob(self): + # CueDetailResponse-only field. Pin that the SDK accepts the + # blob shape the server returns, opaquely (the keys evolve + # server-side and we don't want to lock them). + payload = _base_cue_payload() + payload["stats"] = { + "success_rate_7d": 0.94, + "miss_rate_7d": 0.02, + "total_executions_7d": 156, + } + cue = Cue.model_validate(payload) + assert cue.stats == { + "success_rate_7d": 0.94, + "miss_rate_7d": 0.02, + "total_executions_7d": 156, + } + + +class TestRoundTrip: + def test_full_response_roundtrip(self): + # Comprehensive: every new field set, ensure the model accepts + # the union shape and re-serializes to a dict that contains all + # the field names the server expects to see in a write-side + # request (when the SDK eventually grows builder-style helpers + # that send these fields back to the server). + payload = _base_cue_payload() + payload.update({ + "delivery": {"timeout_seconds": 90, "outcome_deadline_seconds": 900}, + "alerts": {"channels": ["email"]}, + "catch_up": "skip_missed", + "verification": { + "mode": "evidence_required", + "required_assertions": ["external_id"], + }, + "on_success_fire": "cue_next", + "require_payload_override": True, + "required_payload_keys": ["task"], + "stats": {"success_rate_7d": 1.0}, + }) + cue = Cue.model_validate(payload) + + # All fields present in dict roundtrip. + d = cue.model_dump() + assert d["delivery"]["timeout_seconds"] == 90 + assert d["catch_up"] == "skip_missed" + assert d["on_success_fire"] == "cue_next" + assert d["require_payload_override"] is True + assert d["required_payload_keys"] == ["task"] + + +class TestCueList: + def test_list_with_new_fields_in_each_cue(self): + list_payload = { + "cues": [ + {**_base_cue_payload(), "id": "cue_1", "require_payload_override": True}, + {**_base_cue_payload(), "id": "cue_2", "catch_up": "replay_all"}, + ], + "total": 2, + "limit": 50, + "offset": 0, + } + cl = CueList.model_validate(list_payload) + assert len(cl.cues) == 2 + assert cl.cues[0].require_payload_override is True + assert cl.cues[1].catch_up == "replay_all" diff --git a/tests/test_cues_resource.py b/tests/test_cues_resource.py new file mode 100644 index 0000000..fa7f6ec --- /dev/null +++ b/tests/test_cues_resource.py @@ -0,0 +1,57 @@ +"""Unit tests for CuesResource methods that don't fit the staging-integration pattern.""" + +from unittest.mock import MagicMock + +from cueapi.resources.cues import CuesResource + + +class TestFire: + def test_fire_no_payload_override(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_test", "status": "queued"} + resource = CuesResource(mock_client) + + result = resource.fire("cue_abc123") + + mock_client._post.assert_called_once_with("/v1/cues/cue_abc123/fire", json={}) + assert result["id"] == "exec_test" + + def test_fire_with_payload_override_only(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_test"} + resource = CuesResource(mock_client) + + payload = {"task": "downstream", "scope": "single-row"} + resource.fire("cue_abc123", payload_override=payload) + + mock_client._post.assert_called_once_with( + "/v1/cues/cue_abc123/fire", + json={"payload_override": payload}, + ) + + def test_fire_with_payload_override_and_merge_strategy(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_test"} + resource = CuesResource(mock_client) + + payload = {"run_id": "ad-hoc-001"} + resource.fire("cue_abc123", payload_override=payload, merge_strategy="replace") + + mock_client._post.assert_called_once_with( + "/v1/cues/cue_abc123/fire", + json={"payload_override": payload, "merge_strategy": "replace"}, + ) + + def test_fire_omits_merge_strategy_when_not_passed(self): + # When the caller omits merge_strategy, the wrapper must NOT send a + # client-side default. The server's Pydantic default of "merge" + # applies. This pins the contract so a future refactor can't silently + # start sending a strategy that overrides the server's choice. + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_test"} + resource = CuesResource(mock_client) + + resource.fire("cue_abc123", payload_override={"k": "v"}) + + sent_body = mock_client._post.call_args.kwargs["json"] + assert "merge_strategy" not in sent_body diff --git a/tests/test_executions_resource.py b/tests/test_executions_resource.py index 9f6c7e1..f95bb9a 100644 --- a/tests/test_executions_resource.py +++ b/tests/test_executions_resource.py @@ -50,6 +50,122 @@ def test_report_with_evidence(self): assert body["result_type"] == "tweet" assert body["success"] is True + def test_report_with_result_ref(self): + # result_ref was added in 0.1.4 (PR #20); confirm it reaches the body. + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome( + "exec_123", + success=True, + result_ref="batch-id:7842", + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body["result_ref"] == "batch-id:7842" + assert body["success"] is True + + def test_report_with_summary(self): + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome( + "exec_123", + success=True, + summary="Generated 47 qualified leads", + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body["summary"] == "Generated 47 qualified leads" + + def test_report_with_artifacts(self): + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + artifacts = [ + {"name": "leads.csv", "url": "https://storage/leads.csv"}, + {"name": "report.pdf", "url": "https://storage/report.pdf"}, + ] + resource.report_outcome( + "exec_123", + success=True, + artifacts=artifacts, + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body["artifacts"] == artifacts + + def test_report_with_metadata(self): + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome( + "exec_123", + success=True, + metadata={"agent": "lead-finder-v3", "duration_ms": 1420}, + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body["metadata"] == {"agent": "lead-finder-v3", "duration_ms": 1420} + + def test_report_with_all_evidence_fields(self): + # Single request that exercises every optional kwarg the API + # currently accepts. Guards against a future refactor silently + # dropping one of them. + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome( + "exec_123", + success=True, + result="ok", + metadata={"foo": "bar"}, + external_id="ext-1", + result_url="https://example.com/1", + result_ref="ref-1", + result_type="report", + summary="done", + artifacts=[{"name": "a.json", "url": "https://example.com/a"}], + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body == { + "success": True, + "result": "ok", + "metadata": {"foo": "bar"}, + "external_id": "ext-1", + "result_url": "https://example.com/1", + "result_ref": "ref-1", + "result_type": "report", + "summary": "done", + "artifacts": [{"name": "a.json", "url": "https://example.com/a"}], + } + + def test_report_omits_none_kwargs(self): + # Optional kwargs left at their None default must NOT appear + # in the POST body. Important because the server distinguishes + # "field not provided" from "field explicitly set to null" + # for evidence merging semantics. + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome("exec_123", success=True) + + body = mock_client._post.call_args.kwargs["json"] + assert body == {"success": True} + for key in ( + "result", + "error", + "metadata", + "external_id", + "result_url", + "result_ref", + "result_type", + "summary", + "artifacts", + ): + assert key not in body + class TestContextManager: def test_clean_exit_reports_success(self): @@ -154,7 +270,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 +285,218 @@ 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"} + + +class TestListClaimable: + # Filtering MUST be server-side via query params, not client-side after + # fetch. Client-side filter hits the LIMIT 50 starvation bug fixed in the + # 2026-04-25 prod incident (see cueapi-core app/routers/executions.py + # docstring at line 122-131). + + def test_list_claimable_no_filters_sends_no_params(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + resource.list_claimable() + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", params={}, + ) + + def test_list_claimable_passes_task_as_query_param(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + resource.list_claimable(task="cowork-workspace") + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", + params={"task": "cowork-workspace"}, + ) + + def test_list_claimable_passes_agent_as_query_param(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + resource.list_claimable(agent="writer-bot") + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", + params={"agent": "writer-bot"}, + ) + + def test_list_claimable_passes_both_task_and_agent(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + resource.list_claimable(task="t", agent="a") + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", + params={"task": "t", "agent": "a"}, + ) + + +class TestClaim: + def test_claim_posts_to_specific_execution_with_worker_id_in_body(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "claimed": True, + "execution_id": "exec_abc123", + "lease_seconds": 900, + } + resource = ExecutionsResource(mock_client) + + result = resource.claim("exec_abc123", worker_id="cowork-workspace") + + mock_client._post.assert_called_once_with( + "/v1/executions/exec_abc123/claim", + json={"worker_id": "cowork-workspace"}, ) + assert result["claimed"] is True + + +class TestClaimNext: + # Two branches: with task and without. Without task is a single POST. + # With task is a fan-out (list_claimable filtered, pick first, claim by ID) + # because the server's POST /v1/executions/claim does not accept a task + # filter today. + + def test_claim_next_without_task_sends_single_post(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "claimed": True, + "execution_id": "exec_test", + "lease_seconds": 900, + } + resource = ExecutionsResource(mock_client) + + resource.claim_next(worker_id="cowork-workspace") + + mock_client._post.assert_called_once_with( + "/v1/executions/claim", + json={"worker_id": "cowork-workspace"}, + ) + + def test_claim_next_with_task_fans_out_to_list_then_claim(self): + mock_client = MagicMock() + mock_client._get.return_value = { + "executions": [ + {"execution_id": "exec_first"}, + {"execution_id": "exec_second"}, + ], + } + mock_client._post.return_value = { + "claimed": True, + "execution_id": "exec_first", + "lease_seconds": 900, + } + resource = ExecutionsResource(mock_client) + + result = resource.claim_next( + worker_id="cowork-workspace", task="cowork-workspace" + ) + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", params={"task": "cowork-workspace"}, + ) + mock_client._post.assert_called_once_with( + "/v1/executions/exec_first/claim", + json={"worker_id": "cowork-workspace"}, + ) + assert result["claimed"] is True + + def test_claim_next_with_task_and_empty_list_returns_no_claim(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + result = resource.claim_next( + worker_id="cowork-workspace", task="no-such-task" + ) + + mock_client._post.assert_not_called() + assert result == { + "claimed": False, + "reason": "no_executions_for_task", + "task": "no-such-task", + } diff --git a/tests/test_messages_resource.py b/tests/test_messages_resource.py new file mode 100644 index 0000000..d5c5ede --- /dev/null +++ b/tests/test_messages_resource.py @@ -0,0 +1,139 @@ +"""Tests for MessagesResource.""" + +import pytest +from unittest.mock import MagicMock + +from cueapi.resources.messages import MessagesResource + + +class TestSend: + def test_minimal_body_and_from_header(self): + # Pin: --from goes in X-Cueapi-From-Agent HEADER, NOT in body. + # The server's MessageCreate is extra="forbid" and would 400 on + # `{"from": "..."}` in the body, but we want this caught at unit + # test time, not silently at integration. + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "msg_x", "delivery_state": "queued", "thread_id": "thr_x", + } + r = MessagesResource(mock_client) + + r.send(from_agent="sender@x", to="recipient@y", body="hi") + + mock_client._post.assert_called_once_with( + "/v1/messages", + json={"to": "recipient@y", "body": "hi"}, + headers={"X-Cueapi-From-Agent": "sender@x"}, + ) + + def test_with_all_optionals(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send( + from_agent="sender@x", + to="recipient@y", + body="hi", + subject="re: hello", + reply_to="msg_abcdef123456", + priority=5, + expects_reply=True, + reply_to_agent="alt@x", + metadata={"k": "v"}, + idempotency_key="idemp-key-1", + ) + + call = mock_client._post.call_args + assert call.args == ("/v1/messages",) + assert call.kwargs["json"] == { + "to": "recipient@y", + "body": "hi", + "subject": "re: hello", + "reply_to": "msg_abcdef123456", + "priority": 5, + "expects_reply": True, + "reply_to_agent": "alt@x", + "metadata": {"k": "v"}, + } + assert call.kwargs["headers"] == { + "X-Cueapi-From-Agent": "sender@x", + "Idempotency-Key": "idemp-key-1", + } + + def test_omits_expects_reply_when_default(self): + # Pin: default False MUST NOT appear in body. Server's Pydantic + # default is False; sending `expects_reply: false` is no-op + adds + # noise. Refactor that always-sends would slip past the typed + # server schema but be caught here. + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x"} + r = MessagesResource(mock_client) + + r.send(from_agent="x", to="y", body="hi") + + body = mock_client._post.call_args.kwargs["json"] + assert "expects_reply" not in body + + def test_idempotency_key_too_long_raises_client_side(self): + mock_client = MagicMock() + r = MessagesResource(mock_client) + + with pytest.raises(ValueError, match="255"): + r.send( + from_agent="x", to="y", body="hi", + idempotency_key="x" * 256, + ) + # Crucially: must NOT have hit the wire. + mock_client._post.assert_not_called() + + def test_omits_idempotency_key_header_when_unset(self): + # Headers should ONLY contain X-Cueapi-From-Agent when no + # idempotency_key is passed. Pin so a refactor can't silently + # start adding `Idempotency-Key: None` (httpx would coerce). + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x"} + r = MessagesResource(mock_client) + + r.send(from_agent="x", to="y", body="hi") + + headers = mock_client._post.call_args.kwargs["headers"] + assert headers == {"X-Cueapi-From-Agent": "x"} + assert "Idempotency-Key" not in headers + + +class TestGet: + def test_get(self): + mock_client = MagicMock() + mock_client._get.return_value = {"id": "msg_x"} + r = MessagesResource(mock_client) + + r.get("msg_x") + + mock_client._get.assert_called_once_with("/v1/messages/msg_x") + + +class TestMarkRead: + def test_mark_read(self): + mock_client = MagicMock() + mock_client._post.return_value = {"delivery_state": "read"} + r = MessagesResource(mock_client) + + r.mark_read("msg_x") + + mock_client._post.assert_called_once_with( + "/v1/messages/msg_x/read", json={}, + ) + + +class TestAck: + def test_ack(self): + mock_client = MagicMock() + mock_client._post.return_value = {"delivery_state": "acked"} + r = MessagesResource(mock_client) + + r.ack("msg_x") + + mock_client._post.assert_called_once_with( + "/v1/messages/msg_x/ack", json={}, + ) diff --git a/tests/test_usage_resource.py b/tests/test_usage_resource.py new file mode 100644 index 0000000..aa1be8b --- /dev/null +++ b/tests/test_usage_resource.py @@ -0,0 +1,34 @@ +"""Tests for UsageResource.""" + +from unittest.mock import MagicMock + +from cueapi.resources.usage import UsageResource + + +class TestGet: + def test_get_calls_get_usage(self): + mock_client = MagicMock() + mock_client._get.return_value = { + "plan": {"name": "pro", "interval": "monthly"}, + "cues": {"active": 12, "limit": 100}, + "executions": {"used": 543, "limit": 5000}, + "rate_limit": {"limit": 200}, + } + resource = UsageResource(mock_client) + + result = resource.get() + + mock_client._get.assert_called_once_with("/v1/usage") + assert result["plan"]["name"] == "pro" + assert result["cues"]["active"] == 12 + + def test_get_returns_server_dict_unchanged(self): + # Pin the no-transform behavior so a future refactor can't + # silently start coercing the response into a typed object + # without bumping the major version. + mock_client = MagicMock() + mock_client._get.return_value = {"unexpected_field": "value"} + resource = UsageResource(mock_client) + + result = resource.get() + assert result == {"unexpected_field": "value"} diff --git a/tests/test_workers_resource.py b/tests/test_workers_resource.py new file mode 100644 index 0000000..e9f96d3 --- /dev/null +++ b/tests/test_workers_resource.py @@ -0,0 +1,55 @@ +"""Tests for WorkersResource.""" + +from unittest.mock import MagicMock + +from cueapi.resources.workers import WorkersResource + + +class TestList: + def test_list_calls_get_workers(self): + mock_client = MagicMock() + mock_client._get.return_value = { + "workers": [ + { + "worker_id": "worker-1", + "handlers": ["task-a"], + "last_heartbeat": "2026-05-04T17:30:00Z", + "heartbeat_status": "online", + "seconds_since_heartbeat": 5, + } + ], + "total": 1, + } + resource = WorkersResource(mock_client) + + result = resource.list() + + mock_client._get.assert_called_once_with("/v1/workers") + assert result["total"] == 1 + assert result["workers"][0]["worker_id"] == "worker-1" + + def test_list_passes_no_params(self): + # Endpoint accepts no query params; SDK MUST NOT silently start + # passing params (would couple to a future server-side change). + # Pinning the bare-call shape. + mock_client = MagicMock() + mock_client._get.return_value = {"workers": [], "total": 0} + resource = WorkersResource(mock_client) + + resource.list() + + mock_client._get.assert_called_once_with("/v1/workers") + # No params kwarg. + assert "params" not in mock_client._get.call_args.kwargs + + +class TestDelete: + def test_delete_calls_delete_workers_id(self): + mock_client = MagicMock() + mock_client._delete.return_value = None # 204 -> None per client _request + resource = WorkersResource(mock_client) + + result = resource.delete("worker-1") + + mock_client._delete.assert_called_once_with("/v1/workers/worker-1") + assert result is None