awp-python 0.1.0a1__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.
Files changed (71) hide show
  1. awp/__init__.py +64 -0
  2. awp/_spec/lifecycle.json +190 -0
  3. awp/_spec/schemas/action-cancel-result.schema.json +13 -0
  4. awp/_spec/schemas/action-ref.schema.json +11 -0
  5. awp/_spec/schemas/action-schema.schema.json +37 -0
  6. awp/_spec/schemas/action-status.schema.json +39 -0
  7. awp/_spec/schemas/action-submit-result.schema.json +16 -0
  8. awp/_spec/schemas/action-submit.schema.json +19 -0
  9. awp/_spec/schemas/agent-manifest.schema.json +27 -0
  10. awp/_spec/schemas/approval-requested.schema.json +25 -0
  11. awp/_spec/schemas/approval-respond.schema.json +31 -0
  12. awp/_spec/schemas/common.schema.json +150 -0
  13. awp/_spec/schemas/embodiment.schema.json +22 -0
  14. awp/_spec/schemas/empty-result.schema.json +8 -0
  15. awp/_spec/schemas/error.schema.json +25 -0
  16. awp/_spec/schemas/frame-inline.schema.json +20 -0
  17. awp/_spec/schemas/frame-tree.schema.json +26 -0
  18. awp/_spec/schemas/obs-report.schema.json +40 -0
  19. awp/_spec/schemas/observation-channel.schema.json +28 -0
  20. awp/_spec/schemas/ping-result.schema.json +13 -0
  21. awp/_spec/schemas/ping.schema.json +12 -0
  22. awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
  23. awp/_spec/schemas/reset-result.schema.json +11 -0
  24. awp/_spec/schemas/reset.schema.json +12 -0
  25. awp/_spec/schemas/restore.schema.json +11 -0
  26. awp/_spec/schemas/safety-policy.schema.json +81 -0
  27. awp/_spec/schemas/session-open.schema.json +41 -0
  28. awp/_spec/schemas/session-ready.schema.json +50 -0
  29. awp/_spec/schemas/session-resume.schema.json +12 -0
  30. awp/_spec/schemas/session-state.schema.json +14 -0
  31. awp/_spec/schemas/session-telemetry.schema.json +22 -0
  32. awp/_spec/schemas/session-transfer-result.schema.json +12 -0
  33. awp/_spec/schemas/session-transfer.schema.json +11 -0
  34. awp/_spec/schemas/snapshot-result.schema.json +11 -0
  35. awp/_spec/schemas/subscribe-result.schema.json +11 -0
  36. awp/_spec/schemas/subscribe.schema.json +11 -0
  37. awp/_spec/schemas/task-update.schema.json +11 -0
  38. awp/_spec/schemas/tick-result.schema.json +9 -0
  39. awp/_spec/schemas/tick.schema.json +12 -0
  40. awp/_spec/schemas/unsubscribe.schema.json +11 -0
  41. awp/_spec/schemas/world-event.schema.json +45 -0
  42. awp/_spec/schemas/world-manifest.schema.json +61 -0
  43. awp/aio.py +380 -0
  44. awp/client.py +781 -0
  45. awp/clock.py +61 -0
  46. awp/errors.py +112 -0
  47. awp/frames.py +174 -0
  48. awp/jsonrpc.py +84 -0
  49. awp/lifecycle.py +78 -0
  50. awp/py.typed +0 -0
  51. awp/schema.py +158 -0
  52. awp_python-0.1.0a1.dist-info/METADATA +127 -0
  53. awp_python-0.1.0a1.dist-info/RECORD +71 -0
  54. awp_python-0.1.0a1.dist-info/WHEEL +4 -0
  55. awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
  56. awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
  57. awp_sim/__init__.py +8 -0
  58. awp_sim/__main__.py +5 -0
  59. awp_sim/arm.py +163 -0
  60. awp_sim/audit.py +138 -0
  61. awp_sim/cli.py +238 -0
  62. awp_sim/config.py +225 -0
  63. awp_sim/demo.py +85 -0
  64. awp_sim/loopback.py +222 -0
  65. awp_sim/py.typed +0 -0
  66. awp_sim/recorder.py +72 -0
  67. awp_sim/replay.py +134 -0
  68. awp_sim/scenarios.py +427 -0
  69. awp_sim/server.py +333 -0
  70. awp_sim/session.py +153 -0
  71. awp_sim/world.py +1612 -0
awp_sim/server.py ADDED
@@ -0,0 +1,333 @@
1
+ """Serve a World over the WebSocket control binding (AWP-TRN-001) with inline frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import ipaddress
8
+ import itertools
9
+ import json
10
+ import logging
11
+ import ssl
12
+ import time
13
+ from collections import deque
14
+ from collections.abc import Hashable, Sequence
15
+ from dataclasses import replace
16
+ from http import HTTPStatus
17
+ from pathlib import Path
18
+ from urllib.parse import urlsplit
19
+
20
+ from websockets.asyncio.server import Server as WsServer
21
+ from websockets.asyncio.server import ServerConnection, serve
22
+ from websockets.datastructures import Headers
23
+ from websockets.exceptions import ConnectionClosed
24
+ from websockets.http11 import Request, Response
25
+ from websockets.typing import Subprotocol
26
+
27
+ from awp import jsonrpc
28
+
29
+ from .recorder import TraceRecorder
30
+ from .world import Close, Output, Send, SendFrame, World
31
+
32
+ log = logging.getLogger("awp_sim")
33
+
34
+ SUBPROTOCOL = Subprotocol("awp")
35
+ BEARER_PREFIX = "awp.bearer."
36
+ STREAM_PATH = "/stream"
37
+
38
+ Item = Send | SendFrame | Close
39
+
40
+
41
+ def _channel(item: Item) -> int | None:
42
+ """The latest-wins channel an item may be replaced on, if any."""
43
+ if isinstance(item, SendFrame) and item.latest_wins:
44
+ return item.frame.channel_id
45
+ if isinstance(item, Send) and item.latest_wins:
46
+ return int(item.msg["params"]["channel_id"])
47
+ return None
48
+
49
+
50
+ def _keep_resync(old: Item, new: Item) -> Item:
51
+ """The newer frame, carrying the resync flag of the one it replaces (AWP-DAT-009)."""
52
+ if isinstance(old, SendFrame) and isinstance(new, SendFrame) and old.frame.resync:
53
+ return replace(new, frame=replace(new.frame, resync=True, keyframe=True))
54
+ if isinstance(old, Send) and isinstance(new, Send) and old.msg["params"].get("flags", 0) & 0x08:
55
+ params = {**new.msg["params"], "flags": new.msg["params"].get("flags", 0) | 0x09}
56
+ return replace(new, msg={**new.msg, "params": params})
57
+ return new
58
+
59
+
60
+ def _bearer(headers: Headers) -> str | None:
61
+ """The credential from `Authorization` or an `awp.bearer.<token>` subprotocol (AWP-SEC-005)."""
62
+ auth = headers.get("Authorization") or ""
63
+ if auth.startswith("Bearer "):
64
+ return auth.removeprefix("Bearer ")
65
+ for v in headers.get_all("Sec-WebSocket-Protocol"):
66
+ for p in v.split(","):
67
+ if p.strip().startswith(BEARER_PREFIX):
68
+ return p.strip().removeprefix(BEARER_PREFIX)
69
+ return None
70
+
71
+
72
+ class _Outbox:
73
+ """Ordered sends, where a pending latest-wins frame is replaced by a newer one on its
74
+ channel instead of queueing behind it (AWP-TRN-009). Bounded: a peer that stops reading is
75
+ disconnected rather than buffered without limit."""
76
+
77
+ LIMIT = 10_000
78
+
79
+ def __init__(self) -> None:
80
+ self._items: deque[list[Item]] = deque()
81
+ self._slots: dict[int, list[Item]] = {}
82
+ self._ready = asyncio.Event()
83
+
84
+ def put(self, item: Item) -> bool:
85
+ """Queue `item`; False if the queue is full."""
86
+ channel = _channel(item)
87
+ if channel is not None:
88
+ cell = self._slots.get(channel)
89
+ if cell is not None:
90
+ cell[0] = _keep_resync(cell[0], item) # a resync is never replaced away
91
+ return True
92
+ cell = self._slots[channel] = [item]
93
+ else:
94
+ cell = [item]
95
+ if len(self._items) >= self.LIMIT:
96
+ return False
97
+ self._items.append(cell)
98
+ self._ready.set()
99
+ return True
100
+
101
+ async def get(self) -> Item:
102
+ while not self._items:
103
+ self._ready.clear()
104
+ await self._ready.wait()
105
+ cell = self._items.popleft()
106
+ item = cell[0]
107
+ channel = _channel(item)
108
+ if channel is not None:
109
+ self._slots.pop(channel, None)
110
+ return item
111
+
112
+
113
+ def _is_loopback(host: str) -> bool:
114
+ if host in ("localhost", ""):
115
+ return True
116
+ try:
117
+ return ipaddress.ip_address(host).is_loopback
118
+ except ValueError:
119
+ return False
120
+
121
+
122
+ class Server:
123
+ def __init__(
124
+ self,
125
+ world: World,
126
+ *,
127
+ host: str = "127.0.0.1",
128
+ port: int = 8710,
129
+ token: str | None = None,
130
+ ssl_context: ssl.SSLContext | None = None,
131
+ allow_insecure: bool = False,
132
+ record_dir: Path | str | None = None,
133
+ step_ms: float = 2.0,
134
+ stream_binding: bool = False,
135
+ approver_token: str | None = None,
136
+ ) -> None:
137
+ if not _is_loopback(host) and not allow_insecure:
138
+ if token is None:
139
+ raise ValueError("non-loopback worlds must authenticate agents (AWP-SEC-002)")
140
+ if ssl_context is None:
141
+ raise ValueError("connections that leave the machine must use TLS (AWP-SEC-001)")
142
+ self.world = world
143
+ self.host = host
144
+ self.port = port
145
+ self.token = token
146
+ self.ssl_context = ssl_context
147
+ self.step_s = step_ms / 1000
148
+ self.recorder = TraceRecorder(record_dir) if record_dir else None
149
+ self.stream_binding = stream_binding
150
+ self.approver_token = approver_token
151
+ self._ids = itertools.count(1)
152
+ self._sockets: dict[Hashable, tuple[ServerConnection, _Outbox]] = {}
153
+ self._server: WsServer | None = None
154
+ self._loop: asyncio.Task[None] | None = None
155
+ self._background: set[asyncio.Task[None]] = set()
156
+
157
+ @property
158
+ def url(self) -> str:
159
+ scheme = "wss" if self.ssl_context else "ws"
160
+ return f"{scheme}://{self.host}:{self.port}"
161
+
162
+ async def start(self) -> None:
163
+ self._server = await serve(
164
+ self._handle,
165
+ self.host,
166
+ self.port,
167
+ ssl=self.ssl_context,
168
+ subprotocols=[SUBPROTOCOL],
169
+ select_subprotocol=self._select_subprotocol,
170
+ process_request=self._authorize,
171
+ ping_interval=None, # AWP heartbeats govern liveness (AWP-SAF-001)
172
+ max_size=16 * 1024 * 1024,
173
+ )
174
+ self.port = self._server.sockets[0].getsockname()[1]
175
+ if self.stream_binding:
176
+ self.world.stream_url = self.url + STREAM_PATH
177
+ self._loop = asyncio.create_task(self._run())
178
+ log.info("awp-sim %s world listening on %s", self.world.config.mode, self.url)
179
+
180
+ async def stop(self) -> None:
181
+ if self._loop is not None:
182
+ self._loop.cancel()
183
+ with contextlib.suppress(asyncio.CancelledError):
184
+ await self._loop
185
+ if self._server is not None:
186
+ self._server.close()
187
+ await self._server.wait_closed()
188
+ if self.recorder is not None:
189
+ self.recorder.close()
190
+
191
+ async def __aenter__(self) -> Server:
192
+ await self.start()
193
+ return self
194
+
195
+ async def __aexit__(self, *exc: object) -> None:
196
+ await self.stop()
197
+
198
+ def engage_estop(self) -> None:
199
+ self._dispatch(self.world.engage_estop(time.monotonic_ns()))
200
+
201
+ def release_estop(self) -> None:
202
+ self._dispatch(self.world.release_estop(time.monotonic_ns()))
203
+
204
+ # ------------------------------------------------------------ handshake
205
+
206
+ def _authorize(self, connection: ServerConnection, request: Request) -> Response | None:
207
+ if "token=" in urlsplit(request.path).query:
208
+ return connection.respond(
209
+ HTTPStatus.BAD_REQUEST, "credentials must not appear in URLs\n"
210
+ )
211
+ if urlsplit(request.path).path == STREAM_PATH: # the session token is the credential
212
+ if _bearer(request.headers) is None:
213
+ return connection.respond(HTTPStatus.UNAUTHORIZED, "missing session token\n")
214
+ return None
215
+ if self.approver_token is not None and _bearer(request.headers) == self.approver_token:
216
+ return None # an approver (AWP-APR-005)
217
+ if self.token is None or self._credential_ok(request.headers):
218
+ return None
219
+ return connection.respond(HTTPStatus.UNAUTHORIZED, "missing or invalid bearer token\n")
220
+
221
+ def _credential_ok(self, headers: Headers) -> bool:
222
+ """AWP-SEC-005: the Authorization header, or the awp.bearer.<token> subprotocol."""
223
+ if headers.get("Authorization") == f"Bearer {self.token}":
224
+ return True
225
+ offered = [
226
+ p.strip() for v in headers.get_all("Sec-WebSocket-Protocol") for p in v.split(",")
227
+ ]
228
+ return f"{BEARER_PREFIX}{self.token}" in offered
229
+
230
+ def _select_subprotocol(
231
+ self, connection: ServerConnection, offered: Sequence[Subprotocol]
232
+ ) -> Subprotocol | None:
233
+ return SUBPROTOCOL if SUBPROTOCOL in offered else None # never echo a credential
234
+
235
+ # ------------------------------------------------------------ connections
236
+
237
+ async def _handle(self, ws: ServerConnection) -> None:
238
+ if ws.request is not None and urlsplit(ws.request.path).path == STREAM_PATH:
239
+ await self._handle_stream(ws)
240
+ return
241
+ conn = next(self._ids)
242
+ outbox = _Outbox()
243
+ self._sockets[conn] = (ws, outbox)
244
+ writer = asyncio.create_task(self._write(conn, ws, outbox))
245
+ approver = (
246
+ self.approver_token is not None
247
+ and ws.request is not None
248
+ and _bearer(ws.request.headers) == self.approver_token
249
+ )
250
+ self._dispatch(self.world.connect(conn, time.monotonic_ns(), approver=approver))
251
+ try:
252
+ async for raw in ws:
253
+ if self.recorder is not None:
254
+ with contextlib.suppress(ValueError):
255
+ self.recorder.record(conn, self._session_id(conn), "agent", json.loads(raw))
256
+ self._dispatch(self.world.receive_text(conn, raw, time.monotonic_ns()))
257
+ except ConnectionClosed:
258
+ pass
259
+ except Exception:
260
+ log.exception("closing connection %s after an internal error", conn)
261
+ await ws.close(code=1011)
262
+ finally:
263
+ writer.cancel()
264
+ self._sockets.pop(conn, None)
265
+ self._dispatch(self.world.disconnect(conn, time.monotonic_ns()))
266
+ if self.recorder is not None:
267
+ self.recorder.forget(conn)
268
+
269
+ async def _handle_stream(self, ws: ServerConnection) -> None:
270
+ """A stream connection: binary frames only, one per message (AWP-TRN-003)."""
271
+ conn = next(self._ids)
272
+ outbox = _Outbox()
273
+ self._sockets[conn] = (ws, outbox)
274
+ writer = asyncio.create_task(self._write(conn, ws, outbox))
275
+ token = _bearer(ws.request.headers) if ws.request is not None else None
276
+ self._dispatch(self.world.attach_stream(conn, token or "", time.monotonic_ns()))
277
+ try:
278
+ async for raw in ws:
279
+ if isinstance(raw, str):
280
+ await ws.close(code=1008, reason="AWP_MALFORMED: text on a stream connection")
281
+ break
282
+ self._dispatch(self.world.receive_stream(conn, raw, time.monotonic_ns()))
283
+ except ConnectionClosed:
284
+ pass
285
+ finally:
286
+ writer.cancel()
287
+ self._sockets.pop(conn, None)
288
+ self._dispatch(self.world.stream_lost(conn, time.monotonic_ns()))
289
+
290
+ async def _write(self, conn: Hashable, ws: ServerConnection, outbox: _Outbox) -> None:
291
+ with contextlib.suppress(ConnectionClosed, asyncio.CancelledError):
292
+ while True:
293
+ send = await outbox.get()
294
+ if isinstance(send, Close): # everything queued before it has been sent
295
+ await ws.close(code=1008, reason=send.reason[:120])
296
+ return
297
+ if isinstance(send, SendFrame):
298
+ await ws.send(self.world.frame_bytes(send, time.monotonic_ns()))
299
+ continue
300
+ msg = send.msg
301
+ if msg.get("method") == "obs.frame":
302
+ msg = self.world.frame_sent(send, time.monotonic_ns())
303
+ if self.recorder is not None:
304
+ self.recorder.record(conn, send.session, "world", msg)
305
+ await ws.send(jsonrpc.encode(msg))
306
+
307
+ def _dispatch(self, outputs: list[Output]) -> None:
308
+ for out in outputs:
309
+ entry = self._sockets.get(out.conn)
310
+ if entry is None:
311
+ continue
312
+ ws, outbox = entry
313
+ if not outbox.put(out):
314
+ log.warning("closing connection %s: send queue full", out.conn)
315
+ self._sockets.pop(out.conn, None)
316
+ self._close_later(ws)
317
+
318
+ def _session_id(self, conn: Hashable) -> str | None:
319
+ session = self.world.session_of(conn)
320
+ return session.id if session else None
321
+
322
+ def _close_later(self, ws: ServerConnection) -> None:
323
+ task = asyncio.create_task(ws.close(code=1008, reason="send queue full"))
324
+ self._background.add(task)
325
+ task.add_done_callback(self._background.discard)
326
+
327
+ async def _run(self) -> None:
328
+ while True:
329
+ try:
330
+ self._dispatch(self.world.advance(time.monotonic_ns()))
331
+ except Exception: # keep the watchdog and heartbeats running for everyone else
332
+ log.exception("world.advance failed")
333
+ await asyncio.sleep(self.step_s)
awp_sim/session.py ADDED
@@ -0,0 +1,153 @@
1
+ """Per-session state: grants, status delivery, actions, and telemetry samples."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Hashable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from awp.lifecycle import ActionState
11
+
12
+ from .arm import Vec3
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ChannelGrant:
17
+ name: str
18
+ channel_id: int
19
+ rate_hz: float | None
20
+ loss_class: str
21
+ seq: int = 0
22
+ next_due_ns: int = 0
23
+ resync: bool = False
24
+ command: bool = False # an agent→world command channel (AWP-CMD-002)
25
+
26
+ def to_wire(self) -> dict[str, Any]:
27
+ return {"channel": self.name, "rate_hz": self.rate_hz, "channel_id": self.channel_id}
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class Action:
32
+ action_id: str
33
+ content: dict[str, Any]
34
+ decl: dict[str, Any]
35
+ received_ns: int
36
+ deadline_ns: int | None = None
37
+ target: Vec3 | None = None
38
+ v_max: float = 0.0
39
+ state: ActionState = ActionState.SUBMITTED
40
+ status: dict[str, Any] = field(default_factory=dict)
41
+ cancel_reason: str | None = None
42
+ cancel_started_ns: int | None = None
43
+ replaces: bool = False
44
+ blends: bool = False
45
+ approval_id: str | None = None
46
+ stream: dict[str, int] | None = None # streaming-duration progress (AWP-CMD-004)
47
+ last_frame_ns: int = 0
48
+ command_seq: int = 0
49
+ failing_with: str | None = None # terminal reason to report once the safe abort completes
50
+ last_progress_ns: int = 0
51
+ terminal_ns: int | None = None
52
+
53
+ @property
54
+ def type(self) -> str:
55
+ return str(self.content["type"])
56
+
57
+ @property
58
+ def group(self) -> str:
59
+ return str(self.decl.get("concurrency_group", f"_{self.type}"))
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class Telemetry:
64
+ observation: list[int] = field(default_factory=list)
65
+ admission: list[int] = field(default_factory=list)
66
+ observation_to_action: list[int] = field(default_factory=list)
67
+ channels: dict[int, list[int]] = field(default_factory=dict)
68
+ command: list[int] = field(default_factory=list)
69
+
70
+ def snapshot(self, window_ms: int) -> dict[str, Any]:
71
+ params: dict[str, Any] = {"window_ms": window_ms}
72
+ for key, values in (
73
+ ("observation_latency_ns", self.observation),
74
+ ("admission_latency_ns", self.admission),
75
+ ("observation_to_action_ns", self.observation_to_action),
76
+ ("command_latency_ns", self.command),
77
+ ):
78
+ if values:
79
+ params[key] = stats(values)
80
+ if self.channels:
81
+ params["channels"] = {str(c): stats(v) for c, v in self.channels.items() if v}
82
+ return params
83
+
84
+
85
+ def stats(values: list[int]) -> dict[str, int]:
86
+ ordered = sorted(values)
87
+ last = len(ordered) - 1
88
+ return {
89
+ "count": len(ordered),
90
+ "p50": ordered[round(0.5 * last)],
91
+ "p95": ordered[round(0.95 * last)],
92
+ "max": ordered[-1],
93
+ }
94
+
95
+
96
+ @dataclass(slots=True)
97
+ class Session:
98
+ id: str
99
+ token: str
100
+ mode: str
101
+ embodiment: str | None
102
+ origin_ns: int
103
+ clock_anchor: str
104
+ conn: Hashable | None
105
+ action_types: list[str]
106
+ admin: list[str]
107
+ grants: dict[str, ChannelGrant] = field(default_factory=dict)
108
+ state: str = "ready"
109
+ seq: int = 0
110
+ log: dict[int, tuple[str, dict[str, Any]]] = field(default_factory=dict)
111
+ acked: int = 0
112
+ actions: dict[str, Action] = field(default_factory=dict)
113
+ running: dict[str, Action] = field(default_factory=dict)
114
+ queues: dict[str, deque[Action]] = field(default_factory=dict)
115
+ staged: list[Action] = field(default_factory=list)
116
+ last_agent_ns: int = 0
117
+ last_admitted_ns: int | None = None
118
+ suspended_ns: int | None = None
119
+ task: dict[str, Any] | None = None
120
+ stream_conn: Hashable | None = None
121
+ stream_lost_ns: int | None = None
122
+ stream_degraded_reported: bool = False
123
+ safe_state: bool = False
124
+ degraded_reported: bool = False
125
+ closing: list[tuple[Hashable, Any]] = field(default_factory=list) # close requests to answer
126
+ closing_reason: str | None = None
127
+ last_telemetry_ns: int = 0
128
+ telemetry: Telemetry = field(default_factory=Telemetry)
129
+ next_channel_id: int = 1
130
+
131
+ def next_seq(self) -> int:
132
+ self.seq += 1
133
+ return self.seq
134
+
135
+ def retain(self, seq: int, method: str, params: dict[str, Any]) -> None:
136
+ """Keep a sequenced notification until the agent acknowledges it (AWP-CTL-010)."""
137
+ self.log[seq] = (method, params)
138
+
139
+ def acknowledge(self, seq: int) -> None:
140
+ if seq <= self.acked:
141
+ return
142
+ for s in [s for s in self.log if s <= seq]:
143
+ del self.log[s]
144
+ self.acked = seq
145
+
146
+ def replay_after(self, seq: int) -> list[tuple[str, dict[str, Any]]]:
147
+ return [self.log[s] for s in sorted(self.log) if s > seq]
148
+
149
+ def pre_execution(self) -> list[Action]:
150
+ return [a for a in self.actions.values() if a.state.pre_execution]
151
+
152
+ def queue(self, group: str) -> deque[Action]:
153
+ return self.queues.setdefault(group, deque())