"""The engine's event bus, read the way a browser reads it. Polling answers "what is true now", so a run that starts and finishes between two ticks was only ever a row in the history. This subscribes to the same websocket the dashboard in a browser does — nothing new is served for it. Blocking on purpose: `subscribe` is a thread's whole job, and the caller brings the thread. Reconnecting is this module's, since a dashboard outlives the engine it supervises. """ from __future__ import annotations import json import threading from collections.abc import Callable, Collection from typing import Any from urllib.parse import urlsplit, urlunsplit #: What a reconnect waits before trying again, growing to a minute — the shape #: the engine's own supervision backs off with. BACKOFF = (1.0, 5.0, 30.0, 60.0) #: How long a read waits before looking at `stop`. The socket is otherwise #: silent for as long as the house is, and a dashboard that is closing should #: not wait for the next event to notice. POLL_S = 1.0 def socket_url(url: str, token: str) -> str: """The websocket beside an engine's HTTP address. The token goes in the query string because a handshake carries no headers of its own — the same reason the browser sends it that way. """ parts = urlsplit(url) scheme = "wss" if parts.scheme == "https" else "ws" return urlunsplit((scheme, parts.netloc, "/api/v1/flows/ws", f"token={token}", "")) def subscribe( url: str, token: str, on_event: Callable[[dict[str, Any]], None], stop: threading.Event, kinds: Collection[str] | None = None, on_error: Callable[[Exception], None] | None = None, ) -> None: """Hand every published event to `on_event` until `stop` is set. `kinds` keeps only the event types named, which is worth doing here rather than in the caller: `message_value` is most of what the bus carries and a screen drawing runs wants none of it. A failure is reported once per outage. A dashboard is the thing that stops and starts the engine, so a reconnect loop announcing itself every second would bury the log pane it shares. """ from websockets.sync.client import connect attempt = 0 while not stop.is_set(): try: with connect(socket_url(url, token), open_timeout=POLL_S * 5) as socket: attempt = 0 while not stop.is_set(): try: frame = socket.recv(timeout=POLL_S) except TimeoutError: continue payload = json.loads(frame) events = ( payload.get("events") or [] if payload.get("type") == "batch" else [payload] ) for event in events: if kinds is None or event.get("type") in kinds: on_event(event) return except Exception as exc: # noqa: BLE001 — any transport failure retries if attempt == 0 and on_error is not None: on_error(exc) stop.wait(BACKOFF[min(attempt, len(BACKOFF) - 1)]) attempt += 1