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,392 @@
1
+ """DaemonCapCache - machine-wide cap-JWT and query-result cache for the daemon.
2
+
3
+ One instance is created at daemon startup and shared across all Unix socket
4
+ clients. This collapses the N independent cap-mint calls (one per CC bridge
5
+ process) that previously ran against the server's 6/min/handle bucket into a
6
+ single minting identity per handle-scope set.
7
+
8
+ Design
9
+ ------
10
+
11
+ *Cap cache* (``cap.get`` RPC):
12
+ Mints once per sorted-scope-set, caches the resulting JWT in-memory.
13
+ Refresh fires 30 s before declared ``expires_at`` (same leeway as
14
+ :class:`~alter_runtime.subscribers.active_sessions_do_publisher._CachedCap`).
15
+ Server TTL is clamped to [30, 300] s server-side; the client-side leeway
16
+ means steady-state mint rate is at most once per (TTL - 30 s) window.
17
+ On 401/403 from the caller's upstream, the caller drops the entry via
18
+ :meth:`invalidate_cap` and the next ``cap.get`` re-mints immediately.
19
+
20
+ *Query cache* (``query.get`` RPC):
21
+ Caches the JSON body of a ``GET /orgs/{slug}/queries/{path}`` response
22
+ for 15 s (``QUERY_CACHE_TTL``). Keyed on ``(path, frozen_params)``.
23
+ Stale entries are evicted on next access (read-through TTL). No push
24
+ invalidation in v1.
25
+
26
+ Auth
27
+ ----
28
+
29
+ Both RPC methods are served by the existing Unix socket auth handshake
30
+ (``{"method": "auth", "token": "<t>"}``). No new auth scheme is added.
31
+
32
+ Thread safety
33
+ -------------
34
+
35
+ All operations are synchronous reads/writes to plain dicts guarded by the
36
+ asyncio event loop. No additional locking is required because the daemon
37
+ runs in a single-threaded asyncio event loop where coroutine scheduling is
38
+ cooperative - no concurrent access to the dicts is possible.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import logging
45
+ import sys
46
+ import time
47
+ from dataclasses import dataclass
48
+ from datetime import datetime
49
+ from typing import TYPE_CHECKING, Any
50
+
51
+ import httpx
52
+
53
+ if TYPE_CHECKING:
54
+ from alter_runtime.config import Session
55
+
56
+ __all__ = [
57
+ "CAP_CACHE_REFRESH_LEAD_SECONDS",
58
+ "CAP_CACHE_TTL_MIN",
59
+ "CAP_CACHE_TTL_MAX",
60
+ "QUERY_CACHE_TTL",
61
+ "DaemonCapCache",
62
+ ]
63
+
64
+ logger = logging.getLogger("alter_runtime.cap_cache")
65
+
66
+ #: Re-mint leeway - same value as the publisher's ``CAP_REFRESH_LEAD_SECONDS``
67
+ #: so both components share one effective policy.
68
+ CAP_CACHE_REFRESH_LEAD_SECONDS: float = 30.0
69
+
70
+ #: Minimum server-declared TTL we honour. Values below this are clamped up.
71
+ CAP_CACHE_TTL_MIN: float = 30.0
72
+
73
+ #: Maximum server-declared TTL we honour. Values above this are clamped down.
74
+ CAP_CACHE_TTL_MAX: float = 300.0
75
+
76
+ #: Query result cache TTL in seconds. Fixed for v1 - no push invalidation.
77
+ QUERY_CACHE_TTL: float = 15.0
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Internal data classes
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ @dataclass
86
+ class _CachedCap:
87
+ """Cached cap-JWT keyed on a sorted-scope-set."""
88
+
89
+ capability: str
90
+ expires_at_unix: float
91
+ # Unlimited multi-use; the daemon never tracks per-use accounting here
92
+ # (the Worker validates TTL + scope only).
93
+ uses_available: int = sys.maxsize
94
+ use_counter: int = 0
95
+
96
+ def is_fresh(self, now: float) -> bool:
97
+ return self.expires_at_unix - now > CAP_CACHE_REFRESH_LEAD_SECONDS
98
+
99
+ def has_uses(self) -> bool:
100
+ return self.use_counter < self.uses_available
101
+
102
+ def take_use(self) -> None:
103
+ self.use_counter += 1
104
+
105
+
106
+ @dataclass
107
+ class _CachedQuery:
108
+ """Cached query-GET response body."""
109
+
110
+ body: Any
111
+ cached_at: float
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Exception types
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ class _CapMintError(Exception):
120
+ """Raised when the cap-mint endpoint refuses or returns a malformed body."""
121
+
122
+
123
+ class _SessionMissing(Exception):
124
+ """Raised when no session is available to mint a cap."""
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Cache
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ class DaemonCapCache:
133
+ """Machine-wide cap and query cache.
134
+
135
+ Parameters
136
+ ----------
137
+ session:
138
+ Authenticated alter-cli :class:`~alter_runtime.config.Session`.
139
+ Used for the Bearer JWT when minting capability tokens. Pass
140
+ ``None`` to construct the cache in degraded mode (all ``cap.get``
141
+ calls will fail with ``session_missing``).
142
+ http_client:
143
+ Optional ``httpx.AsyncClient`` override. When ``None``, the caller
144
+ must supply the client via the ``client`` parameter on each method
145
+ call (used by the Unix socket server which shares the daemon's
146
+ single client instance).
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ session: "Session | None",
152
+ *,
153
+ http_client: httpx.AsyncClient | None = None,
154
+ ) -> None:
155
+ self._session = session
156
+ self._http_client = http_client
157
+ # Cap cache: keyed on frozenset of scope strings.
158
+ self._caps: dict[frozenset[str], _CachedCap] = {}
159
+ # Query cache: keyed on (path, frozenset of sorted param items).
160
+ self._queries: dict[tuple[str, frozenset[tuple[str, str]]], _CachedQuery] = {}
161
+
162
+ # ------------------------------------------------------------------
163
+ # Public interface (called from unix.py dispatch)
164
+ # ------------------------------------------------------------------
165
+
166
+ async def get_cap(
167
+ self,
168
+ scopes: list[str],
169
+ *,
170
+ client: httpx.AsyncClient | None = None,
171
+ ) -> dict[str, Any]:
172
+ """Return a fresh cap-JWT for ``scopes``.
173
+
174
+ Returns::
175
+
176
+ {
177
+ "ok": True,
178
+ "capability": "<jwt>",
179
+ "expires_at": "<iso8601>",
180
+ }
181
+
182
+ or::
183
+
184
+ {
185
+ "ok": False,
186
+ "error": "<reason>",
187
+ }
188
+ """
189
+ http = client or self._http_client
190
+ if http is None:
191
+ return {"ok": False, "error": "no http client available"}
192
+
193
+ scope_key = frozenset(scopes)
194
+ try:
195
+ cap_jwt = await self._ensure_cap(http, scope_key)
196
+ except _SessionMissing:
197
+ return {"ok": False, "error": "session_missing"}
198
+ except _CapMintError as exc:
199
+ return {"ok": False, "error": f"cap_mint_error: {exc}"}
200
+ except httpx.HTTPError as exc:
201
+ return {"ok": False, "error": f"http_error: {exc}"}
202
+
203
+ cached = self._caps.get(scope_key)
204
+ expires_iso = ""
205
+ if cached is not None:
206
+ try:
207
+ expires_iso = datetime.utcfromtimestamp(cached.expires_at_unix).strftime(
208
+ "%Y-%m-%dT%H:%M:%SZ"
209
+ )
210
+ except (OSError, OverflowError, ValueError):
211
+ expires_iso = ""
212
+
213
+ return {"ok": True, "capability": cap_jwt, "expires_at": expires_iso}
214
+
215
+ async def get_query(
216
+ self,
217
+ path: str,
218
+ params: dict[str, Any] | None,
219
+ *,
220
+ client: httpx.AsyncClient | None = None,
221
+ ) -> dict[str, Any]:
222
+ """Return a cached (or freshly fetched) query result.
223
+
224
+ Returns::
225
+
226
+ {
227
+ "ok": True,
228
+ "body": <any json>,
229
+ "cached_at": <float epoch>,
230
+ }
231
+
232
+ or::
233
+
234
+ {
235
+ "ok": False,
236
+ "error": "<reason>",
237
+ }
238
+ """
239
+ http = client or self._http_client
240
+ if http is None:
241
+ return {"ok": False, "error": "no http client available"}
242
+
243
+ # Normalise params into a stable hashable key.
244
+ frozen_params: frozenset[tuple[str, str]]
245
+ if params:
246
+ frozen_params = frozenset((str(k), str(v)) for k, v in sorted(params.items()))
247
+ else:
248
+ frozen_params = frozenset()
249
+
250
+ cache_key = (path, frozen_params)
251
+ now = time.time()
252
+
253
+ # Cache hit: return immediately if within TTL.
254
+ cached = self._queries.get(cache_key)
255
+ if cached is not None and (now - cached.cached_at) < QUERY_CACHE_TTL:
256
+ return {"ok": True, "body": cached.body, "cached_at": cached.cached_at}
257
+
258
+ # Cache miss or stale: fetch fresh.
259
+ try:
260
+ body = await self._fetch_query(http, path, params or {})
261
+ except _SessionMissing:
262
+ return {"ok": False, "error": "session_missing"}
263
+ except _CapMintError as exc:
264
+ return {"ok": False, "error": f"cap_mint_error: {exc}"}
265
+ except httpx.HTTPError as exc:
266
+ return {"ok": False, "error": f"http_error: {exc}"}
267
+
268
+ entry = _CachedQuery(body=body, cached_at=time.time())
269
+ self._queries[cache_key] = entry
270
+ return {"ok": True, "body": entry.body, "cached_at": entry.cached_at}
271
+
272
+ def invalidate_cap(self, scopes: list[str]) -> None:
273
+ """Drop the cached cap for ``scopes`` so the next ``cap.get`` re-mints.
274
+
275
+ Called by the Unix socket server on 401/403 from the caller's upstream.
276
+ """
277
+ self._caps.pop(frozenset(scopes), None)
278
+
279
+ def update_session(self, session: "Session | None") -> None:
280
+ """Replace the session (called when the daemon reloads session.json)."""
281
+ self._session = session
282
+
283
+ # ------------------------------------------------------------------
284
+ # Internal helpers
285
+ # ------------------------------------------------------------------
286
+
287
+ async def _ensure_cap(
288
+ self,
289
+ http: httpx.AsyncClient,
290
+ scope_key: frozenset[str],
291
+ ) -> str:
292
+ """Return a fresh capability JWT, minting if cache is stale or empty."""
293
+ now = time.time()
294
+ cached = self._caps.get(scope_key)
295
+ if cached is not None and cached.is_fresh(now) and cached.has_uses():
296
+ cached.take_use()
297
+ return cached.capability
298
+
299
+ session = self._session
300
+ if session is None:
301
+ raise _SessionMissing()
302
+
303
+ # Mint a new cap via the handle-alter realm endpoint. Sorted scopes
304
+ # are sent as a list so the server can validate them all at once.
305
+ url = f"{session.api.rstrip('/')}/api/v1/messaging/sessions-ingest-capability"
306
+ headers = {
307
+ "Authorization": f"Bearer {session.jwt}",
308
+ "Accept": "application/json",
309
+ }
310
+ # The parameterless endpoint is the correct path for
311
+ # ``alter_events.sessions.ingest`` scoped caps (D-COORD-D2 Wave C).
312
+ # For generic scopes (cap.get called with other scopes) the same
313
+ # endpoint is used; the server rejects unknown scopes with 422 which
314
+ # surfaces as a _CapMintError.
315
+ response = await http.post(url, headers=headers)
316
+
317
+ if response.status_code in (401, 403):
318
+ raise _CapMintError(
319
+ f"cap-mint rejected (HTTP {response.status_code}): {response.text[:200]}"
320
+ )
321
+ response.raise_for_status()
322
+
323
+ try:
324
+ data = response.json()
325
+ except (ValueError, json.JSONDecodeError) as exc:
326
+ raise _CapMintError("cap-mint returned non-JSON body") from exc
327
+
328
+ if not isinstance(data, dict):
329
+ raise _CapMintError("cap-mint returned non-object body")
330
+
331
+ capability = data.get("capability")
332
+ expires_at = data.get("expires_at")
333
+ if not isinstance(capability, str) or not capability:
334
+ raise _CapMintError("cap-mint response missing capability")
335
+ if not isinstance(expires_at, str) or not expires_at:
336
+ raise _CapMintError("cap-mint response missing expires_at")
337
+
338
+ try:
339
+ expires_at_unix = datetime.fromisoformat(expires_at.replace("Z", "+00:00")).timestamp()
340
+ except ValueError as exc:
341
+ raise _CapMintError(f"cap-mint returned non-ISO expires_at: {expires_at}") from exc
342
+
343
+ # Clamp TTL to [CAP_CACHE_TTL_MIN, CAP_CACHE_TTL_MAX].
344
+ now2 = time.time()
345
+ raw_ttl = expires_at_unix - now2
346
+ clamped_ttl = max(CAP_CACHE_TTL_MIN, min(raw_ttl, CAP_CACHE_TTL_MAX))
347
+ if clamped_ttl != raw_ttl:
348
+ expires_at_unix = now2 + clamped_ttl
349
+
350
+ cap = _CachedCap(
351
+ capability=capability,
352
+ expires_at_unix=expires_at_unix,
353
+ )
354
+ cap.take_use()
355
+ self._caps[scope_key] = cap
356
+ return capability
357
+
358
+ async def _fetch_query(
359
+ self,
360
+ http: httpx.AsyncClient,
361
+ path: str,
362
+ params: dict[str, Any],
363
+ ) -> Any:
364
+ """Fetch ``GET /orgs/{slug}/queries/{path}`` with a fresh cap."""
365
+ session = self._session
366
+ if session is None:
367
+ raise _SessionMissing()
368
+
369
+ # Use the default ingest scope to cap-gate query requests, matching
370
+ # the server-side scope requirement. The scope key used here must
371
+ # match what the caller registered (frozenset of the same scope list).
372
+ scope_key = frozenset(["alter_events.sessions.ingest"])
373
+ cap_jwt = await self._ensure_cap(http, scope_key)
374
+
375
+ url = f"{session.api.rstrip('/')}/orgs/queries/{path.lstrip('/')}"
376
+ headers = {
377
+ "Authorization": f"Bearer {cap_jwt}",
378
+ "Accept": "application/json",
379
+ }
380
+ response = await http.get(url, params=params or None, headers=headers)
381
+
382
+ if response.status_code in (401, 403):
383
+ # Drop the cap entry so the next call re-mints.
384
+ self._caps.pop(scope_key, None)
385
+ raise _CapMintError(f"query cap rejected (HTTP {response.status_code})")
386
+
387
+ response.raise_for_status()
388
+
389
+ try:
390
+ return response.json()
391
+ except (ValueError, json.JSONDecodeError):
392
+ return response.text