alter-runtime 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,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)