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,467 @@
|
|
|
1
|
+
"""SessionPresenceWriter - projects cross-host CC presence into the shared cache.
|
|
2
|
+
|
|
3
|
+
The Worker holds a /queries/presence projection that aggregates session_presence
|
|
4
|
+
intent rows across hosts (the alter monorepo's ``.claude/hooks/cc-broadcast.sh``
|
|
5
|
+
emits these as start/heartbeat/stop). This subscriber polls the projection on
|
|
6
|
+
a configurable cadence and writes the result to
|
|
7
|
+
``~/.local/share/org-alter/state/sessions.json``. The bash awareness hook
|
|
8
|
+
(``.claude/hooks/cc-awareness.sh`` in the alter monorepo) merges that cache
|
|
9
|
+
with same-host ``/dev/shm`` sibling files to give every CC session a unified
|
|
10
|
+
view of all parallel sessions across hosts.
|
|
11
|
+
|
|
12
|
+
D-RT1 (local sovereign daemon), D-RT9 (graceful fallback). Designed to be the
|
|
13
|
+
canonical L3 writer the Phase B handover called out - the alternative was a
|
|
14
|
+
standalone systemd timer that would have been deprecated as soon as
|
|
15
|
+
alter-runtime caught up. Landing it here from the start avoids the churn.
|
|
16
|
+
|
|
17
|
+
Auth flow per cycle:
|
|
18
|
+
|
|
19
|
+
1. **Cap mint.** ``POST {api}/api/v1/org-alter/caps`` body
|
|
20
|
+
``{"scopes":["alter_org.read"], "residual_seconds": 240}`` with
|
|
21
|
+
``Authorization: Bearer <session.jwt>``. Returns
|
|
22
|
+
``{capability, expires_at, jti, sub, aud_recipient, scopes, uses?, mode?}``.
|
|
23
|
+
2. **Query.** ``GET {worker}/orgs/{slug}/queries/presence`` with
|
|
24
|
+
``Authorization: Bearer <cap>`` and ``X-Cap-Use-Index: <index>``.
|
|
25
|
+
|
|
26
|
+
Caps are kept in-memory with a 30s lead refresh; bounded multi-use caps
|
|
27
|
+
(proposed-D-CAP-1) are honoured. The reader does not subscribe to the bus -
|
|
28
|
+
awareness is a file-mediated handoff to bash hooks, and the bus would add
|
|
29
|
+
nothing useful.
|
|
30
|
+
|
|
31
|
+
Cache file shape mirrors the Worker's PresenceResponse so the bash hook's
|
|
32
|
+
``jq`` path stays simple::
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
"presence": [
|
|
36
|
+
{"actor": "~blake", "tool": "cc", "session_id": "abc...",
|
|
37
|
+
"started_at": "...", "last_seen": "...", "state": "heartbeat"},
|
|
38
|
+
...
|
|
39
|
+
],
|
|
40
|
+
"window_ms": 1800000,
|
|
41
|
+
"now": "2026-05-07T...",
|
|
42
|
+
"writer": "alter-runtime",
|
|
43
|
+
"writer_at": "2026-05-07T..."
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Failure modes:
|
|
47
|
+
|
|
48
|
+
* No session.json - log once, idle until the next poll. The daemon never
|
|
49
|
+
manages login.
|
|
50
|
+
* Cap-mint reject (4xx, 503) - exponential back-off up to MAX_POLL_BACKOFF.
|
|
51
|
+
* Worker reject - exponential back-off; cache file is left as-is so stale
|
|
52
|
+
cross-host data keeps rendering for the rolling-window grace period.
|
|
53
|
+
* Network error - exponential back-off.
|
|
54
|
+
|
|
55
|
+
The subscriber never raises out of :meth:`run` - the supervisor's
|
|
56
|
+
exponential-backoff restart machinery is a last-resort safety net only.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import asyncio
|
|
62
|
+
import contextlib
|
|
63
|
+
import json
|
|
64
|
+
import logging
|
|
65
|
+
import os
|
|
66
|
+
import time
|
|
67
|
+
from dataclasses import dataclass, field
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
from typing import TYPE_CHECKING, Any
|
|
70
|
+
|
|
71
|
+
import httpx
|
|
72
|
+
|
|
73
|
+
from alter_runtime.config import DaemonConfig
|
|
74
|
+
from alter_runtime.daemon import Component
|
|
75
|
+
from alter_runtime.http_auth import backend_default_headers
|
|
76
|
+
from alter_runtime.subscribers.do_sse import _build_tls_context
|
|
77
|
+
|
|
78
|
+
if TYPE_CHECKING:
|
|
79
|
+
from alter_runtime.config import Session
|
|
80
|
+
|
|
81
|
+
__all__ = ["SessionPresenceWriter", "session_presence_cache_path"]
|
|
82
|
+
|
|
83
|
+
logger = logging.getLogger("alter_runtime.subscribers.session_presence")
|
|
84
|
+
|
|
85
|
+
#: Cap scope required for /queries/presence reads.
|
|
86
|
+
READ_SCOPE: str = "alter_org.read"
|
|
87
|
+
|
|
88
|
+
#: Default residual-lifetime ceiling at mint time. The backend caps this at
|
|
89
|
+
#: 300s for read scopes and the cache refreshes 30s before expiry, so the
|
|
90
|
+
#: effective re-mint cadence is ~210s.
|
|
91
|
+
DEFAULT_CAP_RESIDUAL_SECONDS: int = 240
|
|
92
|
+
|
|
93
|
+
#: Refresh leeway - re-mint when expiry is closer than this.
|
|
94
|
+
CAP_REFRESH_LEAD_SECONDS: float = 30.0
|
|
95
|
+
|
|
96
|
+
#: Upper bound on the subscriber's exponential backoff when either cap-mint
|
|
97
|
+
#: or the Worker query is failing.
|
|
98
|
+
MAX_POLL_BACKOFF_SECONDS: float = 60.0
|
|
99
|
+
|
|
100
|
+
#: Filename under the org-alter state directory that the bash awareness hook
|
|
101
|
+
#: reads. Co-ordinated with .claude/hooks/cc-awareness.sh in the alter
|
|
102
|
+
#: monorepo - changing this requires a paired edit there.
|
|
103
|
+
CACHE_FILENAME: str = "sessions.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def session_presence_cache_path() -> Path:
|
|
107
|
+
"""Return the canonical sessions.json cache path.
|
|
108
|
+
|
|
109
|
+
Honours ``ORG_ALTER_STATE_DIR`` for parity with mcp-alter-collective.
|
|
110
|
+
"""
|
|
111
|
+
override = os.environ.get("ORG_ALTER_STATE_DIR")
|
|
112
|
+
if override:
|
|
113
|
+
base = Path(override).expanduser()
|
|
114
|
+
else:
|
|
115
|
+
base = Path.home() / ".local" / "share" / "org-alter"
|
|
116
|
+
return base / "state" / CACHE_FILENAME
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# In-memory cap cache
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class _CachedCap:
|
|
126
|
+
capability: str
|
|
127
|
+
expires_at_unix: float
|
|
128
|
+
uses_available: int
|
|
129
|
+
use_counter: int
|
|
130
|
+
|
|
131
|
+
def is_fresh(self, now: float) -> bool:
|
|
132
|
+
return self.expires_at_unix - now > CAP_REFRESH_LEAD_SECONDS
|
|
133
|
+
|
|
134
|
+
def has_uses(self) -> bool:
|
|
135
|
+
return self.use_counter < self.uses_available
|
|
136
|
+
|
|
137
|
+
def take_use(self) -> int:
|
|
138
|
+
index = self.use_counter
|
|
139
|
+
self.use_counter += 1
|
|
140
|
+
return index
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Subscriber state - mirrors McpFallbackSubscriber's _FallbackState pattern
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class _PresenceState:
|
|
150
|
+
"""Internal state (exposed for tests)."""
|
|
151
|
+
|
|
152
|
+
poll_count: int = 0
|
|
153
|
+
write_count: int = 0
|
|
154
|
+
backoff: float = 0.0
|
|
155
|
+
last_response_at: float = 0.0
|
|
156
|
+
history: list[str] = field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Component
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class SessionPresenceWriter(Component):
|
|
165
|
+
"""Polls the org-alter presence projection and writes a local cache.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
config:
|
|
170
|
+
Loaded :class:`DaemonConfig`. Reads the new
|
|
171
|
+
``session_presence_*`` and ``org_alter_*`` fields.
|
|
172
|
+
session:
|
|
173
|
+
Authenticated alter-cli :class:`Session`. Used for the bearer JWT
|
|
174
|
+
when minting caps. Without a session the component idles silently
|
|
175
|
+
and re-checks on every poll cycle.
|
|
176
|
+
cache_path:
|
|
177
|
+
Override the cache file path. Tests inject ``tmp_path``.
|
|
178
|
+
http_client:
|
|
179
|
+
Optional ``httpx.AsyncClient`` override for tests.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
name = "session_presence"
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
config: DaemonConfig,
|
|
187
|
+
session: Session | None,
|
|
188
|
+
*,
|
|
189
|
+
cache_path: Path | None = None,
|
|
190
|
+
http_client: httpx.AsyncClient | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
self._config = config
|
|
193
|
+
self._session = session
|
|
194
|
+
self._cache_path: Path = (
|
|
195
|
+
cache_path if cache_path is not None else session_presence_cache_path()
|
|
196
|
+
)
|
|
197
|
+
self._http_client = http_client
|
|
198
|
+
self._owns_client = http_client is None
|
|
199
|
+
self._stop_event = asyncio.Event()
|
|
200
|
+
self._state = _PresenceState()
|
|
201
|
+
self._cap: _CachedCap | None = None
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# Component lifecycle
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
async def run(self) -> None:
|
|
208
|
+
if not self._config.session_presence_enabled:
|
|
209
|
+
logger.info("session_presence disabled by config - idle")
|
|
210
|
+
await self._stop_event.wait()
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
logger.info(
|
|
214
|
+
"session_presence starting cache=%s interval=%.1fs",
|
|
215
|
+
self._cache_path,
|
|
216
|
+
self._config.session_presence_poll_interval_seconds,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
client = self._http_client or httpx.AsyncClient(
|
|
220
|
+
timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
|
|
221
|
+
verify=_build_tls_context(),
|
|
222
|
+
# Backend default headers — CF Access service-token bundle
|
|
223
|
+
# (D-SUBSTRATE-UNIFIED-1 §2.3 Option A) merged with the
|
|
224
|
+
# canonical ``X-Alter-Client-*`` identity headers
|
|
225
|
+
# (D-MIN-VERSION-FLOOR-1 §3) so the server-side floor
|
|
226
|
+
# middleware can identify the daemon.
|
|
227
|
+
headers=backend_default_headers(),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
while not self._stop_event.is_set():
|
|
232
|
+
await self._poll_once_safe(client)
|
|
233
|
+
await self._sleep_interruptible(self._config.session_presence_poll_interval_seconds)
|
|
234
|
+
finally:
|
|
235
|
+
if self._owns_client:
|
|
236
|
+
with contextlib.suppress(Exception):
|
|
237
|
+
await client.aclose()
|
|
238
|
+
logger.info("session_presence stopped")
|
|
239
|
+
|
|
240
|
+
async def stop(self) -> None:
|
|
241
|
+
self._stop_event.set()
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
# Poll loop
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
async def _poll_once_safe(self, client: httpx.AsyncClient) -> None:
|
|
248
|
+
"""Wrap _poll_once with backoff + last-resort exception swallowing.
|
|
249
|
+
|
|
250
|
+
The supervisor restarts on bare exceptions, so we'd rather log and
|
|
251
|
+
continue than tear down the component for a transient blip.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
await self._poll_once(client)
|
|
255
|
+
self._state.backoff = 0.0
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
raise
|
|
258
|
+
except _SessionMissing:
|
|
259
|
+
# No alter session - log once, sleep through cycle.
|
|
260
|
+
if "no_session" not in self._state.history:
|
|
261
|
+
self._state.history.append("no_session")
|
|
262
|
+
logger.info(
|
|
263
|
+
"session_presence: no alter session - run `alter login`. "
|
|
264
|
+
"Will retry on the next poll silently."
|
|
265
|
+
)
|
|
266
|
+
self._state.backoff = max(self._state.backoff, 5.0)
|
|
267
|
+
except (httpx.HTTPError, _CapMintError, _WorkerQueryError) as exc:
|
|
268
|
+
self._state.backoff = min(
|
|
269
|
+
max(self._state.backoff * 2 if self._state.backoff else 2.0, 2.0),
|
|
270
|
+
MAX_POLL_BACKOFF_SECONDS,
|
|
271
|
+
)
|
|
272
|
+
logger.warning(
|
|
273
|
+
"session_presence poll failed: %s - backoff %.1fs",
|
|
274
|
+
exc,
|
|
275
|
+
self._state.backoff,
|
|
276
|
+
)
|
|
277
|
+
except Exception as exc: # noqa: BLE001 - last-resort safety net
|
|
278
|
+
logger.exception("session_presence poll unexpected error: %s", exc)
|
|
279
|
+
self._state.backoff = MAX_POLL_BACKOFF_SECONDS
|
|
280
|
+
|
|
281
|
+
async def _poll_once(self, client: httpx.AsyncClient) -> None:
|
|
282
|
+
"""Mint cap if needed, GET presence, write cache."""
|
|
283
|
+
session = self._session
|
|
284
|
+
if session is None:
|
|
285
|
+
raise _SessionMissing()
|
|
286
|
+
|
|
287
|
+
cap, use_index = await self._get_cap(client, session)
|
|
288
|
+
response = await self._fetch_presence(client, cap, use_index)
|
|
289
|
+
self._write_cache(response)
|
|
290
|
+
self._state.poll_count += 1
|
|
291
|
+
self._state.write_count += 1
|
|
292
|
+
self._state.last_response_at = time.time()
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
# Cap minting
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
async def _get_cap(
|
|
299
|
+
self,
|
|
300
|
+
client: httpx.AsyncClient,
|
|
301
|
+
session: Session,
|
|
302
|
+
) -> tuple[str, int]:
|
|
303
|
+
"""Return (capability, use_index). Mints a new cap if cache is stale."""
|
|
304
|
+
now = time.time()
|
|
305
|
+
if self._cap is not None and self._cap.is_fresh(now) and self._cap.has_uses():
|
|
306
|
+
return self._cap.capability, self._cap.take_use()
|
|
307
|
+
|
|
308
|
+
url = f"{session.api.rstrip('/')}/api/v1/org-alter/caps"
|
|
309
|
+
body = {
|
|
310
|
+
"scopes": [READ_SCOPE],
|
|
311
|
+
"residual_seconds": DEFAULT_CAP_RESIDUAL_SECONDS,
|
|
312
|
+
}
|
|
313
|
+
headers = {
|
|
314
|
+
"Authorization": f"Bearer {session.jwt}",
|
|
315
|
+
"Content-Type": "application/json",
|
|
316
|
+
"Accept": "application/json",
|
|
317
|
+
}
|
|
318
|
+
response = await client.post(url, json=body, headers=headers)
|
|
319
|
+
if response.status_code == 401 or response.status_code == 403:
|
|
320
|
+
raise _CapMintError(
|
|
321
|
+
f"cap-mint rejected (HTTP {response.status_code}): {response.text[:200]}"
|
|
322
|
+
)
|
|
323
|
+
response.raise_for_status()
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
data = response.json()
|
|
327
|
+
except ValueError as exc:
|
|
328
|
+
raise _CapMintError("cap-mint returned non-JSON body") from exc
|
|
329
|
+
|
|
330
|
+
if not isinstance(data, dict):
|
|
331
|
+
raise _CapMintError("cap-mint returned non-object body")
|
|
332
|
+
|
|
333
|
+
capability = data.get("capability")
|
|
334
|
+
expires_at = data.get("expires_at")
|
|
335
|
+
if not isinstance(capability, str) or not capability:
|
|
336
|
+
raise _CapMintError("cap-mint response missing capability")
|
|
337
|
+
if not isinstance(expires_at, str) or not expires_at:
|
|
338
|
+
raise _CapMintError("cap-mint response missing expires_at")
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
from datetime import datetime
|
|
342
|
+
|
|
343
|
+
expires_at_unix = datetime.fromisoformat(expires_at.replace("Z", "+00:00")).timestamp()
|
|
344
|
+
except ValueError as exc:
|
|
345
|
+
raise _CapMintError(f"cap-mint returned non-ISO expires_at: {expires_at}") from exc
|
|
346
|
+
|
|
347
|
+
# Worker rejects mode=stream caps client-side via the same gate as
|
|
348
|
+
# mcp-alter-collective - surface it as a clean error here too.
|
|
349
|
+
mode = data.get("mode")
|
|
350
|
+
if mode is not None and mode != "office":
|
|
351
|
+
raise _CapMintError(f"cap-mint returned unsupported mode: {mode!r}")
|
|
352
|
+
|
|
353
|
+
uses_raw = data.get("uses")
|
|
354
|
+
uses_available = uses_raw if isinstance(uses_raw, int) and uses_raw >= 1 else 1
|
|
355
|
+
|
|
356
|
+
cap = _CachedCap(
|
|
357
|
+
capability=capability,
|
|
358
|
+
expires_at_unix=expires_at_unix,
|
|
359
|
+
uses_available=uses_available,
|
|
360
|
+
use_counter=1,
|
|
361
|
+
)
|
|
362
|
+
self._cap = cap
|
|
363
|
+
return capability, 0
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
# Worker query
|
|
367
|
+
# ------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
async def _fetch_presence(
|
|
370
|
+
self,
|
|
371
|
+
client: httpx.AsyncClient,
|
|
372
|
+
capability: str,
|
|
373
|
+
use_index: int,
|
|
374
|
+
) -> dict[str, Any]:
|
|
375
|
+
"""GET /queries/presence with the cap. Returns parsed JSON dict."""
|
|
376
|
+
endpoint = self._config.org_alter_presence_endpoint
|
|
377
|
+
headers = {
|
|
378
|
+
"Authorization": f"Bearer {capability}",
|
|
379
|
+
"Accept": "application/json",
|
|
380
|
+
"X-Cap-Use-Index": str(use_index),
|
|
381
|
+
}
|
|
382
|
+
response = await client.get(endpoint, headers=headers)
|
|
383
|
+
if response.status_code == 401 or response.status_code == 403:
|
|
384
|
+
# Cap was rejected - drop it so the next cycle re-mints.
|
|
385
|
+
self._cap = None
|
|
386
|
+
raise _WorkerQueryError(
|
|
387
|
+
f"presence query auth rejected (HTTP {response.status_code}): {response.text[:200]}"
|
|
388
|
+
)
|
|
389
|
+
response.raise_for_status()
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
data = response.json()
|
|
393
|
+
except ValueError as exc:
|
|
394
|
+
raise _WorkerQueryError("presence query returned non-JSON body") from exc
|
|
395
|
+
|
|
396
|
+
if not isinstance(data, dict):
|
|
397
|
+
raise _WorkerQueryError("presence query returned non-object body")
|
|
398
|
+
return data
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
# Atomic write
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def _write_cache(self, worker_response: dict[str, Any]) -> None:
|
|
405
|
+
"""Augment the Worker payload with writer metadata, atomic-write to disk."""
|
|
406
|
+
from datetime import datetime, timezone
|
|
407
|
+
|
|
408
|
+
envelope = dict(worker_response)
|
|
409
|
+
envelope["writer"] = "alter-runtime"
|
|
410
|
+
envelope["writer_at"] = datetime.now(tz=timezone.utc).isoformat()
|
|
411
|
+
|
|
412
|
+
self._cache_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
413
|
+
tmp_path = self._cache_path.with_suffix(self._cache_path.suffix + ".tmp")
|
|
414
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
415
|
+
fd = os.open(tmp_path, flags, 0o600)
|
|
416
|
+
try:
|
|
417
|
+
with contextlib.suppress(OSError):
|
|
418
|
+
os.fchmod(fd, 0o600)
|
|
419
|
+
payload = json.dumps(envelope, separators=(",", ":"), ensure_ascii=False)
|
|
420
|
+
os.write(fd, payload.encode("utf-8"))
|
|
421
|
+
os.fsync(fd)
|
|
422
|
+
finally:
|
|
423
|
+
os.close(fd)
|
|
424
|
+
os.replace(tmp_path, self._cache_path)
|
|
425
|
+
with contextlib.suppress(OSError):
|
|
426
|
+
os.chmod(self._cache_path, 0o600)
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
# Helpers
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
async def _sleep_interruptible(self, seconds: float) -> None:
|
|
433
|
+
"""Sleep ``seconds`` or until stop is set, whichever comes first."""
|
|
434
|
+
effective = max(seconds, self._state.backoff)
|
|
435
|
+
if effective <= 0:
|
|
436
|
+
return
|
|
437
|
+
try:
|
|
438
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=effective)
|
|
439
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def state(self) -> _PresenceState:
|
|
444
|
+
"""Current state (used by tests)."""
|
|
445
|
+
return self._state
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def cache_path(self) -> Path:
|
|
449
|
+
return self._cache_path
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# Internal exception types - kept private so callers can't grep for them
|
|
454
|
+
# outside this module
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class _SessionMissing(Exception):
|
|
459
|
+
"""Raised when the alter-cli session is absent."""
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class _CapMintError(Exception):
|
|
463
|
+
"""Raised when /api/v1/org-alter/caps refuses or returns malformed body."""
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class _WorkerQueryError(Exception):
|
|
467
|
+
"""Raised when /queries/presence refuses or returns malformed body."""
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Minimal Server-Sent Events parser for the alter-runtime daemon.
|
|
2
|
+
|
|
3
|
+
This is a deliberately self-contained, stdlib-only port of
|
|
4
|
+
``backend/app/services/identity_events/subscriber.py`` (lines ~128-192). The
|
|
5
|
+
runtime daemon ships to user devices and must not import from the backend
|
|
6
|
+
package (different runtime, different deploy boundary), so we copy the parser
|
|
7
|
+
verbatim rather than vendoring the backend module.
|
|
8
|
+
|
|
9
|
+
The parser is intentionally minimal: it understands ``event``, ``data`` and
|
|
10
|
+
``id`` fields per the SSE spec, joins multi-line ``data:`` payloads with
|
|
11
|
+
newlines, and silently drops keepalive comments (lines starting with ``:``).
|
|
12
|
+
Unknown fields (``retry``, etc.) are ignored. The wire contract is locked in
|
|
13
|
+
``cloudflare/workers/handle-alter/src/sse.ts`` and the only producer on the
|
|
14
|
+
other side of the socket is ALTER's own DO, so we do not bother handling
|
|
15
|
+
arbitrary SSE producer quirks.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
buffer = ""
|
|
20
|
+
async for chunk in stream.aiter_text():
|
|
21
|
+
buffer += chunk
|
|
22
|
+
frames, buffer = parse_sse_frames(buffer)
|
|
23
|
+
for frame in frames:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
The returned ``remainder`` is the partial trailing frame (if any) and must be
|
|
27
|
+
prepended to the next chunk so that frames split across read boundaries are
|
|
28
|
+
reassembled correctly.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
|
|
36
|
+
__all__ = ["SSEFrame", "parse_sse_frames"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SSEFrame:
|
|
41
|
+
"""A single parsed Server-Sent Events frame."""
|
|
42
|
+
|
|
43
|
+
event: str
|
|
44
|
+
"""Value of the ``event:`` field, or ``"message"`` per the spec default."""
|
|
45
|
+
|
|
46
|
+
data: str
|
|
47
|
+
"""Concatenated ``data:`` values (newline-joined)."""
|
|
48
|
+
|
|
49
|
+
id: str | None = None
|
|
50
|
+
"""Value of the ``id:`` field, if supplied. Used as the SSE
|
|
51
|
+
``Last-Event-ID`` header on reconnect."""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def json(self) -> dict:
|
|
55
|
+
"""Parse ``data`` as JSON. Raises ``ValueError`` if malformed."""
|
|
56
|
+
return json.loads(self.data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_sse_frames(buffer: str) -> tuple[list[SSEFrame], str]:
|
|
60
|
+
"""Parse SSE frames out of a raw text buffer.
|
|
61
|
+
|
|
62
|
+
Returns ``(frames, remainder)`` where ``remainder`` is the trailing
|
|
63
|
+
partial frame (if any) that should be prepended to the next chunk.
|
|
64
|
+
Comments (lines starting with ``:``) and blank-only buffers are
|
|
65
|
+
tolerated and simply produce no frames.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
frames: list[SSEFrame] = []
|
|
69
|
+
|
|
70
|
+
# Frames are separated by a blank line. A trailing blank line may be
|
|
71
|
+
# absent if the chunk ends mid-frame; that portion is returned as the
|
|
72
|
+
# remainder.
|
|
73
|
+
parts = buffer.split("\n\n")
|
|
74
|
+
remainder = parts[-1]
|
|
75
|
+
complete_parts = parts[:-1]
|
|
76
|
+
|
|
77
|
+
for raw in complete_parts:
|
|
78
|
+
frame = _parse_single_frame(raw)
|
|
79
|
+
if frame is not None:
|
|
80
|
+
frames.append(frame)
|
|
81
|
+
|
|
82
|
+
return frames, remainder
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_single_frame(raw: str) -> SSEFrame | None:
|
|
86
|
+
"""Parse a single (complete) SSE frame.
|
|
87
|
+
|
|
88
|
+
Returns ``None`` if the frame is a pure comment or has no data.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
event_name = "message"
|
|
92
|
+
data_lines: list[str] = []
|
|
93
|
+
event_id: str | None = None
|
|
94
|
+
|
|
95
|
+
for line in raw.split("\n"):
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
if line.startswith(":"):
|
|
99
|
+
# Comment - silently ignored (keepalive).
|
|
100
|
+
continue
|
|
101
|
+
if ":" not in line:
|
|
102
|
+
field_name = line
|
|
103
|
+
value = ""
|
|
104
|
+
else:
|
|
105
|
+
field_name, _, value = line.partition(":")
|
|
106
|
+
# Per SSE spec, a single leading space after ':' is ignored.
|
|
107
|
+
if value.startswith(" "):
|
|
108
|
+
value = value[1:]
|
|
109
|
+
|
|
110
|
+
if field_name == "event":
|
|
111
|
+
event_name = value
|
|
112
|
+
elif field_name == "data":
|
|
113
|
+
data_lines.append(value)
|
|
114
|
+
elif field_name == "id":
|
|
115
|
+
event_id = value
|
|
116
|
+
# ``retry:`` and unknown fields are ignored.
|
|
117
|
+
|
|
118
|
+
if not data_lines:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
return SSEFrame(
|
|
122
|
+
event=event_name,
|
|
123
|
+
data="\n".join(data_lines),
|
|
124
|
+
id=event_id,
|
|
125
|
+
)
|