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,415 @@
|
|
|
1
|
+
"""AdaptersWriter - projects ``adapter_status`` events into ``adapters.jsonl``.
|
|
2
|
+
|
|
3
|
+
Substrate adapter status for D-OBS-1 (Obsidian), D-ATLAS-1
|
|
4
|
+
(Atlas substrate-recogniser), and alter-ebpf. Records when an adapter is
|
|
5
|
+
paired, actively streaming, or in error. Latest record per ``adapter`` is the
|
|
6
|
+
current state; ``status=error`` surfaces an alert tile in the widget.
|
|
7
|
+
|
|
8
|
+
Sources
|
|
9
|
+
-------
|
|
10
|
+
|
|
11
|
+
The writer subscribes to two bus topics:
|
|
12
|
+
|
|
13
|
+
* ``identity.event`` - for ``adapter_status`` frames flowing in from the DO SSE
|
|
14
|
+
ingress (e.g. server-side declarations of pairing state).
|
|
15
|
+
* ``local.signal`` - for the *local adapter* pattern used by
|
|
16
|
+
:mod:`alter_runtime.adapters.git_watcher` and its successors. Local
|
|
17
|
+
adapters publish ``{"kind": "adapter_status", "payload": {...},
|
|
18
|
+
"source": "<adapter_name>"}`` onto the egress topic; this writer
|
|
19
|
+
projects those into the same JSONL so the widget sees one merged
|
|
20
|
+
stream regardless of provenance.
|
|
21
|
+
|
|
22
|
+
Schema is locked at ``docs/schemas/adapters.schema.json``. Allowed adapters
|
|
23
|
+
are ``obsidian | atlas | ebpf`` - codex/cursor parity belongs on
|
|
24
|
+
``active-sessions.jsonl``, not here.
|
|
25
|
+
|
|
26
|
+
IaI compliance: every record carries ``provenance_class`` +
|
|
27
|
+
``consent_tier`` per the schema. The schema enumerates which
|
|
28
|
+
``provenance_class`` values are valid for each adapter (Obsidian =
|
|
29
|
+
``passive_local_document``; Atlas / ebpf = ``passive_individual_local_only``
|
|
30
|
+
LOCAL-ONLY per IaI clause 1).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import contextlib
|
|
37
|
+
import errno
|
|
38
|
+
import fcntl
|
|
39
|
+
import json
|
|
40
|
+
import logging
|
|
41
|
+
import os
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import TYPE_CHECKING, Any
|
|
45
|
+
|
|
46
|
+
from alter_runtime.config import DaemonConfig, data_dir, runtime_state_dir
|
|
47
|
+
from alter_runtime.daemon import Component
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ADAPTERS_FILENAME",
|
|
54
|
+
"ADAPTERS_ROTATED_FILENAME",
|
|
55
|
+
"ADAPTERS_STATE_FILENAME",
|
|
56
|
+
"ROTATION_THRESHOLD_BYTES",
|
|
57
|
+
"AdaptersWriter",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger("alter_runtime.subscribers.adapters_writer")
|
|
61
|
+
|
|
62
|
+
#: Rotate the JSONL file once it exceeds this many bytes (10 MiB).
|
|
63
|
+
ROTATION_THRESHOLD_BYTES: int = 10 * 1024 * 1024
|
|
64
|
+
|
|
65
|
+
#: Filename for the adapters JSONL (within ``data_dir()``).
|
|
66
|
+
ADAPTERS_FILENAME: str = "adapters.jsonl"
|
|
67
|
+
|
|
68
|
+
#: Filename for the rotated tail (single generation).
|
|
69
|
+
ADAPTERS_ROTATED_FILENAME: str = "adapters.jsonl.1"
|
|
70
|
+
|
|
71
|
+
#: Filename for the dedup checkpoint sidecar (within ``runtime_state_dir()``).
|
|
72
|
+
ADAPTERS_STATE_FILENAME: str = "adapters.json"
|
|
73
|
+
|
|
74
|
+
#: Schema enums - kept in sync with ``docs/schemas/adapters.schema.json``.
|
|
75
|
+
_VALID_ADAPTERS: frozenset[str] = frozenset({"obsidian", "atlas", "ebpf"})
|
|
76
|
+
_VALID_STATES: frozenset[str] = frozenset({"paired", "streaming", "attached", "idle", "error"})
|
|
77
|
+
_VALID_PROVENANCE: frozenset[str] = frozenset(
|
|
78
|
+
{"passive_local_document", "passive_individual_local_only", "active_declaration"}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AdaptersWriter(Component):
|
|
83
|
+
"""Subscribes to ``identity.event`` + ``local.signal`` and projects
|
|
84
|
+
adapter status records.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
config:
|
|
89
|
+
Loaded :class:`DaemonConfig`.
|
|
90
|
+
bus:
|
|
91
|
+
Shared :class:`EventBus`.
|
|
92
|
+
rotation_threshold_bytes:
|
|
93
|
+
Override the rotation threshold (defaults to 10 MiB).
|
|
94
|
+
adapters_path:
|
|
95
|
+
Override the JSONL path. Tests redirect writes to ``tmp_path``.
|
|
96
|
+
state_path:
|
|
97
|
+
Override the checkpoint path.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
name = "adapters_writer"
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
config: DaemonConfig,
|
|
105
|
+
bus: EventBus,
|
|
106
|
+
*,
|
|
107
|
+
rotation_threshold_bytes: int = ROTATION_THRESHOLD_BYTES,
|
|
108
|
+
adapters_path: Path | None = None,
|
|
109
|
+
state_path: Path | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
self._config = config
|
|
112
|
+
self._bus = bus
|
|
113
|
+
self._rotation_threshold_bytes = rotation_threshold_bytes
|
|
114
|
+
|
|
115
|
+
self._adapters_path: Path = (
|
|
116
|
+
adapters_path if adapters_path is not None else data_dir() / ADAPTERS_FILENAME
|
|
117
|
+
)
|
|
118
|
+
self._state_path: Path = (
|
|
119
|
+
state_path if state_path is not None else runtime_state_dir() / ADAPTERS_STATE_FILENAME
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self._lock = asyncio.Lock()
|
|
123
|
+
# Dedup checkpoint: maps record ``id`` -> highest seen ``version``.
|
|
124
|
+
self._seen_versions: dict[str, int] = {}
|
|
125
|
+
self._shutdown_event = asyncio.Event()
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Component lifecycle
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async def run(self) -> None:
|
|
132
|
+
await self._load_checkpoint()
|
|
133
|
+
self._bus.subscribe("identity.event", self.handle_event)
|
|
134
|
+
self._bus.subscribe("local.signal", self.handle_event)
|
|
135
|
+
logger.info(
|
|
136
|
+
"adapters_writer started adapters=%s known_ids=%d",
|
|
137
|
+
self._adapters_path,
|
|
138
|
+
len(self._seen_versions),
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
await self._shutdown_event.wait()
|
|
142
|
+
except asyncio.CancelledError:
|
|
143
|
+
raise
|
|
144
|
+
finally:
|
|
145
|
+
with contextlib.suppress(Exception):
|
|
146
|
+
self._bus.unsubscribe("identity.event", self.handle_event)
|
|
147
|
+
with contextlib.suppress(Exception):
|
|
148
|
+
self._bus.unsubscribe("local.signal", self.handle_event)
|
|
149
|
+
logger.info("adapters_writer stopped")
|
|
150
|
+
|
|
151
|
+
async def stop(self) -> None:
|
|
152
|
+
self._shutdown_event.set()
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Event ingest
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def handle_event(self, event: dict[str, Any]) -> None:
|
|
159
|
+
"""Project a single bus event dict into ``adapters.jsonl``.
|
|
160
|
+
|
|
161
|
+
Accepts both the canonical envelope (``identity.event`` shape) and
|
|
162
|
+
the local-adapter wrapper (``local.signal``: ``{"kind": ...,
|
|
163
|
+
"payload": {...}, "source": ...}``).
|
|
164
|
+
"""
|
|
165
|
+
if not isinstance(event, dict):
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
if event.get("kind") != "adapter_status":
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
record = self._serialise(event)
|
|
172
|
+
if record is None:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
record_id = record["id"]
|
|
176
|
+
record_version = record["version"]
|
|
177
|
+
|
|
178
|
+
async with self._lock:
|
|
179
|
+
prior = self._seen_versions.get(record_id)
|
|
180
|
+
if prior is not None and record_version <= prior:
|
|
181
|
+
logger.debug(
|
|
182
|
+
"adapters_writer: dedupe drop id=%s version=%d <= seen=%d",
|
|
183
|
+
record_id,
|
|
184
|
+
record_version,
|
|
185
|
+
prior,
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
line = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
self._maybe_rotate()
|
|
193
|
+
except OSError as exc:
|
|
194
|
+
logger.warning("adapters_writer: rotation failed: %s", exc)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
self._append_line(line)
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
logger.warning("adapters_writer: append failed: %s - dropping event", exc)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
self._seen_versions[record_id] = record_version
|
|
203
|
+
try:
|
|
204
|
+
self._save_checkpoint()
|
|
205
|
+
except OSError as exc:
|
|
206
|
+
logger.warning("adapters_writer: checkpoint save failed: %s", exc)
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Serialisation
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def _serialise(self, event: dict[str, Any]) -> dict[str, Any] | None:
|
|
213
|
+
body = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
214
|
+
|
|
215
|
+
record_id = event.get("id") or body.get("id")
|
|
216
|
+
version_raw = event.get("version") if "version" in event else body.get("version")
|
|
217
|
+
handle = body.get("handle") or event.get("handle")
|
|
218
|
+
adapter = body.get("adapter")
|
|
219
|
+
stream_subtag = body.get("stream_subtag")
|
|
220
|
+
state = body.get("state")
|
|
221
|
+
provenance_class = body.get("provenance_class")
|
|
222
|
+
consent_tier_raw = body.get("consent_tier")
|
|
223
|
+
last_event_at = (
|
|
224
|
+
body.get("last_event_at")
|
|
225
|
+
or event.get("timestamp")
|
|
226
|
+
or datetime.now(timezone.utc).isoformat()
|
|
227
|
+
)
|
|
228
|
+
error_msg = body.get("error_msg")
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
version_int = int(version_raw) if version_raw is not None else None
|
|
232
|
+
except (TypeError, ValueError):
|
|
233
|
+
logger.warning("adapters_writer: non-integer version=%r - dropping", version_raw)
|
|
234
|
+
return None
|
|
235
|
+
if version_int is None or version_int < 0:
|
|
236
|
+
logger.warning("adapters_writer: missing/negative version - dropping id=%r", record_id)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
if not record_id or not isinstance(record_id, str):
|
|
240
|
+
logger.warning("adapters_writer: missing id - dropping event")
|
|
241
|
+
return None
|
|
242
|
+
if not handle or not isinstance(handle, str):
|
|
243
|
+
logger.warning("adapters_writer: missing handle - dropping id=%s", record_id)
|
|
244
|
+
return None
|
|
245
|
+
if adapter not in _VALID_ADAPTERS:
|
|
246
|
+
logger.warning(
|
|
247
|
+
"adapters_writer: invalid adapter=%r - dropping id=%s",
|
|
248
|
+
adapter,
|
|
249
|
+
record_id,
|
|
250
|
+
)
|
|
251
|
+
return None
|
|
252
|
+
if state not in _VALID_STATES:
|
|
253
|
+
logger.warning("adapters_writer: invalid state=%r - dropping id=%s", state, record_id)
|
|
254
|
+
return None
|
|
255
|
+
if provenance_class not in _VALID_PROVENANCE:
|
|
256
|
+
logger.warning(
|
|
257
|
+
"adapters_writer: invalid provenance_class=%r - dropping id=%s",
|
|
258
|
+
provenance_class,
|
|
259
|
+
record_id,
|
|
260
|
+
)
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
consent_tier_int = int(consent_tier_raw) if consent_tier_raw is not None else None
|
|
265
|
+
except (TypeError, ValueError):
|
|
266
|
+
consent_tier_int = None
|
|
267
|
+
if consent_tier_int not in (1, 2, 3, 4):
|
|
268
|
+
logger.warning(
|
|
269
|
+
"adapters_writer: invalid consent_tier=%r - dropping id=%s",
|
|
270
|
+
consent_tier_raw,
|
|
271
|
+
record_id,
|
|
272
|
+
)
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
record: dict[str, Any] = {
|
|
276
|
+
"id": str(record_id),
|
|
277
|
+
"version": version_int,
|
|
278
|
+
"kind": "adapter_status",
|
|
279
|
+
"handle": str(handle),
|
|
280
|
+
"adapter": str(adapter),
|
|
281
|
+
"state": str(state),
|
|
282
|
+
"provenance_class": str(provenance_class),
|
|
283
|
+
"consent_tier": consent_tier_int,
|
|
284
|
+
"last_event_at": str(last_event_at),
|
|
285
|
+
}
|
|
286
|
+
if stream_subtag is not None:
|
|
287
|
+
record["stream_subtag"] = str(stream_subtag) if stream_subtag else None
|
|
288
|
+
if error_msg is not None:
|
|
289
|
+
record["error_msg"] = str(error_msg) if error_msg else None
|
|
290
|
+
return record
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# File operations
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _ensure_parent(self, path: Path) -> None:
|
|
297
|
+
parent = path.parent
|
|
298
|
+
if not parent.exists():
|
|
299
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
300
|
+
with contextlib.suppress(OSError):
|
|
301
|
+
os.chmod(parent, 0o700)
|
|
302
|
+
|
|
303
|
+
def _append_line(self, line: str) -> None:
|
|
304
|
+
self._ensure_parent(self._adapters_path)
|
|
305
|
+
|
|
306
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
307
|
+
fd = os.open(self._adapters_path, flags, 0o600)
|
|
308
|
+
try:
|
|
309
|
+
with contextlib.suppress(OSError):
|
|
310
|
+
os.fchmod(fd, 0o600)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
314
|
+
except OSError as exc: # pragma: no cover - exotic FS
|
|
315
|
+
if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
|
|
316
|
+
raise
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
os.write(fd, line.encode("utf-8") + b"\n")
|
|
320
|
+
os.fsync(fd)
|
|
321
|
+
finally:
|
|
322
|
+
with contextlib.suppress(OSError):
|
|
323
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
324
|
+
finally:
|
|
325
|
+
os.close(fd)
|
|
326
|
+
|
|
327
|
+
def _maybe_rotate(self) -> None:
|
|
328
|
+
try:
|
|
329
|
+
size = self._adapters_path.stat().st_size
|
|
330
|
+
except FileNotFoundError:
|
|
331
|
+
return
|
|
332
|
+
if size <= self._rotation_threshold_bytes:
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
rotated = self._adapters_path.parent / ADAPTERS_ROTATED_FILENAME
|
|
336
|
+
os.replace(self._adapters_path, rotated)
|
|
337
|
+
logger.info(
|
|
338
|
+
"adapters_writer: rotated %s -> %s (size=%d > threshold=%d)",
|
|
339
|
+
self._adapters_path,
|
|
340
|
+
rotated,
|
|
341
|
+
size,
|
|
342
|
+
self._rotation_threshold_bytes,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
# Checkpoint persistence
|
|
347
|
+
# ------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
async def _load_checkpoint(self) -> None:
|
|
350
|
+
if not self._state_path.exists():
|
|
351
|
+
self._seen_versions = {}
|
|
352
|
+
return
|
|
353
|
+
try:
|
|
354
|
+
raw = self._state_path.read_text(encoding="utf-8")
|
|
355
|
+
data = json.loads(raw)
|
|
356
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
357
|
+
logger.warning(
|
|
358
|
+
"adapters_writer: unable to load checkpoint at %s: %s - starting empty",
|
|
359
|
+
self._state_path,
|
|
360
|
+
exc,
|
|
361
|
+
)
|
|
362
|
+
self._seen_versions = {}
|
|
363
|
+
return
|
|
364
|
+
if not isinstance(data, dict):
|
|
365
|
+
self._seen_versions = {}
|
|
366
|
+
return
|
|
367
|
+
seen = data.get("seen_versions")
|
|
368
|
+
if not isinstance(seen, dict):
|
|
369
|
+
self._seen_versions = {}
|
|
370
|
+
return
|
|
371
|
+
cleaned: dict[str, int] = {}
|
|
372
|
+
for key, value in seen.items():
|
|
373
|
+
try:
|
|
374
|
+
cleaned[str(key)] = int(value)
|
|
375
|
+
except (TypeError, ValueError):
|
|
376
|
+
continue
|
|
377
|
+
self._seen_versions = cleaned
|
|
378
|
+
|
|
379
|
+
def _save_checkpoint(self) -> None:
|
|
380
|
+
self._ensure_parent(self._state_path)
|
|
381
|
+
payload = {
|
|
382
|
+
"seen_versions": dict(self._seen_versions),
|
|
383
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
384
|
+
}
|
|
385
|
+
tmp_path = self._state_path.with_suffix(self._state_path.suffix + ".tmp")
|
|
386
|
+
|
|
387
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
388
|
+
fd = os.open(tmp_path, flags, 0o600)
|
|
389
|
+
try:
|
|
390
|
+
with contextlib.suppress(OSError):
|
|
391
|
+
os.fchmod(fd, 0o600)
|
|
392
|
+
os.write(fd, json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
|
393
|
+
os.fsync(fd)
|
|
394
|
+
finally:
|
|
395
|
+
os.close(fd)
|
|
396
|
+
|
|
397
|
+
os.replace(tmp_path, self._state_path)
|
|
398
|
+
with contextlib.suppress(OSError):
|
|
399
|
+
os.chmod(self._state_path, 0o600)
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
# Test introspection
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def adapters_path(self) -> Path:
|
|
407
|
+
return self._adapters_path
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def state_path(self) -> Path:
|
|
411
|
+
return self._state_path
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def seen_versions(self) -> dict[str, int]:
|
|
415
|
+
return dict(self._seen_versions)
|