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.
- awp/__init__.py +64 -0
- awp/_spec/lifecycle.json +190 -0
- awp/_spec/schemas/action-cancel-result.schema.json +13 -0
- awp/_spec/schemas/action-ref.schema.json +11 -0
- awp/_spec/schemas/action-schema.schema.json +37 -0
- awp/_spec/schemas/action-status.schema.json +39 -0
- awp/_spec/schemas/action-submit-result.schema.json +16 -0
- awp/_spec/schemas/action-submit.schema.json +19 -0
- awp/_spec/schemas/agent-manifest.schema.json +27 -0
- awp/_spec/schemas/approval-requested.schema.json +25 -0
- awp/_spec/schemas/approval-respond.schema.json +31 -0
- awp/_spec/schemas/common.schema.json +150 -0
- awp/_spec/schemas/embodiment.schema.json +22 -0
- awp/_spec/schemas/empty-result.schema.json +8 -0
- awp/_spec/schemas/error.schema.json +25 -0
- awp/_spec/schemas/frame-inline.schema.json +20 -0
- awp/_spec/schemas/frame-tree.schema.json +26 -0
- awp/_spec/schemas/obs-report.schema.json +40 -0
- awp/_spec/schemas/observation-channel.schema.json +28 -0
- awp/_spec/schemas/ping-result.schema.json +13 -0
- awp/_spec/schemas/ping.schema.json +12 -0
- awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
- awp/_spec/schemas/reset-result.schema.json +11 -0
- awp/_spec/schemas/reset.schema.json +12 -0
- awp/_spec/schemas/restore.schema.json +11 -0
- awp/_spec/schemas/safety-policy.schema.json +81 -0
- awp/_spec/schemas/session-open.schema.json +41 -0
- awp/_spec/schemas/session-ready.schema.json +50 -0
- awp/_spec/schemas/session-resume.schema.json +12 -0
- awp/_spec/schemas/session-state.schema.json +14 -0
- awp/_spec/schemas/session-telemetry.schema.json +22 -0
- awp/_spec/schemas/session-transfer-result.schema.json +12 -0
- awp/_spec/schemas/session-transfer.schema.json +11 -0
- awp/_spec/schemas/snapshot-result.schema.json +11 -0
- awp/_spec/schemas/subscribe-result.schema.json +11 -0
- awp/_spec/schemas/subscribe.schema.json +11 -0
- awp/_spec/schemas/task-update.schema.json +11 -0
- awp/_spec/schemas/tick-result.schema.json +9 -0
- awp/_spec/schemas/tick.schema.json +12 -0
- awp/_spec/schemas/unsubscribe.schema.json +11 -0
- awp/_spec/schemas/world-event.schema.json +45 -0
- awp/_spec/schemas/world-manifest.schema.json +61 -0
- awp/aio.py +380 -0
- awp/client.py +781 -0
- awp/clock.py +61 -0
- awp/errors.py +112 -0
- awp/frames.py +174 -0
- awp/jsonrpc.py +84 -0
- awp/lifecycle.py +78 -0
- awp/py.typed +0 -0
- awp/schema.py +158 -0
- awp_python-0.1.0a1.dist-info/METADATA +127 -0
- awp_python-0.1.0a1.dist-info/RECORD +71 -0
- awp_python-0.1.0a1.dist-info/WHEEL +4 -0
- awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
- awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- awp_sim/__init__.py +8 -0
- awp_sim/__main__.py +5 -0
- awp_sim/arm.py +163 -0
- awp_sim/audit.py +138 -0
- awp_sim/cli.py +238 -0
- awp_sim/config.py +225 -0
- awp_sim/demo.py +85 -0
- awp_sim/loopback.py +222 -0
- awp_sim/py.typed +0 -0
- awp_sim/recorder.py +72 -0
- awp_sim/replay.py +134 -0
- awp_sim/scenarios.py +427 -0
- awp_sim/server.py +333 -0
- awp_sim/session.py +153 -0
- 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())
|