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,335 @@
1
+ """FloorLoop — supervised daemon-side floor preflight component.
2
+
3
+ D-MIN-VERSION-FLOOR-1 Phase 3. Sister to ``UpdateLoop``: the update loop
4
+ keeps the daemon fresh by polling the release manifest; the floor loop
5
+ keeps the daemon honest by polling the server-side floor and gating the
6
+ local RPC surface when the running version slips below it.
7
+
8
+ **Cadence (per DR §6).**
9
+
10
+ * Immediate preflight on daemon start.
11
+ * Every ``poll_interval_seconds`` afterwards (default 24h, ±5% jitter for
12
+ fingerprint hygiene — same window as ``UpdateLoop``).
13
+
14
+ **State surface.**
15
+
16
+ The loop holds a single :class:`FloorState` shared with the Unix-socket
17
+ server. The state carries:
18
+
19
+ * The most recent :class:`FloorVerdict` (or ``None`` until the first poll
20
+ completes — initial state is "permit, unknown" so the daemon never blocks
21
+ during cold start).
22
+ * A monotonic counter of successful polls (used by tests + telemetry).
23
+ * A monotonic counter of failed polls.
24
+
25
+ The socket server reads ``state.is_locked()`` on every authenticated method.
26
+ Locked means "the verdict is below floor"; permit-with-warn or unknown
27
+ state never locks.
28
+
29
+ **A15.3 carve-out.**
30
+
31
+ The state object exposes :meth:`allow_safety_critical` so the socket
32
+ server can let through ``ingest`` calls whose payload carries
33
+ ``urgency: "critical"``. The carve-out is enforced at the socket layer,
34
+ not here, but lives close to the state object so the policy is single-
35
+ sourced.
36
+
37
+ **No new dependencies.** ``httpx``, ``asyncio``, and stdlib are sufficient.
38
+ The floor preflight module owns all I/O.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import asyncio
44
+ import logging
45
+ import random
46
+ from dataclasses import dataclass
47
+ from typing import Any
48
+
49
+ from alter_runtime import __version__ as RUNTIME_VERSION
50
+ from alter_runtime.config import DaemonConfig
51
+ from alter_runtime.daemon import Component
52
+ from alter_runtime.floor_preflight import (
53
+ FloorVerdict,
54
+ preflight,
55
+ )
56
+
57
+ __all__ = [
58
+ "FloorLoop",
59
+ "FloorState",
60
+ "SAFETY_CRITICAL_INGEST_KINDS",
61
+ "SAFETY_CRITICAL_INGEST_URGENCIES",
62
+ "is_safety_critical_call",
63
+ ]
64
+
65
+
66
+ logger = logging.getLogger("alter_runtime.floor_loop")
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # A15.3 carve-out — safety-critical comms allowlist
71
+ # ---------------------------------------------------------------------------
72
+ #
73
+ # DR §6 + wave-2 amendment A15.3: MCP server preflight MUST permit
74
+ # ``alter_ingest`` and ``alter_escalations`` calls when the payload carries
75
+ # ``urgency: critical``, even below floor. Floor MUST NOT block emergency
76
+ # comms.
77
+ #
78
+ # The local Unix-socket surface speaks JSON-RPC, not MCP. The closest
79
+ # substrate-honest carry-over is:
80
+ #
81
+ # * ``ingest`` method calls whose payload includes ``urgency: critical`` or
82
+ # ``urgency: emergency`` (both treated as critical-class to widen the
83
+ # safety net).
84
+ # * ``escalate`` / ``escalation`` ingest kinds — pre-allowed regardless of
85
+ # urgency tag, because the kind name is itself the critical signal.
86
+ #
87
+ # This is the daemon-local extension of A15.3, not a re-interpretation. If
88
+ # a new RPC-layer escalation verb lands in a follow-up phase, the
89
+ # allowlist below is the single edit point — keep the carve-out in sync
90
+ # with whatever the public MCP servers expose.
91
+
92
+ #: Ingest kinds that bypass the floor gate regardless of urgency tag.
93
+ #: Empty in v1 — the runtime's local ingest kind whitelist is consent-
94
+ #: scoped and doesn't expose an escalation verb. Reserved for forward
95
+ #: compatibility with mcp-alter / mcp-alter-collective once their floor
96
+ #: preflight lands.
97
+ SAFETY_CRITICAL_INGEST_KINDS: frozenset[str] = frozenset()
98
+
99
+ #: Urgency tags that promote an ``ingest`` payload to safety-critical class.
100
+ SAFETY_CRITICAL_INGEST_URGENCIES: frozenset[str] = frozenset({"critical", "emergency"})
101
+
102
+
103
+ def is_safety_critical_call(method: str, payload: dict[str, Any] | None) -> bool:
104
+ """Return ``True`` when the local RPC call MUST bypass the floor gate.
105
+
106
+ Applied at the Unix-socket dispatch layer after the auth handshake. The
107
+ function is permissive by design — the cost of an unnecessary permit on
108
+ a safety-critical-looking call is far lower than the cost of blocking a
109
+ real emergency revoke / panic / kill-switch payload.
110
+
111
+ Method-level allowlist:
112
+
113
+ * ``ping``, ``auth`` — always permitted (pre-auth handshake, not gated).
114
+ * ``ingest`` with ``payload['urgency']`` in
115
+ :data:`SAFETY_CRITICAL_INGEST_URGENCIES` — promoted to safety-critical.
116
+ * ``ingest`` with ``kind`` in :data:`SAFETY_CRITICAL_INGEST_KINDS` —
117
+ promoted regardless of payload.
118
+ """
119
+ if method in ("ping", "auth"):
120
+ return True
121
+ if method != "ingest":
122
+ return False
123
+ if not isinstance(payload, dict):
124
+ return False
125
+ kind = payload.get("kind")
126
+ if isinstance(kind, str) and kind in SAFETY_CRITICAL_INGEST_KINDS:
127
+ return True
128
+ # Check both top-level urgency (on the request) and nested urgency
129
+ # (inside the ingest payload dict). Either signal flips the carve-out.
130
+ top_urgency = payload.get("urgency")
131
+ if isinstance(top_urgency, str) and top_urgency.lower() in SAFETY_CRITICAL_INGEST_URGENCIES:
132
+ return True
133
+ nested = payload.get("payload")
134
+ if isinstance(nested, dict):
135
+ nested_urgency = nested.get("urgency")
136
+ if (
137
+ isinstance(nested_urgency, str)
138
+ and nested_urgency.lower() in SAFETY_CRITICAL_INGEST_URGENCIES
139
+ ):
140
+ return True
141
+ return False
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # FloorState
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ @dataclass
150
+ class FloorState:
151
+ """Shared mutable state for the floor gate.
152
+
153
+ The :class:`FloorLoop` writes; the :class:`UnixSocketServer` reads. No
154
+ lock is needed because asyncio is single-threaded — the read is atomic
155
+ relative to the write (Python dataclass attribute assignment is a single
156
+ bytecode op for the reference-store case).
157
+ """
158
+
159
+ last_verdict: FloorVerdict | None = None
160
+ successful_polls: int = 0
161
+ failed_polls: int = 0
162
+ #: Set to ``True`` after the first successful preflight. Until then the
163
+ #: socket server treats the state as "unknown → permit" so cold-start
164
+ #: never blocks.
165
+ has_polled: bool = False
166
+
167
+ def is_locked(self) -> bool:
168
+ """Return ``True`` when the daemon is below floor.
169
+
170
+ ``False`` covers both "above floor" and "unknown / no doc yet" — the
171
+ server-side gate is load-bearing for unknown cases, so the daemon
172
+ does not block locally without a verdict.
173
+ """
174
+ if self.last_verdict is None:
175
+ return False
176
+ return not self.last_verdict.ok
177
+
178
+ def envelope_payload(self) -> dict[str, Any] | None:
179
+ """Return the canonical envelope dict for the current state.
180
+
181
+ Returns ``None`` when not locked. The dict is byte-shape-equal to
182
+ the Phase 1 server reject body and the Phase 2 CLI envelope.
183
+ """
184
+ if not self.is_locked() or self.last_verdict is None:
185
+ return None
186
+ envelope = self.last_verdict.envelope
187
+ if envelope is None:
188
+ return None
189
+ return envelope.to_dict()
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # FloorLoop component
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ @dataclass
198
+ class _LoopConfig:
199
+ """Internal config snapshot — keeps the run loop pure."""
200
+
201
+ api_base: str
202
+ poll_interval_seconds: float
203
+ initial_jitter_seconds: float = 0.0
204
+
205
+
206
+ class FloorLoop(Component):
207
+ """Supervised component that drives :class:`FloorState`.
208
+
209
+ Parameters
210
+ ----------
211
+ config:
212
+ Daemon config — only ``mcp_fallback_endpoint`` is consulted. The
213
+ floor lives at ``/api/v1/clients/min-version`` on the same host.
214
+ state:
215
+ Shared :class:`FloorState` written by the loop and read by the
216
+ socket server.
217
+ poll_interval_seconds:
218
+ Override for the 24h default. Tests inject a tiny value to
219
+ exercise the multi-poll path.
220
+ api_base:
221
+ Override the API base derived from ``config``. Tests inject a
222
+ ``httpx.MockTransport`` URL.
223
+ rng:
224
+ Optional ``random.Random`` for deterministic jitter in tests.
225
+ """
226
+
227
+ name = "floor_loop"
228
+
229
+ #: Default 24h cadence — same value as ``UpdateLoop``. Matches DR §6.
230
+ DEFAULT_POLL_INTERVAL_SECONDS: float = 24 * 60 * 60
231
+
232
+ #: Jitter window (±5 %) on the poll interval. Same magnitude as
233
+ #: ``UpdateLoop`` so the two periodic outbound calls stay loosely
234
+ #: phased relative to each other.
235
+ JITTER_FRACTION: float = 0.05
236
+
237
+ def __init__(
238
+ self,
239
+ config: DaemonConfig,
240
+ state: FloorState,
241
+ *,
242
+ poll_interval_seconds: float | None = None,
243
+ api_base: str | None = None,
244
+ rng: random.Random | None = None,
245
+ ) -> None:
246
+ self._state = state
247
+ self._rng = rng or random.Random()
248
+ interval = (
249
+ poll_interval_seconds
250
+ if poll_interval_seconds is not None
251
+ else self.DEFAULT_POLL_INTERVAL_SECONDS
252
+ )
253
+ base = api_base if api_base is not None else self._derive_api_base(config)
254
+ self._cfg = _LoopConfig(
255
+ api_base=base,
256
+ poll_interval_seconds=float(interval),
257
+ )
258
+
259
+ @staticmethod
260
+ def _derive_api_base(config: DaemonConfig) -> str:
261
+ """Derive the API base from ``mcp_fallback_endpoint``.
262
+
263
+ ``mcp_fallback_endpoint`` is ``https://api.truealter.com/api/v1/mcp``
264
+ by default. We strip the ``/api/v1/mcp`` suffix to recover the
265
+ bare API base. If the endpoint shape changes the floor preflight
266
+ URL still resolves cleanly through
267
+ :func:`floor_preflight._resolve_floor_url`.
268
+ """
269
+ endpoint = (config.mcp_fallback_endpoint or "").rstrip("/")
270
+ for tail in ("/api/v1/mcp", "/api/v1", "/v1/mcp", "/v1"):
271
+ if endpoint.endswith(tail):
272
+ return endpoint[: -len(tail)]
273
+ return endpoint or "https://api.truealter.com"
274
+
275
+ async def run(self) -> None:
276
+ """Run the poll loop until cancelled."""
277
+ logger.info(
278
+ "floor_loop starting interval=%.0fs api_base=%s",
279
+ self._cfg.poll_interval_seconds,
280
+ self._cfg.api_base,
281
+ )
282
+ await self._tick()
283
+ while True:
284
+ wait = self._next_wait()
285
+ try:
286
+ await asyncio.sleep(wait)
287
+ except asyncio.CancelledError:
288
+ raise
289
+ await self._tick()
290
+
291
+ def _next_wait(self) -> float:
292
+ """Compute the next sleep duration with ±5 % jitter."""
293
+ base = self._cfg.poll_interval_seconds
294
+ if base <= 0:
295
+ return 0.0
296
+ delta = base * self.JITTER_FRACTION
297
+ # rng.uniform(-delta, +delta) yields the jitter offset; clamp to
298
+ # avoid a non-positive sleep.
299
+ jitter = self._rng.uniform(-delta, delta)
300
+ return max(0.0, base + jitter)
301
+
302
+ async def _tick(self) -> None:
303
+ """One preflight pass. Updates ``state`` regardless of outcome."""
304
+ try:
305
+ verdict = await preflight(
306
+ api_base=self._cfg.api_base,
307
+ daemon_version=RUNTIME_VERSION,
308
+ )
309
+ except Exception as exc: # pragma: no cover — preflight catches its own
310
+ logger.warning("floor_loop_preflight_unhandled err=%s", exc)
311
+ self._state.failed_polls += 1
312
+ return
313
+
314
+ self._state.last_verdict = verdict
315
+ self._state.has_polled = True
316
+ if verdict.envelope is not None:
317
+ logger.warning(
318
+ "floor_loop verdict=below-floor version=%s min_version=%s upgrade=%s",
319
+ verdict.envelope.client_version,
320
+ verdict.envelope.min_version,
321
+ verdict.envelope.upgrade_cmd,
322
+ )
323
+ else:
324
+ logger.info(
325
+ "floor_loop verdict=ok diagnostic=%s warn=%s",
326
+ verdict.diagnostic,
327
+ verdict.warn or "",
328
+ )
329
+ if verdict.diagnostic in (
330
+ "no-cache-no-fetch-permit",
331
+ "stale-cache-permit-warn",
332
+ ):
333
+ self._state.failed_polls += 1
334
+ else:
335
+ self._state.successful_polls += 1