iowap-node 2.3.20__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,1246 @@
1
+ """Shared Relay API client.
2
+
3
+ The ``RelayClient`` here is the common HTTP client that both the
4
+ ``node-cli`` Swiss-army-knife command and the ``node-daemon`` realtime
5
+ daemon use to talk to an AI Relay. Extracted out of ``node_cli.py`` so
6
+ the daemon no longer depends on the whole CLI monolith (T-112).
7
+
8
+ Also carries the small helpers that built the client's config/logging:
9
+ ``_setup_logging``, ``_effective_config``, ``_base_url`` and
10
+ ``_filename_from_response``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import re
18
+ import sys
19
+ import threading
20
+ import time
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from nodes.common.node_config import load_active_status
28
+ from nodes.common.node_utils import load_config, load_token, save_meta, save_token
29
+
30
+ log = logging.getLogger("relay-client")
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # T-210: load measurement with environment-appropriate sources
34
+ # ---------------------------------------------------------------------------
35
+
36
+ # T-210: previous state of /sys/fs/cgroup/cpu.stat (cgroup v2) resp.
37
+ # cpuacct.usage (v1). CPU consumption is a *rate*, so we diff against
38
+ # the last sample; on the very first call there is no previous state
39
+ # and the cgroup rung yields nothing (falls through to loadavg).
40
+ _CGROUP_PREV: dict[str, tuple[float, float]] = {} # path -> (monotonic, usage)
41
+
42
+
43
+ def _read_cgroup_cpu_usage() -> tuple[str, float] | None:
44
+ """Return (rung, cpu-seconds-used) from the process's own cgroup.
45
+
46
+ cgroup v2 first (/sys/fs/cgroup/cpu.stat), then v1 (cpuacct.usage).
47
+ Both report CPU time consumed by the cgroup, which inside an LXC
48
+ container is exactly the CT's own usage (the container's cgroup
49
+ root). On bare metal it equals the host's usage — also correct.
50
+ Returns None when neither file is readable.
51
+ """
52
+ # cgroup v2
53
+ try:
54
+ with open("/sys/fs/cgroup/cpu.stat") as f:
55
+ for line in f:
56
+ if line.startswith("usage_usec"):
57
+ return "cgroup2", int(line.split()[1]) / 1_000_000.0
58
+ except (OSError, ValueError):
59
+ pass
60
+ # cgroup v1 (cpuacct.usage is in NANOSECONDS, like v2's usage_usec*1000)
61
+ for path in ("/sys/fs/cgroup/cpuacct/cpuacct.usage", "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage"):
62
+ try:
63
+ with open(path) as f:
64
+ return "cgroup", int(f.read().strip()) / 1_000_000_000.0
65
+ except (OSError, ValueError):
66
+ continue
67
+ return None
68
+
69
+
70
+ def _measure_load_pct(cpu_count: int) -> tuple[float, str]:
71
+ """Load as percent (0-100) from the best available source.
72
+
73
+ Rungs:
74
+ 1. cgroup CPU consumption (diffed against the previous heartbeat)
75
+ — container-scoped, correct inside LXC/Docker.
76
+ 2. os.getloadavg() — host loadavg; correct on bare metal and
77
+ macOS, but inside LXC it reports the HOST's load (all CTs
78
+ share /proc/loadavg). Kept as fallback because it is the only
79
+ source on macOS (NovaForge).
80
+ """
81
+ now = time.monotonic()
82
+ sample = _read_cgroup_cpu_usage()
83
+ if sample is not None:
84
+ source, usage = sample
85
+ prev = _CGROUP_PREV.get("current")
86
+ _CGROUP_PREV["current"] = (now, usage)
87
+ if prev is not None and now > prev[0]:
88
+ dt = now - prev[0]
89
+ cpu_used = max(usage - prev[1], 0.0)
90
+ # utilization = used CPU-seconds / (elapsed * cores)
91
+ pct = (cpu_used / (dt * max(cpu_count, 1))) * 100.0
92
+ if 0.0 <= pct <= 1000.0: # sanity: CPU can't exceed cores*100
93
+ return min(pct, 100.0), source
94
+ # rung 2: loadavg
95
+ try:
96
+ load_avg = os.getloadavg()[0]
97
+ return min((load_avg / max(cpu_count, 1)) * 100.0, 100.0), "loadavg"
98
+ except (OSError, AttributeError):
99
+ return 0.0, "loadavg"
100
+
101
+
102
+ def _setup_logging(level: str | None = None) -> None:
103
+ if level is None:
104
+ level = os.environ.get("RELAY_LOG_LEVEL", "INFO")
105
+ numeric = getattr(logging, str(level).upper(), logging.INFO)
106
+ logging.basicConfig(
107
+ level=numeric,
108
+ format="%(asctime)s | %(levelname)-7s | node-cli | %(message)s",
109
+ datefmt="%Y-%m-%d %H:%M:%S",
110
+ )
111
+
112
+
113
+ def _utcnow_str() -> str:
114
+ return datetime.now(UTC).isoformat()
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Config helpers (env-var aware)
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def _effective_config() -> dict[str, Any]:
122
+ """Return relay_config.json merged with env-var overrides."""
123
+ cfg = load_config()
124
+ base_url = os.environ.get("RELAY_BASE_URL")
125
+ if base_url:
126
+ cfg["base_url"] = base_url
127
+ hb = os.environ.get("RELAY_HEARTBEAT_INTERVAL")
128
+ if hb is not None:
129
+ try:
130
+ cfg["heartbeat_interval"] = int(hb)
131
+ except ValueError:
132
+ log.warning("ignoring invalid RELAY_HEARTBEAT_INTERVAL=%r", hb)
133
+ ci = os.environ.get("RELAY_CLAIM_INTERVAL")
134
+ if ci is not None:
135
+ try:
136
+ cfg["claim_interval"] = int(ci)
137
+ except ValueError:
138
+ log.warning("ignoring invalid RELAY_CLAIM_INTERVAL=%r", ci)
139
+ mr = os.environ.get("RELAY_MAX_RETRIES")
140
+ if mr is not None:
141
+ try:
142
+ cfg["max_retries"] = int(mr)
143
+ except ValueError:
144
+ log.warning("ignoring invalid RELAY_MAX_RETRIES=%r", mr)
145
+ # T-182: fixed maintenance intervals. rs refreshes on every start + once
146
+ # per day; rt refreshes every 6 days. Both TTLs are 7 days, so both stay
147
+ # comfortably ahead of expiry without any expiry math.
148
+ rs_iv = os.environ.get("RELAY_RS_REFRESH_INTERVAL")
149
+ if rs_iv is not None:
150
+ try:
151
+ cfg["rs_refresh_interval_seconds"] = int(rs_iv)
152
+ except ValueError:
153
+ log.warning("ignoring invalid RELAY_RS_REFRESH_INTERVAL=%r", rs_iv)
154
+ rt_iv = os.environ.get("RELAY_RT_REFRESH_INTERVAL")
155
+ if rt_iv is not None:
156
+ try:
157
+ cfg["rt_refresh_interval_seconds"] = int(rt_iv)
158
+ except ValueError:
159
+ log.warning("ignoring invalid RELAY_RT_REFRESH_INTERVAL=%r", rt_iv)
160
+ return cfg
161
+
162
+
163
+ def _base_url(meta: dict[str, Any], cfg: dict[str, Any]) -> str:
164
+ url = cfg.get("base_url") or meta.get("base_url")
165
+ if not url:
166
+ # T-187: filtered mDNS fallback — accept ONLY the relay service
167
+ # name (configurable, default "IOWAP Relay Service"); never the
168
+ # first-best _http._tcp hit (QNAP/Brother false positives).
169
+ service_name = _mdns_service_name(cfg)
170
+ discovered = discover_relay(service_name=service_name)
171
+ if discovered:
172
+ log.info("mDNS: discovered relay at %s", discovered)
173
+ url = discovered
174
+ if not url:
175
+ raise SystemExit(
176
+ "no base_url configured (set base_url in relay_config.json, RELAY_BASE_URL, "
177
+ "run `node-cli relay discover`, or let the node discover the relay via mDNS)"
178
+ )
179
+ return url.rstrip("/")
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # T-187: filtered mDNS discovery
184
+ # ---------------------------------------------------------------------------
185
+
186
+ DEFAULT_SERVICE_NAME = "IOWAP Relay Service"
187
+
188
+
189
+ def _mdns_service_name(cfg: dict[str, Any]) -> str:
190
+ """Resolve the expected mDNS service name: env > config > default."""
191
+ env = os.environ.get("RELAY_MDNS_SERVICE_NAME")
192
+ if env:
193
+ return env
194
+ from_cfg = cfg.get("mdns_service_name")
195
+ if from_cfg:
196
+ return str(from_cfg)
197
+ return DEFAULT_SERVICE_NAME
198
+
199
+
200
+ def _probe_mdns_services(timeout: float = 2.0) -> dict[str, str]:
201
+ """Browse ``_http._tcp`` and return ``{base_url: service_name}``.
202
+
203
+ Returns ALL neighbours (QNAP, printer, relay, ...); the name filter
204
+ lives in :func:`discover_relay`. Kept as a separate seam so tests can
205
+ stub the browse without any network.
206
+ """
207
+ try:
208
+ from zeroconf import ServiceBrowser, Zeroconf
209
+ except ImportError:
210
+ log.warning("mDNS discovery unavailable (zeroconf not installed)")
211
+ return {}
212
+
213
+ found: dict[str, str] = {}
214
+
215
+ class _Listener:
216
+ def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
217
+ info = zc.get_service_info(type_, name)
218
+ if info is None:
219
+ return
220
+ try:
221
+ addr = info.parsed_addresses()[0] if info.parsed_addresses() else None
222
+ except Exception: # noqa: BLE001
223
+ addr = None
224
+ if not addr:
225
+ return
226
+ port = info.port or 8788
227
+ found[f"http://{addr}:{port}"] = name
228
+
229
+ def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
230
+ pass
231
+
232
+ def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
233
+ pass
234
+
235
+ zc = Zeroconf()
236
+ try:
237
+ browser = ServiceBrowser(zc, "_http._tcp.local.", _Listener())
238
+ import time
239
+
240
+ deadline = time.monotonic() + timeout
241
+ while time.monotonic() < deadline:
242
+ time.sleep(0.1)
243
+ browser.cancel()
244
+ finally:
245
+ zc.close()
246
+ return found
247
+
248
+
249
+ def discover_relay(
250
+ service_name: str | None = None, timeout: float = 2.0
251
+ ) -> str | None:
252
+ """Discover the relay via mDNS, accepting ONLY the relay service name.
253
+
254
+ ``service_name`` defaults to env ``RELAY_MDNS_SERVICE_NAME``, then
255
+ ``mdns_service_name`` from relay_config.json, then
256
+ ``DEFAULT_SERVICE_NAME`` ("IOWAP Relay Service"). Other ``_http._tcp``
257
+ neighbours are ignored — the connection partner is never chosen by
258
+ first-best-win.
259
+ """
260
+ if not service_name:
261
+ from nodes.common.node_utils import load_config
262
+
263
+ service_name = _mdns_service_name(load_config())
264
+ candidates = _probe_mdns_services(timeout=timeout)
265
+ for url, name in candidates.items():
266
+ # Instance names may arrive escaped (avahi-style \032 for spaces).
267
+ label = name.removesuffix("._http._tcp.local.")
268
+ if label.replace("\\032", " ") == service_name:
269
+ return url
270
+ return None
271
+
272
+
273
+ def _discover_relay_mdns(timeout: float = 2.0) -> str | None:
274
+ """Backward-compatible alias for the filtered discovery."""
275
+ return discover_relay(timeout=timeout)
276
+
277
+
278
+ def _cmd_relay_set(args: Any) -> int:
279
+ """node-cli relay set — pin the relay URL or re-enable discovery."""
280
+ from nodes.common import node_utils
281
+
282
+ server_url = getattr(args, "server_url", None)
283
+ discover = getattr(args, "discover", False)
284
+ if server_url and discover:
285
+ print("--server-url and --discover are mutually exclusive")
286
+ raise SystemExit(2)
287
+ if not server_url and not discover:
288
+ print("nothing to do: pass --server-url <url> or --discover")
289
+ raise SystemExit(2)
290
+ cfg = load_config()
291
+ if server_url:
292
+ cfg["base_url"] = server_url
293
+ node_utils.write_json_atomic(node_utils.CONFIG_PATH, cfg)
294
+ print(f"pinned relay base_url: {server_url}")
295
+ print(f" written to {node_utils.CONFIG_PATH} (discovery disabled)")
296
+ else:
297
+ cfg.pop("base_url", None)
298
+ node_utils.write_json_atomic(node_utils.CONFIG_PATH, cfg)
299
+ # T-198: `node register` persists base_url in the node state file
300
+ # and _base_url() resolves cfg.base_url or meta.base_url BEFORE the
301
+ # mDNS fallback — leaving the state-file pin in place would silently
302
+ # keep the pin alive and discovery would never engage. Unpin the
303
+ # state file too (a node without meta — never registered — is fine).
304
+ try:
305
+ meta = node_utils.load_meta()
306
+ except FileNotFoundError:
307
+ meta = None
308
+ if meta is not None and "base_url" in meta:
309
+ del meta["base_url"]
310
+ node_utils.save_meta(meta)
311
+ print(f" removed state-file pin from {node_utils.META_PATH}")
312
+ print("base_url pin removed — discovery enabled (targeted mDNS by service name)")
313
+ print(f" written to {node_utils.CONFIG_PATH}")
314
+ return 0
315
+
316
+
317
+ def _cmd_relay_discover(args: Any) -> int:
318
+ """node-cli relay discover — targeted mDNS lookup, prints the URL."""
319
+ service_name = getattr(args, "name", None)
320
+ timeout = float(getattr(args, "timeout", 2.0) or 2.0)
321
+ url = discover_relay(service_name=service_name, timeout=timeout)
322
+ if url is None:
323
+ expected = service_name or _mdns_service_name(load_config())
324
+ print(
325
+ f"relay not found: no mDNS service named {expected!r} "
326
+ f"(_http._tcp, watched {timeout}s)"
327
+ )
328
+ return 1
329
+ print(url)
330
+ if not service_name:
331
+ print(f" service: {_mdns_service_name(load_config())} (_http._tcp)")
332
+ return 0
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Auth helpers (thin wrappers around poller functions)
337
+ # ---------------------------------------------------------------------------
338
+
339
+ class RelayClient:
340
+ """Minimal HTTP client wrapping heartbeat / claim / complete / submit.
341
+
342
+ Encapsulates token handling so the CLI subcommands and the daemon
343
+ can share a single implementation. On 401/403 it attempts a token
344
+ refresh exactly once.
345
+ """
346
+
347
+ def __init__(self, meta: dict[str, Any], cfg: dict[str, Any]) -> None:
348
+ self.meta = meta
349
+ self.cfg = cfg
350
+ self.base_url = _base_url(meta, cfg)
351
+ # TLS (T-111): optional CA cert path for nodes connecting over https
352
+ # to a relay using a private/self-signed CA. When set, httpx verifies
353
+ # against it instead of the system trust store. Default True = system
354
+ # trust store (public CA / Let's Encrypt).
355
+ self._verify: str | bool = cfg.get("tls_ca_cert") or True
356
+ data = load_token()
357
+ self.token = data["token"] if data else None
358
+ # T-088: track the token expiry so the daemon can refresh
359
+ # proactively before it expires. ``None`` means unknown (e.g.
360
+ # a migrated legacy token or a server that omits expires_at).
361
+ self.token_expires_at: str | None = data.get("expires_at") if data else None
362
+ # T-108: auth-failure streak for exponential backoff. After
363
+ # repeated 401/403 failures the daemon increases the
364
+ # heartbeat/claim spacing instead of hammering in a tight loop.
365
+ self._auth_fail_streak = 0
366
+ # T-182: fixed-interval credential maintenance. The daemons call
367
+ # maybe_refresh_token() every heartbeat tick; the client tracks
368
+ # per-credential timers here.
369
+ # T-185 (Ronny's correction): rt is NOT due at start just because
370
+ # the daemon (re)started — the 6-day cadence counts from the LAST
371
+ # REFRESH, persisted as ``refreshed_at`` in the token envelope so
372
+ # it survives restarts. A missing/unparseable stamp (legacy
373
+ # envelope, fresh install) counts as due: the next maintenance
374
+ # window rotates rt once and re-anchors it. rs IS due at start
375
+ # (``_rs_last_refresh is None``): the daemons consume it
376
+ # synchronously BEFORE any connection starts (quiescence model),
377
+ # and a failed startup rotation stays due so the next maintenance
378
+ # window retries it.
379
+ self._rs_last_refresh: float | None = None
380
+ self._rt_refreshed_at: str | None = (
381
+ data.get("refreshed_at") if data else None
382
+ )
383
+ # T-183: claim pause during credential maintenance. The gate is
384
+ # SET = claims allowed (open). During run_credential_maintenance()
385
+ # it is cleared: claim() skips without HTTP and the 401 fallback
386
+ # waits for the gate instead of refreshing against a token the
387
+ # maintenance just invalidated (2026-09-13 restart race).
388
+ self._maintenance_gate = threading.Event()
389
+ # T-184: which thread owns the gate right now. The maintenance
390
+ # thread itself must neither skip (maybe_refresh_token guard) nor
391
+ # wait ( _refresh_token gate wait) on the gate it just closed —
392
+ # both were silent no-ops/self-deadlocks before.
393
+ self._maintenance_owner: int | None = None
394
+ self._maintenance_gate.set()
395
+ if not self.token:
396
+ print(
397
+ "no runtime token found, attempting recovery with registration secret",
398
+ file=sys.stderr,
399
+ )
400
+ self.token = self._recover_runtime_token()
401
+ if not self.token:
402
+ raise SystemExit("no runtime token available and recovery failed")
403
+
404
+ # -- T-108: backoff + self-healing -------------------------------------
405
+
406
+ # Backoff begins after this many consecutive auth failures.
407
+ _BACKOFF_THRESHOLD = 3
408
+ # Base delay (seconds) once the threshold is reached.
409
+ _BACKOFF_BASE = 10
410
+ # Hard cap (seconds) so the backoff never grows unbounded.
411
+ _BACKOFF_MAX = 300
412
+ # T-183: max seconds a 401 fallback waits for the credential
413
+ # maintenance gate before proceeding with its own refresh.
414
+ _MAINTENANCE_WAIT_TIMEOUT = 120.0
415
+
416
+ def _register_backoff_failure(self) -> None:
417
+ """Record one consecutive auth failure (401/403)."""
418
+ self._auth_fail_streak += 1
419
+
420
+ def _register_backoff_success(self) -> None:
421
+ """Reset the auth-failure streak after a successful auth/refresh."""
422
+ self._auth_fail_streak = 0
423
+
424
+ def _current_backoff(self) -> float:
425
+ """Return the current auth-failure backoff in seconds (0 = none)."""
426
+ if self._auth_fail_streak < self._BACKOFF_THRESHOLD:
427
+ return 0.0
428
+ # Exponential: 10s, 20s, 40s, 80s, 160s — capped at _BACKOFF_MAX.
429
+ exp = min(self._auth_fail_streak - self._BACKOFF_THRESHOLD + 1, 5)
430
+ return float(min(self._BACKOFF_BASE * (2 ** (exp - 1)), self._BACKOFF_MAX))
431
+
432
+ def _reload_token_from_disk(self) -> None:
433
+ """Re-read the token file. Helps when an external process (or a
434
+ manual intervention) corrected the token after the daemon cached
435
+ an invalid value. Reads only — overwrites nothing on disk.
436
+ T-185: adopts the persisted ``refreshed_at`` cadence anchor too,
437
+ so the rt timer follows whatever is actually on disk.
438
+ """
439
+ data = load_token()
440
+ if data and data.get("token"):
441
+ self.token = data["token"]
442
+ self.token_expires_at = data.get("expires_at")
443
+ self._rt_refreshed_at = data.get("refreshed_at")
444
+
445
+ # -- low level ----------------------------------------------------------
446
+
447
+ def _post(
448
+ self, path: str, body: dict[str, Any] | None = None, *, timeout: float | None = None
449
+ ) -> httpx.Response:
450
+ return httpx.post(
451
+ f"{self.base_url}{path}",
452
+ headers={"Authorization": f"Bearer {self.token}"},
453
+ json=body or {},
454
+ timeout=timeout or self.cfg["request_timeout"],
455
+ verify=self._verify,
456
+ )
457
+
458
+ def _get(
459
+ self, path: str, *, timeout: float | None = None
460
+ ) -> httpx.Response:
461
+ return httpx.get(
462
+ f"{self.base_url}{path}",
463
+ headers={"Authorization": f"Bearer {self.token}"},
464
+ timeout=timeout or self.cfg["request_timeout"],
465
+ verify=self._verify,
466
+ )
467
+
468
+ def _get_with_retry(
469
+ self, path: str, *, timeout: float | None = None
470
+ ) -> httpx.Response:
471
+ r = self._get(path, timeout=timeout)
472
+ if r.status_code in (401, 403):
473
+ log.warning("auth error %s on %s, refreshing token", r.status_code, path)
474
+ if self._refresh_token():
475
+ r = self._get(path, timeout=timeout)
476
+ return r
477
+
478
+ def _post_with_retry(
479
+ self, path: str, body: dict[str, Any] | None = None, *, timeout: float | None = None
480
+ ) -> httpx.Response:
481
+ r = self._post(path, body, timeout=timeout)
482
+ if r.status_code in (401, 403):
483
+ log.warning("auth error %s on %s, refreshing token", r.status_code, path)
484
+ if self._refresh_token():
485
+ r = self._post(path, body, timeout=timeout)
486
+ return r
487
+
488
+ # -- token refresh -------------------------------------------------------
489
+
490
+ def _refresh_token(self) -> bool:
491
+ # T-183: a 401 fallback that raced the maintenance start waits for
492
+ # the maintenance to finish, then adopts the token it persisted.
493
+ # Refreshing against the old token would fail: the server already
494
+ # invalidated it (2026-09-13 restart race).
495
+ gate_was_closed = not self._maintenance_gate.is_set()
496
+ if gate_was_closed and self._maintenance_owner != threading.get_ident():
497
+ if not self._maintenance_gate.wait(timeout=self._MAINTENANCE_WAIT_TIMEOUT):
498
+ log.warning(
499
+ "credential maintenance still running after %.0fs; refreshing anyway",
500
+ self._MAINTENANCE_WAIT_TIMEOUT,
501
+ )
502
+ else:
503
+ # Maintenance finished while we waited: adopt its fresh token.
504
+ self._reload_token_from_disk()
505
+ try:
506
+ r = httpx.post(
507
+ f"{self.base_url}/relay/v2/auth/refresh",
508
+ headers={"Authorization": f"Bearer {self.token}"},
509
+ json={"requested_credential": "runtime_token"},
510
+ timeout=self.cfg["request_timeout"],
511
+ verify=self._verify,
512
+ )
513
+ if r.status_code == 200:
514
+ data = r.json()
515
+ new = data.get("token")
516
+ expires_at = data.get("expires_at")
517
+ if new:
518
+ # T-185: re-anchor the persisted cadence stamp — the
519
+ # 6-day interval counts from THIS refresh.
520
+ self._rt_refreshed_at = _utcnow_str()
521
+ save_token(
522
+ new, expires_at=expires_at,
523
+ refreshed_at=self._rt_refreshed_at,
524
+ )
525
+ self.token = new
526
+ self.token_expires_at = expires_at
527
+ # T-182: if the server rotates the rs alongside, keep it.
528
+ self._persist_rotated_secret(data)
529
+ self._register_backoff_success()
530
+ return True
531
+ except Exception as exc:
532
+ log.warning("runtime-token refresh failed: %s", exc)
533
+ recovered = self._recover_runtime_token()
534
+ if recovered is not None:
535
+ self._register_backoff_success()
536
+ return True
537
+ # Refresh + recovery failed: re-read the file from disk — an
538
+ # external process/manual intervention might have corrected the
539
+ # token in the meantime (T-108 task 1). Increment the backoff
540
+ # streak so the daemon does not stay in a tight 401 loop (T-108
541
+ # task 2).
542
+ self._reload_token_from_disk()
543
+ self._register_backoff_failure()
544
+ return False
545
+
546
+ def _recover_runtime_token(self) -> str | None:
547
+ try:
548
+ r = httpx.post(
549
+ f"{self.base_url}/relay/v2/auth/refresh",
550
+ json={
551
+ "node_id": self.meta["node_id"],
552
+ "requested_credential": "runtime_token",
553
+ "registration_secret": self.meta.get("registration_secret"),
554
+ },
555
+ timeout=self.cfg["request_timeout"],
556
+ verify=self._verify,
557
+ )
558
+ r.raise_for_status()
559
+ data = r.json()
560
+ new = data.get("token")
561
+ expires_at = data.get("expires_at")
562
+ if new:
563
+ # T-185: the recovered token is brand-new — re-anchor the
564
+ # persisted cadence stamp; the 6-day interval counts from
565
+ # THIS recovery.
566
+ self._rt_refreshed_at = _utcnow_str()
567
+ save_token(
568
+ new, expires_at=expires_at,
569
+ refreshed_at=self._rt_refreshed_at,
570
+ )
571
+ self.token = new
572
+ self.token_expires_at = expires_at
573
+ # T-182 (Bug 4): the server rotates the rs on recovery and
574
+ # returns it. Persisting it is the whole point — a node that
575
+ # drops the rotated rs cannot recover the NEXT time.
576
+ self._persist_rotated_secret(data)
577
+ return new
578
+ except Exception as exc:
579
+ log.error("registration-secret recovery failed: %s", exc)
580
+ return None
581
+
582
+ # -- proactive refresh (T-118) + credential maintenance (T-182) ----------
583
+
584
+ def _persist_rotated_secret(self, data: dict[str, Any]) -> None:
585
+ """Keep a rotated registration secret from any refresh response.
586
+
587
+ The 2026-09-13 death spiral happened because the server attached a
588
+ rotated rs to the recovery response but the client threw it away
589
+ (relay_client.py:316-323 pre-T-182). A node whose meta file holds a
590
+ stale rs cannot recover once its rt dies — persist whatever the
591
+ server hands us, in both response shapes.
592
+ """
593
+ new_secret = data.get("registration_secret")
594
+ if not new_secret or new_secret == self.meta.get("registration_secret"):
595
+ return
596
+ self.meta["registration_secret"] = new_secret
597
+ save_meta(self.meta)
598
+ log.info("persisted rotated registration secret from refresh response")
599
+
600
+ def _maybe_rotate_rs(self) -> None:
601
+ """Rotate the registration secret on start + every rs interval (T-182).
602
+
603
+ Due when ``_rs_last_refresh is None`` (never done since daemon
604
+ start — T-185: the startup wrapper consumes this synchronously
605
+ before any connection) or the 24h interval has elapsed. Uses the
606
+ valid runtime token (server auth.py Case 3). A failed rotation is
607
+ non-fatal: keep the old secret and try again next tick.
608
+ """
609
+ interval = float(self.cfg.get("rs_refresh_interval_seconds", 86400))
610
+ due = self._rs_last_refresh is None or (
611
+ time.monotonic() - self._rs_last_refresh
612
+ ) >= interval
613
+ if not due:
614
+ return
615
+ if not self.token:
616
+ return
617
+ try:
618
+ r = httpx.post(
619
+ f"{self.base_url}/relay/v2/auth/refresh",
620
+ headers={"Authorization": f"Bearer {self.token}"},
621
+ json={"requested_credential": "registration_secret"},
622
+ timeout=self.cfg["request_timeout"],
623
+ verify=self._verify,
624
+ )
625
+ if r.status_code == 200:
626
+ data = r.json()
627
+ new = data.get("token")
628
+ if new:
629
+ self.meta["registration_secret"] = new
630
+ save_meta(self.meta)
631
+ self._rs_last_refresh = time.monotonic()
632
+ log.info("registration secret rotated proactively")
633
+ else:
634
+ log.warning("rs rotation response without token; retrying next tick")
635
+ else:
636
+ log.warning(
637
+ "rs rotation failed (http %s); retrying next tick", r.status_code
638
+ )
639
+ except Exception as exc: # noqa: BLE001 — maintenance must not kill the daemon
640
+ log.warning("rs rotation error: %s", exc)
641
+
642
+ # -- T-185: rt cadence anchored at the persisted last refresh -----------
643
+
644
+ @staticmethod
645
+ def _parse_iso_ts(value: Any) -> datetime | None:
646
+ """Parse an ISO-8601 timestamp; returns None when unusable."""
647
+ if not isinstance(value, str) or not value:
648
+ return None
649
+ try:
650
+ ts = datetime.fromisoformat(value)
651
+ except ValueError:
652
+ return None
653
+ return ts if ts.tzinfo else ts.replace(tzinfo=UTC)
654
+
655
+ def _rt_due(self) -> bool:
656
+ """Whether the rt refresh interval has elapsed since the LAST
657
+ REFRESH (persisted ``refreshed_at`` in the token envelope).
658
+
659
+ T-185 correction (Ronny): the 6-day cadence must NOT count from
660
+ daemon start — a restart would reset the timer of an aging token
661
+ and it could silently run past its 7-day TTL. The stamp lives on
662
+ disk, so restarts preserve it. Unknown/unparseable stamp → due
663
+ (self-healing: the next window rotates once and re-anchors).
664
+
665
+ Stamping: ``save_token()`` anchors ``refreshed_at`` to NOW on
666
+ every token write, so the refresh/recovery paths re-anchor the
667
+ cadence implicitly; only the RAM mirror is updated here.
668
+ """
669
+ interval = float(self.cfg.get("rt_refresh_interval_seconds", 6 * 86400))
670
+ ts = self._parse_iso_ts(self._rt_refreshed_at)
671
+ if ts is None:
672
+ return True
673
+ return (datetime.now(UTC) - ts).total_seconds() >= interval
674
+
675
+ def maybe_refresh_token(self) -> None:
676
+ """Fixed-interval credential maintenance (T-182).
677
+
678
+ Replaces the T-118 expiry-margin logic. No expiry math anymore —
679
+ the node acts on its own schedule:
680
+ - rs: rotated on daemon start + every ``rs_refresh_interval_seconds``
681
+ (default 24h)
682
+ - rt: refreshed every ``rt_refresh_interval_seconds`` (default 6 days)
683
+ Both credentials carry a 7-day TTL server-side, so fixed intervals
684
+ keep them permanently fresh. A recovery that ran in ``__init__``
685
+ counts as a fresh rt refresh (the recovered token is brand-new).
686
+
687
+ T-183: claims must not run concurrently with the rotation — the
688
+ daemons use :meth:`run_credential_maintenance`, which pauses the
689
+ claim loop for the duration. This method only guards against a
690
+ direct call while another maintenance is in flight.
691
+
692
+ T-185 correction (Ronny): the rt cadence counts from the LAST
693
+ REFRESH, persisted as ``refreshed_at`` in the token envelope —
694
+ never from daemon start, so a restart cannot reset the timer of
695
+ an aging token. A missing/unparseable stamp counts as due: the
696
+ next window rotates rt once and re-anchors the stamp (legacy
697
+ envelopes bootstrap cleanly on the rs-start rotation).
698
+ """
699
+ if self._maintenance_owner == threading.get_ident():
700
+ # This thread IS the maintenance (T-184): never skip your own
701
+ # rotation — the guard is only for other threads.
702
+ pass
703
+ elif not self._maintenance_gate.is_set():
704
+ # Another thread is inside run_credential_maintenance().
705
+ return
706
+ rt_due = self._rt_due()
707
+ if rt_due:
708
+ self._refresh_token()
709
+ self._maybe_rotate_rs()
710
+
711
+ def run_credential_maintenance(self) -> None:
712
+ """Run one maintenance tick with claims paused (T-183).
713
+
714
+ Sequence model instead of locking: pause claims, rotate rt/rs,
715
+ resume claims — which now inherit the fresh tokens. The ``finally``
716
+ guarantees the gate reopens even if the maintenance raises (server
717
+ down), so a wedged claim loop can never outlive one bad tick.
718
+ """
719
+ self._maintenance_gate.clear()
720
+ self._maintenance_owner = threading.get_ident()
721
+ try:
722
+ self.maybe_refresh_token()
723
+ finally:
724
+ self._maintenance_owner = None
725
+ self._maintenance_gate.set()
726
+
727
+ def maintenance_due(self) -> bool:
728
+ """Whether a rotation is due right now (T-185).
729
+
730
+ The heartbeat loop opens the quiescence window ONLY when this is
731
+ true — the window drops SSE/claims/heartbeat, so it must never run
732
+ on a plain 8 s tick without a pending rotation.
733
+
734
+ rt: counted from the persisted last refresh (T-185 correction) —
735
+ see :meth:`_rt_due`. rs: start tick outstanding or 24h elapsed.
736
+ """
737
+ rt_due = self._rt_due()
738
+ rs_interval = float(self.cfg.get("rs_refresh_interval_seconds", 86400))
739
+ rs_due = self._rs_last_refresh is None or (
740
+ time.monotonic() - self._rs_last_refresh
741
+ ) >= rs_interval
742
+ return rt_due or rs_due
743
+
744
+ def refresh_registration_secret(self) -> None:
745
+ """Synchronous rs rotation for daemon startup (T-185).
746
+
747
+ The daemons call this BEFORE starting any connection thread, so the
748
+ very first heartbeat/SSE request presents fresh credentials. A
749
+ failure is non-fatal: rs stays due (``_rs_last_refresh is None``)
750
+ and the next maintenance window retries (same semantics as
751
+ ``_maybe_rotate_rs``).
752
+ """
753
+ self._maybe_rotate_rs()
754
+
755
+ # -- public API ----------------------------------------------------------
756
+
757
+ def _build_heartbeat_payload(
758
+ self, caps: list[dict[str, Any]], in_flight: dict[str, int]
759
+ ) -> dict[str, Any]:
760
+ # T-210: load_source chain. os.getloadavg() reports the HOST
761
+ # loadavg inside LXC containers (shared /proc/loadavg), so all
762
+ # CTs on one Proxmox host reported the same number. Prefer
763
+ # container-scoped sources, fall back to loadavg (correct on
764
+ # bare metal / macOS). Each rung sets load_source so the server
765
+ # (and dashboard) can tell which measurement it got.
766
+ cpu_count = os.cpu_count() or 1
767
+ load_pct, load_source = _measure_load_pct(cpu_count)
768
+ load_cap = float(self.cfg.get("load_cap", cpu_count * 100.0))
769
+ load = min(load_pct, load_cap, 100.0)
770
+
771
+ cap_status: list[dict[str, Any]] = []
772
+ for cap in caps:
773
+ if not cap.get("auto_publish", True):
774
+ continue
775
+ name = cap["name"]
776
+ inflight = in_flight.get(name, 0)
777
+ entry: dict[str, Any] = {
778
+ "name": name,
779
+ "version": cap.get("version", "1.0.0"),
780
+ "available": inflight < cap.get("max_parallel", 1),
781
+ }
782
+ # T-053: forward capability metadata so the server can
783
+ # populate node_capabilities.{description,input_schema}
784
+ # and resolve capability_details on claim/task-view
785
+ # without an extra discovery round-trip. Omit fields that
786
+ # are absent or falsy to keep the heartbeat payload small.
787
+ if cap.get("type"):
788
+ entry["type"] = cap.get("type")
789
+ if cap.get("description"):
790
+ entry["description"] = cap.get("description")
791
+ if cap.get("input_schema"):
792
+ entry["input_schema"] = cap.get("input_schema")
793
+ # T-164: forward upload_modes (inline/artifact/bridge) to the
794
+ # server so the node-capabilities table stores them and file
795
+ # send/file get can use them for mode selection.
796
+ if cap.get("upload_modes"):
797
+ entry["upload_modes"] = cap.get("upload_modes")
798
+ # T-004 (iowap-flow): forward result_path_hints to the server
799
+ # so the flow planner knows valid ${ref.result.path} paths.
800
+ if cap.get("result_path_hints"):
801
+ entry["result_path_hints"] = cap.get("result_path_hints")
802
+ cap_status.append(entry)
803
+
804
+ queue_depth = sum(in_flight.values())
805
+ body: dict[str, Any] = {
806
+ "node_id": self.meta["node_id"],
807
+ "status": "online",
808
+ "available": True,
809
+ "load": load,
810
+ "queue_depth": queue_depth,
811
+ "capabilities": cap_status,
812
+ }
813
+ # T-210: which rung of the load chain produced the value
814
+ # (cgroup2/cgroup/loadavg), so the dashboard can show it.
815
+ body["load_source"] = load_source
816
+
817
+ # T-072: forward node-level node_name + description from the
818
+ # meta file (iowap-agent.json) so the server can store and
819
+ # surface them via `node list` / `node info`.
820
+ node_name = self.meta.get("node_name")
821
+ if node_name:
822
+ body["node_name"] = node_name
823
+ description = self.meta.get("description")
824
+ if description:
825
+ body["description"] = description
826
+
827
+ # T-081: forward the node's requested status (busy/idle) from the
828
+ # active YAML profile. The value is written into the YAML by
829
+ # `node-cli node busy`/`idle` and persists until explicitly
830
+ # changed. When no explicit status is set we send "online" so
831
+ # the server can transition the node from approved/offline to
832
+ # online.
833
+ requested_status = load_active_status()
834
+ if requested_status:
835
+ body["status"] = requested_status
836
+ # T-081: forward the per-node load cap so the server can run
837
+ # its auto-busy logic against the operator-configured ceiling
838
+ # rather than a server-wide default.
839
+ load_cap = self.cfg.get("load_cap")
840
+ if load_cap is not None:
841
+ body["load_cap"] = float(load_cap)
842
+
843
+ return body
844
+
845
+ def heartbeat(self, caps: list[dict[str, Any]], in_flight: dict[str, int]) -> dict[str, Any]:
846
+ body = self._build_heartbeat_payload(caps, in_flight)
847
+ r = self._post_with_retry(
848
+ # T-176: post to /worker-heartbeat (replace mode). The regular
849
+ # /heartbeat endpoint merges capabilities union-only on the
850
+ # server (core/discovery.py), so capabilities removed from the
851
+ # active profile would linger as ghosts on the relay forever.
852
+ # /worker-heartbeat hardcodes replace_capabilities=True, i.e.
853
+ # the server REPLACES the stored set with this heartbeat's
854
+ # list — removals propagate. Server-side this endpoint has
855
+ # existed and shipped since T-081 (api/v2/discovery.py).
856
+ "/relay/v2/discovery/worker-heartbeat",
857
+ body,
858
+ )
859
+ r.raise_for_status()
860
+ return r.json()
861
+
862
+ def claim(self, capability: str) -> dict[str, Any] | None:
863
+ # T-183: during credential maintenance the claim thread pauses —
864
+ # decided locally, no HTTP against a token that is about to rotate.
865
+ if not self._maintenance_gate.is_set():
866
+ return None
867
+ r = self._post_with_retry(
868
+ "/relay/v2/scheduler/claim",
869
+ {"capability": capability},
870
+ )
871
+ if r.status_code == 204:
872
+ return None
873
+ r.raise_for_status()
874
+ data = r.json()
875
+ if not data.get("claimed") or not data.get("stage"):
876
+ return None
877
+ return data["stage"]
878
+
879
+ def complete(self, task_id: str, stage_id: str, result: dict[str, Any]) -> dict[str, Any]:
880
+ r = self._post_with_retry(
881
+ f"/relay/v2/scheduler/stages/{stage_id}/complete",
882
+ {"node_id": self.meta["node_id"], "task_id": task_id, "result": result},
883
+ timeout=self.cfg.get("task_timeout", 600),
884
+ )
885
+ r.raise_for_status()
886
+ return r.json()
887
+
888
+ def submit_simple_task(
889
+ self,
890
+ capability: str,
891
+ payload: dict[str, Any],
892
+ *,
893
+ name: str = "",
894
+ priority: int = 0,
895
+ owner_node_id: str | None = None,
896
+ ) -> dict[str, Any]:
897
+ body = {
898
+ "capability": capability,
899
+ "payload": payload,
900
+ "name": name,
901
+ "priority": priority,
902
+ }
903
+ if owner_node_id:
904
+ body["owner_node_id"] = owner_node_id
905
+ r = self._post_with_retry("/relay/v2/scheduler/task-simple", body)
906
+ r.raise_for_status()
907
+ return r.json()
908
+
909
+ def get_task(self, task_id: str) -> dict[str, Any]:
910
+ """Fetch task details including stages, artifacts, and notes."""
911
+ r = self._get_with_retry(f"/relay/v2/scheduler/tasks/{task_id}")
912
+ if r.status_code == 404:
913
+ return {"error": "not found", "task_id": task_id}
914
+ r.raise_for_status()
915
+ return r.json()
916
+
917
+ # -- T-164: transfer ladder --------------------------------------------
918
+
919
+ def get_transfer_config(self) -> dict[str, Any]:
920
+ """Fetch the server's transfer-ladder config (T-164).
921
+
922
+ ``GET /relay/v2/discovery/transfer-config`` returns
923
+ ``{max_inline_bytes, max_artifact_bytes, max_payload_bytes}``.
924
+ """
925
+ r = self._get_with_retry("/relay/v2/discovery/transfer-config")
926
+ r.raise_for_status()
927
+ return r.json()
928
+
929
+ def get_capability_detail(self, name: str) -> dict[str, Any]:
930
+ """Fetch a single capability's details incl. ``upload_modes`` (T-164).
931
+
932
+ ``GET /relay/v2/discovery/capabilities/{name}`` returns the
933
+ capability with ``input_schema`` and ``upload_modes`` so the
934
+ ``file send``/``file get`` handler can pick the transfer mode.
935
+ """
936
+ r = self._get_with_retry(f"/relay/v2/discovery/capabilities/{name}")
937
+ r.raise_for_status()
938
+ return r.json()
939
+
940
+ def add_task_note(self, task_id: str, message: str, kind: str = "info") -> dict[str, Any]:
941
+ """Append a note to a task (T-052 mini-chat; T-154 kind drives Long-Run)."""
942
+ r = self._post_with_retry(
943
+ f"/relay/v2/scheduler/tasks/{task_id}/notes",
944
+ {"message": message, "kind": kind},
945
+ )
946
+ r.raise_for_status()
947
+ return r.json()
948
+
949
+ # -- T-126: temporary bridge routes -------------------------------------
950
+
951
+ def register_temp_route(
952
+ self,
953
+ path: str,
954
+ method: str,
955
+ upstream: str,
956
+ *,
957
+ ttl_seconds: int,
958
+ channel_id: str,
959
+ description: str = "",
960
+ ) -> dict[str, Any]:
961
+ """Register a temporary bridge route on the server (T-124/T-126).
962
+
963
+ The route is owned by this node (the node_id comes from the
964
+ Bearer token), lives for ``ttl_seconds`` and is tied to
965
+ ``channel_id`` so the caller can revoke it later. Use this for
966
+ large-file handoff (storage upload/download channels) where the
967
+ regular heartbeat routes would be replaced too eagerly.
968
+
969
+ Returns the server response dict containing ``expires_at``.
970
+ """
971
+ body = {
972
+ "path": path,
973
+ "method": method,
974
+ "upstream": upstream,
975
+ "ttl_seconds": ttl_seconds,
976
+ "channel_id": channel_id,
977
+ "description": description,
978
+ }
979
+ r = self._post_with_retry(
980
+ "/relay/v2/dashboard/api/node-routes/register", body
981
+ )
982
+ r.raise_for_status()
983
+ return r.json()
984
+
985
+ def unregister_temp_route(self, path: str, method: str = "GET") -> None:
986
+ """Delete a route owned by this node before its TTL expires (T-126).
987
+
988
+ ``DELETE /api/node-routes/{node_id}/{path}?method=...``. The node
989
+ is resolved from the Bearer token on the server side, so this
990
+ client only needs the path + method it registered earlier. A 404
991
+ (route already expired/reaped) is swallowed.
992
+ """
993
+ import urllib.parse
994
+
995
+ # The path is matched verbatim by the server; keep the leading
996
+ # slash the server expects and URL-encode any segment so a path
997
+ # with special characters survives the routing layer.
998
+ sub = path if path.startswith("/") else "/" + path
999
+ url_path = urllib.parse.quote(sub, safe="/")
1000
+ r = httpx.delete(
1001
+ f"{self.base_url}/relay/v2/dashboard/api/node-routes/{self.meta['node_id']}{url_path}",
1002
+ params={"method": method},
1003
+ headers={"Authorization": f"Bearer {self.token}"},
1004
+ timeout=self.cfg["request_timeout"],
1005
+ verify=self._verify,
1006
+ )
1007
+ if r.status_code in (401, 403):
1008
+ if self._refresh_token():
1009
+ r = httpx.delete(
1010
+ f"{self.base_url}/relay/v2/dashboard/api/node-routes/{self.meta['node_id']}{url_path}",
1011
+ params={"method": method},
1012
+ headers={"Authorization": f"Bearer {self.token}"},
1013
+ timeout=self.cfg["request_timeout"],
1014
+ verify=self._verify,
1015
+ )
1016
+ # 404 means the route already expired/reaped — fine.
1017
+ if r.status_code not in (200, 404):
1018
+ r.raise_for_status()
1019
+
1020
+ def list_temp_routes(self) -> list[dict[str, Any]]:
1021
+ """List this node's own temp routes from the server (T-136).
1022
+
1023
+ ``GET /api/node-routes?node_id=<own>`` (rt-Token). The server
1024
+ resolves the caller's node_id from the Bearer token and returns
1025
+ only routes owned by this node, including ``expires_at`` and
1026
+ ``channel_id`` for each row.
1027
+ """
1028
+ r = self._get_with_retry("/relay/v2/dashboard/api/node-routes")
1029
+ r.raise_for_status()
1030
+ data = r.json()
1031
+ # Server returns ``{"routes": [...]}``.
1032
+ if isinstance(data, list):
1033
+ return data
1034
+ return data.get("routes", [])
1035
+
1036
+ # -- artifact download ---------------------------------------------------
1037
+
1038
+ def download_artifact(
1039
+ self,
1040
+ artifact_id: str,
1041
+ output_path: Path | None = None,
1042
+ *,
1043
+ chunk_size: int = 64 * 1024,
1044
+ ) -> Path:
1045
+ """Download an artifact by id, streaming it to disk chunkwise.
1046
+
1047
+ Falls back to a token refresh on a 401/403, then retries once. The
1048
+ output filename is derived from the Content-Disposition header when
1049
+ no ``output_path`` is supplied.
1050
+ """
1051
+ url = f"{self.base_url}/relay/v2/storage/files/{artifact_id}"
1052
+ timeout = self.cfg.get("request_timeout", 30)
1053
+
1054
+ cm = httpx.stream(
1055
+ "GET",
1056
+ url,
1057
+ headers={"Authorization": f"Bearer {self.token}"},
1058
+ follow_redirects=True,
1059
+ timeout=timeout,
1060
+ verify=self._verify, # T-190 (F3): verify like every other call
1061
+ )
1062
+ resp = cm.__enter__()
1063
+ try:
1064
+ if resp.status_code in (401, 403):
1065
+ # Close this attempt and retry once after refreshing the token.
1066
+ cm.__exit__(None, None, None)
1067
+ refreshed = self._refresh_token()
1068
+ cm = httpx.stream(
1069
+ "GET",
1070
+ url,
1071
+ headers={"Authorization": f"Bearer {self.token}"},
1072
+ follow_redirects=True,
1073
+ timeout=timeout,
1074
+ verify=self._verify, # T-190 (F3)
1075
+ )
1076
+ resp = cm.__enter__()
1077
+ if not refreshed:
1078
+ resp.raise_for_status() # surface the auth error
1079
+ resp.raise_for_status()
1080
+
1081
+ target = output_path or Path(_filename_from_response(resp, artifact_id))
1082
+ with target.open("wb") as f:
1083
+ for chunk in resp.iter_bytes(chunk_size=chunk_size):
1084
+ f.write(chunk)
1085
+ return target
1086
+ finally:
1087
+ cm.__exit__(None, None, None)
1088
+
1089
+ # -- artifact upload -----------------------------------------------------
1090
+
1091
+ def upload_artifact(
1092
+ self,
1093
+ file_path: Path,
1094
+ *,
1095
+ name: str | None = None,
1096
+ task_id: str | None = None,
1097
+ stage_id: str | None = None,
1098
+ ) -> dict[str, Any]:
1099
+ """Upload a local file to the relay as an artifact.
1100
+
1101
+ Returns the server response dict containing ``artifact_id``,
1102
+ ``name``, ``size_bytes``, etc. Falls back to a token refresh
1103
+ on a 401/403, then retries once.
1104
+ """
1105
+ url = f"{self.base_url}/relay/v2/storage/upload"
1106
+ params: dict[str, str] = {}
1107
+ if task_id:
1108
+ params["task_id"] = task_id
1109
+ if stage_id:
1110
+ params["stage_id"] = stage_id
1111
+
1112
+ file_path = Path(file_path)
1113
+ upload_name = name or file_path.name
1114
+
1115
+ def _do_upload() -> httpx.Response:
1116
+ with file_path.open("rb") as f:
1117
+ return httpx.post(
1118
+ url,
1119
+ headers={"Authorization": f"Bearer {self.token}"},
1120
+ files={"file": (upload_name, f, "application/octet-stream")},
1121
+ params=params or None,
1122
+ timeout=self.cfg.get("request_timeout", 30),
1123
+ verify=self._verify,
1124
+ )
1125
+
1126
+ resp = _do_upload()
1127
+ if resp.status_code in (401, 403):
1128
+ self._refresh_token()
1129
+ resp = _do_upload()
1130
+ resp.raise_for_status()
1131
+ return resp.json()
1132
+
1133
+ # -- T-177: unauthenticated server-status probe ---------------------------
1134
+
1135
+ def probe_server(self) -> dict[str, Any]:
1136
+ """GET /health, /ready and /metrics and reduce them to a dict.
1137
+
1138
+ All three endpoints are public (no token) — this probe is read-only
1139
+ monitoring for dashboards/telemetry. Never raises: on any failure
1140
+ the probe degrades to ``{"ok": False, "error": ...}`` so callers
1141
+ (daemon probe thread, status file, telemetry push) can treat the
1142
+ server as unreachable without their own try/except.
1143
+
1144
+ Shape::
1145
+
1146
+ {"ok": True, "version": "2.0.0", "mode": "core",
1147
+ "database": "ok", "scheduler": "ok",
1148
+ "maintenance_age_seconds": 29.0,
1149
+ "nodes_total": 6, "nodes_online": 4,
1150
+ "queue_depth": 0,
1151
+ "tasks_completed": 311, "tasks_failed": 24,
1152
+ "tasks_cancelled": 7,
1153
+ "stages_total": 311, "stages_retry_ratio": 0.0322,
1154
+ "tasks_created_5m": 0, "tasks_completed_5m": 0,
1155
+ "node_load": {"E4W3CBWQ": 12.87, ...}}
1156
+ """
1157
+ result: dict[str, Any] = {"ok": False, "error": ""}
1158
+ timeout = self.cfg.get("request_timeout", 10)
1159
+ try:
1160
+ r = httpx.get(
1161
+ f"{self.base_url}/health",
1162
+ timeout=timeout,
1163
+ verify=self._verify,
1164
+ )
1165
+ r.raise_for_status()
1166
+ health = r.json()
1167
+ result["version"] = health.get("version")
1168
+ result["mode"] = health.get("mode")
1169
+ except Exception as exc: # noqa: BLE001 — probe must never throw
1170
+ result["error"] = f"health: {exc}"
1171
+ return result
1172
+
1173
+ try:
1174
+ r = httpx.get(
1175
+ f"{self.base_url}/ready",
1176
+ timeout=timeout,
1177
+ verify=self._verify,
1178
+ )
1179
+ r.raise_for_status()
1180
+ ready = r.json()
1181
+ result["database"] = ready.get("database")
1182
+ result["scheduler"] = ready.get("scheduler")
1183
+ except Exception as exc: # noqa: BLE001
1184
+ result["error"] = f"ready: {exc}"
1185
+ # /health OK but /ready down → server is up but degraded.
1186
+
1187
+ try:
1188
+ r = httpx.get(
1189
+ f"{self.base_url}/metrics",
1190
+ timeout=timeout,
1191
+ verify=self._verify,
1192
+ )
1193
+ r.raise_for_status()
1194
+ result.update(_parse_prometheus_gauges(r.text))
1195
+ except Exception as exc: # noqa: BLE001
1196
+ result["error"] = f"metrics: {exc}"
1197
+
1198
+ result["ok"] = not result["error"]
1199
+ return result
1200
+
1201
+
1202
+ def _parse_prometheus_gauges(text: str) -> dict[str, Any]:
1203
+ """Pull the interesting gauges out of a Prometheus exposition dump.
1204
+
1205
+ Only simple gauges (``name value``) are collected — histograms are
1206
+ skipped except for the pre-aggregated *_sum/_count lines. Labelled
1207
+ series are mapped: relay_tasks{status=…} → tasks_<status>,
1208
+ relay_node_load{node_id=…} → node_load[node_id].
1209
+ """
1210
+ gauges: dict[str, Any] = {}
1211
+ node_load: dict[str, float] = {}
1212
+ for line in text.splitlines():
1213
+ line = line.strip()
1214
+ if not line or line.startswith("#"):
1215
+ continue
1216
+ name, _, rest = line.partition(" ")
1217
+ value_str = rest.strip()
1218
+ try:
1219
+ value = float(value_str)
1220
+ except ValueError:
1221
+ continue
1222
+ # Labels? → map into structured fields, skip the raw name.
1223
+ if "{" in name:
1224
+ labels = dict(re.findall(r'(\w+)="([^"]*)"', name))
1225
+ bare = name[: name.index("{")]
1226
+ if bare == "relay_tasks":
1227
+ gauges[f"tasks_{labels.get('status', 'unknown')}"] = value
1228
+ elif bare == "relay_stages":
1229
+ gauges[f"stages_{labels.get('status', 'unknown')}"] = value
1230
+ elif bare == "relay_node_load":
1231
+ node_id = labels.get("node_id")
1232
+ if node_id:
1233
+ node_load[node_id] = value
1234
+ continue
1235
+ name = name.removeprefix("relay_")
1236
+ gauges[name] = value
1237
+ if node_load:
1238
+ gauges["node_load"] = node_load
1239
+ return gauges
1240
+
1241
+
1242
+ def _filename_from_response(response: httpx.Response, fallback: str) -> str:
1243
+ """Extract a filename from Content-Disposition, falling back to the id."""
1244
+ cd = response.headers.get("content-disposition", "")
1245
+ m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
1246
+ return m.group(1) if m else fallback