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,273 @@
1
+ """TokenUsageClient - authenticated HTTP client for the token-usage audit endpoint.
2
+
3
+ Posts batches of parsed token-usage events to
4
+ ``POST /api/v1/admin/token-usage/events`` on the ALTER backend.
5
+
6
+ AUTH GAP (TOKEN_USAGE_AUTH_GAP)
7
+ --------------------------------
8
+
9
+ The full ``ensureFreshSession`` JWT refresh substrate (implemented in
10
+ alter-cli/src/commands/login.ts) is not yet wired into alter-runtime's Python
11
+ daemon. Until that Wave 2 follow-up lands, the JWT is:
12
+
13
+ 1. Sourced from ``load_session()`` at adapter startup (the JWT written by
14
+ ``alter login`` to ``~/.config/alter/session.json``).
15
+ 2. Overridable at runtime via the ``ALTER_RUNTIME_SESSION_JWT`` env var (allows
16
+ a wrapper script or systemd ``EnvironmentFile`` to inject a freshly minted
17
+ token without restarting the daemon).
18
+ 3. Passed as a ``Callable[[], str | None]`` provider so the adapter can swap
19
+ in a proper refresh routine in the follow-up without changing the call site.
20
+
21
+ Short-lived JWTs (typ. 300s on the ALTER backend) will cause 401s between
22
+ ``alter login`` and the Wave 2 refresh wiring. The daemon logs a warning and
23
+ drops the batch in that case; the offset is NOT advanced, so the next watchdog
24
+ tick will re-attempt. This is acceptable during the Wave 1 / pre-launch window
25
+ where Blake is the only principal running the daemon.
26
+
27
+ Identification
28
+ --------------
29
+
30
+ ``host_id`` is a stable, opaque identifier for the machine running the daemon,
31
+ derived from ``socket.gethostname()`` plus the first 8 chars of
32
+ ``/etc/machine-id`` (Linux) or ``platform.node()`` (fallback). It is NOT a
33
+ PII field - it's a session-correlation key so the admin dashboard can group
34
+ events by host when a principal operates multiple machines.
35
+
36
+ Rate limiting
37
+ -------------
38
+
39
+ At most one POST per 5 seconds. The caller (ClaudeJsonlWatcher) enforces this
40
+ but TokenUsageClient also tracks its own last-POST timestamp as a defence-in-depth
41
+ guard. Retries use exponential backoff (1s, 2s, 4s) on 5xx, max 3 attempts.
42
+ The batch is dropped (not re-queued) after exhausting retries; the adapter's
43
+ un-advanced offset provides the re-try on the next watchdog tick.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import asyncio
49
+ import functools
50
+ import logging
51
+ import os
52
+ import platform
53
+ import socket
54
+ import time
55
+ from typing import Callable
56
+
57
+ import httpx
58
+
59
+ __all__ = ["TokenUsageClient"]
60
+
61
+ logger = logging.getLogger("alter_runtime.clients.token_usage_client")
62
+
63
+ #: Endpoint path for posting token-usage events.
64
+ TOKEN_USAGE_EVENTS_PATH = "/api/v1/admin/token-usage/events"
65
+
66
+ #: Rate-limit: minimum seconds between successive POSTs.
67
+ MIN_POST_INTERVAL_SECONDS: float = 5.0
68
+
69
+ #: Retry configuration.
70
+ MAX_RETRIES: int = 3
71
+ RETRY_BASE_SECONDS: float = 1.0
72
+ RETRYABLE_HTTP_CODES = frozenset({429, 500, 502, 503, 504})
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # host_id derivation (singleton, derived once)
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ @functools.lru_cache(maxsize=1)
81
+ def _derive_host_id() -> str:
82
+ """Return a stable, opaque identifier for the current machine.
83
+
84
+ Composed from ``socket.gethostname()`` and the first 8 characters of
85
+ ``/etc/machine-id`` (Linux), falling back to ``platform.node()`` when
86
+ the machine-id file is absent or unreadable. The resulting string is
87
+ safe to transmit (no PII) and stable across daemon restarts.
88
+ """
89
+ hostname = socket.gethostname()
90
+ machine_suffix = ""
91
+ machine_id_path = "/etc/machine-id"
92
+ try:
93
+ raw = open(machine_id_path).read().strip() # noqa: WPS515
94
+ if raw:
95
+ machine_suffix = raw[:8]
96
+ except OSError:
97
+ # macOS / Windows / containers without machine-id.
98
+ machine_suffix = platform.node()[:8]
99
+
100
+ return f"{hostname}-{machine_suffix}" if machine_suffix else hostname
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # TokenUsageClient
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class TokenUsageClient:
109
+ """Async client for the ALTER backend token-usage audit endpoint.
110
+
111
+ Parameters
112
+ ----------
113
+ base_url:
114
+ Base URL of the ALTER backend API. Reads ``ALTER_RUNTIME_API_BASE``
115
+ env var at construction if not explicitly provided; defaults to
116
+ ``https://api.truealter.com``.
117
+ jwt_provider:
118
+ Callable that returns the current session JWT string (or ``None`` if
119
+ not yet authenticated). Called on every POST so that a future
120
+ refresh-token flow can inject a fresh JWT without restarting the
121
+ adapter.
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ base_url: str | None = None,
127
+ jwt_provider: Callable[[], str | None] | None = None,
128
+ ) -> None:
129
+ resolved_base = base_url or os.environ.get(
130
+ "ALTER_RUNTIME_API_BASE", "https://api.truealter.com"
131
+ )
132
+ self._base_url = resolved_base.rstrip("/")
133
+ self._jwt_provider = jwt_provider or (lambda: None)
134
+ self._host_id: str = _derive_host_id()
135
+ self._last_post_time: float = 0.0
136
+ self._http: httpx.AsyncClient | None = None
137
+
138
+ def _ensure_http(self) -> httpx.AsyncClient:
139
+ if self._http is None:
140
+ self._http = httpx.AsyncClient(timeout=30.0)
141
+ return self._http
142
+
143
+ async def close(self) -> None:
144
+ """Close the underlying HTTP client."""
145
+ if self._http is not None:
146
+ await self._http.aclose()
147
+ self._http = None
148
+
149
+ async def post_events(self, events: list[dict]) -> dict:
150
+ """POST a batch of token-usage events to the backend.
151
+
152
+ Parameters
153
+ ----------
154
+ events:
155
+ List of parsed event dicts (output of ``parse_assistant_line``
156
+ with ``project_slug`` added by the adapter).
157
+
158
+ Returns
159
+ -------
160
+ dict
161
+ Backend response body, e.g. ``{"inserted": N, "skipped_duplicates": M}``.
162
+
163
+ Raises
164
+ ------
165
+ Exception
166
+ On persistent HTTP failure after ``MAX_RETRIES`` attempts. The
167
+ caller (ClaudeJsonlWatcher) catches this and withholds offset
168
+ advancement so the next tick will re-attempt.
169
+ """
170
+ if not events:
171
+ return {"inserted": 0, "skipped_duplicates": 0}
172
+
173
+ # Self-enforced rate limit (defence-in-depth; adapter also rate-limits).
174
+ now = time.monotonic()
175
+ wait = MIN_POST_INTERVAL_SECONDS - (now - self._last_post_time)
176
+ if wait > 0:
177
+ await asyncio.sleep(wait)
178
+
179
+ jwt = self._jwt_provider()
180
+ if not jwt:
181
+ logger.warning(
182
+ "token_usage_client: no JWT available - dropping batch of %d events. "
183
+ "Run `alter login` or set ALTER_RUNTIME_SESSION_JWT.",
184
+ len(events),
185
+ )
186
+ raise RuntimeError("no JWT available for token-usage POST")
187
+
188
+ payload = {
189
+ "host_id": self._host_id,
190
+ "events": events,
191
+ }
192
+ url = self._base_url + TOKEN_USAGE_EVENTS_PATH
193
+ http = self._ensure_http()
194
+
195
+ last_exc: Exception | None = None
196
+ for attempt in range(MAX_RETRIES):
197
+ try:
198
+ # Per-request headers: Authorization + Content-Type +
199
+ # the canonical ``X-Alter-Client-*`` identity bundle
200
+ # (D-MIN-VERSION-FLOOR-1 §3). The X-Alter-* bundle is
201
+ # required on every authenticated backend call so the
202
+ # server-side floor middleware can identify the daemon.
203
+ from alter_runtime.http_auth import backend_default_headers
204
+
205
+ resp = await http.post(
206
+ url,
207
+ json=payload,
208
+ headers={
209
+ **backend_default_headers(),
210
+ "Authorization": f"Bearer {jwt}",
211
+ "Content-Type": "application/json",
212
+ },
213
+ )
214
+ if resp.status_code in RETRYABLE_HTTP_CODES and attempt < MAX_RETRIES - 1:
215
+ backoff = RETRY_BASE_SECONDS * (2**attempt)
216
+ logger.warning(
217
+ "token_usage_client: HTTP %d (attempt %d/%d), retrying in %.1fs",
218
+ resp.status_code,
219
+ attempt + 1,
220
+ MAX_RETRIES,
221
+ backoff,
222
+ )
223
+ await asyncio.sleep(backoff)
224
+ continue
225
+ resp.raise_for_status()
226
+ self._last_post_time = time.monotonic()
227
+ try:
228
+ return resp.json()
229
+ except Exception:
230
+ return {"inserted": len(events), "skipped_duplicates": 0}
231
+ except httpx.HTTPStatusError as exc:
232
+ status = exc.response.status_code
233
+ if status in RETRYABLE_HTTP_CODES and attempt < MAX_RETRIES - 1:
234
+ backoff = RETRY_BASE_SECONDS * (2**attempt)
235
+ logger.warning(
236
+ "token_usage_client: HTTP %d (attempt %d/%d), retrying in %.1fs",
237
+ status,
238
+ attempt + 1,
239
+ MAX_RETRIES,
240
+ backoff,
241
+ )
242
+ last_exc = exc
243
+ await asyncio.sleep(backoff)
244
+ continue
245
+ logger.error(
246
+ "token_usage_client: persistent HTTP %d - dropping batch of %d events",
247
+ status,
248
+ len(events),
249
+ )
250
+ raise
251
+ except httpx.RequestError as exc:
252
+ if attempt < MAX_RETRIES - 1:
253
+ backoff = RETRY_BASE_SECONDS * (2**attempt)
254
+ logger.warning(
255
+ "token_usage_client: request error (attempt %d/%d): %s, retrying in %.1fs",
256
+ attempt + 1,
257
+ MAX_RETRIES,
258
+ exc,
259
+ backoff,
260
+ )
261
+ last_exc = exc
262
+ await asyncio.sleep(backoff)
263
+ continue
264
+ logger.error(
265
+ "token_usage_client: persistent request error - "
266
+ "dropping batch of %d events: %s",
267
+ len(events),
268
+ exc,
269
+ )
270
+ raise
271
+
272
+ # Should not reach here; re-raise last captured exception.
273
+ raise last_exc or RuntimeError("post_events exhausted retries")