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,391 @@
1
+ """McpFallbackSubscriber - direct MCP polling when the DO is unreachable (D-RT9).
2
+
3
+ When the primary :class:`DoSseSubscriber` is connected to the edge, this
4
+ subscriber is idle. When it transitions to disconnected (via the
5
+ ``identity.disconnected`` bus sentinel), the fallback starts polling the
6
+ backend MCP endpoint at :attr:`DaemonConfig.mcp_fallback_endpoint` and
7
+ publishes synthetic frames onto the bus so that local surfaces (Unix socket,
8
+ D-Bus, inbox projection) never notice the edge is down.
9
+
10
+ The fallback calls the ``alter_whoami`` MCP tool - the one stable, publicly
11
+ documented read-side call on the backend that returns the authenticated
12
+ identity's current attunement / consent-tier / trait-snapshot header. We treat
13
+ the response as a ``state_sync`` synthetic event, synthesise an
14
+ :class:`SSEFrame`, and publish to both ``identity.frame`` and
15
+ ``identity.event`` (mirroring what the DO SSE path does).
16
+
17
+ When the DO reconnects (``identity.connected``), polling stops until the next
18
+ disconnect. This matches D-RT9: "graceful fallback to direct MCP polling when
19
+ the edge is unreachable" - and the surfaces never know which path served them.
20
+
21
+ Design notes
22
+ ------------
23
+
24
+ * **JSON-RPC envelope.** The backend MCP endpoint speaks JSON-RPC 2.0 over
25
+ HTTP POST. We send ``{"jsonrpc": "2.0", "method": "tools/call", "params":
26
+ {"name": "alter_whoami", "arguments": {}}, "id": <counter>}``.
27
+ * **State deduplication.** We cache the hash of the last published state
28
+ dict and skip publishing if the new state is identical - polling produces
29
+ noise otherwise. Tests exercise this.
30
+ * **Auth propagation.** The backend MCP tool accepts the alter-cli JWT as
31
+ the ``Authorization`` bearer header.
32
+ * **Back-off.** If the fallback itself is failing (backend MCP is also down),
33
+ we slow the poll rate with exponential backoff up to one minute between
34
+ attempts, and never log at ERROR - the disconnected state is already
35
+ logged by ``DoSseSubscriber``.
36
+ * **Cold start.** The fallback starts in *idle* mode. If the DO has never
37
+ connected, it waits for the first explicit ``identity.disconnected``
38
+ signal before polling. There is a separate cold-start timer in
39
+ :meth:`run` that activates the fallback if no ``identity.connected`` arrives
40
+ within ``fallback_trigger_after_seconds * 3`` of startup, which covers the
41
+ case where the primary subscriber can never establish a connection at all.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import asyncio
47
+ import contextlib
48
+ import hashlib
49
+ import json
50
+ import logging
51
+ from dataclasses import dataclass, field
52
+ from typing import TYPE_CHECKING, Any
53
+
54
+ import httpx
55
+
56
+ from alter_runtime.config import DaemonConfig
57
+ from alter_runtime.daemon import Component
58
+ from alter_runtime.http_auth import backend_default_headers
59
+ from alter_runtime.subscribers.bus import EventBus
60
+ from alter_runtime.subscribers.do_sse import _build_tls_context
61
+ from alter_runtime.subscribers.sse import SSEFrame
62
+
63
+ if TYPE_CHECKING:
64
+ from alter_runtime.config import Session
65
+
66
+ __all__ = ["McpFallbackSubscriber"]
67
+
68
+ logger = logging.getLogger("alter_runtime.subscribers.mcp_fallback")
69
+
70
+ TOPIC_CONNECTED = "identity.connected"
71
+ TOPIC_DISCONNECTED = "identity.disconnected"
72
+ TOPIC_FRAME = "identity.frame"
73
+ TOPIC_EVENT = "identity.event"
74
+
75
+ #: Upper bound on the fallback's own exponential backoff when the MCP
76
+ #: endpoint is also failing.
77
+ MAX_POLL_BACKOFF_SECONDS: float = 60.0
78
+
79
+
80
+ @dataclass
81
+ class _FallbackState:
82
+ """Internal state for the fallback subscriber (exposed for tests)."""
83
+
84
+ active: bool = False
85
+ poll_count: int = 0
86
+ last_state_hash: str | None = None
87
+ backoff: float = 0.0
88
+ history: list[str] = field(default_factory=list)
89
+
90
+
91
+ class McpFallbackSubscriber(Component):
92
+ """Polls the backend MCP endpoint while the DO is unreachable.
93
+
94
+ Parameters
95
+ ----------
96
+ config:
97
+ Loaded :class:`DaemonConfig`. Uses ``mcp_fallback_endpoint``,
98
+ ``fallback_poll_interval_seconds``, and ``fallback_trigger_after_seconds``.
99
+ session:
100
+ Authenticated alter-cli :class:`Session`. Used for the bearer JWT and
101
+ the handle (for logging and the synthetic frame ``id``).
102
+ bus:
103
+ Shared :class:`EventBus`. The fallback subscribes to connect/disconnect
104
+ sentinels and publishes synthetic frames/events.
105
+ http_client:
106
+ Optional ``httpx.AsyncClient`` override for tests.
107
+ """
108
+
109
+ name = "mcp_fallback"
110
+
111
+ def __init__(
112
+ self,
113
+ config: DaemonConfig,
114
+ session: Session,
115
+ bus: EventBus,
116
+ *,
117
+ http_client: httpx.AsyncClient | None = None,
118
+ ) -> None:
119
+ self._config = config
120
+ self._session = session
121
+ self._bus = bus
122
+ self._http_client = http_client
123
+ self._owns_client = http_client is None
124
+ self._stop_event = asyncio.Event()
125
+ self._state = _FallbackState()
126
+ self._activate_event = asyncio.Event()
127
+ self._request_id_counter = 0
128
+
129
+ # ------------------------------------------------------------------
130
+ # Component lifecycle
131
+ # ------------------------------------------------------------------
132
+
133
+ async def run(self) -> None:
134
+ """Subscribe to bus sentinels and drive the polling loop."""
135
+ logger.info(
136
+ "mcp_fallback starting handle=%s endpoint=%s",
137
+ self._session.handle,
138
+ self._config.mcp_fallback_endpoint,
139
+ )
140
+
141
+ self._bus.subscribe(TOPIC_CONNECTED, self._on_do_connected)
142
+ self._bus.subscribe(TOPIC_DISCONNECTED, self._on_do_disconnected)
143
+
144
+ # Cold-start: if the DO never connects, activate the fallback after
145
+ # fallback_trigger_after_seconds * 3 so local surfaces still have
146
+ # something to render.
147
+ cold_start_deadline = max(self._config.fallback_trigger_after_seconds * 3.0, 10.0)
148
+ cold_start_task = asyncio.create_task(self._cold_start_timer(cold_start_deadline))
149
+
150
+ client = self._http_client or httpx.AsyncClient(
151
+ timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
152
+ # Same strict TLS posture as the DO SSE path - CERT_REQUIRED,
153
+ # check_hostname=True, TLS 1.2 minimum. Closes runtime/M-1
154
+ # from pentest-findings-2026-04-15.md.
155
+ verify=_build_tls_context(),
156
+ # Backend default headers — CF Access service-token bundle
157
+ # (D-SUBSTRATE-UNIFIED-1 §2.3 Option A) merged with the
158
+ # canonical ``X-Alter-Client-*`` identity headers
159
+ # (D-MIN-VERSION-FLOOR-1 §3) so the server-side floor
160
+ # middleware can identify the daemon.
161
+ headers=backend_default_headers(),
162
+ )
163
+
164
+ try:
165
+ while not self._stop_event.is_set():
166
+ # Idle until someone activates us.
167
+ try:
168
+ await asyncio.wait_for(
169
+ self._activate_event.wait(),
170
+ timeout=None,
171
+ )
172
+ except asyncio.CancelledError:
173
+ raise
174
+
175
+ if self._stop_event.is_set():
176
+ return
177
+
178
+ await self._poll_loop(client)
179
+ finally:
180
+ cold_start_task.cancel()
181
+ with contextlib.suppress(asyncio.CancelledError, Exception):
182
+ await cold_start_task
183
+
184
+ self._bus.unsubscribe(TOPIC_CONNECTED, self._on_do_connected)
185
+ self._bus.unsubscribe(TOPIC_DISCONNECTED, self._on_do_disconnected)
186
+
187
+ if self._owns_client:
188
+ try:
189
+ await client.aclose()
190
+ except Exception: # pragma: no cover
191
+ pass
192
+ logger.info("mcp_fallback stopped handle=%s", self._session.handle)
193
+
194
+ async def stop(self) -> None:
195
+ """Cooperative shutdown."""
196
+ self._stop_event.set()
197
+ self._activate_event.set() # unwedge the idle wait
198
+
199
+ # ------------------------------------------------------------------
200
+ # Bus callbacks
201
+ # ------------------------------------------------------------------
202
+
203
+ async def _on_do_disconnected(self, payload: dict[str, Any]) -> None:
204
+ """Primary subscriber reported a disconnect - activate polling."""
205
+ if self._state.active:
206
+ return
207
+ self._state.active = True
208
+ self._state.history.append("activate")
209
+ logger.info(
210
+ "mcp_fallback activating handle=%s reason=%s",
211
+ self._session.handle,
212
+ payload.get("reason"),
213
+ )
214
+ self._activate_event.set()
215
+
216
+ async def _on_do_connected(self, payload: dict[str, Any]) -> None:
217
+ """Primary subscriber reconnected - stand down."""
218
+ if not self._state.active:
219
+ return
220
+ self._state.active = False
221
+ self._state.history.append("deactivate")
222
+ logger.info(
223
+ "mcp_fallback standing down handle=%s (DO reconnected)",
224
+ self._session.handle,
225
+ )
226
+ # Clear the activate event so the next wait_for blocks again.
227
+ self._activate_event.clear()
228
+
229
+ async def _cold_start_timer(self, seconds: float) -> None:
230
+ """Activate the fallback if the DO never connected at startup."""
231
+ try:
232
+ await asyncio.sleep(seconds)
233
+ except asyncio.CancelledError:
234
+ return
235
+ if self._stop_event.is_set():
236
+ return
237
+ if not self._state.active and self._state.poll_count == 0:
238
+ logger.warning(
239
+ "mcp_fallback cold-start: DO has not connected in %.0fs - "
240
+ "activating fallback polling",
241
+ seconds,
242
+ )
243
+ self._state.active = True
244
+ self._state.history.append("cold_start")
245
+ self._activate_event.set()
246
+
247
+ # ------------------------------------------------------------------
248
+ # Polling loop
249
+ # ------------------------------------------------------------------
250
+
251
+ async def _poll_loop(self, client: httpx.AsyncClient) -> None:
252
+ """While active, poll MCP every N seconds and publish synthetic frames."""
253
+ while self._state.active and not self._stop_event.is_set():
254
+ try:
255
+ state = await self._poll_once(client)
256
+ except asyncio.CancelledError:
257
+ raise
258
+ except (httpx.HTTPError, ValueError) as exc:
259
+ await self._on_poll_error(exc)
260
+ await self._sleep_interruptible(self._state.backoff)
261
+ continue
262
+
263
+ if state is not None:
264
+ await self._publish_state(state)
265
+
266
+ # Reset backoff on success
267
+ self._state.backoff = 0.0
268
+ await self._sleep_interruptible(self._config.fallback_poll_interval_seconds)
269
+
270
+ async def _poll_once(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
271
+ """Issue one JSON-RPC call and return the result dict, or None on empty."""
272
+ self._request_id_counter += 1
273
+ body = {
274
+ "jsonrpc": "2.0",
275
+ "id": self._request_id_counter,
276
+ "method": "tools/call",
277
+ "params": {
278
+ "name": "alter_whoami",
279
+ "arguments": {},
280
+ },
281
+ }
282
+ headers = {
283
+ "Authorization": f"Bearer {self._session.jwt}",
284
+ "Content-Type": "application/json",
285
+ "Accept": "application/json",
286
+ }
287
+
288
+ logger.debug("mcp_fallback polling endpoint=%s", self._config.mcp_fallback_endpoint)
289
+ response = await client.post(
290
+ self._config.mcp_fallback_endpoint,
291
+ json=body,
292
+ headers=headers,
293
+ )
294
+ response.raise_for_status()
295
+
296
+ self._state.poll_count += 1
297
+
298
+ try:
299
+ rpc_response = response.json()
300
+ except ValueError:
301
+ logger.warning("mcp_fallback non-JSON response from MCP endpoint")
302
+ return None
303
+
304
+ if not isinstance(rpc_response, dict):
305
+ return None
306
+ if "error" in rpc_response and rpc_response.get("error"):
307
+ err = rpc_response["error"]
308
+ logger.warning(
309
+ "mcp_fallback JSON-RPC error code=%s message=%s",
310
+ err.get("code") if isinstance(err, dict) else err,
311
+ err.get("message") if isinstance(err, dict) else err,
312
+ )
313
+ return None
314
+
315
+ result = rpc_response.get("result")
316
+ if not isinstance(result, dict):
317
+ return None
318
+ return result
319
+
320
+ async def _on_poll_error(self, exc: Exception) -> None:
321
+ """Increase backoff and log. Does not change the active flag."""
322
+ self._state.backoff = min(
323
+ max(self._state.backoff * 2 if self._state.backoff else 2.0, 2.0),
324
+ MAX_POLL_BACKOFF_SECONDS,
325
+ )
326
+ logger.warning(
327
+ "mcp_fallback poll failed: %s - backoff %.1fs",
328
+ exc,
329
+ self._state.backoff,
330
+ )
331
+
332
+ # ------------------------------------------------------------------
333
+ # Publishing
334
+ # ------------------------------------------------------------------
335
+
336
+ async def _publish_state(self, state: dict[str, Any]) -> None:
337
+ """Dedupe against the last published state and publish a synthetic frame."""
338
+ digest = _stable_hash(state)
339
+ if digest == self._state.last_state_hash:
340
+ logger.debug("mcp_fallback state unchanged - skip publish")
341
+ return
342
+ self._state.last_state_hash = digest
343
+
344
+ synthetic_event: dict[str, Any] = {
345
+ "kind": "state_sync",
346
+ "source": "mcp_fallback",
347
+ "handle": self._session.handle,
348
+ "payload": state,
349
+ }
350
+ frame = SSEFrame(
351
+ event="state_sync",
352
+ data=json.dumps(synthetic_event, separators=(",", ":")),
353
+ id=f"fallback-{self._state.poll_count}",
354
+ )
355
+ logger.info(
356
+ "mcp_fallback publishing state_sync poll=%d keys=%s",
357
+ self._state.poll_count,
358
+ sorted(state.keys())[:8],
359
+ )
360
+ await self._bus.publish(TOPIC_FRAME, frame)
361
+ await self._bus.publish(TOPIC_EVENT, synthetic_event)
362
+
363
+ async def _sleep_interruptible(self, seconds: float) -> None:
364
+ """Wait ``seconds`` or until stopped / deactivated."""
365
+ if seconds <= 0:
366
+ return
367
+ try:
368
+ # Wake early if either shutdown or reconnect fires.
369
+ await asyncio.wait_for(self._wait_stop_or_inactive(), timeout=seconds)
370
+ except (TimeoutError, asyncio.TimeoutError):
371
+ return
372
+
373
+ async def _wait_stop_or_inactive(self) -> None:
374
+ """Return as soon as either the component stops or the fallback is deactivated."""
375
+ while not self._stop_event.is_set() and self._state.active:
376
+ await asyncio.sleep(0.05)
377
+
378
+ # ------------------------------------------------------------------
379
+ # Test introspection
380
+ # ------------------------------------------------------------------
381
+
382
+ @property
383
+ def state(self) -> _FallbackState:
384
+ """Current fallback state (used by tests)."""
385
+ return self._state
386
+
387
+
388
+ def _stable_hash(state: dict[str, Any]) -> str:
389
+ """SHA-256 of a canonicalised JSON encoding of ``state`` (for dedupe)."""
390
+ canonical = json.dumps(state, sort_keys=True, separators=(",", ":"))
391
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()