borgee-plugin-sdk 0.1.2__py3-none-any.whl
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.
- borgee_plugin_sdk/__init__.py +83 -0
- borgee_plugin_sdk/_connection.py +1680 -0
- borgee_plugin_sdk/_wire/__init__.py +5 -0
- borgee_plugin_sdk/_wire/frames_generated.py +272 -0
- borgee_plugin_sdk/client.py +400 -0
- borgee_plugin_sdk/cursor.py +220 -0
- borgee_plugin_sdk/errors.py +61 -0
- borgee_plugin_sdk/models.py +326 -0
- borgee_plugin_sdk/py.typed +1 -0
- borgee_plugin_sdk/testing/__init__.py +16 -0
- borgee_plugin_sdk/testing/fake_transport.py +125 -0
- borgee_plugin_sdk-0.1.2.dist-info/METADATA +72 -0
- borgee_plugin_sdk-0.1.2.dist-info/RECORD +16 -0
- borgee_plugin_sdk-0.1.2.dist-info/WHEEL +5 -0
- borgee_plugin_sdk-0.1.2.dist-info/licenses/LICENSE +21 -0
- borgee_plugin_sdk-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1680 @@
|
|
|
1
|
+
"""Bounded BPP WebSocket connection engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import hashlib
|
|
8
|
+
import importlib.metadata
|
|
9
|
+
import inspect
|
|
10
|
+
import ipaddress
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from collections import OrderedDict, deque
|
|
17
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Literal, TypeGuard
|
|
20
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
21
|
+
|
|
22
|
+
from websockets.asyncio.client import ClientConnection
|
|
23
|
+
from websockets.asyncio.client import connect as WebSocketConnect
|
|
24
|
+
from websockets.exceptions import ConnectionClosed, SecurityError
|
|
25
|
+
|
|
26
|
+
from .cursor import CursorStore
|
|
27
|
+
from .errors import (
|
|
28
|
+
BorgeeError,
|
|
29
|
+
NotOnlineError,
|
|
30
|
+
PermissionDeniedError,
|
|
31
|
+
PreResumeError,
|
|
32
|
+
ProtocolCapacityError,
|
|
33
|
+
ProtocolError,
|
|
34
|
+
ResumeAckError,
|
|
35
|
+
StaleDeliveryError,
|
|
36
|
+
)
|
|
37
|
+
from .models import (
|
|
38
|
+
ConfigApplyContext,
|
|
39
|
+
ConfigApplyResult,
|
|
40
|
+
ConfigApplyStatus,
|
|
41
|
+
ConfigUpdate,
|
|
42
|
+
ConnectionState,
|
|
43
|
+
ConnectionStatus,
|
|
44
|
+
DeliveryEvent,
|
|
45
|
+
FaultReason,
|
|
46
|
+
InboundKind,
|
|
47
|
+
InboundMessageEvent,
|
|
48
|
+
InboundReaction,
|
|
49
|
+
Message,
|
|
50
|
+
ReplayMode,
|
|
51
|
+
ReplaySummaryEvent,
|
|
52
|
+
user_from_wire,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
MAX_INCOMING_BYTES = 5 << 20
|
|
56
|
+
MAX_INCOMING_FRAMES = 512
|
|
57
|
+
MAX_OUTBOUND_BYTES = 32 << 10
|
|
58
|
+
REGULAR_MAX_FRAMES = 64
|
|
59
|
+
REGULAR_MAX_BYTES = 2 << 20
|
|
60
|
+
RESERVED_MAX_FRAMES = 32
|
|
61
|
+
RESERVED_MAX_BYTES = 1 << 20
|
|
62
|
+
CONFIG_MAX_FRAMES = 32
|
|
63
|
+
CONFIG_MAX_BYTES = 1 << 20
|
|
64
|
+
REQUEST_MAX_FRAMES = 32
|
|
65
|
+
REQUEST_MAX_BYTES = 1 << 20
|
|
66
|
+
REQUEST_SINGLE_MAX_BYTES = 32 << 10
|
|
67
|
+
ACTION_PENDING_MAX = 256
|
|
68
|
+
PRE_RESUME_TIMEOUT = 8.0
|
|
69
|
+
RESUME_ACK_TIMEOUT = 5.0
|
|
70
|
+
REPLAY_ARRIVAL_TIMEOUT = 5.0
|
|
71
|
+
SERVER_REQUEST_DEADLINE = 8.0
|
|
72
|
+
SERVER_REQUEST_QUEUE_WAIT = 2.0
|
|
73
|
+
SERVER_REQUEST_HANDLER_TIMEOUT = 5.0
|
|
74
|
+
HANDLER_TASK_SLOT_LIMIT = 64
|
|
75
|
+
|
|
76
|
+
_DETACHED_TASK_GUARDIAN: set[asyncio.Task[Any]] = set()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
ConfigHandler = Callable[[ConfigUpdate, ConfigApplyContext], Awaitable[ConfigApplyResult] | ConfigApplyResult]
|
|
80
|
+
ConfigObserver = Callable[[ConfigUpdate], Awaitable[None] | None]
|
|
81
|
+
ServerRequestHandler = Callable[[str, Any], Awaitable[Any] | Any]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True)
|
|
85
|
+
class ConnectionOptions:
|
|
86
|
+
base_url: str
|
|
87
|
+
api_key: str
|
|
88
|
+
agent_id: str
|
|
89
|
+
plugin_id: str
|
|
90
|
+
cursor_store: CursorStore
|
|
91
|
+
allow_insecure_loopback: bool
|
|
92
|
+
replay_mode: ReplayMode
|
|
93
|
+
replay_latest_n: int
|
|
94
|
+
config_update_handler: ConfigHandler | None
|
|
95
|
+
config_update_observer: ConfigObserver | None
|
|
96
|
+
server_request_handler: ServerRequestHandler | None
|
|
97
|
+
logger: logging.Logger
|
|
98
|
+
action_timeout: float
|
|
99
|
+
config_apply_timeout: float
|
|
100
|
+
auto_reconnect: bool
|
|
101
|
+
reconnect_base: float
|
|
102
|
+
reconnect_max: float
|
|
103
|
+
heartbeat_interval: float
|
|
104
|
+
random_source: random.Random
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class _QueuedFrame:
|
|
109
|
+
data: str
|
|
110
|
+
size: int
|
|
111
|
+
completion: asyncio.Future[None]
|
|
112
|
+
deadline: float | None
|
|
113
|
+
send_timeout: float
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _NoRedirectConnect(WebSocketConnect):
|
|
117
|
+
def process_redirect(self, exc: Exception) -> Exception | str:
|
|
118
|
+
raise SecurityError("WebSocket redirects are disabled")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_websockets_runtime() -> None:
|
|
122
|
+
try:
|
|
123
|
+
version = importlib.metadata.version("websockets")
|
|
124
|
+
except importlib.metadata.PackageNotFoundError as exc:
|
|
125
|
+
raise PreResumeError("websockets 15.0.1 is required", "bpp.websocket_dependency_missing") from exc
|
|
126
|
+
if version != "15.0.1":
|
|
127
|
+
raise PreResumeError(
|
|
128
|
+
f"websockets 15.0.1 is required; found {version}",
|
|
129
|
+
"bpp.websocket_dependency_mismatch",
|
|
130
|
+
)
|
|
131
|
+
if not inspect.isclass(WebSocketConnect):
|
|
132
|
+
raise PreResumeError(
|
|
133
|
+
"websockets connect implementation is incompatible", "bpp.websocket_api_mismatch"
|
|
134
|
+
)
|
|
135
|
+
parameters = tuple(inspect.signature(WebSocketConnect.process_redirect).parameters)
|
|
136
|
+
if parameters != ("self", "exc"):
|
|
137
|
+
raise PreResumeError("websockets redirect hook is incompatible", "bpp.websocket_api_mismatch")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def plugin_websocket_url(base_url: str, *, allow_insecure_loopback: bool = False) -> tuple[str, str]:
|
|
141
|
+
try:
|
|
142
|
+
parsed = urlsplit(base_url)
|
|
143
|
+
port = parsed.port
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
raise ValueError("base_url is not a valid HTTP origin") from exc
|
|
146
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
147
|
+
raise ValueError("base_url must be an http or https origin")
|
|
148
|
+
if parsed.username is not None or parsed.password is not None:
|
|
149
|
+
raise ValueError("base_url must not contain user information")
|
|
150
|
+
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
|
151
|
+
raise ValueError("base_url must be an origin without a path, query, or fragment")
|
|
152
|
+
if parsed.scheme == "http":
|
|
153
|
+
if not allow_insecure_loopback or not _is_loopback(parsed.hostname):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"insecure WebSocket transport is restricted to explicitly allowed loopback origins"
|
|
156
|
+
)
|
|
157
|
+
hostname = parsed.hostname.lower()
|
|
158
|
+
host = f"[{hostname}]" if ":" in hostname else hostname
|
|
159
|
+
netloc = host if port is None else f"{host}:{port}"
|
|
160
|
+
origin = urlunsplit((parsed.scheme, netloc, "", "", ""))
|
|
161
|
+
ws_scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
162
|
+
return urlunsplit((ws_scheme, netloc, "/ws/plugin", "", "")), origin
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_loopback(hostname: str) -> bool:
|
|
166
|
+
if hostname.lower() == "localhost":
|
|
167
|
+
return True
|
|
168
|
+
try:
|
|
169
|
+
addr = ipaddress.ip_address(hostname)
|
|
170
|
+
return addr.is_loopback or addr.is_private
|
|
171
|
+
except ValueError:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _OutboundWriter:
|
|
176
|
+
def __init__(self, websocket: ClientConnection) -> None:
|
|
177
|
+
self._websocket = websocket
|
|
178
|
+
self._regular: deque[_QueuedFrame] = deque()
|
|
179
|
+
self._reserved: deque[_QueuedFrame] = deque()
|
|
180
|
+
self._regular_bytes = 0
|
|
181
|
+
self._reserved_bytes = 0
|
|
182
|
+
self._reserved_streak = 0
|
|
183
|
+
self._wakeup = asyncio.Event()
|
|
184
|
+
self._space = asyncio.Event()
|
|
185
|
+
self._space.set()
|
|
186
|
+
self._accepting = True
|
|
187
|
+
self._task = asyncio.create_task(self._run(), name="borgee-plugin-writer")
|
|
188
|
+
|
|
189
|
+
async def send(
|
|
190
|
+
self,
|
|
191
|
+
frame: dict[str, Any],
|
|
192
|
+
*,
|
|
193
|
+
lane: Literal["regular", "reserved"] = "regular",
|
|
194
|
+
deadline: float | None = None,
|
|
195
|
+
send_timeout: float = 0.1,
|
|
196
|
+
) -> None:
|
|
197
|
+
item = self._encode(frame, deadline, send_timeout)
|
|
198
|
+
admission_deadline = min(time.monotonic() + 0.1, deadline or float("inf"))
|
|
199
|
+
while True:
|
|
200
|
+
if not self._accepting:
|
|
201
|
+
raise BorgeeError("connection writer is closed", "bpp.writer_closed")
|
|
202
|
+
if self._admit(item, lane):
|
|
203
|
+
break
|
|
204
|
+
remaining = admission_deadline - time.monotonic()
|
|
205
|
+
if remaining <= 0:
|
|
206
|
+
raise ProtocolCapacityError("outbound queue admission timed out")
|
|
207
|
+
self._space.clear()
|
|
208
|
+
try:
|
|
209
|
+
await asyncio.wait_for(self._space.wait(), remaining)
|
|
210
|
+
except TimeoutError as exc:
|
|
211
|
+
raise ProtocolCapacityError("outbound queue admission timed out") from exc
|
|
212
|
+
await item.completion
|
|
213
|
+
|
|
214
|
+
def send_nowait(
|
|
215
|
+
self,
|
|
216
|
+
frame: dict[str, Any],
|
|
217
|
+
*,
|
|
218
|
+
lane: Literal["regular", "reserved"] = "reserved",
|
|
219
|
+
deadline: float | None = None,
|
|
220
|
+
send_timeout: float = 0.1,
|
|
221
|
+
) -> asyncio.Future[None]:
|
|
222
|
+
item = self._encode(frame, deadline, send_timeout)
|
|
223
|
+
if not self._accepting or not self._admit(item, lane):
|
|
224
|
+
raise ProtocolCapacityError("outbound queue capacity exceeded")
|
|
225
|
+
return item.completion
|
|
226
|
+
|
|
227
|
+
async def close(self, error: BaseException | None = None) -> None:
|
|
228
|
+
self._accepting = False
|
|
229
|
+
self._space.set()
|
|
230
|
+
failure = error or BorgeeError("connection writer closed", "bpp.writer_closed")
|
|
231
|
+
for queue in (self._regular, self._reserved):
|
|
232
|
+
while queue:
|
|
233
|
+
item = queue.popleft()
|
|
234
|
+
if not item.completion.done():
|
|
235
|
+
item.completion.set_exception(failure)
|
|
236
|
+
self._regular_bytes = 0
|
|
237
|
+
self._reserved_bytes = 0
|
|
238
|
+
self._task.cancel()
|
|
239
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
240
|
+
await self._task
|
|
241
|
+
|
|
242
|
+
def _encode(self, frame: dict[str, Any], deadline: float | None, send_timeout: float) -> _QueuedFrame:
|
|
243
|
+
data = json.dumps(frame, separators=(",", ":"), ensure_ascii=False)
|
|
244
|
+
size = len(data.encode("utf-8"))
|
|
245
|
+
if size > MAX_OUTBOUND_BYTES:
|
|
246
|
+
raise ProtocolCapacityError(f"outbound frame exceeds {MAX_OUTBOUND_BYTES} bytes")
|
|
247
|
+
return _QueuedFrame(data, size, asyncio.get_running_loop().create_future(), deadline, send_timeout)
|
|
248
|
+
|
|
249
|
+
def _admit(self, item: _QueuedFrame, lane: Literal["regular", "reserved"]) -> bool:
|
|
250
|
+
if lane == "regular":
|
|
251
|
+
if (
|
|
252
|
+
len(self._regular) >= REGULAR_MAX_FRAMES
|
|
253
|
+
or self._regular_bytes + item.size > REGULAR_MAX_BYTES
|
|
254
|
+
):
|
|
255
|
+
return False
|
|
256
|
+
self._regular.append(item)
|
|
257
|
+
self._regular_bytes += item.size
|
|
258
|
+
else:
|
|
259
|
+
if (
|
|
260
|
+
len(self._reserved) >= RESERVED_MAX_FRAMES
|
|
261
|
+
or self._reserved_bytes + item.size > RESERVED_MAX_BYTES
|
|
262
|
+
):
|
|
263
|
+
return False
|
|
264
|
+
self._reserved.append(item)
|
|
265
|
+
self._reserved_bytes += item.size
|
|
266
|
+
self._wakeup.set()
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
def _take(self) -> _QueuedFrame | None:
|
|
270
|
+
if self._reserved and (not self._regular or self._reserved_streak < 8):
|
|
271
|
+
self._reserved_streak += 1
|
|
272
|
+
item = self._reserved.popleft()
|
|
273
|
+
self._reserved_bytes -= item.size
|
|
274
|
+
elif self._regular:
|
|
275
|
+
self._reserved_streak = 0
|
|
276
|
+
item = self._regular.popleft()
|
|
277
|
+
self._regular_bytes -= item.size
|
|
278
|
+
elif self._reserved:
|
|
279
|
+
self._reserved_streak += 1
|
|
280
|
+
item = self._reserved.popleft()
|
|
281
|
+
self._reserved_bytes -= item.size
|
|
282
|
+
else:
|
|
283
|
+
return None
|
|
284
|
+
self._space.set()
|
|
285
|
+
return item
|
|
286
|
+
|
|
287
|
+
async def _run(self) -> None:
|
|
288
|
+
while True:
|
|
289
|
+
item = self._take()
|
|
290
|
+
if item is None:
|
|
291
|
+
self._wakeup.clear()
|
|
292
|
+
await self._wakeup.wait()
|
|
293
|
+
continue
|
|
294
|
+
if item.deadline is not None and time.monotonic() >= item.deadline:
|
|
295
|
+
if not item.completion.done():
|
|
296
|
+
item.completion.set_exception(TimeoutError("outbound frame deadline expired"))
|
|
297
|
+
continue
|
|
298
|
+
timeout = item.send_timeout
|
|
299
|
+
if item.deadline is not None:
|
|
300
|
+
timeout = min(timeout, max(0.0, item.deadline - time.monotonic()))
|
|
301
|
+
try:
|
|
302
|
+
async with asyncio.timeout(timeout):
|
|
303
|
+
await self._websocket.send(item.data)
|
|
304
|
+
except BaseException as exc:
|
|
305
|
+
if not item.completion.done():
|
|
306
|
+
item.completion.set_exception(exc)
|
|
307
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
308
|
+
raise
|
|
309
|
+
else:
|
|
310
|
+
if not item.completion.done():
|
|
311
|
+
item.completion.set_result(None)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@dataclass(slots=True)
|
|
315
|
+
class _PendingAction:
|
|
316
|
+
operation: str
|
|
317
|
+
future: asyncio.Future[Any]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass(slots=True)
|
|
321
|
+
class _BufferedDelivery:
|
|
322
|
+
event: DeliveryEvent
|
|
323
|
+
cursor: int
|
|
324
|
+
size: int
|
|
325
|
+
generation: int
|
|
326
|
+
replay: bool
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Delivery:
|
|
330
|
+
"""One generation-bound inbound event awaiting an explicit disposition."""
|
|
331
|
+
|
|
332
|
+
def __init__(self, connection: BppConnection, buffered: _BufferedDelivery) -> None:
|
|
333
|
+
self.event = buffered.event
|
|
334
|
+
self.cursor = buffered.cursor
|
|
335
|
+
self._connection = connection
|
|
336
|
+
self._generation = buffered.generation
|
|
337
|
+
self._replay = buffered.replay
|
|
338
|
+
self._resolved: Literal["checkpoint", "fail"] | None = None
|
|
339
|
+
|
|
340
|
+
async def checkpoint(self) -> None:
|
|
341
|
+
if self._resolved == "checkpoint":
|
|
342
|
+
return
|
|
343
|
+
if self._resolved is not None:
|
|
344
|
+
raise BorgeeError("delivery was already failed", "bpp.delivery_resolved")
|
|
345
|
+
await self._connection._settle_delivery(self, checkpoint=True, error=None)
|
|
346
|
+
self._resolved = "checkpoint"
|
|
347
|
+
|
|
348
|
+
async def fail(self, error: BaseException | None = None) -> None:
|
|
349
|
+
if self._resolved == "fail":
|
|
350
|
+
return
|
|
351
|
+
if self._resolved is not None:
|
|
352
|
+
raise BorgeeError("delivery was already checkpointed", "bpp.delivery_resolved")
|
|
353
|
+
await self._connection._settle_delivery(self, checkpoint=False, error=error)
|
|
354
|
+
self._resolved = "fail"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class _RecoverableDisconnect(Exception):
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class _TerminalStop(Exception):
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class BppConnection:
|
|
366
|
+
def __init__(self, options: ConnectionOptions) -> None:
|
|
367
|
+
if not options.api_key:
|
|
368
|
+
raise ValueError("api_key must not be empty")
|
|
369
|
+
if options.replay_latest_n < 1 or options.replay_latest_n > 500:
|
|
370
|
+
raise ValueError("replay_latest_n must be between 1 and 500")
|
|
371
|
+
self._options = options
|
|
372
|
+
self._websocket_url, self._origin = plugin_websocket_url(
|
|
373
|
+
options.base_url,
|
|
374
|
+
allow_insecure_loopback=options.allow_insecure_loopback,
|
|
375
|
+
)
|
|
376
|
+
self._agent_id = options.agent_id
|
|
377
|
+
self._state = ConnectionState(ConnectionStatus.CONNECTING)
|
|
378
|
+
self._state_queue: asyncio.Queue[ConnectionState] = asyncio.Queue(maxsize=1)
|
|
379
|
+
self._online_ready = asyncio.Event()
|
|
380
|
+
# A "stop" signal fired for EITHER a terminal fault OR an explicit client close, so an
|
|
381
|
+
# action waiting for ONLINE fails fast with the real reason instead of the full timeout.
|
|
382
|
+
self._terminal_event = asyncio.Event()
|
|
383
|
+
self._supervisor_task: asyncio.Task[None] | None = None
|
|
384
|
+
self._observer_task: asyncio.Task[None] | None = None
|
|
385
|
+
self._initial_ready: asyncio.Future[None] | None = None
|
|
386
|
+
self._connect_future: asyncio.Future[None] | None = None
|
|
387
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
388
|
+
self._lifecycle_lock = asyncio.Lock()
|
|
389
|
+
self._generation_cleanup_lock = asyncio.Lock()
|
|
390
|
+
self._ever_ready = False
|
|
391
|
+
self._ever_online = False
|
|
392
|
+
self._closing = False
|
|
393
|
+
self._closed_permanently = False
|
|
394
|
+
self._terminal_set = False
|
|
395
|
+
self._terminal_error: BaseException | None = None
|
|
396
|
+
|
|
397
|
+
self._generation_counter = 0
|
|
398
|
+
self._active_generation: int | None = None
|
|
399
|
+
self._websocket: ClientConnection | None = None
|
|
400
|
+
self._writer: _OutboundWriter | None = None
|
|
401
|
+
self._generation_tasks: set[asyncio.Task[Any]] = set()
|
|
402
|
+
self._detached_tasks: set[asyncio.Task[Any]] = set()
|
|
403
|
+
self._handler_slot_tasks: set[asyncio.Task[Any]] = set()
|
|
404
|
+
self._handler_slots_available = HANDLER_TASK_SLOT_LIMIT
|
|
405
|
+
self._background_failure: BaseException | None = None
|
|
406
|
+
self._resume_ack: asyncio.Future[None] | None = None
|
|
407
|
+
self._resume_request: tuple[ReplayMode, int] | None = None
|
|
408
|
+
self._resume_count = 0
|
|
409
|
+
self._replay_received = 0
|
|
410
|
+
self._replay_checkpointed = 0
|
|
411
|
+
self._resume_high_water = 0
|
|
412
|
+
self._resume_ack_seen = False
|
|
413
|
+
self._replay_arrived = asyncio.Event()
|
|
414
|
+
|
|
415
|
+
self._pending_actions: dict[str, _PendingAction] = {}
|
|
416
|
+
self._deliveries: deque[_BufferedDelivery] = deque()
|
|
417
|
+
self._deferred_live: deque[_BufferedDelivery] = deque()
|
|
418
|
+
self._seen_delivery_cursors: set[int] = set()
|
|
419
|
+
self._delivery_bytes = 0
|
|
420
|
+
self._delivery_wakeup = asyncio.Event()
|
|
421
|
+
self._delivery_consumer = False
|
|
422
|
+
self._active_delivery: Delivery | None = None
|
|
423
|
+
self._last_checkpoint = 0
|
|
424
|
+
self._last_received = 0
|
|
425
|
+
self._cursor_key = ""
|
|
426
|
+
self._cursor_write_lock = asyncio.Lock()
|
|
427
|
+
|
|
428
|
+
self._config_queue: asyncio.Queue[tuple[dict[str, Any], int, int]] | None = None
|
|
429
|
+
self._config_queue_bytes = 0
|
|
430
|
+
self._config_acks: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
|
431
|
+
self._request_queue: asyncio.Queue[tuple[dict[str, Any], int, float, int]] | None = None
|
|
432
|
+
self._request_queue_bytes = 0
|
|
433
|
+
self._observer_queue: deque[tuple[ConfigUpdate, int]] = deque()
|
|
434
|
+
self._observer_bytes = 0
|
|
435
|
+
self._observer_wakeup = asyncio.Event()
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def agent_id(self) -> str:
|
|
439
|
+
return self._agent_id
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def state(self) -> ConnectionState:
|
|
443
|
+
return self._state
|
|
444
|
+
|
|
445
|
+
def _is_stopping(self) -> bool:
|
|
446
|
+
# Keep lifecycle checks behind a method call. Their values may change at
|
|
447
|
+
# every await boundary even though a static analyzer sees one task.
|
|
448
|
+
return self._terminal_set or self._closing
|
|
449
|
+
|
|
450
|
+
async def connect(self) -> None:
|
|
451
|
+
async with self._lifecycle_lock:
|
|
452
|
+
if self._closed_permanently:
|
|
453
|
+
raise BorgeeError("client is permanently closed", "bpp.client_closed")
|
|
454
|
+
if self._terminal_error is not None:
|
|
455
|
+
raise self._terminal_error
|
|
456
|
+
future = self._connect_future
|
|
457
|
+
if future is None:
|
|
458
|
+
validate_websockets_runtime()
|
|
459
|
+
self._closing = False
|
|
460
|
+
future = asyncio.get_running_loop().create_future()
|
|
461
|
+
future.add_done_callback(self._consume_future_exception)
|
|
462
|
+
self._connect_future = future
|
|
463
|
+
self._initial_ready = future
|
|
464
|
+
self._observer_task = asyncio.create_task(
|
|
465
|
+
self._run_config_observer(), name="borgee-config-observer"
|
|
466
|
+
)
|
|
467
|
+
self._supervisor_task = asyncio.create_task(
|
|
468
|
+
self._supervise(), name="borgee-plugin-supervisor"
|
|
469
|
+
)
|
|
470
|
+
await asyncio.shield(future)
|
|
471
|
+
|
|
472
|
+
async def close(self) -> None:
|
|
473
|
+
async with self._lifecycle_lock:
|
|
474
|
+
task = self._close_task
|
|
475
|
+
if task is None:
|
|
476
|
+
self._closed_permanently = True
|
|
477
|
+
task = asyncio.create_task(self._close_impl(), name="borgee-plugin-close")
|
|
478
|
+
self._close_task = task
|
|
479
|
+
await asyncio.shield(task)
|
|
480
|
+
|
|
481
|
+
async def _close_impl(self) -> None:
|
|
482
|
+
self._closing = True
|
|
483
|
+
self._terminal_event.set()
|
|
484
|
+
error = BorgeeError("client closed", "bpp.client_closed")
|
|
485
|
+
future = self._connect_future
|
|
486
|
+
if future is not None and not future.done():
|
|
487
|
+
future.set_exception(error)
|
|
488
|
+
task = self._supervisor_task
|
|
489
|
+
if task is not None and task is not asyncio.current_task():
|
|
490
|
+
task.cancel()
|
|
491
|
+
await self._invalidate_generation(error)
|
|
492
|
+
if task is not None and task is not asyncio.current_task():
|
|
493
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
494
|
+
await task
|
|
495
|
+
await self._cleanup_generation(error)
|
|
496
|
+
if self._observer_task is not None:
|
|
497
|
+
self._detach_task(self._observer_task)
|
|
498
|
+
self._set_state(ConnectionState(ConnectionStatus.CLOSED))
|
|
499
|
+
self._delivery_wakeup.set()
|
|
500
|
+
|
|
501
|
+
async def __aenter__(self) -> BppConnection:
|
|
502
|
+
await self.connect()
|
|
503
|
+
return self
|
|
504
|
+
|
|
505
|
+
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
506
|
+
await self.close()
|
|
507
|
+
|
|
508
|
+
async def deliveries(self) -> AsyncIterator[Delivery]:
|
|
509
|
+
if self._delivery_consumer:
|
|
510
|
+
raise RuntimeError("deliveries() supports one consumer")
|
|
511
|
+
self._delivery_consumer = True
|
|
512
|
+
try:
|
|
513
|
+
while True:
|
|
514
|
+
delivery = await self._next_delivery()
|
|
515
|
+
if delivery is None:
|
|
516
|
+
return
|
|
517
|
+
yield delivery
|
|
518
|
+
finally:
|
|
519
|
+
self._delivery_consumer = False
|
|
520
|
+
|
|
521
|
+
async def state_changes(self) -> AsyncIterator[ConnectionState]:
|
|
522
|
+
while True:
|
|
523
|
+
state = await self._state_queue.get()
|
|
524
|
+
yield state
|
|
525
|
+
if state.status in {ConnectionStatus.ERROR, ConnectionStatus.CLOSED}:
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
async def perform(self, operation: str, payload: dict[str, Any]) -> Any:
|
|
529
|
+
return await self._perform(operation, payload, allow_pre_resume=False)
|
|
530
|
+
|
|
531
|
+
async def report_task_started(self, task_id: str, channel_id: str, subject: str) -> None:
|
|
532
|
+
self._require_online()
|
|
533
|
+
await self._send_regular(
|
|
534
|
+
{
|
|
535
|
+
"type": "task_started",
|
|
536
|
+
"task_id": task_id,
|
|
537
|
+
"agent_id": self._agent_id,
|
|
538
|
+
"channel_id": channel_id,
|
|
539
|
+
"subject": subject,
|
|
540
|
+
"started_at": int(time.time() * 1000),
|
|
541
|
+
}
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
async def report_task_finished(
|
|
545
|
+
self,
|
|
546
|
+
task_id: str,
|
|
547
|
+
channel_id: str,
|
|
548
|
+
outcome: str,
|
|
549
|
+
reason: str,
|
|
550
|
+
) -> None:
|
|
551
|
+
self._require_online()
|
|
552
|
+
await self._send_regular(
|
|
553
|
+
{
|
|
554
|
+
"type": "task_finished",
|
|
555
|
+
"task_id": task_id,
|
|
556
|
+
"agent_id": self._agent_id,
|
|
557
|
+
"channel_id": channel_id,
|
|
558
|
+
"outcome": outcome,
|
|
559
|
+
"reason": reason,
|
|
560
|
+
"finished_at": int(time.time() * 1000),
|
|
561
|
+
}
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def send_typing_nowait(self, channel_id: str) -> None:
|
|
565
|
+
"""Fire-and-forget typing indicator frame."""
|
|
566
|
+
writer = self._writer
|
|
567
|
+
if writer is None or self._state.status is not ConnectionStatus.ONLINE:
|
|
568
|
+
return
|
|
569
|
+
try:
|
|
570
|
+
writer.send_nowait(
|
|
571
|
+
{"type": "typing", "channel_id": channel_id},
|
|
572
|
+
lane="regular",
|
|
573
|
+
)
|
|
574
|
+
except (ProtocolCapacityError, BorgeeError):
|
|
575
|
+
pass # best-effort, never fail the caller
|
|
576
|
+
|
|
577
|
+
async def _supervise(self) -> None:
|
|
578
|
+
reconnect_attempt = 0
|
|
579
|
+
initial = True
|
|
580
|
+
try:
|
|
581
|
+
while not self._is_stopping():
|
|
582
|
+
try:
|
|
583
|
+
if self._is_stopping():
|
|
584
|
+
return
|
|
585
|
+
await self._run_generation(initial=initial)
|
|
586
|
+
raise _RecoverableDisconnect("connection ended")
|
|
587
|
+
except asyncio.CancelledError:
|
|
588
|
+
raise
|
|
589
|
+
except _TerminalStop:
|
|
590
|
+
return
|
|
591
|
+
except _RecoverableDisconnect as exc:
|
|
592
|
+
if self._is_stopping():
|
|
593
|
+
return
|
|
594
|
+
if not self._ever_ready or not self._options.auto_reconnect:
|
|
595
|
+
raise PreResumeError(str(exc), "bpp.connection_lost") from exc
|
|
596
|
+
initial = False
|
|
597
|
+
reconnect_attempt += 1
|
|
598
|
+
self._set_state(ConnectionState(ConnectionStatus.RECONNECTING, attempt=reconnect_attempt))
|
|
599
|
+
cap = min(
|
|
600
|
+
self._options.reconnect_max,
|
|
601
|
+
self._options.reconnect_base * (2 ** (reconnect_attempt - 1)),
|
|
602
|
+
)
|
|
603
|
+
delay = cap * (0.5 + self._options.random_source.random() * 0.5)
|
|
604
|
+
await asyncio.sleep(delay)
|
|
605
|
+
if self._is_stopping():
|
|
606
|
+
return
|
|
607
|
+
initial = False
|
|
608
|
+
except asyncio.CancelledError:
|
|
609
|
+
raise
|
|
610
|
+
except BaseException as exc:
|
|
611
|
+
await self._terminal(exc)
|
|
612
|
+
|
|
613
|
+
async def _run_generation(self, *, initial: bool) -> None:
|
|
614
|
+
if self._is_stopping():
|
|
615
|
+
raise _TerminalStop
|
|
616
|
+
self._generation_counter += 1
|
|
617
|
+
generation = self._generation_counter
|
|
618
|
+
self._active_generation = generation
|
|
619
|
+
self._resume_ack_seen = False
|
|
620
|
+
self._background_failure = None
|
|
621
|
+
self._resume_count = 0
|
|
622
|
+
self._replay_received = 0
|
|
623
|
+
self._replay_checkpointed = 0
|
|
624
|
+
self._resume_high_water = 0
|
|
625
|
+
self._last_received = 0
|
|
626
|
+
self._replay_arrived.clear()
|
|
627
|
+
if initial:
|
|
628
|
+
self._set_state(ConnectionState(ConnectionStatus.CONNECTING))
|
|
629
|
+
|
|
630
|
+
receive_task: asyncio.Task[None] | None = None
|
|
631
|
+
opened = False
|
|
632
|
+
try:
|
|
633
|
+
async with asyncio.timeout(PRE_RESUME_TIMEOUT):
|
|
634
|
+
websocket = await _NoRedirectConnect(
|
|
635
|
+
self._websocket_url,
|
|
636
|
+
additional_headers={"Authorization": f"Bearer {self._options.api_key}"},
|
|
637
|
+
open_timeout=PRE_RESUME_TIMEOUT,
|
|
638
|
+
close_timeout=1,
|
|
639
|
+
ping_interval=None,
|
|
640
|
+
max_size=MAX_INCOMING_BYTES,
|
|
641
|
+
max_queue=4,
|
|
642
|
+
write_limit=MAX_OUTBOUND_BYTES,
|
|
643
|
+
)
|
|
644
|
+
if self._is_stopping():
|
|
645
|
+
websocket.transport.abort()
|
|
646
|
+
raise _TerminalStop
|
|
647
|
+
opened = True
|
|
648
|
+
self._websocket = websocket
|
|
649
|
+
self._writer = _OutboundWriter(websocket)
|
|
650
|
+
self._config_queue = asyncio.Queue(maxsize=CONFIG_MAX_FRAMES)
|
|
651
|
+
self._request_queue = asyncio.Queue(maxsize=REQUEST_MAX_FRAMES)
|
|
652
|
+
self._config_queue_bytes = 0
|
|
653
|
+
self._request_queue_bytes = 0
|
|
654
|
+
receive_task = self._spawn(
|
|
655
|
+
self._receive_loop(websocket, generation), "borgee-plugin-receiver"
|
|
656
|
+
)
|
|
657
|
+
self._spawn(self._config_worker(generation), "borgee-config-worker")
|
|
658
|
+
for index in range(8):
|
|
659
|
+
self._spawn(self._request_worker(generation), f"borgee-request-worker-{index}")
|
|
660
|
+
|
|
661
|
+
await self._send_regular(
|
|
662
|
+
{
|
|
663
|
+
"type": "connect",
|
|
664
|
+
"plugin_id": self._options.plugin_id,
|
|
665
|
+
"token": "",
|
|
666
|
+
"version": "bpp-1",
|
|
667
|
+
"capabilities": json.dumps(
|
|
668
|
+
["semantic_action", "inbound_message", "read_file"], separators=(",", ":")
|
|
669
|
+
),
|
|
670
|
+
}
|
|
671
|
+
)
|
|
672
|
+
if not self._agent_id:
|
|
673
|
+
identity = await self._perform("get_me", {}, allow_pre_resume=True)
|
|
674
|
+
try:
|
|
675
|
+
self._agent_id = user_from_wire(identity).id
|
|
676
|
+
except (TypeError, ValueError) as exc:
|
|
677
|
+
raise PreResumeError(
|
|
678
|
+
"authenticated agent identity is invalid", "bpp.identity_invalid"
|
|
679
|
+
) from exc
|
|
680
|
+
|
|
681
|
+
digest = hashlib.sha256(self._origin.encode()).hexdigest()
|
|
682
|
+
self._cursor_key = f"{digest}:{self._agent_id}"
|
|
683
|
+
cursor = await self._read_cursor_detached_on_cancel()
|
|
684
|
+
if not isinstance(cursor, int) or isinstance(cursor, bool) or cursor < 0:
|
|
685
|
+
raise PreResumeError(
|
|
686
|
+
"cursor store returned an invalid cursor", "bpp.cursor_store_read_failed"
|
|
687
|
+
)
|
|
688
|
+
self._last_checkpoint = cursor
|
|
689
|
+
self._last_received = cursor
|
|
690
|
+
self._resume_request = (self._options.replay_mode, cursor)
|
|
691
|
+
self._resume_ack = asyncio.get_running_loop().create_future()
|
|
692
|
+
self._resume_ack.add_done_callback(self._consume_future_exception)
|
|
693
|
+
self._set_state(ConnectionState(ConnectionStatus.RESUMING))
|
|
694
|
+
resume: dict[str, Any] = {
|
|
695
|
+
"type": "session.resume",
|
|
696
|
+
"replay_mode": self._options.replay_mode.value,
|
|
697
|
+
"since_cursor": cursor,
|
|
698
|
+
}
|
|
699
|
+
if self._options.replay_mode is ReplayMode.LATEST_N:
|
|
700
|
+
resume["latest_n"] = self._options.replay_latest_n
|
|
701
|
+
await self._send_regular(resume)
|
|
702
|
+
try:
|
|
703
|
+
await asyncio.wait_for(asyncio.shield(self._resume_ack), RESUME_ACK_TIMEOUT)
|
|
704
|
+
except TimeoutError as exc:
|
|
705
|
+
raise ResumeAckError("session.resume_ack timed out", "bpp.resume_ack_timeout") from exc
|
|
706
|
+
|
|
707
|
+
if self._is_stopping():
|
|
708
|
+
raise _TerminalStop
|
|
709
|
+
if self._initial_ready is not None and not self._initial_ready.done():
|
|
710
|
+
self._initial_ready.set_result(None)
|
|
711
|
+
self._ever_ready = True
|
|
712
|
+
self._spawn(self._heartbeat_loop(generation), "borgee-heartbeat")
|
|
713
|
+
assert receive_task is not None
|
|
714
|
+
await receive_task
|
|
715
|
+
raise _RecoverableDisconnect("WebSocket closed")
|
|
716
|
+
except ConnectionClosed as exc:
|
|
717
|
+
close_code = _connection_close_code(exc)
|
|
718
|
+
if self._background_failure is not None:
|
|
719
|
+
raise self._background_failure from exc
|
|
720
|
+
if close_code == 4004:
|
|
721
|
+
raise PreResumeError("authentication failed", "bpp.auth_failed") from exc
|
|
722
|
+
if close_code == 1009:
|
|
723
|
+
raise ProtocolCapacityError("WebSocket peer rejected a frame for capacity") from exc
|
|
724
|
+
if close_code in {1002, 1003, 1007, 1008}:
|
|
725
|
+
raise ProtocolError(
|
|
726
|
+
f"WebSocket peer closed for a protocol violation ({close_code})",
|
|
727
|
+
"bpp.peer_protocol_close",
|
|
728
|
+
) from exc
|
|
729
|
+
if not self._resume_ack_seen:
|
|
730
|
+
raise PreResumeError(
|
|
731
|
+
"WebSocket closed before resume acknowledgement", "bpp.pre_resume_closed"
|
|
732
|
+
) from exc
|
|
733
|
+
raise _RecoverableDisconnect(f"WebSocket closed with code {close_code}") from exc
|
|
734
|
+
except TimeoutError as exc:
|
|
735
|
+
if not opened and self._ever_ready:
|
|
736
|
+
raise _RecoverableDisconnect("WebSocket dial timed out") from exc
|
|
737
|
+
if not self._resume_ack_seen:
|
|
738
|
+
raise PreResumeError("pre-resume deadline expired", "bpp.pre_resume_timeout") from exc
|
|
739
|
+
raise
|
|
740
|
+
except OSError as exc:
|
|
741
|
+
if not opened and self._ever_ready:
|
|
742
|
+
raise _RecoverableDisconnect("WebSocket dial failed") from exc
|
|
743
|
+
raise PreResumeError("WebSocket dial failed", "bpp.connect_failed") from exc
|
|
744
|
+
finally:
|
|
745
|
+
await self._invalidate_generation(
|
|
746
|
+
BorgeeError("connection generation ended", "bpp.connection_lost")
|
|
747
|
+
)
|
|
748
|
+
await self._cleanup_generation(BorgeeError("connection generation ended", "bpp.connection_lost"))
|
|
749
|
+
|
|
750
|
+
async def _receive_loop(self, websocket: ClientConnection, generation: int) -> None:
|
|
751
|
+
async for raw in websocket:
|
|
752
|
+
received_at = time.monotonic()
|
|
753
|
+
if generation != self._active_generation:
|
|
754
|
+
return
|
|
755
|
+
if isinstance(raw, bytes):
|
|
756
|
+
size = len(raw)
|
|
757
|
+
try:
|
|
758
|
+
text = raw.decode("utf-8")
|
|
759
|
+
except UnicodeDecodeError as exc:
|
|
760
|
+
raise ProtocolError("incoming frame is not UTF-8", "bpp.frame_encoding") from exc
|
|
761
|
+
else:
|
|
762
|
+
text = raw
|
|
763
|
+
size = len(raw.encode("utf-8"))
|
|
764
|
+
if size > MAX_INCOMING_BYTES:
|
|
765
|
+
raise ProtocolCapacityError("incoming frame exceeds five MiB")
|
|
766
|
+
try:
|
|
767
|
+
frame = json.loads(text)
|
|
768
|
+
except (TypeError, json.JSONDecodeError) as exc:
|
|
769
|
+
raise ProtocolError("incoming frame is not valid JSON", "bpp.frame_json") from exc
|
|
770
|
+
if not isinstance(frame, dict) or not isinstance(frame.get("type"), str):
|
|
771
|
+
raise ProtocolError("incoming frame has no string type", "bpp.frame_shape")
|
|
772
|
+
frame_type = frame["type"]
|
|
773
|
+
if frame_type == "ping":
|
|
774
|
+
self._send_reserved_nowait({"type": "pong"}, send_timeout=1.0)
|
|
775
|
+
elif frame_type in {"pong", "agent_toggle"}:
|
|
776
|
+
continue
|
|
777
|
+
elif frame_type == "session.resume_ack":
|
|
778
|
+
self._handle_resume_ack(frame, generation)
|
|
779
|
+
elif frame_type == "semantic_action_result":
|
|
780
|
+
self._handle_action_result(frame)
|
|
781
|
+
elif frame_type == "permission_denied":
|
|
782
|
+
self._handle_permission_denied(frame)
|
|
783
|
+
elif frame_type == "inbound_message":
|
|
784
|
+
self._handle_inbound(frame, size, generation)
|
|
785
|
+
elif frame_type == "session.summary":
|
|
786
|
+
self._handle_summary(frame, size, generation)
|
|
787
|
+
elif frame_type == "agent_config_update":
|
|
788
|
+
self._admit_config(frame, size, generation)
|
|
789
|
+
elif frame_type == "request":
|
|
790
|
+
self._admit_request(frame, size, received_at, generation)
|
|
791
|
+
else:
|
|
792
|
+
self._options.logger.debug("Ignored unknown BPP frame type %s", frame_type)
|
|
793
|
+
|
|
794
|
+
def _handle_resume_ack(self, frame: dict[str, Any], generation: int) -> None:
|
|
795
|
+
if generation != self._active_generation or self._resume_ack_seen or self._resume_request is None:
|
|
796
|
+
raise ResumeAckError("unexpected or duplicate session.resume_ack")
|
|
797
|
+
requested_mode, since_cursor = self._resume_request
|
|
798
|
+
replay_mode = frame.get("replay_mode")
|
|
799
|
+
count = frame.get("count")
|
|
800
|
+
high_water = frame.get("high_water")
|
|
801
|
+
reset = frame.get("reset")
|
|
802
|
+
reason = frame.get("reason")
|
|
803
|
+
if replay_mode != requested_mode.value:
|
|
804
|
+
raise ResumeAckError("session.resume_ack replay mode does not match the request")
|
|
805
|
+
if not _is_int(count) or count < 0 or count > MAX_INCOMING_FRAMES:
|
|
806
|
+
raise ResumeAckError("session.resume_ack count is outside the supported bounds")
|
|
807
|
+
if not _is_int(high_water) or high_water < 0:
|
|
808
|
+
raise ResumeAckError("session.resume_ack high_water is invalid")
|
|
809
|
+
if not isinstance(reset, bool) or not isinstance(reason, str):
|
|
810
|
+
raise ResumeAckError("session.resume_ack reset fields are invalid")
|
|
811
|
+
if reset:
|
|
812
|
+
raise ResumeAckError(f"server requires cursor reset: {reason}", "bpp.resume_reset_required")
|
|
813
|
+
if high_water < since_cursor:
|
|
814
|
+
raise ResumeAckError("session.resume_ack high_water precedes the requested cursor")
|
|
815
|
+
if requested_mode is ReplayMode.SUMMARY and count != 1:
|
|
816
|
+
raise ResumeAckError("summary resume must announce exactly one summary frame")
|
|
817
|
+
self._resume_count = count
|
|
818
|
+
self._resume_high_water = high_water
|
|
819
|
+
self._resume_ack_seen = True
|
|
820
|
+
waiter = self._resume_ack
|
|
821
|
+
if waiter is not None and not waiter.done():
|
|
822
|
+
waiter.set_result(None)
|
|
823
|
+
if count == 0:
|
|
824
|
+
self._replay_arrived.set()
|
|
825
|
+
self._spawn(self._finish_zero_replay(generation), "borgee-zero-replay-checkpoint")
|
|
826
|
+
else:
|
|
827
|
+
self._spawn(self._wait_for_replay_arrival(generation), "borgee-replay-arrival-timeout")
|
|
828
|
+
|
|
829
|
+
def _handle_action_result(self, frame: dict[str, Any]) -> None:
|
|
830
|
+
nonce = frame.get("nonce")
|
|
831
|
+
if not isinstance(nonce, str):
|
|
832
|
+
raise ProtocolError("semantic_action_result nonce is invalid")
|
|
833
|
+
pending = self._pending_actions.pop(nonce, None)
|
|
834
|
+
if pending is None:
|
|
835
|
+
return
|
|
836
|
+
status = frame.get("status")
|
|
837
|
+
if status == "ok":
|
|
838
|
+
payload = frame.get("payload")
|
|
839
|
+
if not isinstance(payload, str):
|
|
840
|
+
pending.future.set_exception(ProtocolError("semantic action payload is not a string"))
|
|
841
|
+
raise ProtocolError("semantic action payload is not a string")
|
|
842
|
+
try:
|
|
843
|
+
result = None if payload == "" else json.loads(payload)
|
|
844
|
+
except json.JSONDecodeError as exc:
|
|
845
|
+
pending.future.set_exception(ProtocolError("semantic action payload is invalid JSON"))
|
|
846
|
+
raise ProtocolError("semantic action payload is invalid JSON") from exc
|
|
847
|
+
if pending.operation == "send_message" and isinstance(result, dict):
|
|
848
|
+
cursor = frame.get("cursor")
|
|
849
|
+
if not _is_int(cursor) or cursor < 0:
|
|
850
|
+
pending.future.set_exception(ProtocolError("semantic action cursor is invalid"))
|
|
851
|
+
raise ProtocolError("semantic action cursor is invalid")
|
|
852
|
+
result = {**result, "cursor": cursor}
|
|
853
|
+
pending.future.set_result(result)
|
|
854
|
+
return
|
|
855
|
+
if status == "error":
|
|
856
|
+
code = frame.get("error")
|
|
857
|
+
if not isinstance(code, str) or not code:
|
|
858
|
+
pending.future.set_exception(ProtocolError("semantic action error code is invalid"))
|
|
859
|
+
raise ProtocolError("semantic action error code is invalid")
|
|
860
|
+
pending.future.set_exception(BorgeeError(f"semantic action {pending.operation} failed", code))
|
|
861
|
+
return
|
|
862
|
+
pending.future.set_exception(ProtocolError("semantic action result status is invalid"))
|
|
863
|
+
raise ProtocolError("semantic action result status is invalid")
|
|
864
|
+
|
|
865
|
+
def _handle_permission_denied(self, frame: dict[str, Any]) -> None:
|
|
866
|
+
request_id = frame.get("request_id")
|
|
867
|
+
action = frame.get("attempted_action")
|
|
868
|
+
capability = frame.get("required_capability")
|
|
869
|
+
scope = frame.get("current_scope")
|
|
870
|
+
if not isinstance(request_id, str):
|
|
871
|
+
raise ProtocolError("permission_denied request_id is invalid")
|
|
872
|
+
if not isinstance(action, str):
|
|
873
|
+
raise ProtocolError("permission_denied action is invalid")
|
|
874
|
+
if not isinstance(capability, str):
|
|
875
|
+
raise ProtocolError("permission_denied capability is invalid")
|
|
876
|
+
if not isinstance(scope, str):
|
|
877
|
+
raise ProtocolError("permission_denied frame is invalid")
|
|
878
|
+
pending = self._pending_actions.pop(request_id, None)
|
|
879
|
+
if pending is not None:
|
|
880
|
+
pending.future.set_exception(
|
|
881
|
+
PermissionDeniedError(
|
|
882
|
+
attempted_action=action,
|
|
883
|
+
required_capability=capability,
|
|
884
|
+
current_scope=scope,
|
|
885
|
+
)
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
def _handle_inbound(self, frame: dict[str, Any], size: int, generation: int) -> None:
|
|
889
|
+
if not self._resume_ack_seen:
|
|
890
|
+
raise ProtocolError("inbound message arrived before session.resume_ack")
|
|
891
|
+
if self._resume_request is not None and self._resume_request[0] is ReplayMode.SUMMARY:
|
|
892
|
+
raise ProtocolError("summary resume received an inbound message")
|
|
893
|
+
cursor = _required_int(frame, "cursor", minimum=1)
|
|
894
|
+
if cursor in self._seen_delivery_cursors:
|
|
895
|
+
return
|
|
896
|
+
replay_pending = self._replay_received < self._resume_count
|
|
897
|
+
if replay_pending:
|
|
898
|
+
if cursor <= self._last_checkpoint:
|
|
899
|
+
raise ProtocolError("inbound cursor is stale", "bpp.cursor_non_monotonic")
|
|
900
|
+
elif cursor < self._last_received:
|
|
901
|
+
raise ProtocolError("inbound cursor moved backwards", "bpp.cursor_non_monotonic")
|
|
902
|
+
elif cursor == self._last_received:
|
|
903
|
+
return
|
|
904
|
+
event = _map_inbound(frame, cursor)
|
|
905
|
+
self._seen_delivery_cursors.add(cursor)
|
|
906
|
+
self._last_received = max(self._last_received, cursor)
|
|
907
|
+
self._enqueue_delivery(event, cursor, size, generation)
|
|
908
|
+
|
|
909
|
+
def _handle_summary(self, frame: dict[str, Any], size: int, generation: int) -> None:
|
|
910
|
+
if not self._resume_ack_seen or self._resume_request is None:
|
|
911
|
+
raise ProtocolError("session summary arrived before session.resume_ack")
|
|
912
|
+
mode, since_cursor = self._resume_request
|
|
913
|
+
cursor = _required_int(frame, "cursor", minimum=0)
|
|
914
|
+
missed_count = _required_int(frame, "missed_count", minimum=0)
|
|
915
|
+
since = _required_int(frame, "since_cursor", minimum=0)
|
|
916
|
+
through = _required_int(frame, "through_cursor", minimum=0)
|
|
917
|
+
summary = frame.get("summary")
|
|
918
|
+
if (
|
|
919
|
+
mode is not ReplayMode.SUMMARY
|
|
920
|
+
or self._resume_count != 1
|
|
921
|
+
or self._replay_received != 0
|
|
922
|
+
or cursor != self._resume_high_water
|
|
923
|
+
or through != self._resume_high_water
|
|
924
|
+
or since != since_cursor
|
|
925
|
+
or not isinstance(summary, str)
|
|
926
|
+
):
|
|
927
|
+
raise ProtocolError("session summary does not correlate with the resume request")
|
|
928
|
+
if cursor < self._last_checkpoint:
|
|
929
|
+
raise ProtocolError("summary cursor is stale", "bpp.cursor_non_monotonic")
|
|
930
|
+
event = ReplaySummaryEvent(cursor, missed_count, since, through, summary)
|
|
931
|
+
self._last_received = max(self._last_received, cursor)
|
|
932
|
+
self._enqueue_delivery(event, cursor, size, generation)
|
|
933
|
+
|
|
934
|
+
def _enqueue_delivery(self, event: DeliveryEvent, cursor: int, size: int, generation: int) -> None:
|
|
935
|
+
buffered_count = len(self._deliveries) + len(self._deferred_live)
|
|
936
|
+
if buffered_count >= MAX_INCOMING_FRAMES or self._delivery_bytes + size > MAX_INCOMING_BYTES:
|
|
937
|
+
raise ProtocolCapacityError("application cursor buffer capacity exceeded")
|
|
938
|
+
replay_pending = self._replay_received < self._resume_count
|
|
939
|
+
replay = replay_pending and cursor <= self._resume_high_water
|
|
940
|
+
if (
|
|
941
|
+
not replay_pending
|
|
942
|
+
and cursor <= self._resume_high_water
|
|
943
|
+
and self._state.status is ConnectionStatus.RESUMING
|
|
944
|
+
):
|
|
945
|
+
raise ProtocolError("received an unannounced replay frame")
|
|
946
|
+
buffered = _BufferedDelivery(event, cursor, size, generation, replay)
|
|
947
|
+
self._delivery_bytes += size
|
|
948
|
+
if replay_pending and not replay:
|
|
949
|
+
self._deferred_live.append(buffered)
|
|
950
|
+
return
|
|
951
|
+
if replay:
|
|
952
|
+
self._replay_received += 1
|
|
953
|
+
self._deliveries.append(buffered)
|
|
954
|
+
if replay and self._replay_received == self._resume_count:
|
|
955
|
+
self._replay_arrived.set()
|
|
956
|
+
self._deliveries.extend(sorted(self._deferred_live, key=lambda item: item.cursor))
|
|
957
|
+
self._deferred_live.clear()
|
|
958
|
+
self._delivery_wakeup.set()
|
|
959
|
+
|
|
960
|
+
async def _wait_for_replay_arrival(self, generation: int) -> None:
|
|
961
|
+
try:
|
|
962
|
+
await asyncio.wait_for(self._replay_arrived.wait(), REPLAY_ARRIVAL_TIMEOUT)
|
|
963
|
+
except TimeoutError as exc:
|
|
964
|
+
if generation == self._active_generation:
|
|
965
|
+
raise ResumeAckError(
|
|
966
|
+
"declared replay frames did not arrive",
|
|
967
|
+
"bpp.replay_incomplete",
|
|
968
|
+
) from exc
|
|
969
|
+
|
|
970
|
+
def _admit_config(self, frame: dict[str, Any], size: int, generation: int) -> None:
|
|
971
|
+
queue = self._config_queue
|
|
972
|
+
if (
|
|
973
|
+
queue is None
|
|
974
|
+
or queue.full()
|
|
975
|
+
or size > CONFIG_MAX_BYTES
|
|
976
|
+
or self._config_queue_bytes + size > CONFIG_MAX_BYTES
|
|
977
|
+
):
|
|
978
|
+
raise ProtocolCapacityError("configuration queue capacity exceeded")
|
|
979
|
+
queue.put_nowait((frame, size, generation))
|
|
980
|
+
self._config_queue_bytes += size
|
|
981
|
+
|
|
982
|
+
def _admit_request(
|
|
983
|
+
self,
|
|
984
|
+
frame: dict[str, Any],
|
|
985
|
+
size: int,
|
|
986
|
+
received_at: float,
|
|
987
|
+
generation: int,
|
|
988
|
+
) -> None:
|
|
989
|
+
request_id = frame.get("id")
|
|
990
|
+
if not isinstance(request_id, str) or not request_id:
|
|
991
|
+
raise ProtocolError("server request id is invalid")
|
|
992
|
+
deadline = received_at + SERVER_REQUEST_DEADLINE
|
|
993
|
+
if size > REQUEST_SINGLE_MAX_BYTES:
|
|
994
|
+
self._send_reserved_nowait(
|
|
995
|
+
{"type": "response", "id": request_id, "error": "server_request_too_large"},
|
|
996
|
+
deadline=deadline,
|
|
997
|
+
)
|
|
998
|
+
return
|
|
999
|
+
queue = self._request_queue
|
|
1000
|
+
if queue is None or queue.full() or self._request_queue_bytes + size > REQUEST_MAX_BYTES:
|
|
1001
|
+
self._send_reserved_nowait(
|
|
1002
|
+
{"type": "response", "id": request_id, "error": "server_request_busy"},
|
|
1003
|
+
deadline=deadline,
|
|
1004
|
+
)
|
|
1005
|
+
return
|
|
1006
|
+
queue.put_nowait((frame, size, received_at, generation))
|
|
1007
|
+
self._request_queue_bytes += size
|
|
1008
|
+
|
|
1009
|
+
async def _config_worker(self, generation: int) -> None:
|
|
1010
|
+
queue = self._config_queue
|
|
1011
|
+
assert queue is not None
|
|
1012
|
+
while generation == self._active_generation:
|
|
1013
|
+
frame, size, item_generation = await queue.get()
|
|
1014
|
+
self._config_queue_bytes -= size
|
|
1015
|
+
if item_generation != self._active_generation:
|
|
1016
|
+
continue
|
|
1017
|
+
update = self._decode_config_update(frame)
|
|
1018
|
+
cached = self._config_acks.get(update.idempotency_key)
|
|
1019
|
+
if cached is not None:
|
|
1020
|
+
self._config_acks.move_to_end(update.idempotency_key)
|
|
1021
|
+
await self._send_reserved(cached)
|
|
1022
|
+
continue
|
|
1023
|
+
|
|
1024
|
+
result = await self._apply_config(update, generation)
|
|
1025
|
+
if generation != self._active_generation:
|
|
1026
|
+
continue
|
|
1027
|
+
reason = (
|
|
1028
|
+
""
|
|
1029
|
+
if result.status is ConfigApplyStatus.APPLIED
|
|
1030
|
+
else (result.reason or FaultReason.UNKNOWN).value
|
|
1031
|
+
)
|
|
1032
|
+
ack = {
|
|
1033
|
+
"type": "agent_config_ack",
|
|
1034
|
+
"cursor": update.cursor,
|
|
1035
|
+
"agent_id": update.agent_id,
|
|
1036
|
+
"schema_version": update.schema_version,
|
|
1037
|
+
"idempotency_key": update.idempotency_key,
|
|
1038
|
+
"status": result.status.value,
|
|
1039
|
+
"reason": reason,
|
|
1040
|
+
"applied_at": int(time.time() * 1000) if result.status is ConfigApplyStatus.APPLIED else 0,
|
|
1041
|
+
}
|
|
1042
|
+
self._config_acks[update.idempotency_key] = ack
|
|
1043
|
+
self._config_acks.move_to_end(update.idempotency_key)
|
|
1044
|
+
while len(self._config_acks) > 256:
|
|
1045
|
+
self._config_acks.popitem(last=False)
|
|
1046
|
+
self._enqueue_config_observer(update, size)
|
|
1047
|
+
await self._send_reserved(ack)
|
|
1048
|
+
|
|
1049
|
+
def _decode_config_update(self, frame: dict[str, Any]) -> ConfigUpdate:
|
|
1050
|
+
agent_id = frame.get("agent_id")
|
|
1051
|
+
idempotency_key = frame.get("idempotency_key")
|
|
1052
|
+
blob = frame.get("blob")
|
|
1053
|
+
if not isinstance(agent_id, str) or not isinstance(idempotency_key, str) or not idempotency_key:
|
|
1054
|
+
raise ProtocolError("configuration update identity is invalid")
|
|
1055
|
+
if not isinstance(blob, str):
|
|
1056
|
+
raise ProtocolError("configuration update blob is not a string")
|
|
1057
|
+
try:
|
|
1058
|
+
decoded_blob = json.loads(blob)
|
|
1059
|
+
except json.JSONDecodeError as exc:
|
|
1060
|
+
raise ProtocolError("configuration update blob is invalid JSON") from exc
|
|
1061
|
+
return ConfigUpdate(
|
|
1062
|
+
agent_id=agent_id,
|
|
1063
|
+
schema_version=_required_int(frame, "schema_version", minimum=0),
|
|
1064
|
+
blob=decoded_blob,
|
|
1065
|
+
idempotency_key=idempotency_key,
|
|
1066
|
+
cursor=_required_int(frame, "cursor", minimum=1),
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
async def _apply_config(self, update: ConfigUpdate, generation: int) -> ConfigApplyResult:
|
|
1070
|
+
handler = self._options.config_update_handler
|
|
1071
|
+
if handler is None:
|
|
1072
|
+
return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.UNKNOWN)
|
|
1073
|
+
cancelled = asyncio.Event()
|
|
1074
|
+
|
|
1075
|
+
async def invoke() -> ConfigApplyResult:
|
|
1076
|
+
value = handler(update, ConfigApplyContext(cancelled))
|
|
1077
|
+
if inspect.isawaitable(value):
|
|
1078
|
+
value = await value
|
|
1079
|
+
return value
|
|
1080
|
+
|
|
1081
|
+
if not self._try_acquire_handler_slot():
|
|
1082
|
+
raise ProtocolCapacityError("configuration handler capacity exceeded")
|
|
1083
|
+
try:
|
|
1084
|
+
task = asyncio.create_task(invoke(), name="borgee-config-handler")
|
|
1085
|
+
except BaseException:
|
|
1086
|
+
self._handler_slots_available += 1
|
|
1087
|
+
raise
|
|
1088
|
+
self._register_handler_task(task)
|
|
1089
|
+
try:
|
|
1090
|
+
done, _ = await asyncio.wait({task}, timeout=self._options.config_apply_timeout)
|
|
1091
|
+
except asyncio.CancelledError:
|
|
1092
|
+
cancelled.set()
|
|
1093
|
+
self._detach_handler_task(task)
|
|
1094
|
+
raise
|
|
1095
|
+
if not done:
|
|
1096
|
+
cancelled.set()
|
|
1097
|
+
self._detach_handler_task(task)
|
|
1098
|
+
return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.RUNTIME_TIMEOUT)
|
|
1099
|
+
try:
|
|
1100
|
+
result = task.result()
|
|
1101
|
+
except BaseException:
|
|
1102
|
+
self._options.logger.exception("Configuration handler failed")
|
|
1103
|
+
return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.UNKNOWN)
|
|
1104
|
+
finally:
|
|
1105
|
+
cancelled.set()
|
|
1106
|
+
self._release_handler_task(task)
|
|
1107
|
+
if generation != self._active_generation or not isinstance(result, ConfigApplyResult):
|
|
1108
|
+
return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.UNKNOWN)
|
|
1109
|
+
status: object = result.status
|
|
1110
|
+
if not isinstance(status, ConfigApplyStatus):
|
|
1111
|
+
return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.UNKNOWN)
|
|
1112
|
+
if status is ConfigApplyStatus.APPLIED:
|
|
1113
|
+
return ConfigApplyResult(ConfigApplyStatus.APPLIED)
|
|
1114
|
+
reason = result.reason if result.reason in set(FaultReason) else FaultReason.UNKNOWN
|
|
1115
|
+
return ConfigApplyResult(status, reason)
|
|
1116
|
+
|
|
1117
|
+
def _enqueue_config_observer(self, update: ConfigUpdate, size: int) -> None:
|
|
1118
|
+
if self._options.config_update_observer is None:
|
|
1119
|
+
return
|
|
1120
|
+
dropped = 0
|
|
1121
|
+
while self._observer_queue and (
|
|
1122
|
+
len(self._observer_queue) >= 64 or self._observer_bytes + size > (1 << 20)
|
|
1123
|
+
):
|
|
1124
|
+
_, old_size = self._observer_queue.popleft()
|
|
1125
|
+
self._observer_bytes -= old_size
|
|
1126
|
+
dropped += 1
|
|
1127
|
+
if size > 1 << 20:
|
|
1128
|
+
dropped += 1
|
|
1129
|
+
else:
|
|
1130
|
+
self._observer_queue.append((update, size))
|
|
1131
|
+
self._observer_bytes += size
|
|
1132
|
+
self._observer_wakeup.set()
|
|
1133
|
+
if dropped:
|
|
1134
|
+
self._options.logger.warning("Dropped %d old configuration observer event(s)", dropped)
|
|
1135
|
+
|
|
1136
|
+
async def _run_config_observer(self) -> None:
|
|
1137
|
+
while True:
|
|
1138
|
+
if not self._observer_queue:
|
|
1139
|
+
self._observer_wakeup.clear()
|
|
1140
|
+
await self._observer_wakeup.wait()
|
|
1141
|
+
continue
|
|
1142
|
+
update, size = self._observer_queue.popleft()
|
|
1143
|
+
self._observer_bytes -= size
|
|
1144
|
+
observer = self._options.config_update_observer
|
|
1145
|
+
if observer is None:
|
|
1146
|
+
continue
|
|
1147
|
+
try:
|
|
1148
|
+
result = observer(update)
|
|
1149
|
+
if inspect.isawaitable(result):
|
|
1150
|
+
await result
|
|
1151
|
+
except asyncio.CancelledError:
|
|
1152
|
+
raise
|
|
1153
|
+
except BaseException:
|
|
1154
|
+
self._options.logger.exception("Configuration observer failed")
|
|
1155
|
+
|
|
1156
|
+
async def _request_worker(self, generation: int) -> None:
|
|
1157
|
+
queue = self._request_queue
|
|
1158
|
+
assert queue is not None
|
|
1159
|
+
while generation == self._active_generation:
|
|
1160
|
+
frame, size, received_at, item_generation = await queue.get()
|
|
1161
|
+
self._request_queue_bytes -= size
|
|
1162
|
+
if item_generation != self._active_generation:
|
|
1163
|
+
continue
|
|
1164
|
+
request_id = frame["id"]
|
|
1165
|
+
deadline = received_at + SERVER_REQUEST_DEADLINE
|
|
1166
|
+
if time.monotonic() - received_at >= SERVER_REQUEST_QUEUE_WAIT:
|
|
1167
|
+
await self._send_request_error(request_id, "server_request_busy", deadline, generation)
|
|
1168
|
+
continue
|
|
1169
|
+
handler = self._options.server_request_handler
|
|
1170
|
+
if handler is None:
|
|
1171
|
+
await self._send_request_error(request_id, "no_server_request_handler", deadline, generation)
|
|
1172
|
+
continue
|
|
1173
|
+
data = frame.get("data")
|
|
1174
|
+
action = data.get("action", "") if isinstance(data, dict) else ""
|
|
1175
|
+
if not isinstance(action, str):
|
|
1176
|
+
action = ""
|
|
1177
|
+
remaining = deadline - time.monotonic()
|
|
1178
|
+
handler_timeout = min(SERVER_REQUEST_HANDLER_TIMEOUT, remaining - 1.0)
|
|
1179
|
+
if handler_timeout <= 0:
|
|
1180
|
+
await self._send_request_error(request_id, "server_request_busy", deadline, generation)
|
|
1181
|
+
continue
|
|
1182
|
+
|
|
1183
|
+
if not self._try_acquire_handler_slot():
|
|
1184
|
+
await self._send_request_error(request_id, "server_request_busy", deadline, generation)
|
|
1185
|
+
continue
|
|
1186
|
+
try:
|
|
1187
|
+
task = asyncio.create_task(
|
|
1188
|
+
self._invoke_server_handler(handler, action, data),
|
|
1189
|
+
name="borgee-server-request-handler",
|
|
1190
|
+
)
|
|
1191
|
+
except BaseException:
|
|
1192
|
+
self._handler_slots_available += 1
|
|
1193
|
+
raise
|
|
1194
|
+
self._register_handler_task(task)
|
|
1195
|
+
try:
|
|
1196
|
+
done, _ = await asyncio.wait({task}, timeout=handler_timeout)
|
|
1197
|
+
except asyncio.CancelledError:
|
|
1198
|
+
self._detach_handler_task(task)
|
|
1199
|
+
raise
|
|
1200
|
+
if not done:
|
|
1201
|
+
self._detach_handler_task(task)
|
|
1202
|
+
await self._send_request_error(request_id, "server_request_timeout", deadline, generation)
|
|
1203
|
+
continue
|
|
1204
|
+
try:
|
|
1205
|
+
result = task.result()
|
|
1206
|
+
except BaseException:
|
|
1207
|
+
await self._send_request_error(request_id, "server_request_failed", deadline, generation)
|
|
1208
|
+
continue
|
|
1209
|
+
finally:
|
|
1210
|
+
self._release_handler_task(task)
|
|
1211
|
+
if generation != self._active_generation:
|
|
1212
|
+
continue
|
|
1213
|
+
response = {"type": "response", "id": request_id, "data": result}
|
|
1214
|
+
try:
|
|
1215
|
+
await self._send_reserved(response, deadline=deadline)
|
|
1216
|
+
except ProtocolCapacityError:
|
|
1217
|
+
await self._send_request_error(request_id, "server_request_too_large", deadline, generation)
|
|
1218
|
+
|
|
1219
|
+
@staticmethod
|
|
1220
|
+
async def _invoke_server_handler(
|
|
1221
|
+
handler: ServerRequestHandler,
|
|
1222
|
+
action: str,
|
|
1223
|
+
data: Any,
|
|
1224
|
+
) -> Any:
|
|
1225
|
+
value = handler(action, data)
|
|
1226
|
+
return await value if inspect.isawaitable(value) else value
|
|
1227
|
+
|
|
1228
|
+
async def _send_request_error(
|
|
1229
|
+
self,
|
|
1230
|
+
request_id: str,
|
|
1231
|
+
code: str,
|
|
1232
|
+
deadline: float,
|
|
1233
|
+
generation: int,
|
|
1234
|
+
) -> None:
|
|
1235
|
+
if generation != self._active_generation or time.monotonic() >= deadline:
|
|
1236
|
+
return
|
|
1237
|
+
await self._send_reserved(
|
|
1238
|
+
{"type": "response", "id": request_id, "error": code},
|
|
1239
|
+
deadline=deadline,
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
async def _perform(self, operation: str, payload: dict[str, Any], *, allow_pre_resume: bool) -> Any:
|
|
1243
|
+
# One action_timeout bounds waiting-for-online AND the response wait together (FIX C).
|
|
1244
|
+
loop = asyncio.get_running_loop()
|
|
1245
|
+
deadline = loop.time() + self._options.action_timeout
|
|
1246
|
+
if not allow_pre_resume:
|
|
1247
|
+
await self._wait_until_online_for_action(operation, deadline)
|
|
1248
|
+
elif self._writer is None or operation != "get_me":
|
|
1249
|
+
raise NotOnlineError("only get_me is allowed before resume")
|
|
1250
|
+
if len(self._pending_actions) >= ACTION_PENDING_MAX:
|
|
1251
|
+
raise ProtocolCapacityError("too many pending semantic actions")
|
|
1252
|
+
nonce = str(uuid.uuid4())
|
|
1253
|
+
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
1254
|
+
self._pending_actions[nonce] = _PendingAction(operation, future)
|
|
1255
|
+
try:
|
|
1256
|
+
await self._send_regular(
|
|
1257
|
+
{
|
|
1258
|
+
"type": "semantic_action",
|
|
1259
|
+
"agent_id": self._agent_id,
|
|
1260
|
+
"action": operation,
|
|
1261
|
+
"payload": json.dumps(payload, separators=(",", ":"), ensure_ascii=False),
|
|
1262
|
+
"nonce": nonce,
|
|
1263
|
+
}
|
|
1264
|
+
)
|
|
1265
|
+
remaining = max(0.0, deadline - loop.time())
|
|
1266
|
+
return await asyncio.wait_for(future, remaining)
|
|
1267
|
+
except TimeoutError as exc:
|
|
1268
|
+
raise BorgeeError(f"semantic action {operation} timed out", "bpp.action_timeout") from exc
|
|
1269
|
+
finally:
|
|
1270
|
+
self._pending_actions.pop(nonce, None)
|
|
1271
|
+
|
|
1272
|
+
async def _read_cursor_detached_on_cancel(self) -> int:
|
|
1273
|
+
task = asyncio.create_task(
|
|
1274
|
+
self._options.cursor_store.read(self._cursor_key),
|
|
1275
|
+
name="borgee-cursor-read",
|
|
1276
|
+
)
|
|
1277
|
+
try:
|
|
1278
|
+
return await asyncio.shield(task)
|
|
1279
|
+
except asyncio.CancelledError:
|
|
1280
|
+
self._detach_task(task)
|
|
1281
|
+
raise
|
|
1282
|
+
|
|
1283
|
+
@staticmethod
|
|
1284
|
+
def _consume_future_exception(future: asyncio.Future[Any]) -> None:
|
|
1285
|
+
if future.cancelled():
|
|
1286
|
+
return
|
|
1287
|
+
with contextlib.suppress(BaseException):
|
|
1288
|
+
future.exception()
|
|
1289
|
+
|
|
1290
|
+
async def _heartbeat_loop(self, generation: int) -> None:
|
|
1291
|
+
while generation == self._active_generation:
|
|
1292
|
+
await asyncio.sleep(self._options.heartbeat_interval)
|
|
1293
|
+
if generation != self._active_generation:
|
|
1294
|
+
return
|
|
1295
|
+
await self._send_regular(
|
|
1296
|
+
{
|
|
1297
|
+
"type": "heartbeat",
|
|
1298
|
+
"plugin_id": self._options.plugin_id,
|
|
1299
|
+
"agent_id": self._agent_id,
|
|
1300
|
+
"status": "online",
|
|
1301
|
+
"reason": "",
|
|
1302
|
+
"timestamp": int(time.time() * 1000),
|
|
1303
|
+
}
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
async def _next_delivery(self) -> Delivery | None:
|
|
1307
|
+
while True:
|
|
1308
|
+
if self._active_delivery is None and self._deliveries:
|
|
1309
|
+
buffered = self._deliveries.popleft()
|
|
1310
|
+
self._delivery_bytes -= buffered.size
|
|
1311
|
+
delivery = Delivery(self, buffered)
|
|
1312
|
+
self._active_delivery = delivery
|
|
1313
|
+
return delivery
|
|
1314
|
+
if (
|
|
1315
|
+
self._active_delivery is None
|
|
1316
|
+
and not self._deliveries
|
|
1317
|
+
and (self._closing or self._terminal_error is not None)
|
|
1318
|
+
):
|
|
1319
|
+
return None
|
|
1320
|
+
self._delivery_wakeup.clear()
|
|
1321
|
+
if self._active_delivery is None and self._deliveries:
|
|
1322
|
+
continue
|
|
1323
|
+
await self._delivery_wakeup.wait()
|
|
1324
|
+
|
|
1325
|
+
async def _settle_delivery(
|
|
1326
|
+
self,
|
|
1327
|
+
delivery: Delivery,
|
|
1328
|
+
*,
|
|
1329
|
+
checkpoint: bool,
|
|
1330
|
+
error: BaseException | None,
|
|
1331
|
+
) -> None:
|
|
1332
|
+
try:
|
|
1333
|
+
async with self._cursor_write_lock:
|
|
1334
|
+
if delivery is not self._active_delivery or delivery._generation != self._active_generation:
|
|
1335
|
+
raise StaleDeliveryError()
|
|
1336
|
+
if checkpoint:
|
|
1337
|
+
if delivery.cursor < self._last_checkpoint:
|
|
1338
|
+
raise StaleDeliveryError("delivery cursor is older than the durable checkpoint")
|
|
1339
|
+
if delivery.cursor > self._last_checkpoint:
|
|
1340
|
+
await self._options.cursor_store.write(self._cursor_key, delivery.cursor)
|
|
1341
|
+
self._last_checkpoint = delivery.cursor
|
|
1342
|
+
if delivery._replay:
|
|
1343
|
+
self._replay_checkpointed += 1
|
|
1344
|
+
if self._replay_checkpointed == self._resume_count:
|
|
1345
|
+
await self._finish_replay_locked(delivery._generation)
|
|
1346
|
+
self._active_delivery = None
|
|
1347
|
+
self._delivery_wakeup.set()
|
|
1348
|
+
return
|
|
1349
|
+
self._active_delivery = None
|
|
1350
|
+
self._delivery_wakeup.set()
|
|
1351
|
+
except StaleDeliveryError:
|
|
1352
|
+
raise
|
|
1353
|
+
except Exception as exc:
|
|
1354
|
+
await self._terminal(exc)
|
|
1355
|
+
raise
|
|
1356
|
+
await self._terminal(error or BorgeeError("delivery failed", "bpp.delivery_failed"))
|
|
1357
|
+
|
|
1358
|
+
async def _finish_zero_replay(self, generation: int) -> None:
|
|
1359
|
+
async with self._cursor_write_lock:
|
|
1360
|
+
if generation != self._active_generation:
|
|
1361
|
+
return
|
|
1362
|
+
await self._finish_replay_locked(generation)
|
|
1363
|
+
|
|
1364
|
+
async def _finish_replay_locked(self, generation: int) -> None:
|
|
1365
|
+
if generation != self._active_generation:
|
|
1366
|
+
raise StaleDeliveryError()
|
|
1367
|
+
if self._resume_high_water > self._last_checkpoint:
|
|
1368
|
+
await self._options.cursor_store.write(self._cursor_key, self._resume_high_water)
|
|
1369
|
+
self._last_checkpoint = self._resume_high_water
|
|
1370
|
+
if generation == self._active_generation:
|
|
1371
|
+
self._set_state(ConnectionState(ConnectionStatus.ONLINE))
|
|
1372
|
+
|
|
1373
|
+
def _require_online(self) -> None:
|
|
1374
|
+
if self._state.status is not ConnectionStatus.ONLINE:
|
|
1375
|
+
raise NotOnlineError()
|
|
1376
|
+
|
|
1377
|
+
async def _wait_until_online_for_action(self, operation: str, deadline: float) -> None:
|
|
1378
|
+
if self._state.status is ConnectionStatus.ONLINE and self._writer is not None:
|
|
1379
|
+
return
|
|
1380
|
+
# Fail fast on a terminal fault or an explicit close so the real reason (e.g.
|
|
1381
|
+
# bpp.auth_failed) surfaces immediately, instead of blocking for the whole action_timeout.
|
|
1382
|
+
if self._is_stopping() or self._closed_permanently:
|
|
1383
|
+
raise self._terminal_error or NotOnlineError("connection is not available")
|
|
1384
|
+
if not self._ever_online:
|
|
1385
|
+
self._require_online()
|
|
1386
|
+
if not self._options.auto_reconnect:
|
|
1387
|
+
raise NotOnlineError("auto reconnect is disabled")
|
|
1388
|
+
loop = asyncio.get_running_loop()
|
|
1389
|
+
remaining = deadline - loop.time()
|
|
1390
|
+
if remaining <= 0:
|
|
1391
|
+
raise BorgeeError(f"semantic action {operation} timed out", "bpp.action_timeout")
|
|
1392
|
+
online_task = asyncio.ensure_future(self._online_ready.wait())
|
|
1393
|
+
terminal_task = asyncio.ensure_future(self._terminal_event.wait())
|
|
1394
|
+
try:
|
|
1395
|
+
done, _pending = await asyncio.wait(
|
|
1396
|
+
{online_task, terminal_task},
|
|
1397
|
+
timeout=remaining,
|
|
1398
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1399
|
+
)
|
|
1400
|
+
finally:
|
|
1401
|
+
for task in (online_task, terminal_task):
|
|
1402
|
+
task.cancel()
|
|
1403
|
+
await asyncio.gather(online_task, terminal_task, return_exceptions=True)
|
|
1404
|
+
if self._is_stopping() or self._closed_permanently or terminal_task in done:
|
|
1405
|
+
raise self._terminal_error or BorgeeError("connection closed", "bpp.client_closed")
|
|
1406
|
+
if not done:
|
|
1407
|
+
raise BorgeeError(f"semantic action {operation} timed out", "bpp.action_timeout")
|
|
1408
|
+
if self._state.status is not ConnectionStatus.ONLINE or self._writer is None:
|
|
1409
|
+
raise NotOnlineError("connection left ONLINE before semantic action could be sent")
|
|
1410
|
+
|
|
1411
|
+
async def _send_regular(self, frame: dict[str, Any]) -> None:
|
|
1412
|
+
writer = self._writer
|
|
1413
|
+
if writer is None:
|
|
1414
|
+
raise NotOnlineError("WebSocket writer is unavailable")
|
|
1415
|
+
await writer.send(frame, lane="regular")
|
|
1416
|
+
|
|
1417
|
+
async def _send_reserved(
|
|
1418
|
+
self,
|
|
1419
|
+
frame: dict[str, Any],
|
|
1420
|
+
*,
|
|
1421
|
+
deadline: float | None = None,
|
|
1422
|
+
) -> None:
|
|
1423
|
+
writer = self._writer
|
|
1424
|
+
if writer is None:
|
|
1425
|
+
raise NotOnlineError("WebSocket writer is unavailable")
|
|
1426
|
+
await writer.send(frame, lane="reserved", deadline=deadline)
|
|
1427
|
+
|
|
1428
|
+
def _send_reserved_nowait(
|
|
1429
|
+
self,
|
|
1430
|
+
frame: dict[str, Any],
|
|
1431
|
+
*,
|
|
1432
|
+
deadline: float | None = None,
|
|
1433
|
+
send_timeout: float = 0.1,
|
|
1434
|
+
) -> None:
|
|
1435
|
+
writer = self._writer
|
|
1436
|
+
if writer is None:
|
|
1437
|
+
raise NotOnlineError("WebSocket writer is unavailable")
|
|
1438
|
+
completion = writer.send_nowait(
|
|
1439
|
+
frame,
|
|
1440
|
+
lane="reserved",
|
|
1441
|
+
deadline=deadline,
|
|
1442
|
+
send_timeout=send_timeout,
|
|
1443
|
+
)
|
|
1444
|
+
completion.add_done_callback(self._consume_send_result)
|
|
1445
|
+
|
|
1446
|
+
def _consume_send_result(self, future: asyncio.Future[None]) -> None:
|
|
1447
|
+
if future.cancelled():
|
|
1448
|
+
return
|
|
1449
|
+
error = future.exception()
|
|
1450
|
+
if error is not None and not self._closing:
|
|
1451
|
+
self._options.logger.warning("Reserved BPP write failed: %s", type(error).__name__)
|
|
1452
|
+
websocket = self._websocket
|
|
1453
|
+
if websocket is not None:
|
|
1454
|
+
websocket.transport.abort()
|
|
1455
|
+
|
|
1456
|
+
def _spawn(self, coroutine: Coroutine[Any, Any, Any], name: str) -> asyncio.Task[Any]:
|
|
1457
|
+
task: asyncio.Task[Any] = asyncio.create_task(coroutine, name=name)
|
|
1458
|
+
self._generation_tasks.add(task)
|
|
1459
|
+
task.add_done_callback(self._generation_task_done)
|
|
1460
|
+
return task
|
|
1461
|
+
|
|
1462
|
+
def _generation_task_done(self, task: asyncio.Task[Any]) -> None:
|
|
1463
|
+
self._generation_tasks.discard(task)
|
|
1464
|
+
if task.cancelled() or self._closing or self._terminal_set:
|
|
1465
|
+
return
|
|
1466
|
+
error = task.exception()
|
|
1467
|
+
if error is not None:
|
|
1468
|
+
if isinstance(error, ConnectionClosed):
|
|
1469
|
+
return
|
|
1470
|
+
self._background_failure = error
|
|
1471
|
+
websocket = self._websocket
|
|
1472
|
+
if websocket is not None:
|
|
1473
|
+
websocket.transport.abort()
|
|
1474
|
+
|
|
1475
|
+
def _detach_task(self, task: asyncio.Task[Any]) -> None:
|
|
1476
|
+
if task.done():
|
|
1477
|
+
self._detached_task_done(task)
|
|
1478
|
+
return
|
|
1479
|
+
task.cancel()
|
|
1480
|
+
_DETACHED_TASK_GUARDIAN.add(task)
|
|
1481
|
+
self._detached_tasks.add(task)
|
|
1482
|
+
task.add_done_callback(self._detached_task_done)
|
|
1483
|
+
|
|
1484
|
+
def _detached_task_done(self, task: asyncio.Task[Any]) -> None:
|
|
1485
|
+
_DETACHED_TASK_GUARDIAN.discard(task)
|
|
1486
|
+
self._detached_tasks.discard(task)
|
|
1487
|
+
if task.cancelled():
|
|
1488
|
+
return
|
|
1489
|
+
with contextlib.suppress(BaseException):
|
|
1490
|
+
task.exception()
|
|
1491
|
+
|
|
1492
|
+
def _try_acquire_handler_slot(self) -> bool:
|
|
1493
|
+
if self._handler_slots_available == 0:
|
|
1494
|
+
return False
|
|
1495
|
+
self._handler_slots_available -= 1
|
|
1496
|
+
return True
|
|
1497
|
+
|
|
1498
|
+
def _register_handler_task(self, task: asyncio.Task[Any]) -> None:
|
|
1499
|
+
self._handler_slot_tasks.add(task)
|
|
1500
|
+
|
|
1501
|
+
def _release_handler_task(self, task: asyncio.Task[Any]) -> None:
|
|
1502
|
+
if task not in self._handler_slot_tasks:
|
|
1503
|
+
return
|
|
1504
|
+
self._handler_slot_tasks.remove(task)
|
|
1505
|
+
self._handler_slots_available += 1
|
|
1506
|
+
|
|
1507
|
+
def _detach_handler_task(self, task: asyncio.Task[Any]) -> None:
|
|
1508
|
+
if task.done():
|
|
1509
|
+
self._handler_task_done(task)
|
|
1510
|
+
return
|
|
1511
|
+
task.cancel()
|
|
1512
|
+
_DETACHED_TASK_GUARDIAN.add(task)
|
|
1513
|
+
self._detached_tasks.add(task)
|
|
1514
|
+
task.add_done_callback(self._handler_task_done)
|
|
1515
|
+
|
|
1516
|
+
def _handler_task_done(self, task: asyncio.Task[Any]) -> None:
|
|
1517
|
+
_DETACHED_TASK_GUARDIAN.discard(task)
|
|
1518
|
+
self._detached_tasks.discard(task)
|
|
1519
|
+
if not task.cancelled():
|
|
1520
|
+
with contextlib.suppress(BaseException):
|
|
1521
|
+
task.exception()
|
|
1522
|
+
self._release_handler_task(task)
|
|
1523
|
+
|
|
1524
|
+
async def _invalidate_generation(self, error: BaseException) -> None:
|
|
1525
|
+
async with self._cursor_write_lock:
|
|
1526
|
+
self._active_generation = None
|
|
1527
|
+
self._online_ready.clear()
|
|
1528
|
+
self._deliveries.clear()
|
|
1529
|
+
self._deferred_live.clear()
|
|
1530
|
+
self._seen_delivery_cursors.clear()
|
|
1531
|
+
self._delivery_bytes = 0
|
|
1532
|
+
self._active_delivery = None
|
|
1533
|
+
self._delivery_wakeup.set()
|
|
1534
|
+
self._fail_pending(error)
|
|
1535
|
+
waiter = self._resume_ack
|
|
1536
|
+
if waiter is not None and not waiter.done():
|
|
1537
|
+
waiter.set_exception(error)
|
|
1538
|
+
|
|
1539
|
+
async def _cleanup_generation(self, error: BaseException) -> None:
|
|
1540
|
+
async with self._generation_cleanup_lock:
|
|
1541
|
+
current = asyncio.current_task()
|
|
1542
|
+
tasks = [task for task in self._generation_tasks if task is not current]
|
|
1543
|
+
for task in tasks:
|
|
1544
|
+
task.cancel()
|
|
1545
|
+
if tasks:
|
|
1546
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
1547
|
+
self._generation_tasks.clear()
|
|
1548
|
+
writer = self._writer
|
|
1549
|
+
self._writer = None
|
|
1550
|
+
if writer is not None:
|
|
1551
|
+
await writer.close(error)
|
|
1552
|
+
websocket = self._websocket
|
|
1553
|
+
self._websocket = None
|
|
1554
|
+
if websocket is not None:
|
|
1555
|
+
try:
|
|
1556
|
+
await asyncio.wait_for(websocket.close(), 1.0)
|
|
1557
|
+
except (TimeoutError, ConnectionClosed):
|
|
1558
|
+
websocket.transport.abort()
|
|
1559
|
+
self._config_queue = None
|
|
1560
|
+
self._request_queue = None
|
|
1561
|
+
self._config_queue_bytes = 0
|
|
1562
|
+
self._request_queue_bytes = 0
|
|
1563
|
+
|
|
1564
|
+
async def _terminal(self, error: BaseException) -> None:
|
|
1565
|
+
async with self._lifecycle_lock:
|
|
1566
|
+
if self._terminal_set or self._closing or self._closed_permanently:
|
|
1567
|
+
return
|
|
1568
|
+
self._terminal_set = True
|
|
1569
|
+
self._terminal_error = error
|
|
1570
|
+
self._terminal_event.set()
|
|
1571
|
+
future = self._connect_future
|
|
1572
|
+
if future is not None and not future.done():
|
|
1573
|
+
future.set_exception(error)
|
|
1574
|
+
supervisor = self._supervisor_task
|
|
1575
|
+
current = asyncio.current_task()
|
|
1576
|
+
if supervisor is not None and supervisor is not current:
|
|
1577
|
+
supervisor.cancel()
|
|
1578
|
+
code = error.code if isinstance(error, BorgeeError) else "bpp.internal"
|
|
1579
|
+
reason = FaultReason.API_KEY_INVALID if code == "bpp.auth_failed" else FaultReason.UNKNOWN
|
|
1580
|
+
self._set_state(ConnectionState(ConnectionStatus.ERROR, reason=reason, code=code))
|
|
1581
|
+
await self._invalidate_generation(error)
|
|
1582
|
+
websocket = self._websocket
|
|
1583
|
+
if websocket is not None:
|
|
1584
|
+
websocket.transport.abort()
|
|
1585
|
+
if supervisor is not None and supervisor is not current:
|
|
1586
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1587
|
+
await supervisor
|
|
1588
|
+
await self._cleanup_generation(error)
|
|
1589
|
+
|
|
1590
|
+
def _fail_pending(self, error: BaseException) -> None:
|
|
1591
|
+
for pending in self._pending_actions.values():
|
|
1592
|
+
if not pending.future.done():
|
|
1593
|
+
pending.future.set_exception(error)
|
|
1594
|
+
self._pending_actions.clear()
|
|
1595
|
+
|
|
1596
|
+
def _set_state(self, state: ConnectionState) -> None:
|
|
1597
|
+
if self._terminal_set and state.status not in {ConnectionStatus.ERROR, ConnectionStatus.CLOSED}:
|
|
1598
|
+
return
|
|
1599
|
+
if self._closed_permanently and state.status is not ConnectionStatus.CLOSED:
|
|
1600
|
+
return
|
|
1601
|
+
self._state = state
|
|
1602
|
+
if state.status is ConnectionStatus.ONLINE:
|
|
1603
|
+
self._ever_online = True
|
|
1604
|
+
self._online_ready.set()
|
|
1605
|
+
else:
|
|
1606
|
+
self._online_ready.clear()
|
|
1607
|
+
if self._state_queue.full():
|
|
1608
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
1609
|
+
self._state_queue.get_nowait()
|
|
1610
|
+
self._state_queue.put_nowait(state)
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def _is_int(value: object) -> TypeGuard[int]:
|
|
1614
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _connection_close_code(error: ConnectionClosed) -> int | None:
|
|
1618
|
+
if error.rcvd is not None:
|
|
1619
|
+
return error.rcvd.code
|
|
1620
|
+
if error.sent is not None:
|
|
1621
|
+
return error.sent.code
|
|
1622
|
+
return None
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _required_int(frame: dict[str, Any], field: str, *, minimum: int) -> int:
|
|
1626
|
+
value = frame.get(field)
|
|
1627
|
+
if not _is_int(value) or value < minimum:
|
|
1628
|
+
raise ProtocolError(f"{field} must be an integer greater than or equal to {minimum}")
|
|
1629
|
+
return value
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _required_str(frame: dict[str, Any], field: str) -> str:
|
|
1633
|
+
value = frame.get(field)
|
|
1634
|
+
if not isinstance(value, str):
|
|
1635
|
+
raise ProtocolError(f"{field} must be a string")
|
|
1636
|
+
return value
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def _map_inbound(frame: dict[str, Any], cursor: int) -> InboundMessageEvent:
|
|
1640
|
+
try:
|
|
1641
|
+
kind = InboundKind(_required_str(frame, "kind"))
|
|
1642
|
+
except ValueError as exc:
|
|
1643
|
+
raise ProtocolError("inbound message kind is invalid") from exc
|
|
1644
|
+
channel_id = _required_str(frame, "channel_id")
|
|
1645
|
+
channel_type = _required_str(frame, "channel_type") or None
|
|
1646
|
+
message_id = _required_str(frame, "message_id")
|
|
1647
|
+
author_id = _required_str(frame, "author_id")
|
|
1648
|
+
author_name = _required_str(frame, "author_name") or None
|
|
1649
|
+
body = _required_str(frame, "body")
|
|
1650
|
+
created_at = _required_int(frame, "created_at", minimum=0)
|
|
1651
|
+
message: Message | None = None
|
|
1652
|
+
reaction: InboundReaction | None = None
|
|
1653
|
+
if kind in {InboundKind.MESSAGE, InboundKind.EDITED, InboundKind.MENTION}:
|
|
1654
|
+
message = Message(
|
|
1655
|
+
id=message_id,
|
|
1656
|
+
channel_id=channel_id,
|
|
1657
|
+
author_id=author_id,
|
|
1658
|
+
body=body,
|
|
1659
|
+
created_at=created_at,
|
|
1660
|
+
)
|
|
1661
|
+
if kind is InboundKind.REACTION:
|
|
1662
|
+
added = frame.get("reaction_added")
|
|
1663
|
+
if not isinstance(added, bool):
|
|
1664
|
+
raise ProtocolError("reaction_added must be a boolean")
|
|
1665
|
+
reaction = InboundReaction(
|
|
1666
|
+
emoji=_required_str(frame, "emoji"),
|
|
1667
|
+
user_id=_required_str(frame, "reactor_id"),
|
|
1668
|
+
added=added,
|
|
1669
|
+
)
|
|
1670
|
+
return InboundMessageEvent(
|
|
1671
|
+
kind=kind,
|
|
1672
|
+
cursor=cursor,
|
|
1673
|
+
channel_id=channel_id,
|
|
1674
|
+
created_at=created_at,
|
|
1675
|
+
channel_type=channel_type,
|
|
1676
|
+
message=message,
|
|
1677
|
+
message_id=message_id if kind in {InboundKind.DELETED, InboundKind.REACTION} else None,
|
|
1678
|
+
reaction=reaction,
|
|
1679
|
+
author_name=author_name,
|
|
1680
|
+
)
|