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/loopback.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Run agents against a World in-process on a virtual clock, recording wire traces.
|
|
2
|
+
|
|
3
|
+
The loopback delivers messages instantly and in order; time moves only when told to. Scenarios and
|
|
4
|
+
tests use it to exercise the real client and world deterministically, including failures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
from collections.abc import Callable, Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from awp.client import ClientConnection, ErrorResponse, Event, Response
|
|
15
|
+
from awp.jsonrpc import Message
|
|
16
|
+
|
|
17
|
+
from .world import Close, Output, Send, SendFrame, World
|
|
18
|
+
|
|
19
|
+
MS = 1_000_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TraceLine:
|
|
24
|
+
sender: str
|
|
25
|
+
msg: Message
|
|
26
|
+
delivered: bool = True
|
|
27
|
+
|
|
28
|
+
def to_json(self) -> dict[str, Any]:
|
|
29
|
+
method = self.msg.get("method") or ("error" if "error" in self.msg else "result")
|
|
30
|
+
line: dict[str, Any] = {"from": self.sender, "step": method, "msg": self.msg}
|
|
31
|
+
if not self.delivered:
|
|
32
|
+
line["delivered"] = False
|
|
33
|
+
return line
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Loopback:
|
|
37
|
+
def __init__(self, world: World, *, step_ms: float = 1.0, start_ns: int = 1_000 * MS) -> None:
|
|
38
|
+
self.world = world
|
|
39
|
+
self.now = start_ns
|
|
40
|
+
self.step_ns = int(step_ms * MS)
|
|
41
|
+
self.agents: list[LoopbackAgent] = []
|
|
42
|
+
self._conn_ids = itertools.count(1)
|
|
43
|
+
|
|
44
|
+
def agent(
|
|
45
|
+
self, name: str = "agent", *, clock_offset_ns: int = -5 * MS, **kw: Any
|
|
46
|
+
) -> LoopbackAgent:
|
|
47
|
+
a = LoopbackAgent(self, name, clock_offset_ns, **kw)
|
|
48
|
+
self.agents.append(a)
|
|
49
|
+
return a
|
|
50
|
+
|
|
51
|
+
def advance(self, ms: float) -> None:
|
|
52
|
+
end = self.now + int(ms * MS)
|
|
53
|
+
while self.now < end:
|
|
54
|
+
self.now = min(end, self.now + self.step_ns)
|
|
55
|
+
for a in self.agents:
|
|
56
|
+
a.tick()
|
|
57
|
+
self.deliver(self.world.advance(self.now))
|
|
58
|
+
self.settle()
|
|
59
|
+
|
|
60
|
+
def run_until(self, predicate: Callable[[], bool], timeout_ms: float) -> bool:
|
|
61
|
+
end = self.now + int(timeout_ms * MS)
|
|
62
|
+
while not predicate():
|
|
63
|
+
if self.now >= end:
|
|
64
|
+
return False
|
|
65
|
+
self.advance(self.step_ns / MS)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def settle(self) -> None:
|
|
69
|
+
"""Exchange messages until neither side has anything left to send."""
|
|
70
|
+
for _ in range(10_000):
|
|
71
|
+
if not any(a.flush() for a in self.agents):
|
|
72
|
+
return
|
|
73
|
+
raise RuntimeError("loopback did not settle")
|
|
74
|
+
|
|
75
|
+
def deliver(self, outputs: Iterable[Output]) -> None:
|
|
76
|
+
for out in outputs:
|
|
77
|
+
stream = next(
|
|
78
|
+
(a for a in self.agents if a.stream is not None and a.stream == out.conn), None
|
|
79
|
+
)
|
|
80
|
+
if stream is not None:
|
|
81
|
+
if isinstance(out, SendFrame):
|
|
82
|
+
data = self.world.frame_bytes(out, self.now)
|
|
83
|
+
stream.events += stream.client.receive_frame(data)
|
|
84
|
+
elif isinstance(out, Close):
|
|
85
|
+
stream.stream = None
|
|
86
|
+
continue
|
|
87
|
+
agent = next((a for a in self.agents if a.conn == out.conn), None)
|
|
88
|
+
if agent is None or isinstance(out, SendFrame):
|
|
89
|
+
continue
|
|
90
|
+
if isinstance(out, Close):
|
|
91
|
+
agent.transport_closed()
|
|
92
|
+
else:
|
|
93
|
+
agent.on_world(out)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LoopbackAgent:
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
net: Loopback,
|
|
100
|
+
name: str,
|
|
101
|
+
clock_offset_ns: int,
|
|
102
|
+
*,
|
|
103
|
+
agent: dict[str, str] | None = None,
|
|
104
|
+
modalities: Iterable[str] = ("proprio/json", "text/event+json"),
|
|
105
|
+
heartbeat_ms: float | None = 500,
|
|
106
|
+
approver: bool = False,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.approver = approver
|
|
109
|
+
self.net = net
|
|
110
|
+
self.name = name
|
|
111
|
+
self.offset = clock_offset_ns
|
|
112
|
+
self.client = ClientConnection(
|
|
113
|
+
agent or {"name": name, "version": "0.1.0", "vendor": "awp-sim"},
|
|
114
|
+
modalities,
|
|
115
|
+
clock_ns=lambda: self.net.now + self.offset,
|
|
116
|
+
)
|
|
117
|
+
self.heartbeat_ms = heartbeat_ms
|
|
118
|
+
self.conn: int | None = None
|
|
119
|
+
self.stream: int | None = None # a ws stream connection, when attached
|
|
120
|
+
self.events: list[Event] = []
|
|
121
|
+
self.trace: list[TraceLine] = []
|
|
122
|
+
# Fault: the first world message matching this is lost and the connection goes half-open.
|
|
123
|
+
self.lose: Callable[[Message], bool] | None = None
|
|
124
|
+
self._last_ping = 0
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------ transport
|
|
127
|
+
|
|
128
|
+
def connect(self) -> None:
|
|
129
|
+
if self.conn is not None:
|
|
130
|
+
self.drop()
|
|
131
|
+
self.conn = next(self.net._conn_ids)
|
|
132
|
+
self.net.deliver(self.net.world.connect(self.conn, self.net.now, approver=self.approver))
|
|
133
|
+
|
|
134
|
+
def attach_stream(self) -> None:
|
|
135
|
+
"""Open a stream connection with the session token (AWP-TRN-003, AWP-SEC-004)."""
|
|
136
|
+
assert self.client.session_token is not None
|
|
137
|
+
self.stream = next(self.net._conn_ids)
|
|
138
|
+
self.net.deliver(
|
|
139
|
+
self.net.world.attach_stream(self.stream, self.client.session_token, self.net.now)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def drop_stream(self) -> None:
|
|
143
|
+
if self.stream is not None:
|
|
144
|
+
stream, self.stream = self.stream, None
|
|
145
|
+
self.net.deliver(self.net.world.stream_lost(stream, self.net.now))
|
|
146
|
+
|
|
147
|
+
def drop(self) -> None:
|
|
148
|
+
"""The agent's side of the connection dies."""
|
|
149
|
+
if self.conn is None:
|
|
150
|
+
return
|
|
151
|
+
conn, self.conn = self.conn, None
|
|
152
|
+
self.client.connection_lost()
|
|
153
|
+
self.net.deliver(self.net.world.disconnect(conn, self.net.now))
|
|
154
|
+
|
|
155
|
+
def abandon(self) -> None:
|
|
156
|
+
"""The agent loses the connection but the world has not noticed: it is half-open."""
|
|
157
|
+
self.conn = None
|
|
158
|
+
self.client.connection_lost()
|
|
159
|
+
|
|
160
|
+
def transport_closed(self) -> None:
|
|
161
|
+
self.conn = None
|
|
162
|
+
self.client.connection_lost()
|
|
163
|
+
|
|
164
|
+
def flush(self) -> bool:
|
|
165
|
+
out = self.client.outgoing()
|
|
166
|
+
if self.conn is None:
|
|
167
|
+
return False
|
|
168
|
+
for msg in out:
|
|
169
|
+
self.trace.append(TraceLine("agent", msg))
|
|
170
|
+
self.net.deliver(self.net.world.receive(self.conn, msg, self.net.now))
|
|
171
|
+
return bool(out)
|
|
172
|
+
|
|
173
|
+
def on_world(self, send: Send) -> None:
|
|
174
|
+
msg = (
|
|
175
|
+
self.net.world.frame_sent(send, self.net.now)
|
|
176
|
+
if send.msg.get("method") == "obs.frame"
|
|
177
|
+
else send.msg
|
|
178
|
+
)
|
|
179
|
+
if self.lose is not None and self.lose(msg):
|
|
180
|
+
self.lose = None
|
|
181
|
+
self.trace.append(TraceLine("world", msg, delivered=False))
|
|
182
|
+
self.abandon()
|
|
183
|
+
return
|
|
184
|
+
self.trace.append(TraceLine("world", msg))
|
|
185
|
+
self.events += self.client.receive(msg)
|
|
186
|
+
|
|
187
|
+
def tick(self) -> None:
|
|
188
|
+
if self.heartbeat_ms is None or self.conn is None or self.client.ready is None:
|
|
189
|
+
return
|
|
190
|
+
if self.net.now - self._last_ping >= self.heartbeat_ms * MS:
|
|
191
|
+
self._last_ping = self.net.now
|
|
192
|
+
self.client.ping()
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------ requests
|
|
195
|
+
|
|
196
|
+
def call(self, rid: int) -> dict[str, Any]:
|
|
197
|
+
"""Settle and return the result of request `rid`; raise AwpError on an error response."""
|
|
198
|
+
self.net.settle()
|
|
199
|
+
for e in self.events:
|
|
200
|
+
if isinstance(e, Response) and e.id == rid:
|
|
201
|
+
return e.result
|
|
202
|
+
if isinstance(e, ErrorResponse) and e.id == rid:
|
|
203
|
+
raise e.error
|
|
204
|
+
raise TimeoutError(f"no response to request {rid}")
|
|
205
|
+
|
|
206
|
+
def submit(self, type: str, params: dict[str, Any], **kw: Any) -> str:
|
|
207
|
+
action_id = self.client.submit(type, params, **kw)
|
|
208
|
+
self.call(self.client.last_id)
|
|
209
|
+
return action_id
|
|
210
|
+
|
|
211
|
+
def open(self, **kw: Any) -> dict[str, Any]:
|
|
212
|
+
self.connect()
|
|
213
|
+
self.call(self.client.initialize())
|
|
214
|
+
ready = self.call(self.client.open_session(**kw))
|
|
215
|
+
self.call(self.client.ping()) # a clock sample on the new session clock (AWP-CLK-008)
|
|
216
|
+
return ready
|
|
217
|
+
|
|
218
|
+
def of(self, kind: type[Any]) -> list[Any]:
|
|
219
|
+
return [e for e in self.events if isinstance(e, kind)]
|
|
220
|
+
|
|
221
|
+
def trace_json(self) -> list[dict[str, Any]]:
|
|
222
|
+
return [line.to_json() for line in self.trace]
|
awp_sim/py.typed
ADDED
|
File without changes
|
awp_sim/recorder.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Record each session's wire traffic as a trace in the spec's JSON Lines format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from collections.abc import Hashable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Any
|
|
10
|
+
|
|
11
|
+
from awp.jsonrpc import Message
|
|
12
|
+
|
|
13
|
+
from .audit import redact
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TraceRecorder:
|
|
17
|
+
"""Writes `<directory>/<session_id>.jsonl`, with credentials redacted as in the audit log.
|
|
18
|
+
|
|
19
|
+
Messages exchanged before a connection has a session (initialize, a failed open, the
|
|
20
|
+
reconnect before session.resume) are held until it does, then written ahead of the session's
|
|
21
|
+
own traffic.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
PENDING_LIMIT = 256
|
|
25
|
+
OPEN_FILES = 64
|
|
26
|
+
|
|
27
|
+
def __init__(self, directory: Path | str) -> None:
|
|
28
|
+
self.directory = Path(directory)
|
|
29
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self._pending: dict[Hashable, list[dict[str, Any]]] = {}
|
|
31
|
+
self._files: OrderedDict[str, IO[str]] = OrderedDict()
|
|
32
|
+
|
|
33
|
+
def record(
|
|
34
|
+
self,
|
|
35
|
+
conn: Hashable,
|
|
36
|
+
session: str | None,
|
|
37
|
+
sender: str,
|
|
38
|
+
msg: Message,
|
|
39
|
+
*,
|
|
40
|
+
delivered: bool = True,
|
|
41
|
+
) -> None:
|
|
42
|
+
method = msg.get("method") or ("error" if "error" in msg else "result")
|
|
43
|
+
line: dict[str, Any] = {"from": sender, "step": method, "msg": redact(msg)}
|
|
44
|
+
if not delivered:
|
|
45
|
+
line["delivered"] = False
|
|
46
|
+
if session is None:
|
|
47
|
+
held = self._pending.setdefault(conn, [])
|
|
48
|
+
if len(held) < self.PENDING_LIMIT:
|
|
49
|
+
held.append(line)
|
|
50
|
+
return
|
|
51
|
+
f = self._file(session)
|
|
52
|
+
for held_line in self._pending.pop(conn, []):
|
|
53
|
+
f.write(json.dumps(held_line, separators=(",", ":")) + "\n")
|
|
54
|
+
f.write(json.dumps(line, separators=(",", ":")) + "\n")
|
|
55
|
+
f.flush()
|
|
56
|
+
|
|
57
|
+
def forget(self, conn: Hashable) -> None:
|
|
58
|
+
self._pending.pop(conn, None)
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
for f in self._files.values():
|
|
62
|
+
f.close()
|
|
63
|
+
self._files.clear()
|
|
64
|
+
|
|
65
|
+
def _file(self, session: str) -> IO[str]:
|
|
66
|
+
f = self._files.pop(session, None)
|
|
67
|
+
if f is None:
|
|
68
|
+
f = (self.directory / f"{session}.jsonl").open("a", encoding="utf-8")
|
|
69
|
+
while len(self._files) >= self.OPEN_FILES:
|
|
70
|
+
self._files.popitem(last=False)[1].close()
|
|
71
|
+
self._files[session] = f # most recently used last
|
|
72
|
+
return f
|
awp_sim/replay.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Replay a replay bundle and compare what the world does with what it recorded (AWP-REP-003).
|
|
2
|
+
|
|
3
|
+
The world is rebuilt from the bundle's configuration and initial state; the session is opened as
|
|
4
|
+
recorded, and the agent's calls that change action state are fed in their recorded order.
|
|
5
|
+
Compared: every frame's payload hash per channel, and the ordered `(state, reason)` sequence of
|
|
6
|
+
every action. Timestamps, sequence numbers, heartbeats, reports, and telemetry are not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from awp.client import ActionUpdated, FrameReceived
|
|
18
|
+
|
|
19
|
+
from .loopback import Loopback
|
|
20
|
+
from .world import World, decode_config, decode_state
|
|
21
|
+
|
|
22
|
+
# What changes action state: the agent's calls, in their recorded order.
|
|
23
|
+
REPLAYED = (
|
|
24
|
+
"action.submit",
|
|
25
|
+
"action.cancel",
|
|
26
|
+
"world.tick",
|
|
27
|
+
"world.reset",
|
|
28
|
+
"world.restore",
|
|
29
|
+
"session.close",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Outcome:
|
|
35
|
+
frames: int
|
|
36
|
+
transitions: int
|
|
37
|
+
difference: str | None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def reproduced(self) -> bool:
|
|
41
|
+
return self.difference is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _recorded(
|
|
45
|
+
records: list[dict[str, Any]],
|
|
46
|
+
) -> tuple[dict[str, list[str]], dict[str, list[tuple[str, Any]]]]:
|
|
47
|
+
channel_of: dict[int, str] = {}
|
|
48
|
+
frames: dict[str, list[str]] = {}
|
|
49
|
+
states: dict[str, list[tuple[str, Any]]] = {}
|
|
50
|
+
for r in records:
|
|
51
|
+
body = r["body"]
|
|
52
|
+
if r["kind"] == "frame" and body.get("method") == "obs.frame":
|
|
53
|
+
name = channel_of.get(body["channel_id"], str(body["channel_id"]))
|
|
54
|
+
frames.setdefault(name, []).append(body["payload_sha256"])
|
|
55
|
+
elif r["kind"] == "message":
|
|
56
|
+
result = body.get("result") or {}
|
|
57
|
+
for g in (
|
|
58
|
+
(result.get("granted") or {}).get("channels", [])
|
|
59
|
+
if isinstance(result.get("granted"), dict)
|
|
60
|
+
else []
|
|
61
|
+
):
|
|
62
|
+
channel_of[g["channel_id"]] = g["channel"]
|
|
63
|
+
params = body.get("params") or {}
|
|
64
|
+
status = params if body.get("method") == "action.status" else None
|
|
65
|
+
if (
|
|
66
|
+
status is None
|
|
67
|
+
and "action_id" in result
|
|
68
|
+
and "state" in result
|
|
69
|
+
and "status_seq" in result
|
|
70
|
+
):
|
|
71
|
+
status = result
|
|
72
|
+
if status is not None and r["direction"] == "world":
|
|
73
|
+
seq = states.setdefault(status["action_id"], [])
|
|
74
|
+
entry = (status["state"], status.get("reason"))
|
|
75
|
+
if not seq or seq[-1] != entry:
|
|
76
|
+
seq.append(entry)
|
|
77
|
+
return frames, states
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def replay(path: Path | str) -> Outcome:
|
|
81
|
+
records = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
|
|
82
|
+
header = records[0]
|
|
83
|
+
if header.get("class") != "replay_bundle":
|
|
84
|
+
raise ValueError(f"{path} is a {header.get('class')}, not a replay_bundle (AWP-AUD-007)")
|
|
85
|
+
body = header["body"]
|
|
86
|
+
world = World(decode_config(body["config"]))
|
|
87
|
+
world.load_state(decode_state(body["initial_state"]))
|
|
88
|
+
net = Loopback(world)
|
|
89
|
+
agent = net.agent("replay", heartbeat_ms=None)
|
|
90
|
+
agent.connect()
|
|
91
|
+
agent.call(agent.client.initialize())
|
|
92
|
+
opened = next(r["body"]["params"] for r in records if r["body"].get("method") == "session.open")
|
|
93
|
+
agent.call(agent.client.request("session.open", opened))
|
|
94
|
+
for r in records:
|
|
95
|
+
msg = r["body"]
|
|
96
|
+
if r["direction"] == "agent" and msg.get("method") in REPLAYED and "id" in msg:
|
|
97
|
+
agent.client.request(msg["method"], msg.get("params") or {})
|
|
98
|
+
net.settle()
|
|
99
|
+
want_frames, want_states = _recorded(records)
|
|
100
|
+
got_frames: dict[str, list[str]] = {}
|
|
101
|
+
for e in agent.of(FrameReceived):
|
|
102
|
+
got_frames.setdefault(e.channel, []).append(hashlib.sha256(e.frame.payload).hexdigest())
|
|
103
|
+
got_states: dict[str, list[tuple[str, Any]]] = {}
|
|
104
|
+
for e in agent.of(ActionUpdated):
|
|
105
|
+
seq = got_states.setdefault(e.action.action_id, [])
|
|
106
|
+
entry = (e.status["state"], e.status.get("reason"))
|
|
107
|
+
if not seq or seq[-1] != entry:
|
|
108
|
+
seq.append(entry)
|
|
109
|
+
difference = None
|
|
110
|
+
for name, hashes in want_frames.items():
|
|
111
|
+
if got_frames.get(name, []) != hashes:
|
|
112
|
+
got = got_frames.get(name, [])
|
|
113
|
+
at = next(
|
|
114
|
+
(i for i, (a, b) in enumerate(zip(hashes, got, strict=False)) if a != b),
|
|
115
|
+
min(len(hashes), len(got)),
|
|
116
|
+
)
|
|
117
|
+
difference = (
|
|
118
|
+
f"channel {name}: frame {at} differs ({len(hashes)} recorded, {len(got)} replayed)"
|
|
119
|
+
)
|
|
120
|
+
break
|
|
121
|
+
if difference is None and got_states != want_states:
|
|
122
|
+
bad = next(
|
|
123
|
+
k
|
|
124
|
+
for k in sorted(set(want_states) | set(got_states))
|
|
125
|
+
if want_states.get(k) != got_states.get(k)
|
|
126
|
+
)
|
|
127
|
+
difference = (
|
|
128
|
+
f"action {bad}: recorded {want_states.get(bad)}, replayed {got_states.get(bad)}"
|
|
129
|
+
)
|
|
130
|
+
return Outcome(
|
|
131
|
+
sum(len(v) for v in want_frames.values()),
|
|
132
|
+
sum(len(v) for v in want_states.values()),
|
|
133
|
+
difference,
|
|
134
|
+
)
|