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,446 @@
|
|
|
1
|
+
"""ActiveSessionsWriter - projects session lifecycle events into JSONL.
|
|
2
|
+
|
|
3
|
+
DISTINCTION FROM ``SessionPresenceWriter`` (``session_presence.py``)
|
|
4
|
+
-------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
* ``SessionPresenceWriter`` writes ``~/.local/share/org-alter/state/sessions.json``
|
|
7
|
+
- a *server projection*. It polls the org-alter Worker's
|
|
8
|
+
``/queries/presence`` endpoint and persists the aggregated cross-host view
|
|
9
|
+
used by the bash awareness hook.
|
|
10
|
+
* ``ActiveSessionsWriter`` (this module) writes
|
|
11
|
+
``~/.local/share/alter-runtime/active-sessions.jsonl`` - a stream of
|
|
12
|
+
*local-observed events*. It consumes ``session_started`` /
|
|
13
|
+
``session_heartbeat`` / ``session_ended`` payloads from the in-process
|
|
14
|
+
EventBus (sourced from DO SSE or local adapters) and appends them
|
|
15
|
+
append-only.
|
|
16
|
+
|
|
17
|
+
Both surfaces coexist. The server projection is the cross-host truth;
|
|
18
|
+
the JSONL is the per-host raw event log. Readers dedup on
|
|
19
|
+
``(tool, session_id)`` keeping the newest ``last_activity``; ``status=complete``
|
|
20
|
+
is the tombstone (see ``docs/schemas/active-sessions.schema.json``).
|
|
21
|
+
|
|
22
|
+
Per D-COORD-DEFAULT-1 D2 - tool-neutral active-session events replace the
|
|
23
|
+
per-tool CC-only ``/dev/shm/cc-sessions/<pid>.json`` surface. Emitted by
|
|
24
|
+
every ALTER client (CC, codex, cursor, alter-cli, android, widget) on
|
|
25
|
+
session start, heartbeat, and end.
|
|
26
|
+
|
|
27
|
+
IaI compliance: every record carries ``provenance_class`` +
|
|
28
|
+
``consent_tier`` per the schema - sessions are always
|
|
29
|
+
``active_composition`` (user is actively driving the tool).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import contextlib
|
|
36
|
+
import errno
|
|
37
|
+
import fcntl
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
from alter_runtime.config import DaemonConfig, data_dir, runtime_state_dir
|
|
46
|
+
from alter_runtime.daemon import Component
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"ACTIVE_SESSIONS_FILENAME",
|
|
53
|
+
"ACTIVE_SESSIONS_ROTATED_FILENAME",
|
|
54
|
+
"ACTIVE_SESSIONS_STATE_FILENAME",
|
|
55
|
+
"ROTATION_THRESHOLD_BYTES",
|
|
56
|
+
"ActiveSessionsWriter",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger("alter_runtime.subscribers.active_sessions_writer")
|
|
60
|
+
|
|
61
|
+
#: Rotate the JSONL file once it exceeds this many bytes (10 MiB).
|
|
62
|
+
ROTATION_THRESHOLD_BYTES: int = 10 * 1024 * 1024
|
|
63
|
+
|
|
64
|
+
#: Filename for the active-sessions JSONL (within ``data_dir()``).
|
|
65
|
+
ACTIVE_SESSIONS_FILENAME: str = "active-sessions.jsonl"
|
|
66
|
+
|
|
67
|
+
#: Filename for the rotated tail (single generation).
|
|
68
|
+
ACTIVE_SESSIONS_ROTATED_FILENAME: str = "active-sessions.jsonl.1"
|
|
69
|
+
|
|
70
|
+
#: Filename for the dedup checkpoint sidecar (within ``runtime_state_dir()``).
|
|
71
|
+
ACTIVE_SESSIONS_STATE_FILENAME: str = "active-sessions.json"
|
|
72
|
+
|
|
73
|
+
#: Schema enums - kept in sync with ``docs/schemas/active-sessions.schema.json``.
|
|
74
|
+
_VALID_KINDS: frozenset[str] = frozenset({"session_started", "session_heartbeat", "session_ended"})
|
|
75
|
+
_VALID_TOOLS: frozenset[str] = frozenset(
|
|
76
|
+
{"cc", "codex", "cursor", "cron", "mcp", "alter-cli", "android", "widget", "obsidian"}
|
|
77
|
+
)
|
|
78
|
+
_VALID_STATUSES: frozenset[str] = frozenset({"active", "idle", "complete"})
|
|
79
|
+
|
|
80
|
+
#: Maximum files_touched entries persisted per record (schema default: 16).
|
|
81
|
+
MAX_FILES_TOUCHED: int = 16
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ActiveSessionsWriter(Component):
|
|
85
|
+
"""Subscribes to ``identity.event`` and appends session-lifecycle records.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
config:
|
|
90
|
+
Loaded :class:`DaemonConfig`.
|
|
91
|
+
bus:
|
|
92
|
+
Shared :class:`EventBus`. Subscribes to ``identity.event``.
|
|
93
|
+
rotation_threshold_bytes:
|
|
94
|
+
Override the rotation threshold (defaults to 10 MiB).
|
|
95
|
+
sessions_path:
|
|
96
|
+
Override the JSONL path. Tests redirect writes to ``tmp_path``.
|
|
97
|
+
state_path:
|
|
98
|
+
Override the checkpoint path.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
name = "active_sessions_writer"
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
config: DaemonConfig,
|
|
106
|
+
bus: EventBus,
|
|
107
|
+
*,
|
|
108
|
+
rotation_threshold_bytes: int = ROTATION_THRESHOLD_BYTES,
|
|
109
|
+
sessions_path: Path | None = None,
|
|
110
|
+
state_path: Path | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._config = config
|
|
113
|
+
self._bus = bus
|
|
114
|
+
self._rotation_threshold_bytes = rotation_threshold_bytes
|
|
115
|
+
|
|
116
|
+
self._sessions_path: Path = (
|
|
117
|
+
sessions_path if sessions_path is not None else data_dir() / ACTIVE_SESSIONS_FILENAME
|
|
118
|
+
)
|
|
119
|
+
self._state_path: Path = (
|
|
120
|
+
state_path
|
|
121
|
+
if state_path is not None
|
|
122
|
+
else runtime_state_dir() / ACTIVE_SESSIONS_STATE_FILENAME
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self._lock = asyncio.Lock()
|
|
126
|
+
# Dedup checkpoint: maps record ``id`` -> highest seen ``version``.
|
|
127
|
+
self._seen_versions: dict[str, int] = {}
|
|
128
|
+
self._shutdown_event = asyncio.Event()
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Component lifecycle
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
async def run(self) -> None:
|
|
135
|
+
await self._load_checkpoint()
|
|
136
|
+
self._bus.subscribe("identity.event", self.handle_event)
|
|
137
|
+
logger.info(
|
|
138
|
+
"active_sessions_writer started sessions=%s known_ids=%d",
|
|
139
|
+
self._sessions_path,
|
|
140
|
+
len(self._seen_versions),
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
await self._shutdown_event.wait()
|
|
144
|
+
except asyncio.CancelledError:
|
|
145
|
+
raise
|
|
146
|
+
finally:
|
|
147
|
+
with contextlib.suppress(Exception):
|
|
148
|
+
self._bus.unsubscribe("identity.event", self.handle_event)
|
|
149
|
+
logger.info("active_sessions_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 ``active-sessions.jsonl``."""
|
|
160
|
+
if not isinstance(event, dict):
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# ---- 1. Filter on kind ---------------------------------------
|
|
164
|
+
if event.get("kind") not in _VALID_KINDS:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# ---- 2. Build the record ------------------------------------
|
|
168
|
+
record = self._serialise(event)
|
|
169
|
+
if record is None:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
record_id = record["id"]
|
|
173
|
+
record_version = record["version"]
|
|
174
|
+
|
|
175
|
+
async with self._lock:
|
|
176
|
+
# ---- 3. Deduplicate on (id, version) ---------------------
|
|
177
|
+
prior = self._seen_versions.get(record_id)
|
|
178
|
+
if prior is not None and record_version <= prior:
|
|
179
|
+
logger.debug(
|
|
180
|
+
"active_sessions_writer: dedupe drop id=%s version=%d <= seen=%d",
|
|
181
|
+
record_id,
|
|
182
|
+
record_version,
|
|
183
|
+
prior,
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
line = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
|
|
188
|
+
|
|
189
|
+
# ---- 4. Rotate if oversized ------------------------------
|
|
190
|
+
try:
|
|
191
|
+
self._maybe_rotate()
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
logger.warning("active_sessions_writer: rotation failed: %s", exc)
|
|
194
|
+
|
|
195
|
+
# ---- 5. Atomic append + fsync ----------------------------
|
|
196
|
+
try:
|
|
197
|
+
self._append_line(line)
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
logger.warning("active_sessions_writer: append failed: %s - dropping event", exc)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# ---- 6. Advance + persist checkpoint --------------------
|
|
203
|
+
self._seen_versions[record_id] = record_version
|
|
204
|
+
try:
|
|
205
|
+
self._save_checkpoint()
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
logger.warning("active_sessions_writer: checkpoint save failed: %s", exc)
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Serialisation
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def _serialise(self, event: dict[str, Any]) -> dict[str, Any] | None:
|
|
214
|
+
body = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
215
|
+
|
|
216
|
+
record_id = event.get("id") or body.get("id")
|
|
217
|
+
version_raw = event.get("version") if "version" in event else body.get("version")
|
|
218
|
+
kind = event.get("kind")
|
|
219
|
+
handle = body.get("handle") or event.get("handle")
|
|
220
|
+
tool = body.get("tool")
|
|
221
|
+
session_id = body.get("session_id")
|
|
222
|
+
machine_id = body.get("machine_id")
|
|
223
|
+
started_at = body.get("started_at")
|
|
224
|
+
last_activity = body.get("last_activity") or event.get("timestamp")
|
|
225
|
+
working_on = body.get("working_on")
|
|
226
|
+
branch = body.get("branch")
|
|
227
|
+
files_touched = body.get("files_touched")
|
|
228
|
+
status = body.get("status")
|
|
229
|
+
consent_tier_raw = body.get("consent_tier")
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
version_int = int(version_raw) if version_raw is not None else None
|
|
233
|
+
except (TypeError, ValueError):
|
|
234
|
+
logger.warning(
|
|
235
|
+
"active_sessions_writer: non-integer version=%r - dropping",
|
|
236
|
+
version_raw,
|
|
237
|
+
)
|
|
238
|
+
return None
|
|
239
|
+
if version_int is None or version_int < 0:
|
|
240
|
+
logger.warning(
|
|
241
|
+
"active_sessions_writer: missing/negative version - dropping id=%r",
|
|
242
|
+
record_id,
|
|
243
|
+
)
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
if not record_id or not isinstance(record_id, str):
|
|
247
|
+
logger.warning("active_sessions_writer: missing id - dropping event")
|
|
248
|
+
return None
|
|
249
|
+
if not handle or not isinstance(handle, str):
|
|
250
|
+
logger.warning("active_sessions_writer: missing handle - dropping id=%s", record_id)
|
|
251
|
+
return None
|
|
252
|
+
if tool not in _VALID_TOOLS:
|
|
253
|
+
logger.warning(
|
|
254
|
+
"active_sessions_writer: invalid tool=%r - dropping id=%s",
|
|
255
|
+
tool,
|
|
256
|
+
record_id,
|
|
257
|
+
)
|
|
258
|
+
return None
|
|
259
|
+
if not session_id or not isinstance(session_id, str):
|
|
260
|
+
logger.warning("active_sessions_writer: missing session_id - dropping id=%s", record_id)
|
|
261
|
+
return None
|
|
262
|
+
if not machine_id or not isinstance(machine_id, str):
|
|
263
|
+
logger.warning("active_sessions_writer: missing machine_id - dropping id=%s", record_id)
|
|
264
|
+
return None
|
|
265
|
+
if not started_at:
|
|
266
|
+
logger.warning("active_sessions_writer: missing started_at - dropping id=%s", record_id)
|
|
267
|
+
return None
|
|
268
|
+
if status not in _VALID_STATUSES:
|
|
269
|
+
logger.warning(
|
|
270
|
+
"active_sessions_writer: invalid status=%r - dropping id=%s",
|
|
271
|
+
status,
|
|
272
|
+
record_id,
|
|
273
|
+
)
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
if not last_activity:
|
|
277
|
+
last_activity = datetime.now(timezone.utc).isoformat()
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
consent_tier_int = int(consent_tier_raw) if consent_tier_raw is not None else None
|
|
281
|
+
except (TypeError, ValueError):
|
|
282
|
+
consent_tier_int = None
|
|
283
|
+
if consent_tier_int not in (1, 2, 3, 4):
|
|
284
|
+
logger.warning(
|
|
285
|
+
"active_sessions_writer: invalid consent_tier=%r - dropping id=%s",
|
|
286
|
+
consent_tier_raw,
|
|
287
|
+
record_id,
|
|
288
|
+
)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
# Bound files_touched to most-recent N (schema default 16) so a
|
|
292
|
+
# runaway client cannot bloat individual records.
|
|
293
|
+
bounded_files: list[str] = []
|
|
294
|
+
if isinstance(files_touched, list):
|
|
295
|
+
bounded_files = [str(p) for p in files_touched if p][-MAX_FILES_TOUCHED:]
|
|
296
|
+
|
|
297
|
+
record: dict[str, Any] = {
|
|
298
|
+
"id": str(record_id),
|
|
299
|
+
"version": version_int,
|
|
300
|
+
"kind": str(kind),
|
|
301
|
+
"handle": str(handle),
|
|
302
|
+
"tool": str(tool),
|
|
303
|
+
"session_id": str(session_id),
|
|
304
|
+
"machine_id": str(machine_id),
|
|
305
|
+
"started_at": str(started_at),
|
|
306
|
+
"last_activity": str(last_activity),
|
|
307
|
+
"status": str(status),
|
|
308
|
+
"provenance_class": "active_composition",
|
|
309
|
+
"consent_tier": consent_tier_int,
|
|
310
|
+
}
|
|
311
|
+
# Optional fields - only emit when present; schema permits omission.
|
|
312
|
+
if working_on is not None:
|
|
313
|
+
record["working_on"] = str(working_on) if working_on else None
|
|
314
|
+
if branch is not None:
|
|
315
|
+
record["branch"] = str(branch) if branch else None
|
|
316
|
+
if bounded_files:
|
|
317
|
+
record["files_touched"] = bounded_files
|
|
318
|
+
elif isinstance(files_touched, list):
|
|
319
|
+
# Caller supplied an explicit empty list - preserve that signal.
|
|
320
|
+
record["files_touched"] = []
|
|
321
|
+
return record
|
|
322
|
+
|
|
323
|
+
# ------------------------------------------------------------------
|
|
324
|
+
# File operations
|
|
325
|
+
# ------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def _ensure_parent(self, path: Path) -> None:
|
|
328
|
+
parent = path.parent
|
|
329
|
+
if not parent.exists():
|
|
330
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
331
|
+
with contextlib.suppress(OSError):
|
|
332
|
+
os.chmod(parent, 0o700)
|
|
333
|
+
|
|
334
|
+
def _append_line(self, line: str) -> None:
|
|
335
|
+
self._ensure_parent(self._sessions_path)
|
|
336
|
+
|
|
337
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
338
|
+
fd = os.open(self._sessions_path, flags, 0o600)
|
|
339
|
+
try:
|
|
340
|
+
with contextlib.suppress(OSError):
|
|
341
|
+
os.fchmod(fd, 0o600)
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
345
|
+
except OSError as exc: # pragma: no cover - exotic FS
|
|
346
|
+
if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
|
|
347
|
+
raise
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
os.write(fd, line.encode("utf-8") + b"\n")
|
|
351
|
+
os.fsync(fd)
|
|
352
|
+
finally:
|
|
353
|
+
with contextlib.suppress(OSError):
|
|
354
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
355
|
+
finally:
|
|
356
|
+
os.close(fd)
|
|
357
|
+
|
|
358
|
+
def _maybe_rotate(self) -> None:
|
|
359
|
+
try:
|
|
360
|
+
size = self._sessions_path.stat().st_size
|
|
361
|
+
except FileNotFoundError:
|
|
362
|
+
return
|
|
363
|
+
if size <= self._rotation_threshold_bytes:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
rotated = self._sessions_path.parent / ACTIVE_SESSIONS_ROTATED_FILENAME
|
|
367
|
+
os.replace(self._sessions_path, rotated)
|
|
368
|
+
logger.info(
|
|
369
|
+
"active_sessions_writer: rotated %s -> %s (size=%d > threshold=%d)",
|
|
370
|
+
self._sessions_path,
|
|
371
|
+
rotated,
|
|
372
|
+
size,
|
|
373
|
+
self._rotation_threshold_bytes,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------
|
|
377
|
+
# Checkpoint persistence
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
async def _load_checkpoint(self) -> None:
|
|
381
|
+
if not self._state_path.exists():
|
|
382
|
+
self._seen_versions = {}
|
|
383
|
+
return
|
|
384
|
+
try:
|
|
385
|
+
raw = self._state_path.read_text(encoding="utf-8")
|
|
386
|
+
data = json.loads(raw)
|
|
387
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
388
|
+
logger.warning(
|
|
389
|
+
"active_sessions_writer: unable to load checkpoint at %s: %s - starting empty",
|
|
390
|
+
self._state_path,
|
|
391
|
+
exc,
|
|
392
|
+
)
|
|
393
|
+
self._seen_versions = {}
|
|
394
|
+
return
|
|
395
|
+
if not isinstance(data, dict):
|
|
396
|
+
self._seen_versions = {}
|
|
397
|
+
return
|
|
398
|
+
seen = data.get("seen_versions")
|
|
399
|
+
if not isinstance(seen, dict):
|
|
400
|
+
self._seen_versions = {}
|
|
401
|
+
return
|
|
402
|
+
cleaned: dict[str, int] = {}
|
|
403
|
+
for key, value in seen.items():
|
|
404
|
+
try:
|
|
405
|
+
cleaned[str(key)] = int(value)
|
|
406
|
+
except (TypeError, ValueError):
|
|
407
|
+
continue
|
|
408
|
+
self._seen_versions = cleaned
|
|
409
|
+
|
|
410
|
+
def _save_checkpoint(self) -> None:
|
|
411
|
+
self._ensure_parent(self._state_path)
|
|
412
|
+
payload = {
|
|
413
|
+
"seen_versions": dict(self._seen_versions),
|
|
414
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
415
|
+
}
|
|
416
|
+
tmp_path = self._state_path.with_suffix(self._state_path.suffix + ".tmp")
|
|
417
|
+
|
|
418
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
419
|
+
fd = os.open(tmp_path, flags, 0o600)
|
|
420
|
+
try:
|
|
421
|
+
with contextlib.suppress(OSError):
|
|
422
|
+
os.fchmod(fd, 0o600)
|
|
423
|
+
os.write(fd, json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
|
424
|
+
os.fsync(fd)
|
|
425
|
+
finally:
|
|
426
|
+
os.close(fd)
|
|
427
|
+
|
|
428
|
+
os.replace(tmp_path, self._state_path)
|
|
429
|
+
with contextlib.suppress(OSError):
|
|
430
|
+
os.chmod(self._state_path, 0o600)
|
|
431
|
+
|
|
432
|
+
# ------------------------------------------------------------------
|
|
433
|
+
# Test introspection
|
|
434
|
+
# ------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def sessions_path(self) -> Path:
|
|
438
|
+
return self._sessions_path
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def state_path(self) -> Path:
|
|
442
|
+
return self._state_path
|
|
443
|
+
|
|
444
|
+
@property
|
|
445
|
+
def seen_versions(self) -> dict[str, int]:
|
|
446
|
+
return dict(self._seen_versions)
|