cloudagent-daemon 0.2.1__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.
@@ -0,0 +1,431 @@
1
+ """Async WebSocket client for the cloudagent daemon.
2
+
3
+ Spec: docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md §4.1
4
+
5
+ Single responsibility: own the WS transport. Connect → hello → ready
6
+ handshake, run a background reader that feeds an inbound queue, auto-reply
7
+ to server pings, and watchdog the connection (>40s of silence ⇒ close).
8
+
9
+ Also exposes ``run_forever`` — a reconnect-on-disconnect supervisor with
10
+ exponential backoff (1→2→4→8→16→30s cap) that sends ``resume {last_seq}``
11
+ after every reconnect so the server can replay unacked frames (Task 5).
12
+
13
+ Out of scope (handled in later tasks):
14
+ - Frame dispatch by type (Task 6)
15
+ - Outbound queue + delivery ack (Task 7)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import platform
23
+ import socket
24
+ import ssl
25
+ import time
26
+ from collections.abc import Awaitable, Callable
27
+ from datetime import UTC, datetime
28
+ from typing import Any
29
+ from uuid import uuid4
30
+
31
+ from websockets.asyncio.client import ClientConnection, connect
32
+ from websockets.exceptions import ConnectionClosed, InvalidStatus
33
+
34
+ from .runtime_detect import DetectedRuntime, detect_runtimes
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ # Spec §4.1: server pings every 30s. We treat 40s of silence as "missed
39
+ # two pings" and close. Watchdog fires every 5s.
40
+ HEARTBEAT_GRACE_SECONDS = 40.0
41
+ WATCHDOG_INTERVAL_SECONDS = 5.0
42
+
43
+
44
+ def _ws_url(server_url: str) -> str:
45
+ """Map ``http(s)://host`` → ``ws(s)://host/v1/daemon/ws``."""
46
+ base = server_url.rstrip("/")
47
+ if base.startswith("https://"):
48
+ base = "wss://" + base[len("https://"):]
49
+ elif base.startswith("http://"):
50
+ base = "ws://" + base[len("http://"):]
51
+ return f"{base}/v1/daemon/ws"
52
+
53
+
54
+ def _new_frame_id() -> str:
55
+ return f"frm_{uuid4().hex[:16]}"
56
+
57
+
58
+ def _now_iso() -> str:
59
+ return datetime.now(UTC).isoformat()
60
+
61
+
62
+ class WSClient:
63
+ """Asyncio WebSocket client for ``/v1/daemon/ws``.
64
+
65
+ Use as ``async with WSClient(server_url, api_key) as client:``. The
66
+ handshake (hello + ready) runs inside ``__aenter__``; on exit we
67
+ cancel the reader/watchdog tasks and close the WS.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ server_url: str,
73
+ api_key: str,
74
+ *,
75
+ daemon_ver: str | None = None,
76
+ detect_runtimes_fn: Callable[[], list[DetectedRuntime]] | None = None,
77
+ insecure: bool = False,
78
+ workspace_root: str | None = None,
79
+ ) -> None:
80
+ # Default the version to the package's __version__ so the WS hello
81
+ # frame stays in sync with the SSE/POST ready POST without
82
+ # callers having to remember to pass it.
83
+ if daemon_ver is None:
84
+ from cloudagent_daemon import __version__ as _v
85
+ daemon_ver = _v
86
+ self._server_url = server_url
87
+ self._api_key = api_key
88
+ self._daemon_ver = daemon_ver
89
+ # Task #113 (2026-05-12) — agent workspace root, surfaced on
90
+ # the legacy WS ``ready`` frame so the legacy path mirrors the
91
+ # SSE+POST ``/v1/daemon/ready`` shape. ``None`` ⇒ omit the
92
+ # field (backend preserves any previously-reported value).
93
+ self._workspace_root = workspace_root
94
+ # DI seam — tests pass a stub returning a known list so the ready
95
+ # frame is deterministic without monkeypatching the module. Defaults
96
+ # to scanning $PATH on the real machine.
97
+ self._detect_runtimes_fn = detect_runtimes_fn or detect_runtimes
98
+ # TLS verification policy. Default = strict (verify cert + hostname).
99
+ # When ``insecure=True`` we build an unverified SSL context — required
100
+ # for self-signed dev/staging environments (e.g. fr cluster pre-Wave-7
101
+ # where nginx-ingress serves the bundled fake cert). Only applies to
102
+ # wss:// URLs; for ws:// the ssl context is unused.
103
+ self._insecure = insecure
104
+ if insecure:
105
+ self._ssl_context: ssl.SSLContext | None = ssl.create_default_context()
106
+ self._ssl_context.check_hostname = False
107
+ self._ssl_context.verify_mode = ssl.CERT_NONE
108
+ else:
109
+ # Let websockets build the default verified context per scheme.
110
+ self._ssl_context = None
111
+ self._ws: ClientConnection | None = None
112
+ self._inbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
113
+ self._reader_task: asyncio.Task[None] | None = None
114
+ self._watchdog_task: asyncio.Task[None] | None = None
115
+ self._host_id: str = ""
116
+ self._bound_agents: list[dict[str, Any]] = []
117
+ self._last_inbound_at: float = 0.0
118
+ self._closed = False
119
+ # Set when the reader loop has exited (server dropped, etc.) so
120
+ # next_frame() can surface the disconnect instead of hanging.
121
+ self._disconnect_event: asyncio.Event = asyncio.Event()
122
+
123
+ @property
124
+ def host_id(self) -> str:
125
+ return self._host_id
126
+
127
+ @property
128
+ def bound_agents(self) -> list[dict[str, Any]]:
129
+ return list(self._bound_agents)
130
+
131
+ def _build_ready_frame(
132
+ self, runtimes_payload: list[dict[str, Any]]
133
+ ) -> dict[str, Any]:
134
+ """Build the ``ready`` frame body sent during handshake.
135
+
136
+ Pulled out as a helper so tests can verify the payload shape
137
+ (notably Task #113's ``workspace_root`` field) without driving
138
+ the full websocket handshake.
139
+ """
140
+ ready_payload: dict[str, Any] = {
141
+ "daemon_ver": self._daemon_ver,
142
+ "runtimes": runtimes_payload,
143
+ "hostname": socket.gethostname(),
144
+ "os": platform.system().lower(),
145
+ }
146
+ # Task #113 — only emit when set; omission preserves backend
147
+ # state (idempotent re-ready).
148
+ if self._workspace_root is not None:
149
+ ready_payload["workspace_root"] = self._workspace_root
150
+ return {
151
+ "v": 1,
152
+ "type": "ready",
153
+ "id": _new_frame_id(),
154
+ "ts": _now_iso(),
155
+ "payload": ready_payload,
156
+ }
157
+
158
+ async def __aenter__(self) -> WSClient:
159
+ # ``additional_headers`` is the websockets v15 spelling (renamed
160
+ # from ``extra_headers``). ``ping_interval=None`` disables the
161
+ # library's auto-ping; we implement application-level heartbeat
162
+ # ourselves per spec §4.1.
163
+ ws_url = _ws_url(self._server_url)
164
+ # Only pass ssl= for wss://. For plain ws:// websockets ignores it
165
+ # but is fussy about None vs default in some library versions.
166
+ connect_kwargs: dict[str, Any] = {
167
+ "additional_headers": {"Authorization": f"Bearer {self._api_key}"},
168
+ "ping_interval": None,
169
+ }
170
+ if ws_url.startswith("wss://") and self._ssl_context is not None:
171
+ connect_kwargs["ssl"] = self._ssl_context
172
+ self._ws = await connect(ws_url, **connect_kwargs)
173
+
174
+ # Hello is the first inbound frame from the server.
175
+ try:
176
+ raw = await self._ws.recv()
177
+ except ConnectionClosed as exc:
178
+ await self._ws.close()
179
+ raise ConnectionError(
180
+ f"server closed before hello: code={exc.rcvd}"
181
+ ) from exc
182
+ hello = json.loads(raw)
183
+ if hello.get("type") != "hello":
184
+ await self._ws.close()
185
+ raise ConnectionError(
186
+ f"expected hello frame, got {hello.get('type')!r}"
187
+ )
188
+ payload = hello.get("payload", {}) or {}
189
+ self._host_id = payload.get("host_id", "")
190
+ self._bound_agents = list(payload.get("agents", []) or [])
191
+ self._last_inbound_at = time.monotonic()
192
+
193
+ # Detect locally installed AI coding runtimes (Task 6). Failures
194
+ # in detection (e.g. a probe binary hangs unexpectedly) must not
195
+ # break the handshake — degrade to empty list so the daemon still
196
+ # comes online and the user can fix runtime config out-of-band.
197
+ try:
198
+ detected = self._detect_runtimes_fn()
199
+ except Exception as exc: # pragma: no cover — defensive
200
+ log.warning("runtime detection failed (%s); reporting empty list", exc)
201
+ detected = []
202
+ runtimes_payload = [r.to_payload() for r in detected]
203
+
204
+ ready_frame = self._build_ready_frame(runtimes_payload)
205
+ await self._ws.send(json.dumps(ready_frame))
206
+
207
+ self._reader_task = asyncio.create_task(self._reader_loop())
208
+ self._watchdog_task = asyncio.create_task(self._watchdog_loop())
209
+ return self
210
+
211
+ async def __aexit__(self, *_exc: Any) -> None:
212
+ self._closed = True
213
+ for task in (self._reader_task, self._watchdog_task):
214
+ if task is not None and not task.done():
215
+ task.cancel()
216
+ try:
217
+ await task
218
+ except (asyncio.CancelledError, Exception):
219
+ pass
220
+ if self._ws is not None:
221
+ try:
222
+ await self._ws.close()
223
+ except Exception:
224
+ pass
225
+
226
+ async def next_frame(self, timeout: float | None = None) -> dict[str, Any]:
227
+ """Wait for the next non-control frame. Pings never surface here.
228
+
229
+ Raises ``ConnectionClosed`` if the underlying reader has already
230
+ exited (server dropped the connection) — otherwise the call would
231
+ hang forever on an empty queue. Callers like ``run_forever``
232
+ rely on this to trigger a reconnect.
233
+ """
234
+ get_task = asyncio.create_task(self._inbound.get())
235
+ disc_task = asyncio.create_task(self._disconnect_event.wait())
236
+ try:
237
+ done, _ = await asyncio.wait(
238
+ {get_task, disc_task},
239
+ timeout=timeout,
240
+ return_when=asyncio.FIRST_COMPLETED,
241
+ )
242
+ if not done:
243
+ raise asyncio.TimeoutError
244
+ if get_task in done:
245
+ return get_task.result()
246
+ # Reader exited before a frame arrived → connection dead.
247
+ raise ConnectionClosed(rcvd=None, sent=None)
248
+ finally:
249
+ for t in (get_task, disc_task):
250
+ if not t.done():
251
+ t.cancel()
252
+
253
+ async def send(self, frame: dict[str, Any]) -> None:
254
+ if self._ws is None:
255
+ raise RuntimeError("WSClient not connected")
256
+ await self._ws.send(json.dumps(frame))
257
+
258
+ async def send_resume(self, last_seq: int) -> None:
259
+ """Send a ``resume`` frame so the server can replay unacked frames.
260
+
261
+ Spec §4.1: after every (re)connect, the daemon tells the server
262
+ the highest seq it has durably processed; the server then replays
263
+ any frames with ``seq > last_seq`` from its catchup buffer.
264
+ """
265
+ await self.send({
266
+ "v": 1,
267
+ "type": "resume",
268
+ "id": _new_frame_id(),
269
+ "ts": _now_iso(),
270
+ "payload": {"last_seq": last_seq},
271
+ })
272
+
273
+ async def _reader_loop(self) -> None:
274
+ assert self._ws is not None
275
+ try:
276
+ async for raw in self._ws:
277
+ self._last_inbound_at = time.monotonic()
278
+ try:
279
+ frame = json.loads(raw)
280
+ except (TypeError, ValueError):
281
+ # Malformed frame — drop and continue. Phase 1c will
282
+ # add a bye/disconnect path for protocol errors.
283
+ continue
284
+ if frame.get("type") == "ping":
285
+ # Inline pong reply, do NOT enqueue. Spec §4.1.
286
+ try:
287
+ await self._ws.send(json.dumps({
288
+ "v": 1,
289
+ "type": "pong",
290
+ "id": _new_frame_id(),
291
+ "ts": _now_iso(),
292
+ "payload": {},
293
+ }))
294
+ except Exception:
295
+ return
296
+ continue
297
+ await self._inbound.put(frame)
298
+ except ConnectionClosed:
299
+ return
300
+ finally:
301
+ # Wake any next_frame() caller waiting on an empty queue.
302
+ self._disconnect_event.set()
303
+
304
+ async def _watchdog_loop(self) -> None:
305
+ """Close the WS if no inbound traffic for HEARTBEAT_GRACE_SECONDS."""
306
+ while True:
307
+ await asyncio.sleep(WATCHDOG_INTERVAL_SECONDS)
308
+ if self._closed or self._ws is None:
309
+ return
310
+ if (
311
+ time.monotonic() - self._last_inbound_at
312
+ > HEARTBEAT_GRACE_SECONDS
313
+ ):
314
+ try:
315
+ await self._ws.close()
316
+ finally:
317
+ return
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Reconnect supervisor (Task 5)
322
+ # ---------------------------------------------------------------------------
323
+
324
+ # Backoff cap from the spec — the longest delay we ever sleep between
325
+ # reconnect attempts. The series is 1, 2, 4, 8, 16, 30, 30, 30, …
326
+ _BACKOFF_INITIAL_SECONDS = 1.0
327
+ _BACKOFF_CAP_SECONDS = 30.0
328
+
329
+
330
+ class AuthFailed(Exception):
331
+ """Raised when the server rejects the WS upgrade with 401/403.
332
+
333
+ Auth failures are terminal — re-trying with the same key is futile
334
+ and could trigger server-side abuse mitigations. ``run_forever``
335
+ surfaces this instead of looping.
336
+ """
337
+
338
+ def __init__(self, status_code: int, message: str = "") -> None:
339
+ self.status_code = status_code
340
+ super().__init__(message or f"auth rejected (HTTP {status_code})")
341
+
342
+
343
+ async def run_forever(
344
+ client_factory: Callable[[], WSClient],
345
+ on_frame: Callable[[dict[str, Any]], Awaitable[None]],
346
+ state_loader: Callable[[], int],
347
+ *,
348
+ on_connect: Callable[[WSClient], Awaitable[None]] | None = None,
349
+ _backoff_factor: float = 1.0,
350
+ _sleep: Callable[[float], Awaitable[None]] | None = None,
351
+ ) -> None:
352
+ """Run the WS client forever, reconnecting with exponential backoff.
353
+
354
+ Args:
355
+ client_factory: Builds a fresh ``WSClient`` per attempt — the
356
+ client is single-use because ``__aexit__`` cancels its
357
+ background tasks.
358
+ on_frame: Coroutine called for every business frame surfaced by
359
+ ``WSClient.next_frame``. Frame dispatch logic lives here
360
+ (Task 6 wires it up to the assign/deliver/tool_call handlers).
361
+ state_loader: Returns the current ``last_seq`` to send in the
362
+ ``resume`` frame after each (re)connect. Called fresh each
363
+ iteration so it always reflects the latest persisted state.
364
+ on_connect: Optional coroutine called once per successful
365
+ (re)connect, after the WS handshake completes and *before*
366
+ the resume frame is sent. Used by ``main_loop.run_daemon``
367
+ to rebind ``OutboundQueue.ws`` to the freshly minted client
368
+ so subsequent outbound sends target the live socket.
369
+ _backoff_factor: Multiplier applied to all sleep durations. Real
370
+ usage leaves this at 1.0 (delays in real seconds); tests
371
+ pass a small value (e.g. 0.01) to keep the suite fast.
372
+ _sleep: Optional sleep override — defaults to ``asyncio.sleep``.
373
+ Test-only hook used to record/replace backoff sleeps without
374
+ patching the global ``asyncio.sleep`` (which would also break
375
+ unrelated waits inside the same test).
376
+
377
+ Raises:
378
+ AuthFailed: Server returned HTTP 401/403 — auth is dead, do not
379
+ retry. Caller should re-prompt the user for their machine
380
+ key (or just exit).
381
+ asyncio.CancelledError: Propagated unchanged so cooperative
382
+ shutdown (signal handlers, ``task.cancel()``) works.
383
+ """
384
+ sleep = _sleep if _sleep is not None else asyncio.sleep
385
+ backoff = _BACKOFF_INITIAL_SECONDS
386
+ while True:
387
+ try:
388
+ client = client_factory()
389
+ async with client:
390
+ # Connection succeeded → reset backoff so the next drop
391
+ # starts the curve over at 1s.
392
+ backoff = _BACKOFF_INITIAL_SECONDS
393
+
394
+ # Notify caller (e.g. main_loop) so it can rebind any
395
+ # client-derived state (OutboundQueue.ws). Runs BEFORE
396
+ # the resume frame so the resume itself uses the new
397
+ # client; runs AFTER aenter so .send is usable.
398
+ if on_connect is not None:
399
+ await on_connect(client)
400
+
401
+ # Send resume BEFORE entering the receive loop so the
402
+ # server replays unacked frames first; on_frame then
403
+ # picks them up in order. last_seq=0 still gets a
404
+ # resume frame — the server treats that as "send me
405
+ # everything in the buffer".
406
+ last_seq = state_loader()
407
+ await client.send_resume(last_seq)
408
+
409
+ while True:
410
+ frame = await client.next_frame()
411
+ await on_frame(frame)
412
+ except asyncio.CancelledError:
413
+ # Cooperative shutdown — bubble up so the caller (signal
414
+ # handler, supervisor task) sees a clean exit.
415
+ raise
416
+ except InvalidStatus as exc:
417
+ code = exc.response.status_code
418
+ if code in (401, 403):
419
+ # Auth is dead. Re-trying won't help and risks lockout.
420
+ log.error("ws auth rejected (HTTP %d) — not retrying", code)
421
+ raise AuthFailed(code) from exc
422
+ # Any other handshake-level rejection (5xx, 426, …) is
423
+ # plausibly transient — back off and retry.
424
+ log.warning("ws handshake rejected (HTTP %d), backing off", code)
425
+ except (ConnectionClosed, OSError, asyncio.TimeoutError) as exc:
426
+ log.warning("ws disconnected (%s), backing off %.1fs",
427
+ type(exc).__name__, backoff)
428
+
429
+ # Sleep with the current backoff, then double (capped at 30s).
430
+ await sleep(min(backoff, _BACKOFF_CAP_SECONDS) * _backoff_factor)
431
+ backoff = min(backoff * 2, _BACKOFF_CAP_SECONDS)