alter-runtime 0.3.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 (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,469 @@
1
+ """InboxWriter - projects ``alter_message`` events into a local JSONL inbox.
2
+
3
+ Wave 1 of *Alter-to-Alter Messaging* (Section 6, Zone C). The InboxWriter is
4
+ a long-lived :class:`alter_runtime.daemon.Component` that:
5
+
6
+ 1. Opens a Server-Sent Events stream against the authenticated user's
7
+ per-handle Durable Object at
8
+ ``https://mcp.truealter.com/events/{handle}/stream``.
9
+ 2. Filters frames whose payload ``kind`` equals ``"alter_message"``.
10
+ 3. Deduplicates against a persisted ``last_seen_do_version`` checkpoint so
11
+ the daemon survives restarts without double-writing events.
12
+ 4. Atomically appends one compact JSON object per line to
13
+ ``$XDG_DATA_HOME/alter-runtime/inbox.jsonl`` (mode ``0o600``, parent dir
14
+ ``0o700``), rotating to ``inbox.jsonl.1`` once the file exceeds 10 MiB.
15
+ 5. Updates ``$XDG_STATE_HOME/alter-runtime/messaging.json`` with the new
16
+ checkpoint via atomic rename.
17
+
18
+ The runtime is a *pure subscriber* - it never sends an ACK back to the DO.
19
+ The DO replays from ``Last-Event-ID`` on reconnect, so checkpoint advance is
20
+ local-only and a write failure simply means the next reconnect re-fetches the
21
+ frame.
22
+
23
+ The component is designed to swallow and log all errors so that a single
24
+ malformed frame, full disk, or transient SSE disconnect cannot crash the
25
+ daemon supervisor. The supervisor will still restart the component with
26
+ exponential backoff if :meth:`run` raises, but :meth:`_handle_frame` only
27
+ ever logs and returns.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import contextlib
34
+ import errno
35
+ import fcntl
36
+ import hashlib
37
+ import json
38
+ import logging
39
+ import os
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from typing import TYPE_CHECKING, Any
43
+
44
+ from alter_runtime.config import DaemonConfig, data_dir, runtime_state_dir
45
+ from alter_runtime.daemon import Component
46
+ from alter_runtime.subscribers.sse import SSEFrame
47
+
48
+ if TYPE_CHECKING:
49
+ from alter_runtime.config import Session
50
+
51
+ __all__ = ["DEFAULT_ROTATION_THRESHOLD_BYTES", "InboxWriter"]
52
+
53
+ logger = logging.getLogger("alter_runtime.subscribers.inbox_writer")
54
+
55
+ #: Rotate the JSONL file once it exceeds this many bytes (10 MiB per §6.4).
56
+ DEFAULT_ROTATION_THRESHOLD_BYTES: int = 10 * 1024 * 1024
57
+
58
+ #: Filename for the inbox cache (within ``data_dir()``).
59
+ INBOX_FILENAME: str = "inbox.jsonl"
60
+
61
+ #: Filename for the rotated tail (single generation; older rotations are not
62
+ #: retained - the audit DB is the source of truth, this is a hot-path cache).
63
+ INBOX_ROTATED_FILENAME: str = "inbox.jsonl.1"
64
+
65
+ #: Filename for the checkpoint (within ``runtime_state_dir()``).
66
+ STATE_FILENAME: str = "messaging.json"
67
+
68
+ #: Hard cap on the per-message ``body_md`` length written to the inbox cache.
69
+ #: The DO projection does not itself bound message bodies, so without a cap
70
+ #: on the consumer side a single pathological message could bloat the cache
71
+ #: file and starve rotation. Closes runtime/M-2 from
72
+ #: pentest-findings-2026-04-15.md.
73
+ MAX_BODY_MD_BYTES: int = 64 * 1024
74
+
75
+
76
+ class InboxWriter(Component):
77
+ """Tails the per-handle SSE stream and projects messages into ``inbox.jsonl``.
78
+
79
+ Parameters
80
+ ----------
81
+ config:
82
+ Loaded :class:`DaemonConfig`. Only ``do_sse_endpoint`` is consulted.
83
+ session:
84
+ Authenticated alter-cli session. ``session.handle`` is used to build
85
+ the per-handle SSE URL and ``session.jwt`` is sent as a Bearer token.
86
+ rotation_threshold_bytes:
87
+ Override the rotation threshold (defaults to 10 MiB). Tests pass a
88
+ small value to exercise the rotation path without writing megabytes.
89
+ inbox_path:
90
+ Override the inbox path. Tests use this to redirect writes to a
91
+ ``tmp_path`` fixture without touching ``$HOME``.
92
+ state_path:
93
+ Override the checkpoint path, same purpose as ``inbox_path``.
94
+ """
95
+
96
+ name = "inbox_writer"
97
+
98
+ def __init__(
99
+ self,
100
+ config: DaemonConfig,
101
+ session: Session,
102
+ *,
103
+ rotation_threshold_bytes: int = DEFAULT_ROTATION_THRESHOLD_BYTES,
104
+ inbox_path: Path | None = None,
105
+ state_path: Path | None = None,
106
+ ) -> None:
107
+ self._config = config
108
+ self._session = session
109
+ self._rotation_threshold_bytes = rotation_threshold_bytes
110
+
111
+ # Lazy-resolve XDG paths so tests that monkeypatch the env vars
112
+ # before construction get the redirected directories. Tests can
113
+ # also override either path explicitly via the keyword args.
114
+ self._inbox_path: Path = (
115
+ inbox_path if inbox_path is not None else data_dir() / INBOX_FILENAME
116
+ )
117
+ self._state_path: Path = (
118
+ state_path if state_path is not None else runtime_state_dir() / STATE_FILENAME
119
+ )
120
+
121
+ self._lock = asyncio.Lock()
122
+ self._last_seen_do_version: int = 0
123
+ self._shutdown_event = asyncio.Event()
124
+
125
+ # ------------------------------------------------------------------
126
+ # Component lifecycle
127
+ # ------------------------------------------------------------------
128
+
129
+ async def run(self) -> None:
130
+ """Long-lived loop. Currently a Wave 1 stub for the SSE socket.
131
+
132
+ The Wave 1 deliverable is the projection logic (filter / dedupe /
133
+ append / rotate / checkpoint), wired so that the backend's eventual
134
+ Last-Event-ID replay or a Wave 2 SSE client can feed frames in via
135
+ :meth:`handle_raw_frame`. The actual long-lived ``httpx.AsyncClient``
136
+ loop against ``do_sse_endpoint`` lands alongside the SSE subscriber
137
+ rewrite in Wave 2 (the spec at §6.2 marks this as a stub).
138
+
139
+ For now :meth:`run` simply loads the checkpoint and blocks on the
140
+ shutdown event so the supervisor can register us cleanly without
141
+ opening a socket. Tests exercise the projection path via
142
+ :meth:`handle_event` directly.
143
+ """
144
+ await self._load_checkpoint()
145
+ logger.info(
146
+ "inbox_writer started handle=%s last_seen_do_version=%d inbox=%s",
147
+ self._session.handle,
148
+ self._last_seen_do_version,
149
+ self._inbox_path,
150
+ )
151
+ try:
152
+ await self._shutdown_event.wait()
153
+ except asyncio.CancelledError:
154
+ raise
155
+ finally:
156
+ logger.info("inbox_writer stopped handle=%s", self._session.handle)
157
+
158
+ async def stop(self) -> None:
159
+ """Cooperative shutdown - release the run loop."""
160
+ self._shutdown_event.set()
161
+
162
+ # ------------------------------------------------------------------
163
+ # Frame ingest - public surface for the SSE socket loop and tests
164
+ # ------------------------------------------------------------------
165
+
166
+ async def handle_raw_frame(self, frame: SSEFrame) -> None:
167
+ """Parse an SSE frame and dispatch to :meth:`handle_event`.
168
+
169
+ Errors are logged and swallowed - the supervisor never sees a write
170
+ failure (per the project convention "swallow and continue").
171
+ """
172
+ try:
173
+ payload = frame.json
174
+ except (ValueError, json.JSONDecodeError) as exc:
175
+ logger.warning("inbox_writer: malformed SSE frame body: %s", exc)
176
+ return
177
+ if not isinstance(payload, dict):
178
+ logger.warning("inbox_writer: SSE frame payload is not a dict: %r", type(payload))
179
+ return
180
+ await self.handle_event(payload)
181
+
182
+ async def handle_event(self, event: dict[str, Any]) -> None:
183
+ """Project a single parsed IdentityEvent dict.
184
+
185
+ This is the unit-test seam - the test suite calls this directly with
186
+ synthesised dicts to avoid having to drive a real SSE socket.
187
+ """
188
+ # ---- 1. Filter on kind ---------------------------------------
189
+ if event.get("kind") != "alter_message":
190
+ return
191
+
192
+ # ---- 2. Deduplicate on do_version ----------------------------
193
+ do_version = event.get("version") or event.get("do_version")
194
+ try:
195
+ do_version_int = int(do_version) if do_version is not None else None
196
+ except (TypeError, ValueError):
197
+ logger.warning("inbox_writer: non-integer do_version=%r - dropping event", do_version)
198
+ return
199
+ if do_version_int is None:
200
+ logger.warning("inbox_writer: alter_message frame missing do_version - dropping")
201
+ return
202
+
203
+ async with self._lock:
204
+ if do_version_int <= self._last_seen_do_version:
205
+ logger.debug(
206
+ "inbox_writer: dedupe drop do_version=%d <= checkpoint=%d",
207
+ do_version_int,
208
+ self._last_seen_do_version,
209
+ )
210
+ return
211
+
212
+ # ---- 3. Project the row ----------------------------------
213
+ line = self._serialise(event, do_version_int)
214
+ if line is None:
215
+ return # already logged
216
+
217
+ # ---- 4. Rotate if oversized -------------------------------
218
+ try:
219
+ self._maybe_rotate()
220
+ except OSError as exc:
221
+ logger.warning("inbox_writer: rotation failed: %s", exc)
222
+ # Continue anyway - better to grow past the threshold than
223
+ # drop the event.
224
+
225
+ # ---- 5. Atomic append + fsync -----------------------------
226
+ try:
227
+ self._append_line(line)
228
+ except OSError as exc:
229
+ logger.warning("inbox_writer: append failed: %s - dropping event", exc)
230
+ return
231
+
232
+ # ---- 6. Advance + persist checkpoint ---------------------
233
+ self._last_seen_do_version = do_version_int
234
+ try:
235
+ self._save_checkpoint()
236
+ except OSError as exc:
237
+ logger.warning("inbox_writer: checkpoint save failed: %s", exc)
238
+
239
+ # ------------------------------------------------------------------
240
+ # Serialisation
241
+ # ------------------------------------------------------------------
242
+
243
+ def _serialise(self, event: dict[str, Any], do_version: int) -> str | None:
244
+ """Build the JSONL line for one ``alter_message`` event.
245
+
246
+ Returns ``None`` if a required field is missing - the caller treats
247
+ that as "drop and continue".
248
+ """
249
+ # The DO emits the IdentityEvent envelope with the message payload
250
+ # either at the top level or nested under ``payload``. Try both so
251
+ # we are tolerant of either shape during the wire-contract rollout.
252
+ body = event.get("payload") if isinstance(event.get("payload"), dict) else event
253
+
254
+ # The Worker's wire contract (handle-alter/src/messages.ts +
255
+ # HandleAlterDO.ts: alter_message synthesis) places the audit-DB row
256
+ # UUID under ``payload.event_id``, not a top-level ``id`` field —
257
+ # ``MessageRequestBody.identity_event_id`` → ``AlterMessagePayload.event_id``.
258
+ # We try the canonical name first, then fall back to top-level ``id``
259
+ # / ``event_id`` for any legacy or future shape that surfaces it
260
+ # elsewhere. Pre-invariant frames omit the field entirely; for those
261
+ # we synthesise a stable id from (sender, sent_at, body_md hash) so
262
+ # the message still lands in the local cache instead of being dropped
263
+ # — the audit DB remains the source of truth for canonical ids.
264
+ message_id = (
265
+ body.get("event_id") or event.get("event_id") or event.get("id") or body.get("id")
266
+ )
267
+ sender = body.get("sender_handle") or body.get("sender")
268
+ body_md = body.get("body_md")
269
+ thread_id = body.get("thread_id")
270
+ received_at = (
271
+ event.get("timestamp") or body.get("sent_at") or datetime.now(timezone.utc).isoformat()
272
+ )
273
+
274
+ if not message_id and sender and body_md is not None:
275
+ # Synthesise a deterministic fallback id so pre-invariant frames
276
+ # (which omit ``event_id``) still project into the inbox cache.
277
+ # The audit DB is the canonical id source; this is purely a
278
+ # local-cache dedup key.
279
+ sent_at_str = str(body.get("sent_at") or received_at)
280
+ digest = hashlib.sha256(
281
+ f"{sender}|{sent_at_str}|{body_md}".encode("utf-8")
282
+ ).hexdigest()[:32]
283
+ message_id = f"local-{digest}"
284
+
285
+ if not message_id or not sender or body_md is None:
286
+ logger.warning(
287
+ "inbox_writer: alter_message missing required field "
288
+ "id=%r sender=%r body_md_present=%s - dropping",
289
+ message_id,
290
+ sender,
291
+ body_md is not None,
292
+ )
293
+ return None
294
+
295
+ body_md_str = str(body_md)
296
+ body_bytes = body_md_str.encode("utf-8")
297
+ truncated = False
298
+ if len(body_bytes) > MAX_BODY_MD_BYTES:
299
+ # Trim at a code-point boundary and drop a marker the surfaces
300
+ # can render if they care to. The audit DB still holds the
301
+ # untruncated original - this is only the local cache.
302
+ body_md_str = body_bytes[:MAX_BODY_MD_BYTES].decode("utf-8", errors="ignore")
303
+ body_md_str += "\n\n[… body truncated at 64 KiB - see audit DB for full content]"
304
+ truncated = True
305
+ logger.warning(
306
+ "inbox_writer: body_md exceeded %d bytes for id=%s - truncating local cache entry",
307
+ MAX_BODY_MD_BYTES,
308
+ message_id,
309
+ )
310
+
311
+ record = {
312
+ "id": str(message_id),
313
+ "sender": str(sender),
314
+ "body_md": body_md_str,
315
+ "thread_id": thread_id if thread_id else None,
316
+ "received_at": str(received_at),
317
+ "do_version": int(do_version),
318
+ }
319
+ if truncated:
320
+ record["body_md_truncated"] = True
321
+ return json.dumps(record, separators=(",", ":"), ensure_ascii=False)
322
+
323
+ # ------------------------------------------------------------------
324
+ # File operations - atomic append, rotation, checkpoint
325
+ # ------------------------------------------------------------------
326
+
327
+ def _ensure_parent(self, path: Path) -> None:
328
+ """Create the parent directory with mode ``0o700`` if missing."""
329
+ parent = path.parent
330
+ if not parent.exists():
331
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
332
+ # Tighten perms in case the dir pre-existed with looser modes - best
333
+ # effort, a chmod failure is non-fatal (e.g. on filesystems that
334
+ # don't honour POSIX modes).
335
+ with contextlib.suppress(OSError):
336
+ os.chmod(parent, 0o700)
337
+
338
+ def _append_line(self, line: str) -> None:
339
+ """Atomically append ``line + '\\n'`` to the inbox file.
340
+
341
+ Uses :func:`os.open` with ``O_APPEND | O_CREAT`` and mode ``0o600``,
342
+ followed by an :func:`fcntl.flock` exclusive lock around the write.
343
+ ``O_APPEND`` makes the write itself atomic against concurrent
344
+ writers on POSIX, and ``flock`` serialises us against a hypothetical
345
+ second daemon instance on the same XDG dir.
346
+ """
347
+ self._ensure_parent(self._inbox_path)
348
+
349
+ flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
350
+ fd = os.open(self._inbox_path, flags, 0o600)
351
+ try:
352
+ # Re-tighten perms (umask may have widened the mode at create
353
+ # time, e.g. when umask is 0o000 in tests).
354
+ with contextlib.suppress(OSError):
355
+ os.fchmod(fd, 0o600)
356
+
357
+ # Exclusive lock for cross-process safety on Linux/macOS.
358
+ try:
359
+ fcntl.flock(fd, fcntl.LOCK_EX)
360
+ except OSError as exc: # pragma: no cover - exotic FS
361
+ if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
362
+ raise
363
+
364
+ try:
365
+ os.write(fd, line.encode("utf-8") + b"\n")
366
+ os.fsync(fd)
367
+ finally:
368
+ with contextlib.suppress(OSError):
369
+ fcntl.flock(fd, fcntl.LOCK_UN)
370
+ finally:
371
+ os.close(fd)
372
+
373
+ def _maybe_rotate(self) -> None:
374
+ """Rotate the inbox file if it exceeds the threshold.
375
+
376
+ Renames ``inbox.jsonl`` to ``inbox.jsonl.1`` (overwriting any
377
+ existing rotated file). The next :meth:`_append_line` call recreates
378
+ the primary file via ``O_CREAT``. No-op if the file does not yet
379
+ exist or is below the threshold.
380
+ """
381
+ try:
382
+ size = self._inbox_path.stat().st_size
383
+ except FileNotFoundError:
384
+ return
385
+ if size <= self._rotation_threshold_bytes:
386
+ return
387
+
388
+ rotated = self._inbox_path.parent / INBOX_ROTATED_FILENAME
389
+ # ``os.replace`` is atomic on POSIX and overwrites the destination
390
+ # if present, which is exactly the single-generation policy from
391
+ # §6.4 of the messaging spec.
392
+ os.replace(self._inbox_path, rotated)
393
+ logger.info(
394
+ "inbox_writer: rotated %s -> %s (size=%d > threshold=%d)",
395
+ self._inbox_path,
396
+ rotated,
397
+ size,
398
+ self._rotation_threshold_bytes,
399
+ )
400
+
401
+ # ------------------------------------------------------------------
402
+ # Checkpoint persistence
403
+ # ------------------------------------------------------------------
404
+
405
+ async def _load_checkpoint(self) -> None:
406
+ """Load ``last_seen_do_version`` from ``messaging.json`` (0 if absent)."""
407
+ if not self._state_path.exists():
408
+ self._last_seen_do_version = 0
409
+ return
410
+ try:
411
+ raw = self._state_path.read_text(encoding="utf-8")
412
+ data = json.loads(raw)
413
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
414
+ logger.warning(
415
+ "inbox_writer: unable to load checkpoint at %s: %s - starting from 0",
416
+ self._state_path,
417
+ exc,
418
+ )
419
+ self._last_seen_do_version = 0
420
+ return
421
+ if not isinstance(data, dict):
422
+ self._last_seen_do_version = 0
423
+ return
424
+ try:
425
+ self._last_seen_do_version = int(data.get("last_seen_do_version") or 0)
426
+ except (TypeError, ValueError):
427
+ self._last_seen_do_version = 0
428
+
429
+ def _save_checkpoint(self) -> None:
430
+ """Atomically write the checkpoint via tmp + ``os.replace``."""
431
+ self._ensure_parent(self._state_path)
432
+ payload = {
433
+ "last_seen_do_version": int(self._last_seen_do_version),
434
+ "updated_at": datetime.now(timezone.utc).isoformat(),
435
+ }
436
+ tmp_path = self._state_path.with_suffix(self._state_path.suffix + ".tmp")
437
+
438
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
439
+ fd = os.open(tmp_path, flags, 0o600)
440
+ try:
441
+ with contextlib.suppress(OSError):
442
+ os.fchmod(fd, 0o600)
443
+ os.write(fd, json.dumps(payload, separators=(",", ":")).encode("utf-8"))
444
+ os.fsync(fd)
445
+ finally:
446
+ os.close(fd)
447
+
448
+ os.replace(tmp_path, self._state_path)
449
+ with contextlib.suppress(OSError):
450
+ os.chmod(self._state_path, 0o600)
451
+
452
+ # ------------------------------------------------------------------
453
+ # Test introspection
454
+ # ------------------------------------------------------------------
455
+
456
+ @property
457
+ def last_seen_do_version(self) -> int:
458
+ """Current checkpoint value (used by tests)."""
459
+ return self._last_seen_do_version
460
+
461
+ @property
462
+ def inbox_path(self) -> Path:
463
+ """Inbox JSONL path (used by tests)."""
464
+ return self._inbox_path
465
+
466
+ @property
467
+ def state_path(self) -> Path:
468
+ """Messaging checkpoint path (used by tests)."""
469
+ return self._state_path