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,432 @@
1
+ """ActiveSessionsGc - periodic sweeper for the active-sessions JSONL.
2
+
3
+ Companion to :class:`ActiveSessionsWriter`. The writer projects
4
+ ``session_started`` / ``session_heartbeat`` / ``session_ended`` bus events
5
+ into ``~/.local/share/alter-runtime/active-sessions.jsonl``, but nothing
6
+ sweeps the live state to detect idle/dead sessions and emit the
7
+ corresponding lifecycle transitions. This component fills that gap.
8
+
9
+ Behaviour
10
+ ---------
11
+
12
+ On every tick (default 60s):
13
+
14
+ 1. Read the JSONL under ``LOCK_SH`` shared lock (writer uses ``LOCK_EX``
15
+ so this serialises correctly without blocking writes), fold to
16
+ ``{(tool, session_id) -> newest row by last_activity}``, skip rows
17
+ already terminal (``status == "complete"``).
18
+ 2. For each ``(tool, session_id)``:
19
+
20
+ * If ``(now - last_activity) > idle_after_seconds`` and the source row
21
+ is not already ``status == "idle"``, emit a ``session_heartbeat``
22
+ with ``status="idle"`` and ``version = source.version + 1``.
23
+ * If ``(now - last_activity) > terminated_after_seconds``:
24
+
25
+ - For ``tool == "cc"``: probe ``os.kill(int(session_id), 0)`` and
26
+ only emit if the PID is dead (catches ``ProcessLookupError``,
27
+ ``PermissionError``, ``OSError``).
28
+ - For other tools: emit on threshold alone (no PID semantics).
29
+ - Emit a ``session_ended`` with ``status="complete"``.
30
+
31
+ 3. Track an internal ``_emitted_versions`` map keyed on
32
+ ``(tool, session_id)`` -> ``(last_status, version)`` so repeated ticks
33
+ over the same stale source do not re-emit the same envelope every
34
+ cycle.
35
+
36
+ The component NEVER writes the JSONL directly - every emit goes through
37
+ the shared :class:`EventBus`, so the writer's ``_VALID_KINDS`` gate, its
38
+ ``(id, version)`` dedup, rotation, and 0o600 file mode are preserved
39
+ transparently. New envelope ``id``s are minted per emit; the dedup
40
+ contract on the writer side is that newer ``version``s for the same
41
+ ``id`` win - here we always carry forward the source row's ``id`` and
42
+ bump ``version`` so the writer accepts the new envelope as the next
43
+ generation of the same session record.
44
+
45
+ Per D-COORD-D2 Wave B - the daemon-side GC pass.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import asyncio
51
+ import contextlib
52
+ import errno
53
+ import fcntl
54
+ import json
55
+ import logging
56
+ import os
57
+ import uuid
58
+ from datetime import datetime, timezone
59
+ from pathlib import Path
60
+ from typing import TYPE_CHECKING, Any, Callable
61
+
62
+ from alter_runtime.config import DaemonConfig, data_dir
63
+ from alter_runtime.daemon import Component
64
+ from alter_runtime.subscribers.active_sessions_writer import (
65
+ ACTIVE_SESSIONS_FILENAME,
66
+ )
67
+
68
+ if TYPE_CHECKING:
69
+ from alter_runtime.subscribers.bus import EventBus
70
+
71
+ __all__ = ["ActiveSessionsGc"]
72
+
73
+ logger = logging.getLogger("alter_runtime.subscribers.active_sessions_gc")
74
+
75
+
76
+ def _default_pid_probe(pid: int) -> bool:
77
+ """Return ``True`` if ``pid`` is alive, ``False`` otherwise.
78
+
79
+ ``os.kill(pid, 0)`` is the canonical liveness probe on POSIX:
80
+
81
+ * Returns silently when the process exists and the caller is
82
+ permitted to signal it.
83
+ * Raises ``ProcessLookupError`` when no such PID exists - the
84
+ definitive "dead" signal.
85
+ * Raises ``PermissionError`` when the PID exists but is owned by a
86
+ different user - treat as "alive" (we cannot reap a process we do
87
+ not own).
88
+ * Raises ``OSError(errno.ESRCH)`` on some exotic kernels - same as
89
+ ``ProcessLookupError``.
90
+ """
91
+ try:
92
+ os.kill(pid, 0)
93
+ except ProcessLookupError:
94
+ return False
95
+ except PermissionError:
96
+ return True
97
+ except OSError as exc:
98
+ if exc.errno == errno.ESRCH:
99
+ return False
100
+ # Any other error class is treated conservatively as "alive" so a
101
+ # transient probe failure does not surface as a spurious
102
+ # session_ended.
103
+ return True
104
+ return True
105
+
106
+
107
+ class ActiveSessionsGc(Component):
108
+ """Periodic sweeper that emits idle / terminated session envelopes.
109
+
110
+ Parameters
111
+ ----------
112
+ config:
113
+ Loaded :class:`DaemonConfig`. Reads
114
+ ``active_sessions_gc_interval_seconds``,
115
+ ``active_sessions_idle_after_seconds``,
116
+ ``active_sessions_terminated_after_seconds``.
117
+ bus:
118
+ Shared :class:`EventBus`. Emits via ``identity.event``.
119
+ sessions_path:
120
+ Override the JSONL path. Tests redirect reads to ``tmp_path``.
121
+ pid_probe:
122
+ Override the PID-liveness probe. Tests inject a stub so they do
123
+ not depend on the host's real process table.
124
+ now:
125
+ Override the clock. Tests pass a frozen ``datetime`` provider.
126
+ """
127
+
128
+ name = "active_sessions_gc"
129
+
130
+ def __init__(
131
+ self,
132
+ config: DaemonConfig,
133
+ bus: EventBus,
134
+ *,
135
+ sessions_path: Path | None = None,
136
+ pid_probe: Callable[[int], bool] | None = None,
137
+ now: Callable[[], datetime] | None = None,
138
+ ) -> None:
139
+ self._config = config
140
+ self._bus = bus
141
+ self._sessions_path: Path = (
142
+ sessions_path if sessions_path is not None else data_dir() / ACTIVE_SESSIONS_FILENAME
143
+ )
144
+ self._pid_probe: Callable[[int], bool] = pid_probe or _default_pid_probe
145
+ self._now: Callable[[], datetime] = now or (lambda: datetime.now(timezone.utc))
146
+
147
+ self._stop_event = asyncio.Event()
148
+ # Tracks the last (status, version) we emitted for a given
149
+ # (tool, session_id) so we never re-emit the same idle/complete
150
+ # envelope on a subsequent tick.
151
+ self._emitted_versions: dict[tuple[str, str], tuple[str, int]] = {}
152
+
153
+ # ------------------------------------------------------------------
154
+ # Component lifecycle
155
+ # ------------------------------------------------------------------
156
+
157
+ async def run(self) -> None:
158
+ logger.info(
159
+ "active_sessions_gc starting sessions=%s interval=%.1fs "
160
+ "idle_after=%.1fs terminated_after=%.1fs",
161
+ self._sessions_path,
162
+ self._config.active_sessions_gc_interval_seconds,
163
+ self._config.active_sessions_idle_after_seconds,
164
+ self._config.active_sessions_terminated_after_seconds,
165
+ )
166
+ try:
167
+ while not self._stop_event.is_set():
168
+ try:
169
+ await self._tick()
170
+ except asyncio.CancelledError:
171
+ raise
172
+ except Exception as exc: # noqa: BLE001 - last-resort safety net
173
+ logger.exception("active_sessions_gc tick failed: %s", exc)
174
+ await self._sleep_interruptible(self._config.active_sessions_gc_interval_seconds)
175
+ finally:
176
+ logger.info("active_sessions_gc stopped")
177
+
178
+ async def stop(self) -> None:
179
+ self._stop_event.set()
180
+
181
+ # ------------------------------------------------------------------
182
+ # Tick
183
+ # ------------------------------------------------------------------
184
+
185
+ async def _tick(self) -> None:
186
+ """One sweep: read live state, emit idle/terminated where due."""
187
+ rows = self._read_live_rows()
188
+ if not rows:
189
+ return
190
+
191
+ live = self._fold_newest(rows)
192
+ now = self._now()
193
+ idle_after = self._config.active_sessions_idle_after_seconds
194
+ terminated_after = self._config.active_sessions_terminated_after_seconds
195
+
196
+ for key, source in live.items():
197
+ tool, session_id = key
198
+ last_activity_raw = source.get("last_activity")
199
+ if not isinstance(last_activity_raw, str) or not last_activity_raw:
200
+ continue
201
+ try:
202
+ last_activity = datetime.fromisoformat(last_activity_raw.replace("Z", "+00:00"))
203
+ except ValueError:
204
+ continue
205
+ if last_activity.tzinfo is None:
206
+ last_activity = last_activity.replace(tzinfo=timezone.utc)
207
+
208
+ age = (now - last_activity).total_seconds()
209
+ if age <= 0:
210
+ continue
211
+
212
+ current_status = source.get("status")
213
+ current_version = source.get("version")
214
+ if not isinstance(current_version, int):
215
+ continue
216
+
217
+ # ----- 1. Terminated (overrides idle) -------------------
218
+ if age > terminated_after:
219
+ if tool == "cc":
220
+ pid = self._parse_pid(session_id)
221
+ if pid is None:
222
+ # Non-numeric CC session id - cannot probe, skip
223
+ # the terminated emit (the row will keep ageing
224
+ # but we never reap what we cannot probe).
225
+ continue
226
+ if self._pid_probe(pid):
227
+ continue
228
+ await self._emit(
229
+ source,
230
+ kind="session_ended",
231
+ new_status="complete",
232
+ now=now,
233
+ )
234
+ continue
235
+
236
+ # ----- 2. Idle ------------------------------------------
237
+ if age > idle_after and current_status != "idle":
238
+ await self._emit(
239
+ source,
240
+ kind="session_heartbeat",
241
+ new_status="idle",
242
+ now=now,
243
+ )
244
+
245
+ # ------------------------------------------------------------------
246
+ # Emit
247
+ # ------------------------------------------------------------------
248
+
249
+ async def _emit(
250
+ self,
251
+ source: dict[str, Any],
252
+ *,
253
+ kind: str,
254
+ new_status: str,
255
+ now: datetime,
256
+ ) -> None:
257
+ """Publish a new envelope via the bus iff we haven't already."""
258
+ tool = source.get("tool")
259
+ session_id = source.get("session_id")
260
+ if not isinstance(tool, str) or not isinstance(session_id, str):
261
+ return
262
+ key = (tool, session_id)
263
+
264
+ next_version = int(source["version"]) + 1
265
+ prior = self._emitted_versions.get(key)
266
+ if prior is not None:
267
+ prior_status, prior_version = prior
268
+ if prior_status == new_status and prior_version >= next_version:
269
+ # We already emitted this transition; nothing to do.
270
+ return
271
+
272
+ payload: dict[str, Any] = {
273
+ "id": source.get("id") or str(uuid.uuid4()),
274
+ "version": next_version,
275
+ "kind": kind,
276
+ "handle": source.get("handle"),
277
+ "tool": tool,
278
+ "session_id": session_id,
279
+ "machine_id": source.get("machine_id"),
280
+ "started_at": source.get("started_at"),
281
+ "last_activity": now.isoformat(),
282
+ "status": new_status,
283
+ "consent_tier": source.get("consent_tier"),
284
+ "provenance_class": source.get("provenance_class", "active_composition"),
285
+ }
286
+ # Optional fields - carry forward only when present.
287
+ if "working_on" in source:
288
+ payload["working_on"] = source.get("working_on")
289
+ if "files_touched" in source:
290
+ payload["files_touched"] = source.get("files_touched")
291
+
292
+ # Reassign envelope id to a fresh value per the design lock:
293
+ # writer dedup is by (id, version), but here we are minting a NEW
294
+ # envelope that represents a new lifecycle transition. Carrying
295
+ # the source id forward bumps the same record; minting a fresh
296
+ # id makes the GC emit independently observable. The brief
297
+ # spec'd ``str(uuid.uuid4())`` - honour that.
298
+ payload["id"] = str(uuid.uuid4())
299
+
300
+ await self._bus.publish("identity.event", payload)
301
+ self._emitted_versions[key] = (new_status, next_version)
302
+ logger.debug(
303
+ "active_sessions_gc emitted kind=%s status=%s tool=%s session=%s version=%d",
304
+ kind,
305
+ new_status,
306
+ tool,
307
+ session_id,
308
+ next_version,
309
+ )
310
+
311
+ # ------------------------------------------------------------------
312
+ # File reading
313
+ # ------------------------------------------------------------------
314
+
315
+ def _read_live_rows(self) -> list[dict[str, Any]]:
316
+ """Read every line of the JSONL under a shared lock.
317
+
318
+ Returns an empty list when the file is missing or unreadable.
319
+ """
320
+ if not self._sessions_path.exists():
321
+ return []
322
+
323
+ rows: list[dict[str, Any]] = []
324
+ flags = os.O_RDONLY
325
+ try:
326
+ fd = os.open(self._sessions_path, flags)
327
+ except OSError as exc:
328
+ logger.warning("active_sessions_gc: open failed: %s", exc)
329
+ return []
330
+
331
+ try:
332
+ try:
333
+ fcntl.flock(fd, fcntl.LOCK_SH)
334
+ except OSError as exc: # pragma: no cover - exotic FS
335
+ if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
336
+ logger.warning("active_sessions_gc: LOCK_SH failed: %s", exc)
337
+ return []
338
+ try:
339
+ buf = b""
340
+ while True:
341
+ chunk = os.read(fd, 65536)
342
+ if not chunk:
343
+ break
344
+ buf += chunk
345
+ finally:
346
+ with contextlib.suppress(OSError):
347
+ fcntl.flock(fd, fcntl.LOCK_UN)
348
+ finally:
349
+ os.close(fd)
350
+
351
+ for raw_line in buf.decode("utf-8", errors="replace").splitlines():
352
+ line = raw_line.strip()
353
+ if not line:
354
+ continue
355
+ try:
356
+ row = json.loads(line)
357
+ except (ValueError, json.JSONDecodeError):
358
+ continue
359
+ if isinstance(row, dict):
360
+ rows.append(row)
361
+ return rows
362
+
363
+ def _fold_newest(self, rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
364
+ """Fold rows to ``{(tool, session_id): newest non-tombstone row}``.
365
+
366
+ ``status == "complete"`` is the tombstone - once a session ends
367
+ we never want to resurrect it as idle/terminated.
368
+ """
369
+ folded: dict[tuple[str, str], dict[str, Any]] = {}
370
+ for row in rows:
371
+ tool = row.get("tool")
372
+ session_id = row.get("session_id")
373
+ if not isinstance(tool, str) or not isinstance(session_id, str):
374
+ continue
375
+ key = (tool, session_id)
376
+ current = folded.get(key)
377
+ if current is None:
378
+ folded[key] = row
379
+ continue
380
+ if self._compare_last_activity(row, current) > 0:
381
+ folded[key] = row
382
+
383
+ # Drop tombstones - sessions already complete are not GC's
384
+ # business.
385
+ return {key: row for key, row in folded.items() if row.get("status") != "complete"}
386
+
387
+ @staticmethod
388
+ def _compare_last_activity(a: dict[str, Any], b: dict[str, Any]) -> int:
389
+ """Return >0 if ``a`` is newer than ``b``, <0 if older, 0 equal."""
390
+ a_ts = a.get("last_activity") or ""
391
+ b_ts = b.get("last_activity") or ""
392
+ if a_ts == b_ts:
393
+ # Tie-breaker on version so a heartbeat at the same ISO
394
+ # timestamp beats the prior start.
395
+ a_v = a.get("version") if isinstance(a.get("version"), int) else -1
396
+ b_v = b.get("version") if isinstance(b.get("version"), int) else -1
397
+ return (a_v or -1) - (b_v or -1)
398
+ return 1 if a_ts > b_ts else -1
399
+
400
+ @staticmethod
401
+ def _parse_pid(session_id: str) -> int | None:
402
+ """Return ``session_id`` as ``int`` if it parses, else ``None``."""
403
+ try:
404
+ pid = int(session_id)
405
+ except (TypeError, ValueError):
406
+ return None
407
+ return pid if pid > 0 else None
408
+
409
+ # ------------------------------------------------------------------
410
+ # Helpers
411
+ # ------------------------------------------------------------------
412
+
413
+ async def _sleep_interruptible(self, seconds: float) -> None:
414
+ """Sleep ``seconds`` or until stop is set, whichever comes first."""
415
+ if seconds <= 0:
416
+ return
417
+ try:
418
+ await asyncio.wait_for(self._stop_event.wait(), timeout=seconds)
419
+ except (TimeoutError, asyncio.TimeoutError):
420
+ return
421
+
422
+ # ------------------------------------------------------------------
423
+ # Test introspection
424
+ # ------------------------------------------------------------------
425
+
426
+ @property
427
+ def sessions_path(self) -> Path:
428
+ return self._sessions_path
429
+
430
+ @property
431
+ def emitted_versions(self) -> dict[tuple[str, str], tuple[str, int]]:
432
+ return dict(self._emitted_versions)