agent-party 0.2.0__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 (90) hide show
  1. agent_party/__init__.py +36 -0
  2. agent_party/__main__.py +11 -0
  3. agent_party/adapters/__init__.py +7 -0
  4. agent_party/adapters/local/__init__.py +8 -0
  5. agent_party/adapters/local/fs_storage.py +504 -0
  6. agent_party/adapters/local/index.py +206 -0
  7. agent_party/adapters/local/lifecycle.py +462 -0
  8. agent_party/adapters/local/presence.py +343 -0
  9. agent_party/adapters/local/protocol.py +437 -0
  10. agent_party/adapters/local/server.py +1867 -0
  11. agent_party/adapters/local/sessions.py +165 -0
  12. agent_party/adapters/local/waiting.py +219 -0
  13. agent_party/capability/__init__.py +90 -0
  14. agent_party/capability/guard.py +759 -0
  15. agent_party/capability/revocation.py +82 -0
  16. agent_party/capability/tokens.py +446 -0
  17. agent_party/cli.py +58 -0
  18. agent_party/client/__init__.py +32 -0
  19. agent_party/client/blocking.py +565 -0
  20. agent_party/client/commands.py +914 -0
  21. agent_party/client/exchange.py +481 -0
  22. agent_party/client/exits.py +177 -0
  23. agent_party/client/link.py +218 -0
  24. agent_party/client/lock.py +178 -0
  25. agent_party/client/render.py +282 -0
  26. agent_party/client/state.py +571 -0
  27. agent_party/client/transport.py +412 -0
  28. agent_party/clock.py +114 -0
  29. agent_party/console/__init__.py +56 -0
  30. agent_party/console/__main__.py +86 -0
  31. agent_party/console/app.py +502 -0
  32. agent_party/console/client.py +235 -0
  33. agent_party/console/render.py +280 -0
  34. agent_party/console/view.py +461 -0
  35. agent_party/core/__init__.py +118 -0
  36. agent_party/core/index.py +170 -0
  37. agent_party/core/lifecycle.py +285 -0
  38. agent_party/core/memory_storage.py +122 -0
  39. agent_party/core/models.py +623 -0
  40. agent_party/core/room.py +582 -0
  41. agent_party/core/storage.py +174 -0
  42. agent_party/crypto/__init__.py +101 -0
  43. agent_party/crypto/envelope.py +411 -0
  44. agent_party/crypto/keys.py +87 -0
  45. agent_party/crypto/messages.py +158 -0
  46. agent_party/crypto/secretbox.py +146 -0
  47. agent_party/errors.py +80 -0
  48. agent_party/identity/__init__.py +122 -0
  49. agent_party/identity/models.py +1173 -0
  50. agent_party/identity/providers.py +341 -0
  51. agent_party/identity/service.py +1328 -0
  52. agent_party/identity/storage.py +582 -0
  53. agent_party/ids.py +119 -0
  54. agent_party/invite/__init__.py +45 -0
  55. agent_party/invite/logs.py +133 -0
  56. agent_party/invite/page.py +348 -0
  57. agent_party/mcp/__init__.py +67 -0
  58. agent_party/mcp/__main__.py +18 -0
  59. agent_party/mcp/channel.py +313 -0
  60. agent_party/mcp/cli.py +199 -0
  61. agent_party/mcp/config.py +318 -0
  62. agent_party/mcp/jsonrpc.py +118 -0
  63. agent_party/mcp/org.py +379 -0
  64. agent_party/mcp/registry.py +187 -0
  65. agent_party/mcp/room.py +657 -0
  66. agent_party/mcp/safety.py +64 -0
  67. agent_party/mcp/server.py +188 -0
  68. agent_party/mcp/session.py +372 -0
  69. agent_party/mcp/transports.py +82 -0
  70. agent_party/metering/__init__.py +99 -0
  71. agent_party/metering/admission.py +56 -0
  72. agent_party/metering/backoff.py +115 -0
  73. agent_party/metering/buckets.py +189 -0
  74. agent_party/metering/errors.py +82 -0
  75. agent_party/metering/limits.py +143 -0
  76. agent_party/metering/service.py +549 -0
  77. agent_party/metering/usage.py +177 -0
  78. agent_party/orgapi/__init__.py +78 -0
  79. agent_party/orgapi/app.py +689 -0
  80. agent_party/orgapi/client.py +417 -0
  81. agent_party/orgapi/commands.py +808 -0
  82. agent_party/orgapi/credentials.py +111 -0
  83. agent_party/orgapi/links.py +138 -0
  84. agent_party/orgapi/service.py +438 -0
  85. agent_party/orgapi/state.py +107 -0
  86. agent_party/presence.py +281 -0
  87. agent_party-0.2.0.dist-info/METADATA +139 -0
  88. agent_party-0.2.0.dist-info/RECORD +90 -0
  89. agent_party-0.2.0.dist-info/WHEEL +4 -0
  90. agent_party-0.2.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,36 @@
1
+ # Chunk: docs/chunks/foundation_scaffold - Project skeleton and shared primitives
2
+ """agent-party: ephemeral, end-to-end encrypted chat rooms for autonomous agents.
3
+
4
+ The three modules exported here are the cross-cutting primitives every other
5
+ part of the system agrees on: the injected clock, the error taxonomy, and
6
+ identifier derivation. Domain behavior lives in the subpackages.
7
+ """
8
+
9
+ from agent_party.clock import Clock, ManualClock, SystemClock
10
+ from agent_party.errors import AgentChatError, ErrorCode
11
+ from agent_party.ids import (
12
+ derive_participant_id,
13
+ new_link_id,
14
+ new_org_id,
15
+ new_room_id,
16
+ )
17
+
18
+ __all__ = [
19
+ "AgentChatError",
20
+ "Clock",
21
+ "ErrorCode",
22
+ "ManualClock",
23
+ "SystemClock",
24
+ "derive_participant_id",
25
+ "new_link_id",
26
+ "new_org_id",
27
+ "new_room_id",
28
+ ]
29
+
30
+ __version__ = "0.2.0"
31
+
32
+ #: The User-Agent every client transport sends. A request with no User-Agent, or
33
+ #: the default `Python-urllib/x`, is blocked at Cloudflare's edge (error 1010,
34
+ #: "banned by browser signature") before it ever reaches the deployed Worker, so
35
+ #: naming ourselves is what lets the client talk to the hosted service at all.
36
+ USER_AGENT = f"agent-party/{__version__}"
@@ -0,0 +1,11 @@
1
+ # Chunk: docs/chunks/foundation_scaffold - Project skeleton and shared primitives
2
+ """Make the CLI reachable as `python -m agent_party`.
3
+
4
+ The console script is the product surface; this exists so tests can exercise
5
+ the entry point without depending on where the script landed on PATH.
6
+ """
7
+
8
+ from agent_party.cli import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,7 @@
1
+ # Chunk: docs/chunks/foundation_scaffold - Reserved by the scaffold
2
+ """Host adapters that put the portable core behind a real transport.
3
+
4
+ One adapter per host. `local/` is the fast test target; the Cloudflare Workers
5
+ adapter lives in `workers/agent-chat/` because it is TypeScript. Both answer to
6
+ the same conformance suite (DEC-008).
7
+ """
@@ -0,0 +1,8 @@
1
+ # Chunk: docs/chunks/foundation_scaffold - Reserved by the scaffold
2
+ """The local server adapter.
3
+
4
+ Owned by `docs/chunks/room_local_server`: WebSocket with the challenge-attach
5
+ handshake, the HTTP blocking read that drains everything past a cursor, session
6
+ tokens, and filesystem-backed durable storage. This is the fast target the
7
+ conformance suite runs against.
8
+ """
@@ -0,0 +1,504 @@
1
+ # Chunk: docs/chunks/room_local_server - Local server adapter: filesystem storage
2
+ """Filesystem-backed `RoomStorage`.
3
+
4
+ The durable half of the local adapter. `src/agent_party/core/storage.py` states
5
+ the contract and `tests/unit/room_core_storage_contract.py` asserts it; this
6
+ module supplies a `tmp_path`-shaped root and passes that suite with a four-line
7
+ subclass and no new tests.
8
+
9
+ Layout under the configured root::
10
+
11
+ <root>/rooms/<room_id>/
12
+ room.json the Room record
13
+ meta.json {"head": N, "bytes": B}
14
+ messages.jsonl one JSON object per line, append-only
15
+ participants/<participant_id>.json
16
+ .lock flock target for position assignment
17
+
18
+ Three obligations are the ones an adapter gets wrong, so each is answered here
19
+ in the same order `storage.py` states them.
20
+
21
+ **Positions are assigned atomically.** Reading the head, writing the message
22
+ line, and writing the new head all happen under an exclusive `fcntl.flock` on
23
+ `.lock`, and the whole locked region is synchronous — there is no `await`
24
+ between the read and the write, so no interleaving can occur inside it. The
25
+ locked region runs in `asyncio.to_thread`, which means this adapter *genuinely
26
+ suspends* between concurrent appends. That matters more than it looks:
27
+ `tests/unit/test_room_core_memory_storage.py` observes that the in-memory
28
+ adapter has no suspension point anywhere, so the contract's concurrency test
29
+ passes against it without ever exercising the property it names. Against this
30
+ adapter the interleaving is real and `flock` is what the test actually proves.
31
+
32
+ The lock is POSIX advisory locking, which attaches to the *open file
33
+ description* rather than to the process, so two `open()` calls in one process —
34
+ one per worker thread — do contend. It is still advisory and still
35
+ filesystem-dependent: over NFS it is not a guarantee. This adapter is a
36
+ development and conformance target, not a deployment.
37
+
38
+ **No clock is read.** `sent_at` arrives as an argument, from the core's injected
39
+ clock. There is no `datetime.now` in this module, deliberately: an adapter that
40
+ stamped its own time would put a wall clock under a suite that must not have
41
+ one.
42
+
43
+ **No derived state is stored.** A participant is a whole row with no `state`
44
+ column; `Participant.state(now, live_participant_ids)` derives it at read time
45
+ (DEC-011). `put_participant` is a whole-row replace, written to a temporary file
46
+ and `os.replace`d, so a concurrent reader sees the old row or the new one and
47
+ never half of either.
48
+
49
+ Identifiers become path segments, so every one is checked with `is_valid_id`
50
+ before it is joined to the root. A malformed identifier yields the empty answer
51
+ on a read path and raises on a write path; nothing can traverse out of the root.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import asyncio
57
+ import base64
58
+ import fcntl
59
+ import json
60
+ import os
61
+ import shutil
62
+ from collections.abc import Callable
63
+ from datetime import datetime, timedelta
64
+ from pathlib import Path
65
+ from typing import Any
66
+
67
+ from agent_party.core.models import (
68
+ CloseReason,
69
+ LivenessMode,
70
+ Participant,
71
+ Room,
72
+ StoredMessage,
73
+ )
74
+ from agent_party.ids import is_valid_id
75
+
76
+ __all__ = ["FileSystemRoomStorage"]
77
+
78
+ _ROOMS = "rooms"
79
+ _ROOM_RECORD = "room.json"
80
+ _META = "meta.json"
81
+ _MESSAGES = "messages.jsonl"
82
+ _PARTICIPANTS = "participants"
83
+ _LOCK = ".lock"
84
+
85
+ #: `meta.json` keys. The head and the byte total are maintained together under
86
+ #: the append lock so they can never disagree about what the log contains.
87
+ _HEAD = "head"
88
+ _BYTES = "bytes"
89
+
90
+
91
+ class FileSystemRoomStorage:
92
+ """A durable `RoomStorage` over a directory tree."""
93
+
94
+ __slots__ = ("_root",)
95
+
96
+ def __init__(self, root: Path | str) -> None:
97
+ self._root = Path(root)
98
+ (self._root / _ROOMS).mkdir(parents=True, exist_ok=True)
99
+
100
+ @property
101
+ def root(self) -> Path:
102
+ """The directory this adapter owns.
103
+
104
+ Public because reclamation is verified by inspecting storage rather
105
+ than by asking the API (`docs/trunk/TESTING_PHILOSOPHY.md`), and a test
106
+ that cannot see the bytes cannot make that assertion.
107
+ """
108
+ return self._root
109
+
110
+ # --- Paths -------------------------------------------------------------
111
+
112
+ def _room_dir(self, room_id: str) -> Path | None:
113
+ """The room's directory, or `None` if `room_id` is not an identifier.
114
+
115
+ Returning `None` rather than raising is what lets every read path answer
116
+ "nothing here" for a malformed identifier, which is the same answer it
117
+ gives for one that is well-formed and absent. Write paths turn the
118
+ `None` into a `ValueError`.
119
+ """
120
+ if not is_valid_id(room_id):
121
+ return None
122
+ return self._root / _ROOMS / room_id
123
+
124
+ def _require_room_dir(self, room_id: str) -> Path:
125
+ directory = self._room_dir(room_id)
126
+ if directory is None:
127
+ raise ValueError(f"not a well-formed room_id: {room_id!r}")
128
+ return directory
129
+
130
+ @staticmethod
131
+ def _participant_path(room_dir: Path, participant_id: str) -> Path | None:
132
+ if not is_valid_id(participant_id):
133
+ return None
134
+ return room_dir / _PARTICIPANTS / f"{participant_id}.json"
135
+
136
+ # --- Rooms -------------------------------------------------------------
137
+
138
+ async def create_room(self, room: Room) -> None:
139
+ await _in_thread(self._create_room, room)
140
+
141
+ def _create_room(self, room: Room) -> None:
142
+ directory = self._require_room_dir(room.room_id)
143
+ if (directory / _ROOM_RECORD).exists():
144
+ raise ValueError(f"room already exists: {room.room_id}")
145
+ (directory / _PARTICIPANTS).mkdir(parents=True, exist_ok=True)
146
+ _write_atomic(directory / _META, json.dumps({_HEAD: 0, _BYTES: 0}))
147
+ (directory / _MESSAGES).touch(exist_ok=True)
148
+ (directory / _LOCK).touch(exist_ok=True)
149
+ _write_atomic(directory / _ROOM_RECORD, json.dumps(_room_to_json(room)))
150
+
151
+ async def get_room(self, room_id: str) -> Room | None:
152
+ return await _in_thread(self._get_room, room_id)
153
+
154
+ def _get_room(self, room_id: str) -> Room | None:
155
+ directory = self._room_dir(room_id)
156
+ if directory is None:
157
+ return None
158
+ raw = _read_text(directory / _ROOM_RECORD)
159
+ return None if raw is None else _room_from_json(json.loads(raw))
160
+
161
+ async def put_room(self, room: Room) -> None:
162
+ await _in_thread(self._put_room, room)
163
+
164
+ def _put_room(self, room: Room) -> None:
165
+ directory = self._require_room_dir(room.room_id)
166
+ if not (directory / _ROOM_RECORD).exists():
167
+ raise ValueError(f"room does not exist: {room.room_id}")
168
+ _write_atomic(directory / _ROOM_RECORD, json.dumps(_room_to_json(room)))
169
+
170
+ async def delete_room(self, room_id: str) -> None:
171
+ await _in_thread(self._delete_room, room_id)
172
+
173
+ def _delete_room(self, room_id: str) -> None:
174
+ # Everything the room owns is under one directory, so destruction is one
175
+ # `rmtree` rather than a per-key sweep — and it is idempotent, because
176
+ # SPEC gives a room three independent ways to die (DEC-002).
177
+ directory = self._room_dir(room_id)
178
+ if directory is not None:
179
+ shutil.rmtree(directory, ignore_errors=True)
180
+
181
+ # --- The log -----------------------------------------------------------
182
+
183
+ async def head(self, room_id: str) -> int:
184
+ return await _in_thread(lambda: int(self._meta(room_id)[_HEAD]))
185
+
186
+ async def stored_bytes(self, room_id: str) -> int:
187
+ return await _in_thread(lambda: int(self._meta(room_id)[_BYTES]))
188
+
189
+ def _meta(self, room_id: str) -> dict[str, int]:
190
+ directory = self._room_dir(room_id)
191
+ if directory is None:
192
+ return {_HEAD: 0, _BYTES: 0}
193
+ raw = _read_text(directory / _META)
194
+ if raw is None:
195
+ return {_HEAD: 0, _BYTES: 0}
196
+ meta = json.loads(raw)
197
+ return {_HEAD: int(meta[_HEAD]), _BYTES: int(meta[_BYTES])}
198
+
199
+ async def append_message(
200
+ self, room_id: str, sender: str, sent_at: datetime, body: bytes
201
+ ) -> StoredMessage:
202
+ return await _in_thread(
203
+ self._append_message, room_id, sender, sent_at, bytes(body)
204
+ )
205
+
206
+ def _append_message(
207
+ self, room_id: str, sender: str, sent_at: datetime, body: bytes
208
+ ) -> StoredMessage:
209
+ directory = self._require_room_dir(room_id)
210
+ lock_path = directory / _LOCK
211
+ if not lock_path.exists():
212
+ raise ValueError(f"room does not exist: {room_id}")
213
+
214
+ # Everything between here and the unlock is synchronous. The position is
215
+ # read, used, and written back without any opportunity for another
216
+ # thread or another process to observe it half-assigned.
217
+ with open(lock_path) as lock_file:
218
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
219
+ try:
220
+ meta = self._meta(room_id)
221
+ message = StoredMessage(
222
+ room_id=room_id,
223
+ position=meta[_HEAD] + 1,
224
+ sender=sender,
225
+ sent_at=sent_at,
226
+ body=body,
227
+ )
228
+ with open(directory / _MESSAGES, "a", encoding="utf-8") as log:
229
+ log.write(json.dumps(_message_to_json(message)) + "\n")
230
+ log.flush()
231
+ os.fsync(log.fileno())
232
+ _write_atomic(
233
+ directory / _META,
234
+ json.dumps(
235
+ {
236
+ _HEAD: message.position,
237
+ _BYTES: meta[_BYTES] + message.byte_size,
238
+ }
239
+ ),
240
+ )
241
+ return message
242
+ finally:
243
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
244
+
245
+ async def read_after(
246
+ self, room_id: str, cursor: int, limit: int | None = None
247
+ ) -> list[StoredMessage]:
248
+ return await _in_thread(self._read_after, room_id, cursor, limit)
249
+
250
+ def _read_after(
251
+ self, room_id: str, cursor: int, limit: int | None
252
+ ) -> list[StoredMessage]:
253
+ directory = self._room_dir(room_id)
254
+ if directory is None:
255
+ return []
256
+ path = directory / _MESSAGES
257
+ if not path.exists():
258
+ return []
259
+ if limit is not None and limit <= 0:
260
+ return []
261
+
262
+ found: list[StoredMessage] = []
263
+ # Chunk: docs/chunks/organization_api - a reader racing a destruction
264
+ #
265
+ # The `exists` check above and this `open` are two syscalls with a
266
+ # thread hop's worth of gap between them, and `delete_room` can land in
267
+ # it: an explicit close disconnects a socket whose delivery loop already
268
+ # has a read in flight. A room whose bytes are gone has no messages —
269
+ # which is exactly what the `directory is None` branch above already
270
+ # says — so the disappearance is that answer arriving late, not an
271
+ # error. Raising here would surface as an untyped `FileNotFoundError`
272
+ # out of the socket handler, where the `closing` frame has already been
273
+ # delivered and there is nothing left to report.
274
+ try:
275
+ log = open(path, encoding="utf-8")
276
+ except FileNotFoundError:
277
+ return []
278
+ with log:
279
+ for line in log:
280
+ line = line.strip()
281
+ if not line:
282
+ continue
283
+ # Filtering on the recorded position rather than slicing by line
284
+ # index: the identity "line i holds position i + 1" is true
285
+ # because rooms never trim, but relying on it would make a
286
+ # partially written trailing line silently shift every position.
287
+ record = json.loads(line)
288
+ if record["position"] <= cursor:
289
+ continue
290
+ found.append(_message_from_json(record))
291
+ if limit is not None and len(found) >= limit:
292
+ break
293
+ return found
294
+
295
+ # --- The participant table ---------------------------------------------
296
+
297
+ async def put_participant(self, participant: Participant) -> None:
298
+ await _in_thread(self._put_participant, participant)
299
+
300
+ def _put_participant(self, participant: Participant) -> None:
301
+ directory = self._require_room_dir(participant.room_id)
302
+ if not (directory / _ROOM_RECORD).exists():
303
+ raise ValueError(f"room does not exist: {participant.room_id}")
304
+ path = self._participant_path(directory, participant.participant_id)
305
+ if path is None:
306
+ raise ValueError(
307
+ f"not a well-formed participant_id: {participant.participant_id!r}"
308
+ )
309
+ path.parent.mkdir(parents=True, exist_ok=True)
310
+ # A whole-row replace, written through a temporary file: a reader either
311
+ # sees the row that was there before or the one being written, never a
312
+ # truncated row.
313
+ _write_atomic(path, json.dumps(_participant_to_json(participant)))
314
+
315
+ async def get_participant(
316
+ self, room_id: str, participant_id: str
317
+ ) -> Participant | None:
318
+ return await _in_thread(self._get_participant, room_id, participant_id)
319
+
320
+ def _get_participant(self, room_id: str, participant_id: str) -> Participant | None:
321
+ directory = self._room_dir(room_id)
322
+ if directory is None:
323
+ return None
324
+ path = self._participant_path(directory, participant_id)
325
+ if path is None:
326
+ return None
327
+ raw = _read_text(path)
328
+ return None if raw is None else _participant_from_json(json.loads(raw))
329
+
330
+ async def list_participants(self, room_id: str) -> list[Participant]:
331
+ return await _in_thread(self._list_participants, room_id)
332
+
333
+ def _list_participants(self, room_id: str) -> list[Participant]:
334
+ directory = self._room_dir(room_id)
335
+ if directory is None:
336
+ return []
337
+ table = directory / _PARTICIPANTS
338
+ if not table.is_dir():
339
+ return []
340
+ rows: list[Participant] = []
341
+ # The same race `_read_after` documents above, one directory up:
342
+ # `is_dir` and `iterdir` are two syscalls and `delete_room` can land
343
+ # between them. A roster read racing a reclamation is the ordinary case
344
+ # rather than an exotic one — `broadcast_roster` runs on every presence
345
+ # change, and reclamation destroys storage under whatever is in flight.
346
+ # The individual row reads below are already guarded by `_read_text`;
347
+ # this is the enumeration that reaches them.
348
+ try:
349
+ entries = list(table.iterdir())
350
+ except FileNotFoundError:
351
+ return []
352
+ for path in entries:
353
+ if path.suffix != ".json":
354
+ continue
355
+ raw = _read_text(path)
356
+ if raw is not None:
357
+ rows.append(_participant_from_json(json.loads(raw)))
358
+ # A total, stable order: joined_at, then participant_id for ties. A
359
+ # roster whose order wobbles between two reads that saw no change is a
360
+ # roster that publishes spurious updates.
361
+ return sorted(rows, key=lambda row: (row.joined_at, row.participant_id))
362
+
363
+
364
+ # --- Encoding ---------------------------------------------------------------
365
+ #
366
+ # Everything durable goes through these six functions. They are deliberately
367
+ # explicit rather than reflective over the dataclasses: the on-disk shape is a
368
+ # format, and a format that changes whenever a field is renamed is not one.
369
+
370
+
371
+ def _room_to_json(room: Room) -> dict[str, Any]:
372
+ return {
373
+ "room_id": room.room_id,
374
+ "org_id": room.org_id,
375
+ "created_at": room.created_at.isoformat(),
376
+ "ttl": room.ttl.total_seconds(),
377
+ "idle_timeout": room.idle_timeout.total_seconds(),
378
+ "max_participants": room.max_participants,
379
+ "max_bytes": room.max_bytes,
380
+ "max_message_bytes": room.max_message_bytes,
381
+ "last_activity_at": _instant_to_json(room.last_activity_at),
382
+ "closed_at": _instant_to_json(room.closed_at),
383
+ "close_reason": None if room.close_reason is None else room.close_reason.value,
384
+ }
385
+
386
+
387
+ def _room_from_json(record: dict[str, Any]) -> Room:
388
+ return Room(
389
+ room_id=record["room_id"],
390
+ org_id=record["org_id"],
391
+ created_at=datetime.fromisoformat(record["created_at"]),
392
+ ttl=timedelta(seconds=record["ttl"]),
393
+ idle_timeout=timedelta(seconds=record["idle_timeout"]),
394
+ max_participants=record["max_participants"],
395
+ max_bytes=record["max_bytes"],
396
+ max_message_bytes=record["max_message_bytes"],
397
+ last_activity_at=_instant_from_json(record["last_activity_at"]),
398
+ closed_at=_instant_from_json(record["closed_at"]),
399
+ close_reason=(
400
+ None
401
+ if record["close_reason"] is None
402
+ else CloseReason(record["close_reason"])
403
+ ),
404
+ )
405
+
406
+
407
+ def _participant_to_json(participant: Participant) -> dict[str, Any]:
408
+ return {
409
+ "room_id": participant.room_id,
410
+ "participant_id": participant.participant_id,
411
+ "public_key": participant.public_key.hex(),
412
+ "mode": participant.mode.value,
413
+ "joined_at": participant.joined_at.isoformat(),
414
+ "link_id": participant.link_id,
415
+ "lease_expires_at": _instant_to_json(participant.lease_expires_at),
416
+ "cursor": participant.cursor,
417
+ "evicted_at": _instant_to_json(participant.evicted_at),
418
+ "departed_at": _instant_to_json(participant.departed_at),
419
+ }
420
+
421
+
422
+ def _participant_from_json(record: dict[str, Any]) -> Participant:
423
+ return Participant(
424
+ room_id=record["room_id"],
425
+ participant_id=record["participant_id"],
426
+ public_key=bytes.fromhex(record["public_key"]),
427
+ mode=LivenessMode(record["mode"]),
428
+ joined_at=datetime.fromisoformat(record["joined_at"]),
429
+ link_id=record["link_id"],
430
+ lease_expires_at=_instant_from_json(record["lease_expires_at"]),
431
+ cursor=record["cursor"],
432
+ evicted_at=_instant_from_json(record["evicted_at"]),
433
+ departed_at=_instant_from_json(record["departed_at"]),
434
+ )
435
+
436
+
437
+ def _message_to_json(message: StoredMessage) -> dict[str, Any]:
438
+ return {
439
+ "room_id": message.room_id,
440
+ "position": message.position,
441
+ "sender": message.sender,
442
+ "sent_at": message.sent_at.isoformat(),
443
+ # base64 rather than hex, and never `decode`: the body is opaque bytes
444
+ # that are `nonce || ciphertext` in production and arbitrary in tests,
445
+ # including sequences that are not valid UTF-8.
446
+ "body": base64.b64encode(message.body).decode("ascii"),
447
+ }
448
+
449
+
450
+ def _message_from_json(record: dict[str, Any]) -> StoredMessage:
451
+ return StoredMessage(
452
+ room_id=record["room_id"],
453
+ position=record["position"],
454
+ sender=record["sender"],
455
+ sent_at=datetime.fromisoformat(record["sent_at"]),
456
+ body=base64.b64decode(record["body"]),
457
+ )
458
+
459
+
460
+ def _instant_to_json(instant: datetime | None) -> str | None:
461
+ return None if instant is None else instant.isoformat()
462
+
463
+
464
+ def _instant_from_json(raw: str | None) -> datetime | None:
465
+ return None if raw is None else datetime.fromisoformat(raw)
466
+
467
+
468
+ # --- Filesystem helpers -----------------------------------------------------
469
+
470
+
471
+ def _read_text(path: Path) -> str | None:
472
+ try:
473
+ return path.read_text(encoding="utf-8")
474
+ except FileNotFoundError:
475
+ return None
476
+
477
+
478
+ def _write_atomic(path: Path, text: str) -> None:
479
+ """Write `text` to `path` through a temporary file in the same directory.
480
+
481
+ `os.replace` is atomic within a filesystem, so a reader observes the old
482
+ contents or the new ones. A plain `write_text` would let a reader see a
483
+ truncated record — which for `meta.json` means a lost head.
484
+ """
485
+ temporary = path.with_name(f"{path.name}.{os.getpid()}.{id(text):x}.tmp")
486
+ try:
487
+ with open(temporary, "w", encoding="utf-8") as handle:
488
+ handle.write(text)
489
+ handle.flush()
490
+ os.fsync(handle.fileno())
491
+ os.replace(temporary, path)
492
+ except BaseException:
493
+ temporary.unlink(missing_ok=True)
494
+ raise
495
+
496
+
497
+ async def _in_thread[T](work: Callable[..., T], *args: Any) -> T:
498
+ """Run blocking filesystem work off the event loop.
499
+
500
+ Every method routes through here, which is what makes this adapter suspend
501
+ where a durable adapter really does — see the module docstring on why that
502
+ matters to the storage contract's concurrency test.
503
+ """
504
+ return await asyncio.to_thread(work, *args)