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,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
+ )