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,426 @@
|
|
|
1
|
+
"""PresenceWriter - projects ``presence_set`` events into ``presence.jsonl``.
|
|
2
|
+
|
|
3
|
+
Wave 2 of the *Identity Presence consolidation* (per D-COORD-DEFAULT-1 Wave A).
|
|
4
|
+
The PresenceWriter is a long-lived :class:`alter_runtime.daemon.Component`
|
|
5
|
+
that subscribes to the in-process :class:`EventBus` ``identity.event`` topic,
|
|
6
|
+
filters frames whose payload ``kind`` equals ``"presence_set"``, and atomically
|
|
7
|
+
appends one compact JSON object per line to
|
|
8
|
+
``$XDG_DATA_HOME/alter-runtime/presence.jsonl``.
|
|
9
|
+
|
|
10
|
+
The record shape is locked at ``docs/schemas/presence.schema.json``:
|
|
11
|
+
|
|
12
|
+
* ``id``, ``version``, ``kind`` (``"presence_set"``), ``handle``, ``state``
|
|
13
|
+
(``here|focus|open|quiet``), ``provenance_class`` (``active_declaration``),
|
|
14
|
+
``consent_tier``, ``set_at``, ``set_via`` are required.
|
|
15
|
+
* ``expires_at`` and ``supersedes_id`` are optional.
|
|
16
|
+
|
|
17
|
+
Dedupe key is ``(id, version)``: a higher ``version`` for the same ``id``
|
|
18
|
+
supersedes - the writer keeps the latest non-superseded checkpoint per id
|
|
19
|
+
in ``$XDG_STATE_HOME/alter-runtime/presence.json``.
|
|
20
|
+
|
|
21
|
+
Like :class:`InboxWriter`, the writer:
|
|
22
|
+
|
|
23
|
+
* writes mode ``0o600`` files under parent dir mode ``0o700`` (relies on the
|
|
24
|
+
daemon ``UMask=0077``);
|
|
25
|
+
* rotates the JSONL to ``presence.jsonl.1`` once the file exceeds 10 MiB;
|
|
26
|
+
* swallows and logs all errors so a single malformed event, full disk, or
|
|
27
|
+
permission failure cannot crash the daemon supervisor.
|
|
28
|
+
|
|
29
|
+
IaI compliance: every record carries ``provenance_class`` +
|
|
30
|
+
``consent_tier`` per the schema - presence states are always
|
|
31
|
+
``active_declaration`` (user explicitly emits the state).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import contextlib
|
|
38
|
+
import errno
|
|
39
|
+
import fcntl
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
from datetime import datetime, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import TYPE_CHECKING, Any
|
|
46
|
+
|
|
47
|
+
from alter_runtime.config import DaemonConfig, data_dir, runtime_state_dir
|
|
48
|
+
from alter_runtime.daemon import Component
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"PRESENCE_FILENAME",
|
|
55
|
+
"PRESENCE_ROTATED_FILENAME",
|
|
56
|
+
"PRESENCE_STATE_FILENAME",
|
|
57
|
+
"ROTATION_THRESHOLD_BYTES",
|
|
58
|
+
"PresenceWriter",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger("alter_runtime.subscribers.presence_writer")
|
|
62
|
+
|
|
63
|
+
#: Rotate the JSONL file once it exceeds this many bytes (10 MiB, matching
|
|
64
|
+
#: :mod:`inbox_writer`).
|
|
65
|
+
ROTATION_THRESHOLD_BYTES: int = 10 * 1024 * 1024
|
|
66
|
+
|
|
67
|
+
#: Filename for the presence JSONL (within ``data_dir()``).
|
|
68
|
+
PRESENCE_FILENAME: str = "presence.jsonl"
|
|
69
|
+
|
|
70
|
+
#: Filename for the rotated tail (single generation).
|
|
71
|
+
PRESENCE_ROTATED_FILENAME: str = "presence.jsonl.1"
|
|
72
|
+
|
|
73
|
+
#: Filename for the dedup checkpoint sidecar (within ``runtime_state_dir()``).
|
|
74
|
+
PRESENCE_STATE_FILENAME: str = "presence.json"
|
|
75
|
+
|
|
76
|
+
#: Schema enums - kept in sync with ``docs/schemas/presence.schema.json``.
|
|
77
|
+
_VALID_STATES: frozenset[str] = frozenset({"here", "focus", "open", "quiet"})
|
|
78
|
+
_VALID_SET_VIA: frozenset[str] = frozenset(
|
|
79
|
+
{"alter-cli", "widget", "cc", "codex", "cursor", "android", "obsidian"}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PresenceWriter(Component):
|
|
84
|
+
"""Subscribes to ``identity.event`` and projects ``presence_set`` events.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
config:
|
|
89
|
+
Loaded :class:`DaemonConfig` (reserved for future knobs).
|
|
90
|
+
bus:
|
|
91
|
+
Shared :class:`EventBus`. The writer subscribes to ``identity.event``
|
|
92
|
+
in :meth:`run` and unsubscribes on :meth:`stop`.
|
|
93
|
+
rotation_threshold_bytes:
|
|
94
|
+
Override the rotation threshold (defaults to 10 MiB). Tests use a
|
|
95
|
+
small value to exercise the rotation path.
|
|
96
|
+
presence_path:
|
|
97
|
+
Override the JSONL path. Tests redirect writes to ``tmp_path``.
|
|
98
|
+
state_path:
|
|
99
|
+
Override the checkpoint path.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
name = "presence_writer"
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
config: DaemonConfig,
|
|
107
|
+
bus: EventBus,
|
|
108
|
+
*,
|
|
109
|
+
rotation_threshold_bytes: int = ROTATION_THRESHOLD_BYTES,
|
|
110
|
+
presence_path: Path | None = None,
|
|
111
|
+
state_path: Path | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self._config = config
|
|
114
|
+
self._bus = bus
|
|
115
|
+
self._rotation_threshold_bytes = rotation_threshold_bytes
|
|
116
|
+
|
|
117
|
+
self._presence_path: Path = (
|
|
118
|
+
presence_path if presence_path is not None else data_dir() / PRESENCE_FILENAME
|
|
119
|
+
)
|
|
120
|
+
self._state_path: Path = (
|
|
121
|
+
state_path if state_path is not None else runtime_state_dir() / PRESENCE_STATE_FILENAME
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self._lock = asyncio.Lock()
|
|
125
|
+
# Dedup checkpoint: maps record ``id`` -> highest seen ``version``.
|
|
126
|
+
self._seen_versions: dict[str, int] = {}
|
|
127
|
+
self._shutdown_event = asyncio.Event()
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Component lifecycle
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
async def run(self) -> None:
|
|
134
|
+
await self._load_checkpoint()
|
|
135
|
+
self._bus.subscribe("identity.event", self.handle_event)
|
|
136
|
+
logger.info(
|
|
137
|
+
"presence_writer started presence=%s known_ids=%d",
|
|
138
|
+
self._presence_path,
|
|
139
|
+
len(self._seen_versions),
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
await self._shutdown_event.wait()
|
|
143
|
+
except asyncio.CancelledError:
|
|
144
|
+
raise
|
|
145
|
+
finally:
|
|
146
|
+
with contextlib.suppress(Exception):
|
|
147
|
+
self._bus.unsubscribe("identity.event", self.handle_event)
|
|
148
|
+
logger.info("presence_writer stopped")
|
|
149
|
+
|
|
150
|
+
async def stop(self) -> None:
|
|
151
|
+
"""Cooperative shutdown - release the run loop."""
|
|
152
|
+
self._shutdown_event.set()
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Event ingest - public surface (also called directly by tests)
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def handle_event(self, event: dict[str, Any]) -> None:
|
|
159
|
+
"""Project a single bus event dict into ``presence.jsonl``.
|
|
160
|
+
|
|
161
|
+
The bus delivers ``identity.event`` payloads in either the canonical
|
|
162
|
+
envelope (``kind`` at top-level) or the egress wrapper used by local
|
|
163
|
+
adapters (``{"kind": ..., "payload": {...}}``). Both shapes are
|
|
164
|
+
accepted.
|
|
165
|
+
"""
|
|
166
|
+
if not isinstance(event, dict):
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# ---- 1. Filter on kind ---------------------------------------
|
|
170
|
+
if event.get("kind") != "presence_set":
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# ---- 2. Build the record ------------------------------------
|
|
174
|
+
record = self._serialise(event)
|
|
175
|
+
if record is None:
|
|
176
|
+
return # already logged
|
|
177
|
+
|
|
178
|
+
record_id = record["id"]
|
|
179
|
+
record_version = record["version"]
|
|
180
|
+
|
|
181
|
+
async with self._lock:
|
|
182
|
+
# ---- 3. Deduplicate on (id, version) ---------------------
|
|
183
|
+
prior = self._seen_versions.get(record_id)
|
|
184
|
+
if prior is not None and record_version <= prior:
|
|
185
|
+
logger.debug(
|
|
186
|
+
"presence_writer: dedupe drop id=%s version=%d <= seen=%d",
|
|
187
|
+
record_id,
|
|
188
|
+
record_version,
|
|
189
|
+
prior,
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
line = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
|
|
194
|
+
|
|
195
|
+
# ---- 4. Rotate if oversized ------------------------------
|
|
196
|
+
try:
|
|
197
|
+
self._maybe_rotate()
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
logger.warning("presence_writer: rotation failed: %s", exc)
|
|
200
|
+
|
|
201
|
+
# ---- 5. Atomic append + fsync ----------------------------
|
|
202
|
+
try:
|
|
203
|
+
self._append_line(line)
|
|
204
|
+
except OSError as exc:
|
|
205
|
+
logger.warning("presence_writer: append failed: %s - dropping event", exc)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# ---- 6. Advance + persist checkpoint --------------------
|
|
209
|
+
self._seen_versions[record_id] = record_version
|
|
210
|
+
try:
|
|
211
|
+
self._save_checkpoint()
|
|
212
|
+
except OSError as exc:
|
|
213
|
+
logger.warning("presence_writer: checkpoint save failed: %s", exc)
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# Serialisation
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _serialise(self, event: dict[str, Any]) -> dict[str, Any] | None:
|
|
220
|
+
"""Build the JSONL record for one ``presence_set`` event.
|
|
221
|
+
|
|
222
|
+
Returns ``None`` if a required field is missing or invalid - caller
|
|
223
|
+
treats that as "drop and continue".
|
|
224
|
+
"""
|
|
225
|
+
# Tolerate both the canonical top-level envelope and the local-adapter
|
|
226
|
+
# ``{"kind": ..., "payload": {...}}`` wrapper.
|
|
227
|
+
body = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
228
|
+
|
|
229
|
+
record_id = event.get("id") or body.get("id")
|
|
230
|
+
version_raw = event.get("version") if "version" in event else body.get("version")
|
|
231
|
+
handle = body.get("handle") or event.get("handle")
|
|
232
|
+
state = body.get("state")
|
|
233
|
+
provenance_class = body.get("provenance_class", "active_declaration")
|
|
234
|
+
consent_tier_raw = body.get("consent_tier")
|
|
235
|
+
set_at = (
|
|
236
|
+
body.get("set_at") or event.get("timestamp") or datetime.now(timezone.utc).isoformat()
|
|
237
|
+
)
|
|
238
|
+
set_via = body.get("set_via")
|
|
239
|
+
expires_at = body.get("expires_at")
|
|
240
|
+
supersedes_id = body.get("supersedes_id")
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
version_int = int(version_raw) if version_raw is not None else None
|
|
244
|
+
except (TypeError, ValueError):
|
|
245
|
+
logger.warning("presence_writer: non-integer version=%r - dropping", version_raw)
|
|
246
|
+
return None
|
|
247
|
+
if version_int is None or version_int < 0:
|
|
248
|
+
logger.warning("presence_writer: missing/negative version - dropping id=%r", record_id)
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
if not record_id or not isinstance(record_id, str):
|
|
252
|
+
logger.warning("presence_writer: missing id - dropping event")
|
|
253
|
+
return None
|
|
254
|
+
if not handle or not isinstance(handle, str):
|
|
255
|
+
logger.warning("presence_writer: missing handle - dropping id=%s", record_id)
|
|
256
|
+
return None
|
|
257
|
+
if state not in _VALID_STATES:
|
|
258
|
+
logger.warning("presence_writer: invalid state=%r - dropping id=%s", state, record_id)
|
|
259
|
+
return None
|
|
260
|
+
if set_via not in _VALID_SET_VIA:
|
|
261
|
+
logger.warning(
|
|
262
|
+
"presence_writer: invalid set_via=%r - dropping id=%s", set_via, record_id
|
|
263
|
+
)
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
consent_tier_int = int(consent_tier_raw) if consent_tier_raw is not None else None
|
|
268
|
+
except (TypeError, ValueError):
|
|
269
|
+
consent_tier_int = None
|
|
270
|
+
if consent_tier_int not in (1, 2, 3, 4):
|
|
271
|
+
logger.warning(
|
|
272
|
+
"presence_writer: invalid consent_tier=%r - dropping id=%s",
|
|
273
|
+
consent_tier_raw,
|
|
274
|
+
record_id,
|
|
275
|
+
)
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
record: dict[str, Any] = {
|
|
279
|
+
"id": str(record_id),
|
|
280
|
+
"version": version_int,
|
|
281
|
+
"kind": "presence_set",
|
|
282
|
+
"handle": str(handle),
|
|
283
|
+
"state": str(state),
|
|
284
|
+
"provenance_class": "active_declaration",
|
|
285
|
+
"consent_tier": consent_tier_int,
|
|
286
|
+
"set_at": str(set_at),
|
|
287
|
+
"set_via": str(set_via),
|
|
288
|
+
}
|
|
289
|
+
# Optional fields - emit explicitly (including null) so consumers
|
|
290
|
+
# don't have to .get with a default; the schema allows ``null``.
|
|
291
|
+
if expires_at is not None:
|
|
292
|
+
record["expires_at"] = str(expires_at) if expires_at else None
|
|
293
|
+
if supersedes_id is not None:
|
|
294
|
+
record["supersedes_id"] = str(supersedes_id) if supersedes_id else None
|
|
295
|
+
|
|
296
|
+
# Ignore caller-supplied provenance_class - the schema locks it to
|
|
297
|
+
# ``active_declaration`` for this kind.
|
|
298
|
+
_ = provenance_class
|
|
299
|
+
return record
|
|
300
|
+
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
# File operations - atomic append, rotation, checkpoint
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
def _ensure_parent(self, path: Path) -> None:
|
|
306
|
+
parent = path.parent
|
|
307
|
+
if not parent.exists():
|
|
308
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
309
|
+
with contextlib.suppress(OSError):
|
|
310
|
+
os.chmod(parent, 0o700)
|
|
311
|
+
|
|
312
|
+
def _append_line(self, line: str) -> None:
|
|
313
|
+
self._ensure_parent(self._presence_path)
|
|
314
|
+
|
|
315
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
316
|
+
fd = os.open(self._presence_path, flags, 0o600)
|
|
317
|
+
try:
|
|
318
|
+
with contextlib.suppress(OSError):
|
|
319
|
+
os.fchmod(fd, 0o600)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
323
|
+
except OSError as exc: # pragma: no cover - exotic FS
|
|
324
|
+
if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
|
|
325
|
+
raise
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
os.write(fd, line.encode("utf-8") + b"\n")
|
|
329
|
+
os.fsync(fd)
|
|
330
|
+
finally:
|
|
331
|
+
with contextlib.suppress(OSError):
|
|
332
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
333
|
+
finally:
|
|
334
|
+
os.close(fd)
|
|
335
|
+
|
|
336
|
+
def _maybe_rotate(self) -> None:
|
|
337
|
+
try:
|
|
338
|
+
size = self._presence_path.stat().st_size
|
|
339
|
+
except FileNotFoundError:
|
|
340
|
+
return
|
|
341
|
+
if size <= self._rotation_threshold_bytes:
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
rotated = self._presence_path.parent / PRESENCE_ROTATED_FILENAME
|
|
345
|
+
os.replace(self._presence_path, rotated)
|
|
346
|
+
logger.info(
|
|
347
|
+
"presence_writer: rotated %s -> %s (size=%d > threshold=%d)",
|
|
348
|
+
self._presence_path,
|
|
349
|
+
rotated,
|
|
350
|
+
size,
|
|
351
|
+
self._rotation_threshold_bytes,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
# Checkpoint persistence
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
async def _load_checkpoint(self) -> None:
|
|
359
|
+
"""Load the per-id version map from ``presence.json`` (empty if absent)."""
|
|
360
|
+
if not self._state_path.exists():
|
|
361
|
+
self._seen_versions = {}
|
|
362
|
+
return
|
|
363
|
+
try:
|
|
364
|
+
raw = self._state_path.read_text(encoding="utf-8")
|
|
365
|
+
data = json.loads(raw)
|
|
366
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
367
|
+
logger.warning(
|
|
368
|
+
"presence_writer: unable to load checkpoint at %s: %s - starting empty",
|
|
369
|
+
self._state_path,
|
|
370
|
+
exc,
|
|
371
|
+
)
|
|
372
|
+
self._seen_versions = {}
|
|
373
|
+
return
|
|
374
|
+
if not isinstance(data, dict):
|
|
375
|
+
self._seen_versions = {}
|
|
376
|
+
return
|
|
377
|
+
seen = data.get("seen_versions")
|
|
378
|
+
if not isinstance(seen, dict):
|
|
379
|
+
self._seen_versions = {}
|
|
380
|
+
return
|
|
381
|
+
cleaned: dict[str, int] = {}
|
|
382
|
+
for key, value in seen.items():
|
|
383
|
+
try:
|
|
384
|
+
cleaned[str(key)] = int(value)
|
|
385
|
+
except (TypeError, ValueError):
|
|
386
|
+
continue
|
|
387
|
+
self._seen_versions = cleaned
|
|
388
|
+
|
|
389
|
+
def _save_checkpoint(self) -> None:
|
|
390
|
+
"""Atomically write the per-id version map via tmp + ``os.replace``."""
|
|
391
|
+
self._ensure_parent(self._state_path)
|
|
392
|
+
payload = {
|
|
393
|
+
"seen_versions": dict(self._seen_versions),
|
|
394
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
395
|
+
}
|
|
396
|
+
tmp_path = self._state_path.with_suffix(self._state_path.suffix + ".tmp")
|
|
397
|
+
|
|
398
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
399
|
+
fd = os.open(tmp_path, flags, 0o600)
|
|
400
|
+
try:
|
|
401
|
+
with contextlib.suppress(OSError):
|
|
402
|
+
os.fchmod(fd, 0o600)
|
|
403
|
+
os.write(fd, json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
|
404
|
+
os.fsync(fd)
|
|
405
|
+
finally:
|
|
406
|
+
os.close(fd)
|
|
407
|
+
|
|
408
|
+
os.replace(tmp_path, self._state_path)
|
|
409
|
+
with contextlib.suppress(OSError):
|
|
410
|
+
os.chmod(self._state_path, 0o600)
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
# Test introspection
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def presence_path(self) -> Path:
|
|
418
|
+
return self._presence_path
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def state_path(self) -> Path:
|
|
422
|
+
return self._state_path
|
|
423
|
+
|
|
424
|
+
@property
|
|
425
|
+
def seen_versions(self) -> dict[str, int]:
|
|
426
|
+
return dict(self._seen_versions)
|