File size: 14,561 Bytes
6778532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
"""Clients that publish this node into the mesh.

Two transports exist because the mesh has two reachable control planes with
different admission rules, and exactly one of them can accept a public node today:

``DeviceV2Client`` β€” the canonical MeshStack control plane
    Talks to the ``meshstack-device-v2`` edge function with Ed25519 request
    signing. This is the contract owned by the Mesh backend lane and it is what
    a paired ThoxOS device uses. It requires (a) a device row on
    ``meshstack_devices_v2`` with our public key, minted through a pairing
    session by an authenticated mesh owner, and (b) a ``base_url`` that passes
    ``validateLocalBaseUrl`` β€” RFC1918, loopback, link-local, CGNAT or
    ``.local`` only.

    Requirement (b) is why a Colab tunnel cannot currently self-register here:
    ``https://<x>.trycloudflare.com`` and ``https://<x>.hf.space`` are public
    hostnames and the function rejects them by design (anti-SSRF: the mesh must
    not be induced to call arbitrary internet hosts). We surface that refusal as
    ``PublicUrlRejectedError`` rather than retrying it, and the agent degrades to
    the controller transport.

``ControllerClient`` β€” this repo's mesh controller
    The pre-existing in-memory registry (``controller/mesh.py``) that already
    accepts public Colab and Space URLs and health-checks them. It is the
    transport that makes a live demo possible today, and it is the natural place
    for a public-node broker to live even after the v2 policy question is settled.

Both implement ``MeshClient`` so the agent is written once against one interface.
"""

from __future__ import annotations

import json
import logging
import time
import urllib.error
import urllib.request
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Mapping

from .config import NodeConfig
from .errors import (
    MeshRejectedError,
    MeshTransportError,
    PublicUrlRejectedError,
)
from .signing import DeviceIdentity, check_clock_skew

logger = logging.getLogger("thox.mesh")

#: Substring of the edge function's private-address refusal.
_PRIVATE_URL_REFUSAL = "private mesh, LAN, link-local, or loopback"

#: HTTP statuses worth retrying; everything else is a contract decision.
_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})


@dataclass
class Registration:
    """Result of publishing this node into the mesh."""

    endpoint_id: str
    transport: str
    base_url: str
    raw: dict[str, Any]


class MeshClient(ABC):
    """Interface the agent uses to join, hold and leave the mesh."""

    name: str = "abstract"

    @abstractmethod
    def register(self, config: NodeConfig, base_url: str, capabilities: Mapping[str, Any], status: str) -> Registration:
        """Publish (or refresh) this node's endpoint. Must be idempotent."""

    @abstractmethod
    def heartbeat(self, config: NodeConfig, registration: Registration, capabilities: Mapping[str, Any], status: str) -> None:
        """Renew liveness and refresh telemetry."""

    @abstractmethod
    def deregister(self, registration: Registration) -> None:
        """Remove the endpoint from routing. Best-effort; must never raise."""


# ── MeshStack v2 (canonical) ─────────────────────────────────────────────


class DeviceV2Client(MeshClient):
    """Signed client for the ``meshstack-device-v2`` edge function.

    Note there is no Supabase API key anywhere in this class. The function runs
    with JWT verification disabled and authenticates purely on the Ed25519
    signature, so the device private key is the entire credential. That is a
    deliberately smaller secret surface than shipping an anon key into a public
    notebook, where it would be trivially extractable.
    """

    name = "device_v2"

    def __init__(self, identity: DeviceIdentity, function_url: str, *, timeout: int = 20) -> None:
        self._identity = identity
        self._url = function_url
        self._timeout = timeout
        self._clock_checked = False

    def _call(self, action: str, payload: Mapping[str, Any]) -> dict[str, Any]:
        """Sign and POST one action, translating failures into typed errors."""
        body_payload = dict(payload)
        body_payload["action"] = action
        signed = self._identity.sign_request(action, body_payload)
        request = urllib.request.Request(self._url, data=signed.body, headers=signed.headers, method="POST")
        try:
            with urllib.request.urlopen(request, timeout=self._timeout) as response:
                return json.loads(response.read().decode("utf-8") or "{}")
        except urllib.error.HTTPError as exc:
            detail = _read_error_body(exc)
            message = detail.get("message") or detail.get("error") or f"HTTP {exc.code}"
            if _PRIVATE_URL_REFUSAL in message:
                raise PublicUrlRejectedError(message, status=exc.code, action=action) from exc
            if exc.code in _RETRYABLE_STATUS:
                raise MeshTransportError(f"{action} failed with retryable HTTP {exc.code}: {message}") from exc
            raise MeshRejectedError(message, status=exc.code, action=action) from exc
        except urllib.error.URLError as exc:
            raise MeshTransportError(f"{action} could not reach the mesh: {exc.reason}") from exc
        except (TimeoutError, json.JSONDecodeError) as exc:
            raise MeshTransportError(f"{action} failed: {exc}") from exc

    def register(self, config: NodeConfig, base_url: str, capabilities: Mapping[str, Any], status: str) -> Registration:
        response = self._call(
            "register_model",
            {
                "model_id": config.model_id,
                "alias": config.alias,
                "protocol": config.protocol,
                "base_url": base_url,
                "context_window": config.context_window,
                "capabilities": dict(capabilities),
                "resource_requirements": {
                    "node_kind": config.node_kind,
                    "ephemeral": config.ephemeral,
                    "context_window": config.context_window,
                },
                "status": status,
            },
        )
        endpoint = response.get("endpoint") or {}
        endpoint_id = endpoint.get("id")
        if not endpoint_id:
            raise MeshRejectedError("register_model returned no endpoint id", action="register_model")
        logger.info("registered endpoint %s via device_v2 (%s)", endpoint_id, base_url)
        return Registration(endpoint_id=endpoint_id, transport=self.name, base_url=base_url, raw=endpoint)

    def heartbeat(self, config: NodeConfig, registration: Registration, capabilities: Mapping[str, Any], status: str) -> None:
        """Renew device liveness, then refresh the endpoint's telemetry.

        The backend's ``heartbeat`` action updates the **device** row only β€” it
        never touches ``meshstack_model_endpoints_v2``. Telemetry therefore has
        to ride on a ``register_model`` upsert, which is idempotent on
        ``(device_id, alias)`` and is the contract's own refresh path.
        """
        response = self._call(
            "heartbeat",
            {
                "capabilities": dict(capabilities),
                "resource_snapshot": capabilities.get("thox_telemetry", {}),
                "app_version": "thoxmesh-node/1.0",
            },
        )
        if not self._clock_checked:
            server_time = response.get("server_time")
            if isinstance(server_time, str):
                parsed = _parse_server_time(server_time)
                if parsed is not None:
                    skew = check_clock_skew(parsed)
                    logger.info("clock skew vs mesh: %.1fs", skew)
            self._clock_checked = True
        self.register(config, registration.base_url, capabilities, status)

    def deregister(self, registration: Registration) -> None:
        try:
            self._call("unregister_model", {"endpoint_id": registration.endpoint_id})
            logger.info("deregistered endpoint %s", registration.endpoint_id)
        except Exception as exc:  # noqa: BLE001 - shutdown must not raise
            logger.warning("deregister failed (endpoint will expire by lease): %s", exc)


# ── Controller (public-node broker, works today) ─────────────────────────


class ControllerClient(MeshClient):
    """Client for this repo's mesh controller, which accepts public URLs.

    The controller keeps endpoints in memory and health-checks them, so a lapsed
    Colab node is dropped by its own health loop as well as by our lease.
    """

    name = "controller"

    def __init__(self, controller_url: str, *, timeout: int = 20, token: str | None = None) -> None:
        self._url = controller_url.rstrip("/")
        self._timeout = timeout
        self._token = token

    def _post(self, path: str, payload: Mapping[str, Any]) -> dict[str, Any]:
        body = json.dumps(payload).encode("utf-8")
        headers = {"content-type": "application/json"}
        if self._token:
            headers["authorization"] = f"Bearer {self._token}"
        request = urllib.request.Request(f"{self._url}{path}", data=body, headers=headers, method="POST")
        try:
            with urllib.request.urlopen(request, timeout=self._timeout) as response:
                return json.loads(response.read().decode("utf-8") or "{}")
        except urllib.error.HTTPError as exc:
            detail = _read_error_body(exc)
            message = detail.get("message") or detail.get("error") or f"HTTP {exc.code}"
            if exc.code in _RETRYABLE_STATUS:
                raise MeshTransportError(f"{path} retryable HTTP {exc.code}: {message}") from exc
            raise MeshRejectedError(message, status=exc.code, action=path) from exc
        except urllib.error.URLError as exc:
            raise MeshTransportError(f"{path} could not reach the controller: {exc.reason}") from exc
        except (TimeoutError, json.JSONDecodeError) as exc:
            raise MeshTransportError(f"{path} failed: {exc}") from exc

    def register(self, config: NodeConfig, base_url: str, capabilities: Mapping[str, Any], status: str) -> Registration:
        response = self._post(
            "/mesh/register",
            {
                "model_id": config.model_id,
                "alias": config.alias,
                "base_url": base_url,
                "source": config.node_kind,
                "protocol": config.protocol,
                "context_window": config.context_window,
                "capabilities": dict(capabilities),
                "status": status,
            },
        )
        logger.info("registered %s via controller (%s)", config.model_id, base_url)
        return Registration(
            endpoint_id=response.get("model_id") or config.model_id,
            transport=self.name,
            base_url=base_url,
            raw=response,
        )

    def heartbeat(self, config: NodeConfig, registration: Registration, capabilities: Mapping[str, Any], status: str) -> None:
        # The controller's register is an upsert, so a re-register is the heartbeat.
        self.register(config, registration.base_url, capabilities, status)

    def deregister(self, registration: Registration) -> None:
        try:
            self._post("/mesh/deregister", {"model_id": registration.endpoint_id})
            logger.info("deregistered %s from controller", registration.endpoint_id)
        except Exception as exc:  # noqa: BLE001 - shutdown must not raise
            logger.warning("controller deregister failed: %s", exc)


class NullClient(MeshClient):
    """No-op transport for local development and tests.

    Chosen when no mesh credentials and no controller URL are configured, so the
    node still serves completions and can be exercised end-to-end without any
    control plane at all.
    """

    name = "none"

    def register(self, config: NodeConfig, base_url: str, capabilities: Mapping[str, Any], status: str) -> Registration:
        logger.warning("no mesh transport configured; serving locally without registration")
        return Registration(endpoint_id="local", transport=self.name, base_url=base_url, raw={})

    def heartbeat(self, config: NodeConfig, registration: Registration, capabilities: Mapping[str, Any], status: str) -> None:
        return None

    def deregister(self, registration: Registration) -> None:
        return None


def build_client(config: NodeConfig, identity: DeviceIdentity | None) -> MeshClient:
    """Pick a transport from configuration and available credentials.

    ``auto`` prefers the canonical control plane when a device identity exists,
    because that is the contract everything else in the fleet speaks; it falls
    back to the controller so an unpaired node is still useful.
    """
    if config.transport == "device_v2":
        if identity is None:
            raise MeshRejectedError(
                "transport device_v2 requires THOX_MESH_DEVICE_ID and a device key; "
                "pair the device first or use THOX_MESH_TRANSPORT=controller"
            )
        return DeviceV2Client(identity, config.device_function_url)
    if config.transport == "controller":
        return ControllerClient(config.controller_url)
    # auto
    if identity is not None:
        return DeviceV2Client(identity, config.device_function_url)
    if config.controller_url:
        return ControllerClient(config.controller_url)
    return NullClient()


def _read_error_body(exc: urllib.error.HTTPError) -> dict[str, Any]:
    """Decode a JSON error body, tolerating non-JSON responses."""
    try:
        raw = exc.read().decode("utf-8")
    except Exception:  # noqa: BLE001
        return {}
    try:
        parsed = json.loads(raw)
        return parsed if isinstance(parsed, dict) else {"message": raw[:400]}
    except json.JSONDecodeError:
        return {"message": raw[:400]}


def _parse_server_time(value: str) -> int | None:
    """Parse the backend's ISO timestamp into epoch milliseconds."""
    text = value.strip().replace("Z", "+0000")
    for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"):
        try:
            import datetime as _dt

            return int(_dt.datetime.strptime(text, fmt).timestamp() * 1000)
        except ValueError:
            continue
    return None