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