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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|