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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- 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")
|