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,58 @@
1
+ """Subscribers - long-lived components that consume the per-handle event stream.
2
+
3
+ Each subscriber is a :class:`alter_runtime.daemon.Component` that registers
4
+ with the daemon supervisor and is restarted with exponential backoff on
5
+ failure. Subscribers are the network-facing half of the runtime: they own the
6
+ SSE socket against ``https://mcp.truealter.com/events/{handle}/stream`` and
7
+ project events into local on-disk caches + the in-process :class:`EventBus`
8
+ that other ALTER surfaces (CC hooks, the alter-cli, downstream adapters) read
9
+ from.
10
+
11
+ * Wave 1 shipped :class:`InboxWriter`, the shared :class:`SSEFrame` parser,
12
+ and the skeleton supervisor.
13
+ * Wave 2 adds :class:`EventBus`, :class:`DoSseSubscriber` (primary L1 ingress),
14
+ and :class:`McpFallbackSubscriber` (fallback via direct MCP polling, per D-RT9).
15
+ * Wave 6 adds :class:`AgentFrameSubscriber` (D-AGENT-CHANNEL-1 Phase 2 §8) —
16
+ projects ``agent_frame`` deliveries to ``~/.cache/alter/agent-frames.jsonl``
17
+ and re-publishes per-kind bus topics.
18
+ """
19
+
20
+ from alter_runtime.subscribers.active_sessions_cron_emitter import ActiveSessionsCronEmitter
21
+ from alter_runtime.subscribers.active_sessions_do_publisher import ActiveSessionsDoPublisher
22
+ from alter_runtime.subscribers.active_sessions_gc import ActiveSessionsGc
23
+ from alter_runtime.subscribers.active_sessions_writer import ActiveSessionsWriter
24
+ from alter_runtime.subscribers.adapters_writer import AdaptersWriter
25
+ from alter_runtime.subscribers.agent_frames import AgentFrameSubscriber
26
+ from alter_runtime.subscribers.bus import EventBus
27
+ from alter_runtime.subscribers.cache_writer import CacheWriter, project_state_to_cache
28
+ from alter_runtime.subscribers.ceremony_echo import CeremonyEchoWriter
29
+ from alter_runtime.subscribers.do_sse import DoSseSubscriber
30
+ from alter_runtime.subscribers.ebpf import EbpfSubscriber
31
+ from alter_runtime.subscribers.inbox_writer import InboxWriter
32
+ from alter_runtime.subscribers.mcp_fallback import McpFallbackSubscriber
33
+ from alter_runtime.subscribers.presence_writer import PresenceWriter
34
+ from alter_runtime.subscribers.session_presence import SessionPresenceWriter
35
+ from alter_runtime.subscribers.sse import SSEFrame, parse_sse_frames
36
+ from alter_runtime.subscribers.weave_intent_writer import WeaveIntentWriter
37
+
38
+ __all__ = [
39
+ "ActiveSessionsCronEmitter",
40
+ "ActiveSessionsDoPublisher",
41
+ "ActiveSessionsGc",
42
+ "ActiveSessionsWriter",
43
+ "AdaptersWriter",
44
+ "AgentFrameSubscriber",
45
+ "CacheWriter",
46
+ "CeremonyEchoWriter",
47
+ "DoSseSubscriber",
48
+ "EbpfSubscriber",
49
+ "EventBus",
50
+ "InboxWriter",
51
+ "McpFallbackSubscriber",
52
+ "PresenceWriter",
53
+ "SSEFrame",
54
+ "SessionPresenceWriter",
55
+ "WeaveIntentWriter",
56
+ "parse_sse_frames",
57
+ "project_state_to_cache",
58
+ ]
@@ -0,0 +1,313 @@
1
+ """ActiveSessionsCronEmitter - translates cron routine lifecycle events
2
+ into ``tool: "cron"`` active-session envelopes.
3
+
4
+ Wave D of D-COORD-D2. Sibling to :class:`ActiveSessionsWriter` (the
5
+ local-disk projection), :class:`ActiveSessionsGc` (the idle/terminated
6
+ sweeper), and :class:`ActiveSessionsDoPublisher` (the cross-host
7
+ publisher). This component does NOT touch disk or the network - it
8
+ listens for cron routine lifecycle events on the in-process
9
+ :class:`EventBus` and re-publishes them as ``identity.event`` envelopes
10
+ that the shipped writer already knows how to project.
11
+
12
+ Design contract per D-COORD-D2 §3 Wave D item 3 + §7.2 (canon schema):
13
+
14
+ * **Two emits per routine invocation.** ``cron.routine.started`` on the
15
+ bus produces a ``session_started`` envelope (``version=0``,
16
+ ``status="active"``); ``cron.routine.finished`` produces a
17
+ ``session_ended`` envelope (``version=1``, ``status="complete"``).
18
+ Both envelopes share the same ``session_id`` so reader dedup folds
19
+ them as the same lifecycle.
20
+ * **No heartbeat.** Cron routines are short by design; the start/end
21
+ pair is sufficient. Long-running routines (>5 min) MAY emit
22
+ heartbeats in a follow-up - out of scope for Wave D.
23
+ * **No per-routine code.** The SDK handles every routine
24
+ transparently. Routine authors never call into the active-sessions
25
+ contract directly.
26
+ * **No emit without a handle.** When ``~/.config/alter/session.json``
27
+ is absent (degraded daemon mode), the component idles - there is no
28
+ Sovereign-tier handle to bind a session envelope to. This mirrors
29
+ the writer's ``handle`` required-field guard.
30
+
31
+ Field mapping for ``tool: "cron"``:
32
+
33
+ * ``session_id`` - UUIDv4 minted per routine invocation. The publisher
34
+ of ``cron.routine.started`` is expected to mint the UUID once and
35
+ carry it through to the matching ``cron.routine.finished`` payload.
36
+ * ``working_on`` - first 200 chars of the routine name (e.g.
37
+ ``"phone-link-knowledge-scan"``).
38
+ * ``files_touched`` - empty list. Cron routines do not touch files via
39
+ a tracked surface.
40
+ * ``machine_id`` - the daemon's stable host id derivation, shared with
41
+ :class:`TokenUsageClient` for consistency across alter-runtime
42
+ surfaces.
43
+ * ``consent_tier`` - the session's declared consent tier, unless the
44
+ routine config carries a per-routine override.
45
+ * ``started_at`` + ``last_activity`` - ISO 8601 UTC at envelope emit.
46
+ * ``provenance_class`` - literal ``"active_composition"``.
47
+
48
+ The component is additive - no existing cron logic is refactored.
49
+ When a real CronCreate fabric lands inside alter-runtime, the
50
+ publisher emits ``cron.routine.started`` / ``cron.routine.finished``
51
+ at the right call sites and this subscriber translates each into the
52
+ envelope contract above.
53
+
54
+ Per D-COORD-D2 Wave D - the daemon-side CronCreate emitter.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import asyncio
60
+ import contextlib
61
+ import logging
62
+ import uuid
63
+ from datetime import datetime, timezone
64
+ from typing import TYPE_CHECKING, Any, Callable
65
+
66
+ from alter_runtime.config import DaemonConfig
67
+ from alter_runtime.daemon import Component
68
+
69
+ if TYPE_CHECKING:
70
+ from alter_runtime.config import Session
71
+ from alter_runtime.subscribers.bus import EventBus
72
+
73
+ __all__ = [
74
+ "CRON_TOOL_NAME",
75
+ "CRON_ROUTINE_STARTED_TOPIC",
76
+ "CRON_ROUTINE_FINISHED_TOPIC",
77
+ "MAX_WORKING_ON_CHARS",
78
+ "ActiveSessionsCronEmitter",
79
+ ]
80
+
81
+ logger = logging.getLogger("alter_runtime.subscribers.active_sessions_cron_emitter")
82
+
83
+ #: Tool name emitted in every envelope - matches the schema enum.
84
+ CRON_TOOL_NAME: str = "cron"
85
+
86
+ #: Bus topic published by the cron fabric on routine start.
87
+ CRON_ROUTINE_STARTED_TOPIC: str = "cron.routine.started"
88
+
89
+ #: Bus topic published by the cron fabric on routine finish.
90
+ CRON_ROUTINE_FINISHED_TOPIC: str = "cron.routine.finished"
91
+
92
+ #: Cap on the routine-name string emitted as ``working_on`` per §7.2.
93
+ MAX_WORKING_ON_CHARS: int = 200
94
+
95
+
96
+ def _derive_machine_id() -> str:
97
+ """Return the same stable host identifier the rest of alter-runtime
98
+ uses for cross-host de-duplication.
99
+
100
+ Sources the singleton derivation from
101
+ :func:`alter_runtime.clients.token_usage_client._derive_host_id` so a
102
+ single host emits the same ``machine_id`` across every subscriber.
103
+ """
104
+ # Imported lazily to avoid a hard import-time dependency on the
105
+ # token-usage client (which itself imports ``httpx`` and friends).
106
+ from alter_runtime.clients.token_usage_client import _derive_host_id
107
+
108
+ return _derive_host_id()
109
+
110
+
111
+ class ActiveSessionsCronEmitter(Component):
112
+ """Subscribes to cron routine lifecycle events and emits envelopes.
113
+
114
+ Parameters
115
+ ----------
116
+ config:
117
+ Loaded :class:`DaemonConfig`. Not consumed today; accepted for
118
+ symmetry with sibling components and so future config knobs
119
+ (heartbeat cadence, per-tool consent override) can be wired
120
+ without changing the supervisor's registration signature.
121
+ bus:
122
+ Shared :class:`EventBus`. Subscribes to
123
+ :data:`CRON_ROUTINE_STARTED_TOPIC` and
124
+ :data:`CRON_ROUTINE_FINISHED_TOPIC`; publishes to
125
+ ``identity.event`` so the shipped writer projects each envelope.
126
+ session:
127
+ The alter-cli session (handle + consent tier). ``None`` ->
128
+ degraded mode (no envelope emit; the GC / writer / DO publisher
129
+ already accept this path).
130
+ machine_id_provider:
131
+ Override the machine-id derivation. Tests inject a stub so they
132
+ do not depend on the host's real ``/etc/machine-id``.
133
+ now:
134
+ Override the clock. Tests pass a frozen ``datetime`` provider.
135
+ """
136
+
137
+ name = "active_sessions_cron_emitter"
138
+
139
+ def __init__(
140
+ self,
141
+ config: DaemonConfig,
142
+ bus: EventBus,
143
+ session: Session | None,
144
+ *,
145
+ machine_id_provider: Callable[[], str] | None = None,
146
+ now: Callable[[], datetime] | None = None,
147
+ ) -> None:
148
+ self._config = config
149
+ self._bus = bus
150
+ self._session = session
151
+ self._machine_id_provider = machine_id_provider or _derive_machine_id
152
+ self._now: Callable[[], datetime] = now or (lambda: datetime.now(timezone.utc))
153
+
154
+ self._stop_event = asyncio.Event()
155
+
156
+ # ------------------------------------------------------------------
157
+ # Component lifecycle
158
+ # ------------------------------------------------------------------
159
+
160
+ async def run(self) -> None:
161
+ if self._session is None:
162
+ logger.info(
163
+ "active_sessions_cron_emitter: no alter-cli session - "
164
+ "idling. Routine lifecycle events on the bus will be "
165
+ "ignored until `alter login` populates session.json."
166
+ )
167
+ try:
168
+ await self._stop_event.wait()
169
+ except asyncio.CancelledError:
170
+ raise
171
+ finally:
172
+ logger.info("active_sessions_cron_emitter stopped (degraded)")
173
+ return
174
+
175
+ self._bus.subscribe(CRON_ROUTINE_STARTED_TOPIC, self.handle_routine_started)
176
+ self._bus.subscribe(CRON_ROUTINE_FINISHED_TOPIC, self.handle_routine_finished)
177
+ logger.info(
178
+ "active_sessions_cron_emitter started handle=%s topics=%s,%s",
179
+ self._session.handle,
180
+ CRON_ROUTINE_STARTED_TOPIC,
181
+ CRON_ROUTINE_FINISHED_TOPIC,
182
+ )
183
+ try:
184
+ await self._stop_event.wait()
185
+ except asyncio.CancelledError:
186
+ raise
187
+ finally:
188
+ with contextlib.suppress(Exception):
189
+ self._bus.unsubscribe(CRON_ROUTINE_STARTED_TOPIC, self.handle_routine_started)
190
+ with contextlib.suppress(Exception):
191
+ self._bus.unsubscribe(CRON_ROUTINE_FINISHED_TOPIC, self.handle_routine_finished)
192
+ logger.info("active_sessions_cron_emitter stopped")
193
+
194
+ async def stop(self) -> None:
195
+ self._stop_event.set()
196
+
197
+ # ------------------------------------------------------------------
198
+ # Event ingest
199
+ # ------------------------------------------------------------------
200
+
201
+ async def handle_routine_started(self, event: dict[str, Any]) -> None:
202
+ """Translate a ``cron.routine.started`` bus event into a
203
+ ``session_started`` envelope on ``identity.event``."""
204
+ await self._emit(event, kind="session_started", status="active", version=0)
205
+
206
+ async def handle_routine_finished(self, event: dict[str, Any]) -> None:
207
+ """Translate a ``cron.routine.finished`` bus event into a
208
+ ``session_ended`` envelope on ``identity.event``.
209
+
210
+ ``version`` defaults to ``1`` (one bump above the matching
211
+ ``session_started``). Publishers MAY override by carrying an
212
+ explicit ``version`` field in the payload - useful if a future
213
+ heartbeat path lands and routines are no longer guaranteed to
214
+ be a two-emit lifecycle.
215
+ """
216
+ version_override = event.get("version") if isinstance(event, dict) else None
217
+ try:
218
+ version = int(version_override) if version_override is not None else 1
219
+ except (TypeError, ValueError):
220
+ version = 1
221
+ await self._emit(event, kind="session_ended", status="complete", version=version)
222
+
223
+ # ------------------------------------------------------------------
224
+ # Envelope construction
225
+ # ------------------------------------------------------------------
226
+
227
+ async def _emit(
228
+ self,
229
+ event: dict[str, Any],
230
+ *,
231
+ kind: str,
232
+ status: str,
233
+ version: int,
234
+ ) -> None:
235
+ """Build + publish a single envelope on ``identity.event``."""
236
+ if not isinstance(event, dict):
237
+ logger.warning("active_sessions_cron_emitter: non-dict event payload - dropping")
238
+ return
239
+ if self._session is None:
240
+ # ``run()`` already guards against this - defensive only.
241
+ return
242
+
243
+ session_id = event.get("session_id")
244
+ if not isinstance(session_id, str) or not session_id:
245
+ logger.warning(
246
+ "active_sessions_cron_emitter: missing session_id on %s payload - dropping",
247
+ kind,
248
+ )
249
+ return
250
+
251
+ routine_name = event.get("routine") or event.get("name") or event.get("working_on")
252
+ working_on: str | None
253
+ if isinstance(routine_name, str) and routine_name:
254
+ working_on = routine_name[:MAX_WORKING_ON_CHARS]
255
+ else:
256
+ working_on = None
257
+
258
+ # Honour a per-routine consent override iff valid; otherwise
259
+ # fall back to the session's declared tier. The writer rejects
260
+ # any tier outside 1..4 so we sanity-check here too.
261
+ consent_override = event.get("consent_tier")
262
+ consent_tier: int = self._session.consent_tier
263
+ if consent_override is not None:
264
+ try:
265
+ consent_int = int(consent_override)
266
+ except (TypeError, ValueError):
267
+ consent_int = -1
268
+ if consent_int in (1, 2, 3, 4):
269
+ consent_tier = consent_int
270
+ else:
271
+ logger.warning(
272
+ "active_sessions_cron_emitter: invalid consent_tier override=%r - "
273
+ "falling back to session tier=%d",
274
+ consent_override,
275
+ self._session.consent_tier,
276
+ )
277
+
278
+ started_at = event.get("started_at")
279
+ if not isinstance(started_at, str) or not started_at:
280
+ started_at = self._now().isoformat()
281
+
282
+ now_iso = self._now().isoformat()
283
+
284
+ # Per the spec - each envelope mints a fresh UUID id; dedup is
285
+ # by (id, version) on the writer side. session_id is the
286
+ # lifecycle key that pairs start + end.
287
+ envelope: dict[str, Any] = {
288
+ "id": str(uuid.uuid4()),
289
+ "version": version,
290
+ "kind": kind,
291
+ "handle": self._session.handle,
292
+ "tool": CRON_TOOL_NAME,
293
+ "session_id": session_id,
294
+ "machine_id": self._machine_id_provider(),
295
+ "started_at": started_at,
296
+ "last_activity": now_iso,
297
+ "status": status,
298
+ "provenance_class": "active_composition",
299
+ "consent_tier": consent_tier,
300
+ "working_on": working_on,
301
+ "files_touched": [],
302
+ }
303
+
304
+ await self._bus.publish("identity.event", envelope)
305
+ logger.debug(
306
+ "active_sessions_cron_emitter emitted kind=%s status=%s "
307
+ "session=%s version=%d working_on=%r",
308
+ kind,
309
+ status,
310
+ session_id,
311
+ version,
312
+ working_on,
313
+ )