continuum-task-server-sdk 0.0.9__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: continuum-task-server-sdk
3
- Version: 0.0.9
3
+ Version: 0.1.0
4
4
  Summary: Python SDK for the Continuum Task Server
5
5
  Project-URL: Homepage, https://github.com/ContinuumWorkflow/continuum-task-server-sdk-python
6
6
  Project-URL: Issues, https://github.com/ContinuumWorkflow/continuum-task-server-sdk-python/issues
@@ -19,6 +19,7 @@ Classifier: Topic :: Software Development :: Libraries
19
19
  Requires-Python: >=3.10
20
20
  Requires-Dist: httpx<1.0,>=0.27
21
21
  Requires-Dist: pydantic<3.0,>=2.6
22
+ Requires-Dist: websocket-client<2.0,>=1.8
22
23
  Provides-Extra: dev
23
24
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
25
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -30,8 +31,9 @@ Description-Content-Type: text/markdown
30
31
 
31
32
  Python client for the [Continuum](https://github.com/ContinuumWorkflow) task server. Designed so a 20-line script can stand up a worker that claims queue items, runs your code, and reports results.
32
33
 
33
- - **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown. Long-running claims (`auto_complete=False`) need **your** heartbeat loop (documented below).
34
+ - **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown. Defaults to HTTP polling; set ``transport="websocket"`` (or ``CONTINUUM_TRANSPORT=websocket`` in the example) to claim from STOMP ``work.available`` hints instead.
34
35
  - **`ContinuumClient`** — thin pythonic client over the management + queue REST APIs (task types, task items, versions, queue, content store).
36
+ - **`ContinuumWebSocketClient`** — synchronous STOMP-over-WebSocket client for the worker hot path (enqueue, claim, heartbeat, status, ``subscribeWork``, durable ``subscribeWait`` / ``ackEvent``).
35
37
 
36
38
  Requires Python 3.10+.
37
39
 
@@ -63,19 +65,24 @@ server = TaskServer(
63
65
  api_key=os.environ["CONTINUUM_API_KEY"],
64
66
  )
65
67
 
68
+
66
69
  @server.task("echo")
67
70
  def echo(item):
68
71
  return {"echoed": item.input_data_json}
69
72
 
73
+
70
74
  @server.task("greet")
71
75
  def greet(item):
72
76
  name = (item.input_data_json or {}).get("name", "world")
73
77
  return {"message": f"hello, {name}"}
74
78
 
79
+
75
80
  if __name__ == "__main__":
76
81
  server.run()
77
82
  ```
78
83
 
84
+ Set `CONTINUUM_TRANSPORT=websocket` (and enable Queue API WebSockets) to replace polling with STOMP wake-ups. HTTP polling remains the default.
85
+
79
86
  Run it. The server polls `/api/queue/claim` for `echo` and `greet`, claims items as they become available, runs your handler in a thread, heartbeats while it runs, and:
80
87
 
81
88
  - **Handler returns** a value → queue item marked `ENDED`, return value JSON-encoded as `outputData` (default `auto_complete=True`).
@@ -95,6 +102,7 @@ def wait_for_mail(item):
95
102
  db.insert_outstanding(queue_item_id=str(item.id), payload=item.input_data_json)
96
103
  # Returns without ENDED — no background heartbeat from TaskServer
97
104
 
105
+
98
106
  # Elsewhere: each time you poll your DB for outstanding work (including after restart):
99
107
  for row in db.outstanding_rows():
100
108
  server.client.queue.heartbeat(row.queue_item_id)
@@ -110,11 +118,12 @@ Standalone process (no `TaskServer`): build a `ContinuumClient` with the worker
110
118
  TaskServer(
111
119
  base_url="http://localhost:8080",
112
120
  api_key="...",
113
- max_workers=4, # thread pool size across all tasks
114
- poll_interval=1.0, # initial poll delay (backs off when idle)
115
- max_poll_interval=5.0, # max idle poll delay
121
+ max_workers=4, # thread pool size across all tasks
122
+ poll_interval=1.0, # initial poll delay (backs off when idle)
123
+ max_poll_interval=5.0, # max idle poll delay
116
124
  heartbeat_interval=15.0, # how often to call /heartbeat per running task
117
- shutdown_timeout=30.0, # how long to wait for handlers during shutdown
125
+ shutdown_timeout=30.0, # how long to wait for handlers during shutdown
126
+ transport="http", # "websocket" uses STOMP; httpx transports still accepted
118
127
  )
119
128
  ```
120
129
 
@@ -122,8 +131,7 @@ Per-task concurrency limit:
122
131
 
123
132
  ```python
124
133
  @server.task("docker-run", concurrency=2)
125
- def run_docker(item):
126
- ...
134
+ def run_docker(item): ...
127
135
  ```
128
136
 
129
137
  Handler signature: `def handler(item: QueueItem) -> dict | list | str | None`. Inside, you have:
@@ -163,9 +171,44 @@ with ContinuumClient(base_url="http://localhost:8080", api_key="...") as client:
163
171
 
164
172
  `input_data` / `output_data` accept `dict` / `list` / `str` / `None`; non-string values are JSON-encoded for you.
165
173
 
174
+ ## WebSocket / STOMP
175
+
176
+ The Queue API worker socket is native STOMP at `/ws/queue` (API-key handshake, no SockJS/JWT). `http://` base URLs become `ws://`; `https://` becomes `wss://`.
177
+
178
+ ```python
179
+ from continuum_task_server import ContinuumWebSocketClient, TaskStatus, WaitMode
180
+
181
+ with ContinuumWebSocketClient(base_url, api_key) as queue:
182
+ queue.subscribe_work("echo")
183
+ hint = queue.next_event(timeout=30) # work.available is a hint, not a reservation
184
+ item = queue.claim("echo") # None if another worker won
185
+ queue.heartbeat(item.id)
186
+ wait = queue.subscribe_wait(
187
+ "run-42",
188
+ mode=WaitMode.EACH,
189
+ queue_item_ids=[item.id],
190
+ )
191
+ # Or create the child and wait in one transaction:
192
+ # created = queue.enqueue_and_subscribe_wait(
193
+ # task_name="echo", subscriber_id="run-42", mode=WaitMode.EACH, input_data={"hello": "world"},
194
+ # )
195
+ queue.update_status(item.id, TaskStatus.ENDED, output_data={"ok": True})
196
+ completed = queue.next_event(timeout=30) # queue.completed; dedupe by event_id
197
+ queue.ack_event("run-42", completed.event_id)
198
+ queue.unsubscribe_work("echo")
199
+ ```
200
+
201
+ - **TaskServer WebSocket mode** uses this client for subscribe / claim / heartbeat / status. There is no HTTP fallback poll; recovery is reconnect + resubscribe. The API also checks PostgreSQL at subscribe time and periodically for connected sessions.
202
+ - **Reconnect** restores `/user/queue/replies` and `/user/queue/events`, every `subscribeWork` task name, and every durable `subscriberId` via bind-only `subscribeWait` (replays unacked completions from PostgreSQL).
203
+ - **Acks are explicit.** Do not assume the SDK acked a delivery. Completions are at-least-once; ignore duplicate `eventId`s.
204
+ - **Claim and enqueue are not retried** if the socket drops after the frame is written. The reply may have been lost; check queue state instead of sending the same command again. Heartbeat and status wait for reconnect and retry.
205
+ - **Durable waits** use a client-chosen `subscriberId` scoped to the API-key owner. `EACH` delivers per terminal item; `ALL` delivers one summary. Bind with only `subscriberId` after reconnect.
206
+
207
+ See `examples/websocket_bindings.py` and `examples/wait_subscriber.py`.
208
+
166
209
  ### Errors
167
210
 
168
- All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`. Each carries `status_code` and `body`.
211
+ All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `ServerError`, plus WebSocket `TransportError`, `ProtocolError`, `CommandTimeoutError`, and `AmbiguousCommandError`. Each carries `status_code` and `body` when they come from the API.
169
212
 
170
213
  ```python
171
214
  from continuum_task_server import ContinuumClient, NotFoundError
@@ -202,7 +245,7 @@ ruff format --check .
202
245
  pytest
203
246
  ```
204
247
 
205
- Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport — no live server required.
248
+ Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport and an in-memory WebSocket for STOMP tests — no live server required.
206
249
 
207
250
  ## Versioning
208
251
 
@@ -2,8 +2,9 @@
2
2
 
3
3
  Python client for the [Continuum](https://github.com/ContinuumWorkflow) task server. Designed so a 20-line script can stand up a worker that claims queue items, runs your code, and reports results.
4
4
 
5
- - **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown. Long-running claims (`auto_complete=False`) need **your** heartbeat loop (documented below).
5
+ - **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown. Defaults to HTTP polling; set ``transport="websocket"`` (or ``CONTINUUM_TRANSPORT=websocket`` in the example) to claim from STOMP ``work.available`` hints instead.
6
6
  - **`ContinuumClient`** — thin pythonic client over the management + queue REST APIs (task types, task items, versions, queue, content store).
7
+ - **`ContinuumWebSocketClient`** — synchronous STOMP-over-WebSocket client for the worker hot path (enqueue, claim, heartbeat, status, ``subscribeWork``, durable ``subscribeWait`` / ``ackEvent``).
7
8
 
8
9
  Requires Python 3.10+.
9
10
 
@@ -35,19 +36,24 @@ server = TaskServer(
35
36
  api_key=os.environ["CONTINUUM_API_KEY"],
36
37
  )
37
38
 
39
+
38
40
  @server.task("echo")
39
41
  def echo(item):
40
42
  return {"echoed": item.input_data_json}
41
43
 
44
+
42
45
  @server.task("greet")
43
46
  def greet(item):
44
47
  name = (item.input_data_json or {}).get("name", "world")
45
48
  return {"message": f"hello, {name}"}
46
49
 
50
+
47
51
  if __name__ == "__main__":
48
52
  server.run()
49
53
  ```
50
54
 
55
+ Set `CONTINUUM_TRANSPORT=websocket` (and enable Queue API WebSockets) to replace polling with STOMP wake-ups. HTTP polling remains the default.
56
+
51
57
  Run it. The server polls `/api/queue/claim` for `echo` and `greet`, claims items as they become available, runs your handler in a thread, heartbeats while it runs, and:
52
58
 
53
59
  - **Handler returns** a value → queue item marked `ENDED`, return value JSON-encoded as `outputData` (default `auto_complete=True`).
@@ -67,6 +73,7 @@ def wait_for_mail(item):
67
73
  db.insert_outstanding(queue_item_id=str(item.id), payload=item.input_data_json)
68
74
  # Returns without ENDED — no background heartbeat from TaskServer
69
75
 
76
+
70
77
  # Elsewhere: each time you poll your DB for outstanding work (including after restart):
71
78
  for row in db.outstanding_rows():
72
79
  server.client.queue.heartbeat(row.queue_item_id)
@@ -82,11 +89,12 @@ Standalone process (no `TaskServer`): build a `ContinuumClient` with the worker
82
89
  TaskServer(
83
90
  base_url="http://localhost:8080",
84
91
  api_key="...",
85
- max_workers=4, # thread pool size across all tasks
86
- poll_interval=1.0, # initial poll delay (backs off when idle)
87
- max_poll_interval=5.0, # max idle poll delay
92
+ max_workers=4, # thread pool size across all tasks
93
+ poll_interval=1.0, # initial poll delay (backs off when idle)
94
+ max_poll_interval=5.0, # max idle poll delay
88
95
  heartbeat_interval=15.0, # how often to call /heartbeat per running task
89
- shutdown_timeout=30.0, # how long to wait for handlers during shutdown
96
+ shutdown_timeout=30.0, # how long to wait for handlers during shutdown
97
+ transport="http", # "websocket" uses STOMP; httpx transports still accepted
90
98
  )
91
99
  ```
92
100
 
@@ -94,8 +102,7 @@ Per-task concurrency limit:
94
102
 
95
103
  ```python
96
104
  @server.task("docker-run", concurrency=2)
97
- def run_docker(item):
98
- ...
105
+ def run_docker(item): ...
99
106
  ```
100
107
 
101
108
  Handler signature: `def handler(item: QueueItem) -> dict | list | str | None`. Inside, you have:
@@ -135,9 +142,44 @@ with ContinuumClient(base_url="http://localhost:8080", api_key="...") as client:
135
142
 
136
143
  `input_data` / `output_data` accept `dict` / `list` / `str` / `None`; non-string values are JSON-encoded for you.
137
144
 
145
+ ## WebSocket / STOMP
146
+
147
+ The Queue API worker socket is native STOMP at `/ws/queue` (API-key handshake, no SockJS/JWT). `http://` base URLs become `ws://`; `https://` becomes `wss://`.
148
+
149
+ ```python
150
+ from continuum_task_server import ContinuumWebSocketClient, TaskStatus, WaitMode
151
+
152
+ with ContinuumWebSocketClient(base_url, api_key) as queue:
153
+ queue.subscribe_work("echo")
154
+ hint = queue.next_event(timeout=30) # work.available is a hint, not a reservation
155
+ item = queue.claim("echo") # None if another worker won
156
+ queue.heartbeat(item.id)
157
+ wait = queue.subscribe_wait(
158
+ "run-42",
159
+ mode=WaitMode.EACH,
160
+ queue_item_ids=[item.id],
161
+ )
162
+ # Or create the child and wait in one transaction:
163
+ # created = queue.enqueue_and_subscribe_wait(
164
+ # task_name="echo", subscriber_id="run-42", mode=WaitMode.EACH, input_data={"hello": "world"},
165
+ # )
166
+ queue.update_status(item.id, TaskStatus.ENDED, output_data={"ok": True})
167
+ completed = queue.next_event(timeout=30) # queue.completed; dedupe by event_id
168
+ queue.ack_event("run-42", completed.event_id)
169
+ queue.unsubscribe_work("echo")
170
+ ```
171
+
172
+ - **TaskServer WebSocket mode** uses this client for subscribe / claim / heartbeat / status. There is no HTTP fallback poll; recovery is reconnect + resubscribe. The API also checks PostgreSQL at subscribe time and periodically for connected sessions.
173
+ - **Reconnect** restores `/user/queue/replies` and `/user/queue/events`, every `subscribeWork` task name, and every durable `subscriberId` via bind-only `subscribeWait` (replays unacked completions from PostgreSQL).
174
+ - **Acks are explicit.** Do not assume the SDK acked a delivery. Completions are at-least-once; ignore duplicate `eventId`s.
175
+ - **Claim and enqueue are not retried** if the socket drops after the frame is written. The reply may have been lost; check queue state instead of sending the same command again. Heartbeat and status wait for reconnect and retry.
176
+ - **Durable waits** use a client-chosen `subscriberId` scoped to the API-key owner. `EACH` delivers per terminal item; `ALL` delivers one summary. Bind with only `subscriberId` after reconnect.
177
+
178
+ See `examples/websocket_bindings.py` and `examples/wait_subscriber.py`.
179
+
138
180
  ### Errors
139
181
 
140
- All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`. Each carries `status_code` and `body`.
182
+ All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `ServerError`, plus WebSocket `TransportError`, `ProtocolError`, `CommandTimeoutError`, and `AmbiguousCommandError`. Each carries `status_code` and `body` when they come from the API.
141
183
 
142
184
  ```python
143
185
  from continuum_task_server import ContinuumClient, NotFoundError
@@ -174,7 +216,7 @@ ruff format --check .
174
216
  pytest
175
217
  ```
176
218
 
177
- Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport — no live server required.
219
+ Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport and an in-memory WebSocket for STOMP tests — no live server required.
178
220
 
179
221
  ## Versioning
180
222
 
@@ -0,0 +1,74 @@
1
+ """Continuum Task Server SDK for Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .client import ContinuumClient
6
+ from .exceptions import (
7
+ AmbiguousCommandError,
8
+ BadRequestError,
9
+ CommandTimeoutError,
10
+ ConflictError,
11
+ ContinuumError,
12
+ ForbiddenError,
13
+ NotFoundError,
14
+ ProtocolError,
15
+ RateLimitError,
16
+ ServerError,
17
+ TransportError,
18
+ UnauthorizedError,
19
+ )
20
+ from .models import (
21
+ Content,
22
+ EnqueueAndSubscribeWaitResult,
23
+ EventAckResult,
24
+ QueueEvent,
25
+ QueueEventType,
26
+ QueueItem,
27
+ TaskItem,
28
+ TaskItemVersion,
29
+ TaskStatus,
30
+ TaskType,
31
+ TransportMode,
32
+ WaitMode,
33
+ WaitSubscriptionResult,
34
+ WaitTargetStatus,
35
+ WorkSubscriptionResult,
36
+ )
37
+ from .server import TaskServer
38
+ from .websocket import ContinuumWebSocketClient, WebSocketOptions
39
+
40
+ __all__ = [
41
+ "AmbiguousCommandError",
42
+ "BadRequestError",
43
+ "CommandTimeoutError",
44
+ "ConflictError",
45
+ "Content",
46
+ "ContinuumClient",
47
+ "ContinuumError",
48
+ "ContinuumWebSocketClient",
49
+ "EnqueueAndSubscribeWaitResult",
50
+ "EventAckResult",
51
+ "ForbiddenError",
52
+ "NotFoundError",
53
+ "ProtocolError",
54
+ "QueueEvent",
55
+ "QueueEventType",
56
+ "QueueItem",
57
+ "RateLimitError",
58
+ "ServerError",
59
+ "TaskItem",
60
+ "TaskItemVersion",
61
+ "TaskServer",
62
+ "TaskStatus",
63
+ "TaskType",
64
+ "TransportError",
65
+ "TransportMode",
66
+ "UnauthorizedError",
67
+ "WaitMode",
68
+ "WaitSubscriptionResult",
69
+ "WaitTargetStatus",
70
+ "WebSocketOptions",
71
+ "WorkSubscriptionResult",
72
+ ]
73
+
74
+ __version__ = "0.1.0"
@@ -0,0 +1,137 @@
1
+ """STOMP 1.2 frame codec. Internal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ NULL = "\x00"
8
+ LF = "\n"
9
+
10
+ _ESCAPE = {
11
+ "\\": "\\\\",
12
+ "\r": "\\r",
13
+ "\n": "\\n",
14
+ ":": "\\c",
15
+ }
16
+ _UNESCAPE = {
17
+ "\\\\": "\\",
18
+ "\\r": "\r",
19
+ "\\n": "\n",
20
+ "\\c": ":",
21
+ }
22
+
23
+
24
+ @dataclass
25
+ class StompFrame:
26
+ command: str
27
+ headers: dict[str, str] = field(default_factory=dict)
28
+ body: str = ""
29
+
30
+
31
+ def encode_frame(frame: StompFrame) -> str:
32
+ """Serialize a STOMP frame including the terminating NUL."""
33
+ lines = [frame.command]
34
+ headers = dict(frame.headers)
35
+ body = frame.body or ""
36
+ if body and "content-length" not in {k.lower() for k in headers}:
37
+ headers["content-length"] = str(len(body.encode("utf-8")))
38
+ for key, value in headers.items():
39
+ lines.append(f"{_escape(key)}:{_escape(value)}")
40
+ return LF.join(lines) + LF + LF + body + NULL
41
+
42
+
43
+ def encode_heartbeat() -> str:
44
+ return LF
45
+
46
+
47
+ def _escape(text: str) -> str:
48
+ out: list[str] = []
49
+ for ch in text:
50
+ out.append(_ESCAPE.get(ch, ch))
51
+ return "".join(out)
52
+
53
+
54
+ def _unescape(text: str) -> str:
55
+ out: list[str] = []
56
+ i = 0
57
+ while i < len(text):
58
+ if text[i] == "\\" and i + 1 < len(text):
59
+ pair = text[i : i + 2]
60
+ if pair in _UNESCAPE:
61
+ out.append(_UNESCAPE[pair])
62
+ i += 2
63
+ continue
64
+ out.append(text[i])
65
+ i += 1
66
+ return "".join(out)
67
+
68
+
69
+ class StompDecoder:
70
+ """Incremental decoder for one or more STOMP frames, including heartbeats."""
71
+
72
+ def __init__(self) -> None:
73
+ self._buffer = ""
74
+
75
+ def feed(self, data: str | bytes) -> list[StompFrame | None]:
76
+ """Return complete frames. ``None`` entries are heartbeat newlines."""
77
+ if isinstance(data, bytes):
78
+ chunk = data.decode("utf-8")
79
+ else:
80
+ chunk = data
81
+ self._buffer += chunk
82
+ frames: list[StompFrame | None] = []
83
+ while self._buffer:
84
+ if self._buffer[0] in "\r\n":
85
+ if self._buffer.startswith("\r\n"):
86
+ self._buffer = self._buffer[2:]
87
+ else:
88
+ self._buffer = self._buffer[1:]
89
+ frames.append(None)
90
+ continue
91
+ nul = self._buffer.find(NULL)
92
+ if nul < 0:
93
+ break
94
+ raw = self._buffer[:nul]
95
+ self._buffer = self._buffer[nul + 1 :]
96
+ if raw.startswith("\r\n"):
97
+ raw = raw[2:]
98
+ elif raw.startswith("\n"):
99
+ raw = raw[1:]
100
+ if raw == "":
101
+ frames.append(None)
102
+ continue
103
+ frames.append(_parse_frame(raw))
104
+ return frames
105
+
106
+
107
+ def _parse_frame(raw: str) -> StompFrame:
108
+ header_end = raw.find("\n\n")
109
+ if header_end < 0:
110
+ header_block = raw
111
+ body = ""
112
+ else:
113
+ header_block = raw[:header_end]
114
+ body = raw[header_end + 2 :]
115
+ lines = header_block.split("\n")
116
+ command = lines[0].strip()
117
+ headers: dict[str, str] = {}
118
+ content_length: int | None = None
119
+ for line in lines[1:]:
120
+ if not line:
121
+ continue
122
+ idx = line.find(":")
123
+ if idx < 0:
124
+ continue
125
+ key = _unescape(line[:idx])
126
+ value = _unescape(line[idx + 1 :])
127
+ if key.lower() == "content-length":
128
+ try:
129
+ content_length = int(value)
130
+ except ValueError:
131
+ content_length = None
132
+ if key not in headers:
133
+ headers[key] = value
134
+ if content_length is not None:
135
+ encoded = body.encode("utf-8")
136
+ body = encoded[:content_length].decode("utf-8")
137
+ return StompFrame(command=command, headers=headers, body=body)
@@ -145,7 +145,7 @@ class TaskItemVersionsApi:
145
145
  }
146
146
  )
147
147
  return TaskItemVersion.model_validate(
148
- self._http.patch_json(f"{self._PATH}/{task_item_id}/versions/{version_id}", body)
148
+ self._http.post_json(f"{self._PATH}/versions/{version_id}", body)
149
149
  )
150
150
 
151
151
  def deactivate(self, task_item_id: UUID | str, version_id: UUID | str) -> TaskItemVersion:
@@ -25,7 +25,7 @@ class ContinuumError(Exception):
25
25
 
26
26
 
27
27
  class BadRequestError(ContinuumError):
28
- """HTTP 400."""
28
+ """HTTP / command 400."""
29
29
 
30
30
 
31
31
  class UnauthorizedError(ContinuumError):
@@ -44,21 +44,46 @@ class ConflictError(ContinuumError):
44
44
  """HTTP 409."""
45
45
 
46
46
 
47
+ class RateLimitError(ContinuumError):
48
+ """HTTP / command 429."""
49
+
50
+
47
51
  class ServerError(ContinuumError):
48
52
  """HTTP 5xx."""
49
53
 
50
54
 
55
+ class TransportError(ContinuumError):
56
+ """WebSocket / STOMP connection failed or dropped."""
57
+
58
+
59
+ class ProtocolError(ContinuumError):
60
+ """Malformed STOMP frame or unexpected protocol response."""
61
+
62
+
63
+ class CommandTimeoutError(ContinuumError):
64
+ """Timed out waiting for a correlated command reply."""
65
+
66
+
67
+ class AmbiguousCommandError(TransportError):
68
+ """A non-idempotent command was sent but its reply was lost.
69
+
70
+ The server may or may not have applied the mutation (claim / enqueue).
71
+ Do not retry with the same intent without checking queue state.
72
+ """
73
+
74
+
51
75
  _STATUS_TO_EXC: dict[int, type[ContinuumError]] = {
52
76
  400: BadRequestError,
53
77
  401: UnauthorizedError,
54
78
  403: ForbiddenError,
55
79
  404: NotFoundError,
56
80
  409: ConflictError,
81
+ 429: RateLimitError,
57
82
  }
58
83
 
59
84
 
60
85
  def error_for_status(status_code: int, body: str) -> ContinuumError:
61
- """Build the appropriate exception for a non-2xx HTTP response."""
86
+ """Build the appropriate exception for a non-2xx HTTP or command status."""
62
87
  exc_cls = _STATUS_TO_EXC.get(status_code)
63
88
  if exc_cls is None:
64
89
  if 500 <= status_code < 600:
@@ -111,3 +111,87 @@ class Content(_Base):
111
111
 
112
112
  def as_text(self, encoding: str = "utf-8") -> str:
113
113
  return self.data.decode(encoding)
114
+
115
+
116
+ class TransportMode(str, Enum):
117
+ """How ``TaskServer`` obtains work."""
118
+
119
+ HTTP = "http"
120
+ WEBSOCKET = "websocket"
121
+
122
+
123
+ class WaitMode(str, Enum):
124
+ """Durable completion-wait mode."""
125
+
126
+ EACH = "EACH"
127
+ ALL = "ALL"
128
+
129
+
130
+ class QueueEventType(str, Enum):
131
+ """Wire ``eventType`` values on ``/user/queue/events``."""
132
+
133
+ WORK_AVAILABLE = "work.available"
134
+ COMPLETED = "queue.completed"
135
+
136
+
137
+ class WaitTargetStatus(_Base):
138
+ """Per-item outcome on an ALL-mode completion summary."""
139
+
140
+ queue_item_id: UUID = Field(alias="queueItemId")
141
+ status: TaskStatus | str | None = None
142
+
143
+
144
+ class QueueEvent(_Base):
145
+ """Compact ``work.available`` hint or durable ``queue.completed`` delivery."""
146
+
147
+ event_id: UUID = Field(alias="eventId")
148
+ event_version: int = Field(default=1, alias="eventVersion")
149
+ event_type: QueueEventType = Field(alias="eventType")
150
+ occurred_at: datetime | None = Field(default=None, alias="occurredAt")
151
+ queue_item_id: UUID | None = Field(default=None, alias="queueItemId")
152
+ organization_id: UUID | None = Field(default=None, alias="organizationId")
153
+ task_type_id: UUID | None = Field(default=None, alias="taskTypeId")
154
+ task_name: str | None = Field(default=None, alias="taskName")
155
+ status: TaskStatus | None = None
156
+ subscription_id: UUID | None = Field(default=None, alias="subscriptionId")
157
+ correlation: str | None = None
158
+ wait_mode: WaitMode | None = Field(default=None, alias="waitMode")
159
+ targets: list[WaitTargetStatus] | None = None
160
+
161
+
162
+ class WorkSubscriptionResult(_Base):
163
+ """Result of ``subscribeWork`` / ``unsubscribeWork``."""
164
+
165
+ task_name: str | None = Field(default=None, alias="taskName")
166
+ subscribed: bool | None = None
167
+
168
+
169
+ class WaitSubscriptionResult(_Base):
170
+ """Result of ``subscribeWait`` / bind-only replay."""
171
+
172
+ subscription_id: UUID | None = Field(default=None, alias="subscriptionId")
173
+ subscriber_id: str | None = Field(default=None, alias="subscriberId")
174
+ wait_mode: WaitMode | None = Field(default=None, alias="waitMode")
175
+ pending_count: int = Field(default=0, alias="pendingCount")
176
+ closed: bool = False
177
+ subscribed: bool | None = None
178
+
179
+
180
+ class EventAckResult(_Base):
181
+ """Result of ``ackEvent``."""
182
+
183
+ subscription_id: UUID | None = Field(default=None, alias="subscriptionId")
184
+ closed: bool = False
185
+ acked: bool = True
186
+
187
+
188
+ class EnqueueAndSubscribeWaitResult(_Base):
189
+ """Combined result of ``enqueueAndSubscribeWait``."""
190
+
191
+ item: QueueItem
192
+ subscription_id: UUID | None = Field(default=None, alias="subscriptionId")
193
+ subscriber_id: str | None = Field(default=None, alias="subscriberId")
194
+ wait_mode: WaitMode | None = Field(default=None, alias="waitMode")
195
+ pending_count: int = Field(default=0, alias="pendingCount")
196
+ closed: bool = False
197
+ subscribed: bool | None = None