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,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)