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,1311 @@
1
+ #!/usr/bin/env python3
2
+ """node-cli — generic capability-driven daemon & CLI for IOWAP.
3
+
4
+ The CLI is fully capability-agnostic: all behaviour is driven by an
5
+ external YAML profile (see NODE_CLI_SPEC.md). Subcommands:
6
+
7
+ Daemon control:
8
+ node-cli daemon start | stop | status | restart | foreground
9
+
10
+ One-shot operations:
11
+ node-cli heartbeat
12
+ node-cli claim <capability>
13
+ node-cli complete <stage_id> --task <task_id> --result-file <path>
14
+ node-cli task submit --name <name> --stage <cap>:<json_payload> [--priority N] [--owner <node_id>]
15
+ node-cli artifact download <artifact_id> [--output <path>]
16
+ node-cli artifact upload <file> [--name <name>] [--task-id <id>] [--stage-id <id>]
17
+
18
+ Handler primitives (T-179):
19
+ node-cli hp put <path> --cap <capability> [--name <name>]
20
+ node-cli hp get [--file <envelope.json>] [--output <path>] [--out-dir <dir>]
21
+
22
+ Capability profile management:
23
+ node-cli capabilities list | validate [profile] | publish <profile>
24
+ node-cli capabilities diff [profile] | current
25
+
26
+ Status:
27
+ node-cli status
28
+ node-cli reload
29
+ node-cli node busy — mark this node as busy (T-084)
30
+ node-cli node idle — mark this node as idle (T-084)
31
+ node-cli node status — show local + server node status (T-084)
32
+ node-cli node clear-status — restore automatic status handling (T-084)
33
+
34
+ Updates (T-062):
35
+ node-cli update check — fetch origin, compare local vs. upstream
36
+ node-cli update apply — git pull + restart the node-cli service
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import functools
43
+ import json
44
+ import logging
45
+ import os
46
+ import signal
47
+ import subprocess
48
+ import sys
49
+ import threading
50
+ import time
51
+ from datetime import datetime, timezone
52
+ from pathlib import Path
53
+ from typing import Any
54
+
55
+ import httpx
56
+
57
+ from nodes.common import file_serve
58
+ from nodes.common.handler_runner import run_handler
59
+ from nodes.common.node_config import (
60
+ ACTIVE_PATH, # noqa: F401 — re-exported so the test fixture's cli.ACTIVE_PATH patch keeps working (T-117)
61
+ BASE_DIR,
62
+ PROFILES_DIR, # noqa: F401 — re-exported for the test fixture's cli.PROFILES_DIR patch
63
+ current_profile_name,
64
+ invalidate_active_cache,
65
+ load_active_profile,
66
+ )
67
+ from nodes.common.node_utils import (
68
+ SERVICE_UNIT, # noqa: F401 — referenced by the update subparser's default
69
+ RESTART_COMMAND, # noqa: F401 — referenced by the update subparser's default
70
+ STATUS_PATH,
71
+ TOKEN_PATH,
72
+ load_json,
73
+ load_meta,
74
+ post_result_rescue_note,
75
+ write_json_atomic,
76
+ )
77
+ from nodes.common import node_utils as _nu
78
+ from nodes.common.node_utils import (
79
+ pid_running as _nu_pid_running,
80
+ )
81
+ from nodes.common.node_utils import (
82
+ read_pid as _nu_read_pid,
83
+ )
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Paths
87
+ # ---------------------------------------------------------------------------
88
+
89
+ PID_PATH = BASE_DIR / "node-cli.pid"
90
+ LOG_PATH = BASE_DIR / "node-cli.log"
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Logging
94
+ # ---------------------------------------------------------------------------
95
+
96
+ log = logging.getLogger("node-cli")
97
+
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Shared relay client — extracted to relay_client.py (T-112)
102
+ # Re-exported here so node_daemon.py and existing tests that reference
103
+ # node_cli.RelayClient / node_cli._effective_config / etc. keep working.
104
+ # The underscore helpers are re-exported for downstream consumers; they
105
+ # are not used directly in this module, hence the noqa.
106
+ # ---------------------------------------------------------------------------
107
+ # ---------------------------------------------------------------------------
108
+ # @with_client decorator — eliminates boilerplate from _cmd_* functions
109
+ # ---------------------------------------------------------------------------
110
+ # T-117: cli submodules are imported for build_parser() registration. They
111
+ # must NOT import node_cli (avoids circular import + keeps the cli.RelayClient
112
+ # monkeypatch working). Handlers are plain (client, args) -> int and wrapped
113
+ # with @with_client here at registration time.
114
+ from nodes.common.cli import (
115
+ cli_artifact,
116
+ cli_bridge,
117
+ cli_capabilities,
118
+ cli_docs,
119
+ cli_file,
120
+ cli_hp, # T-179: handler primitives (hp put/get) — subcommand registration below
121
+ cli_node,
122
+ cli_route,
123
+ cli_server,
124
+ cli_task,
125
+ cli_update,
126
+ )
127
+ from nodes.common.relay_client import ( # noqa: F401
128
+ RelayClient,
129
+ _base_url,
130
+ _cmd_relay_discover,
131
+ _cmd_relay_set,
132
+ _effective_config,
133
+ _filename_from_response,
134
+ _setup_logging,
135
+ _utcnow_str,
136
+ )
137
+
138
+
139
+ def with_client(func):
140
+ """Decorator: injects (client, args) into _cmd_* functions.
141
+
142
+ Handles _setup_logging, load_meta, _effective_config, RelayClient
143
+ so every command doesn't repeat the same 4 lines.
144
+ When --json is set, forces log level to ERROR so INFO/WARNING
145
+ messages don't pollute the JSON output on stdout.
146
+ """
147
+ @functools.wraps(func)
148
+ def wrapper(args):
149
+ log_level = "ERROR" if getattr(args, "json", False) else args.log_level
150
+ _setup_logging(log_level)
151
+ meta = load_meta()
152
+ cfg = _effective_config()
153
+ client = RelayClient(meta, cfg)
154
+ return func(client, args)
155
+ return wrapper
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Daemon
160
+ # ---------------------------------------------------------------------------
161
+
162
+ class Daemon:
163
+ """The long-running daemon: heartbeat thread + claim/execute/complete loop."""
164
+
165
+ def __init__(self, client: RelayClient, cfg: dict[str, Any]) -> None:
166
+ self.client = client
167
+ self.cfg = cfg
168
+ self.in_flight: dict[str, int] = {}
169
+ self.tasks_completed = 0
170
+ self.tasks_failed = 0
171
+ self.last_heartbeat_status = "unknown"
172
+ self._lock = threading.Lock()
173
+ self._stop_event = threading.Event()
174
+ self._hb_thread: threading.Thread | None = None
175
+ # T-177: last server probe result (health/ready/metrics), written by
176
+ # the probe thread and merged into the status file by _write_status.
177
+ self.server_probe: dict[str, Any] = {"ok": False, "error": "probe pending"}
178
+ self._probe_thread: threading.Thread | None = None
179
+ self._started_at = datetime.now(timezone.utc)
180
+ # T-060: per-task failure counter so the daemon stops reclaiming
181
+ # stages for a task whose handler keeps failing (exit 0 with an
182
+ # ``error`` result or an exception). Mirrors the server-side
183
+ # retry_count guard so both sides converge on the same budget.
184
+ self._failed_tasks: dict[str, int] = {}
185
+
186
+ # -- signal handling -----------------------------------------------------
187
+
188
+ def _install_signal_handlers(self) -> None:
189
+ signal.signal(signal.SIGTERM, self._on_term)
190
+ signal.signal(signal.SIGINT, self._on_term)
191
+ signal.signal(signal.SIGHUP, self._on_hup)
192
+
193
+ def _on_term(self, *_: Any) -> None:
194
+ log.info("received shutdown signal, stopping daemon …")
195
+ self._stop_event.set()
196
+
197
+ def _on_hup(self, *_: Any) -> None:
198
+ log.info("received SIGHUP, invalidating capability cache")
199
+ invalidate_active_cache()
200
+
201
+ # -- status file ---------------------------------------------------------
202
+
203
+ def _write_status(self, error: str | None = None) -> None:
204
+ caps = load_active_profile()
205
+ # T-108: loop detection — when the daemon is stuck in an auth
206
+ # error loop (backoff > 0 + 401/403), we enrich the error string
207
+ # and mark the status as degraded so that `node info`/dashboard
208
+ # and the log clearly signal that the token file must be checked
209
+ # or the daemon restarted.
210
+ backoff = self.client._current_backoff()
211
+ auth_loop = backoff > 0 and "401" in (error or "")
212
+ if auth_loop:
213
+ error = (error or "") + " | AUTH-LOOP: token invalid — check the token file or restart the daemon"
214
+ status = {
215
+ "pid": os.getpid(),
216
+ "node_id": self.client.meta.get("node_id"),
217
+ "started_at": self._started_at.isoformat(),
218
+ "last_heartbeat": _utcnow_str(),
219
+ "heartbeat_status": self.last_heartbeat_status,
220
+ "active_profile": current_profile_name(),
221
+ "token_present": bool(self.client.token),
222
+ "capabilities": [
223
+ {"name": c["name"], "claimable": c.get("claimable", False)} for c in caps
224
+ ],
225
+ "in_flight": dict(self.in_flight),
226
+ "tasks_completed": self.tasks_completed,
227
+ "tasks_failed": self.tasks_failed,
228
+ "failed_tasks": dict(self._failed_tasks),
229
+ "error": error,
230
+ "auth_loop": auth_loop,
231
+ "auth_backoff_seconds": backoff,
232
+ "server": self.server_probe,
233
+ }
234
+ try:
235
+ write_json_atomic(STATUS_PATH, status)
236
+ except OSError as exc:
237
+ log.warning("could not write status file: %s", exc)
238
+
239
+ # -- heartbeat thread ----------------------------------------------------
240
+
241
+ def _heartbeat_loop(self) -> None:
242
+ interval = self.cfg["heartbeat_interval"]
243
+ while not self._stop_event.is_set():
244
+ error: str | None = None
245
+ try:
246
+ # T-182/T-183: fixed-interval credential maintenance with
247
+ # claims paused. T-185: only when a rotation is actually
248
+ # due — the heartbeat tick must not rotate without need.
249
+ if self.client.maintenance_due():
250
+ self.client.run_credential_maintenance()
251
+ caps = load_active_profile()
252
+ with self._lock:
253
+ inflight = dict(self.in_flight)
254
+ hb = self.client.heartbeat(caps, inflight)
255
+ self.last_heartbeat_status = hb.get("status", "ok")
256
+ log.info("heartbeat %s", self.last_heartbeat_status)
257
+ except httpx.HTTPStatusError as exc:
258
+ error = f"http {exc.response.status_code}"
259
+ log.error("heartbeat http error %s", error)
260
+ except Exception as exc: # noqa: BLE001 — daemon must survive
261
+ error = str(exc)
262
+ log.error("heartbeat error: %s", exc)
263
+ self._write_status(error=error)
264
+ # T-108: back off after repeated auth failures so the daemon
265
+ # does not hammer every ~8s. Immediately 0 after success.
266
+ sleep_interval = interval + self.client._current_backoff()
267
+ for _ in range(max(1, int(sleep_interval))):
268
+ if self._stop_event.is_set():
269
+ return
270
+ time.sleep(1)
271
+
272
+ # -- T-177: server probe thread -------------------------------------------
273
+
274
+ def _probe_loop(self) -> None:
275
+ """Probe relay /health + /ready + /metrics on a fixed cadence.
276
+
277
+ Runs in its own daemon thread so a hanging HTTP call can never
278
+ stall heartbeats or claims. Failures are recorded (never raised):
279
+ the next tick retries. 30 s cadence → worst-case server-outage
280
+ visibility latency is probe + telemetry interval (~90 s end-to-end).
281
+ """
282
+ interval = 30
283
+ while not self._stop_event.is_set():
284
+ try:
285
+ self.server_probe = self.client.probe_server()
286
+ except Exception as exc: # noqa: BLE001 — probe must survive
287
+ log.warning("server probe failed: %s", exc)
288
+ self.server_probe = {"ok": False, "error": str(exc)}
289
+ for _ in range(interval):
290
+ if self._stop_event.is_set():
291
+ return
292
+ time.sleep(1)
293
+
294
+ def _start_probe_thread(self) -> None:
295
+ self._probe_thread = threading.Thread(
296
+ target=self._probe_loop, daemon=True, name="server-probe"
297
+ )
298
+ self._probe_thread.start()
299
+
300
+ def _start_heartbeat_thread(self) -> None:
301
+ self._hb_thread = threading.Thread(
302
+ target=self._heartbeat_loop, daemon=True, name="heartbeat"
303
+ )
304
+ self._hb_thread.start()
305
+
306
+ # -- claim/execute/complete loop ----------------------------------------
307
+
308
+ def _run_stage(self, cap: dict[str, Any], stage: dict[str, Any]) -> None:
309
+ name = cap["name"]
310
+ stage_id = stage.get("stage_id")
311
+ task_id = stage.get("task_id")
312
+ log.info("claimed %s stage %s (task %s)", name, stage_id, task_id)
313
+ with self._lock:
314
+ self.in_flight[name] = self.in_flight.get(name, 0) + 1
315
+ try:
316
+ context = {
317
+ "RELAY_STAGE_ID": str(stage_id or ""),
318
+ "RELAY_TASK_ID": str(task_id or ""),
319
+ "RELAY_CAPABILITY": name,
320
+ "RELAY_NODE_ID": str(self.client.meta.get("node_id", "")),
321
+ "RELAY_BASE_URL": self.client.base_url,
322
+ "RELAY_TOKEN_FILE": str(TOKEN_PATH),
323
+ }
324
+ result = run_handler(
325
+ cap.get("handler", ""),
326
+ stage,
327
+ context=context,
328
+ timeout=int(cap.get("timeout", 300)),
329
+ )
330
+ try:
331
+ self.client.complete(str(task_id), str(stage_id), result)
332
+ with self._lock:
333
+ if "error" in result:
334
+ self.tasks_failed += 1
335
+ # T-060: count handler-reported errors so the
336
+ # claim loop can stop reclaiming this task.
337
+ if task_id is not None:
338
+ self._failed_tasks[str(task_id)] = (
339
+ self._failed_tasks.get(str(task_id), 0) + 1
340
+ )
341
+ else:
342
+ self.tasks_completed += 1
343
+ log.info("completed stage %s", stage_id)
344
+ except Exception as exc: # noqa: BLE001
345
+ # T-001c-c: the stage is gone server-side (404: expired/
346
+ # re-queued/failed) — the handler's result is rescued as a
347
+ # task note instead of being dropped. Other failures stay
348
+ # plain (retryable); there is no complete_by_script
349
+ # fallthrough here.
350
+ cli_resp = getattr(exc, "response", None)
351
+ if (
352
+ getattr(cli_resp, "status_code", None) == 404
353
+ and task_id
354
+ ):
355
+ cli_saved = post_result_rescue_note(
356
+ self.client,
357
+ str(task_id),
358
+ str(stage_id),
359
+ result,
360
+ reason=str(exc),
361
+ )
362
+ if cli_saved:
363
+ log.info(
364
+ "stage %s complete rejected (404) — result rescued as note",
365
+ stage_id,
366
+ )
367
+ with self._lock:
368
+ self.tasks_failed += 1
369
+ if task_id is not None:
370
+ self._failed_tasks[str(task_id)] = (
371
+ self._failed_tasks.get(str(task_id), 0) + 1
372
+ )
373
+ log.error("failed to report result for stage %s: %s", stage_id, exc)
374
+ except Exception as exc: # noqa: BLE001
375
+ with self._lock:
376
+ self.tasks_failed += 1
377
+ if task_id is not None:
378
+ self._failed_tasks[str(task_id)] = (
379
+ self._failed_tasks.get(str(task_id), 0) + 1
380
+ )
381
+ log.error("stage %s execution failed: %s", stage_id, exc)
382
+ finally:
383
+ with self._lock:
384
+ self.in_flight[name] = max(0, self.in_flight.get(name, 1) - 1)
385
+
386
+ def _claim_loop(self) -> None:
387
+ interval = self.cfg["claim_interval"]
388
+ max_retries = int(self.cfg.get("max_retries", 2))
389
+ while not self._stop_event.is_set():
390
+ try:
391
+ caps = load_active_profile()
392
+ for cap in caps:
393
+ if not cap.get("claimable", False):
394
+ continue
395
+ name = cap["name"]
396
+ with self._lock:
397
+ inflight = self.in_flight.get(name, 0)
398
+ if inflight >= int(cap.get("max_parallel", 1)):
399
+ continue
400
+ stage = self.client.claim(name)
401
+ if stage is None:
402
+ continue
403
+ # T-060: skip stages for tasks that have already
404
+ # exceeded the retry budget on this daemon. The
405
+ # claim will expire on the server side where
406
+ # retry_count > max_retries fails the stage
407
+ # permanently. This prevents the daemon from
408
+ # burning CPU on a handler that keeps failing.
409
+ task_id = str(stage.get("task_id") or "")
410
+ with self._lock:
411
+ failures = self._failed_tasks.get(task_id, 0)
412
+ if task_id and failures >= max_retries:
413
+ log.warning(
414
+ "skipping stage %s for task %s — %d failures >= max_retries %d",
415
+ stage.get("stage_id"), task_id, failures, max_retries,
416
+ )
417
+ continue
418
+ # Run synchronously; the heartbeat thread keeps the
419
+ # node alive. max_parallel is enforced per-capability
420
+ # via in_flight and sequential iteration. Spawning
421
+ # threads here is intentionally avoided to keep the
422
+ # contract simple (see spec §15.2).
423
+ self._run_stage(cap, stage)
424
+ except httpx.HTTPStatusError as exc:
425
+ log.error("claim loop http error %s", exc.response.status_code)
426
+ except Exception as exc: # noqa: BLE001
427
+ log.error("claim loop error: %s", exc)
428
+ # T-108: back off after repeated auth failures.
429
+ sleep_interval = interval + self.client._current_backoff()
430
+ for _ in range(max(1, int(sleep_interval))):
431
+ if self._stop_event.is_set():
432
+ return
433
+ time.sleep(1)
434
+
435
+ # -- lifecycle ----------------------------------------------------------
436
+
437
+ def run(self) -> None:
438
+ self._install_signal_handlers()
439
+ log.info(
440
+ "node-cli daemon starting for node %s (base_url=%s)",
441
+ self.client.meta.get("node_id"),
442
+ self.client.base_url,
443
+ )
444
+ BASE_DIR.mkdir(parents=True, exist_ok=True)
445
+ # T-185: refresh the registration secret synchronously BEFORE any
446
+ # connection thread starts (same quiescence model as the SSE
447
+ # daemon). Non-fatal on failure: rs stays due and the next
448
+ # maintenance window retries.
449
+ try:
450
+ self.client.refresh_registration_secret()
451
+ log.info("startup: registration secret refreshed before connections")
452
+ except Exception as exc: # noqa: BLE001 — startup must survive
453
+ log.warning("startup rs refresh failed: %s", exc)
454
+ self._write_status()
455
+ self._start_heartbeat_thread()
456
+ self._start_probe_thread()
457
+ # T-166 (F1): ephemeral file serve inside the daemon — the CLI
458
+ # process dies after the stdout envelope, the serve endpoint must
459
+ # survive. Mirrors the wiring from node_daemon.py (SSE daemon);
460
+ # bind errors (port in use) = WARNING, the node keeps running
461
+ # without serve (hp put bridge then reports the F5 error instead
462
+ # of crashing).
463
+ try:
464
+ file_serve.set_on_exhausted(
465
+ lambda route_path: file_serve.unregister_after_transfer(
466
+ self.client, route_path
467
+ )
468
+ )
469
+ file_serve.start_serve_thread()
470
+ log.info(
471
+ "ephemeral file serve listening on %s:%d",
472
+ file_serve.serve_host(),
473
+ file_serve.serve_port(),
474
+ )
475
+ except (OSError, RuntimeError) as exc:
476
+ log.warning("ephemeral file serve unavailable: %s", exc)
477
+ try:
478
+ self._claim_loop()
479
+ finally:
480
+ self._stop_event.set()
481
+ if self._hb_thread and self._hb_thread.is_alive():
482
+ self._hb_thread.join(timeout=5)
483
+ self._write_status()
484
+ log.info("daemon stopped")
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Daemon control (start / stop / status)
489
+ # ---------------------------------------------------------------------------
490
+
491
+ def _read_pid() -> int | None:
492
+ # T-117: thin wrapper over node_utils.read_pid(pid_path) so callers and
493
+ # tests keep the original no-arg signature (PID_PATH bound at call time).
494
+ return _nu_read_pid(PID_PATH)
495
+
496
+
497
+ def _pid_running(pid: int) -> bool:
498
+ # T-117: re-export wrapper over node_utils.pid_running(pid).
499
+ return _nu_pid_running(pid)
500
+
501
+
502
+ def _daemon_start(args: argparse.Namespace) -> int:
503
+ _setup_logging(args.log_level)
504
+ pid = _read_pid()
505
+ if pid is not None and _pid_running(pid):
506
+ print(f"daemon already running (pid {pid})")
507
+ return 0
508
+ BASE_DIR.mkdir(parents=True, exist_ok=True)
509
+ log_fd = open(LOG_PATH, "a", encoding="utf-8") # noqa: SIM115 — kept open by child
510
+ proc = subprocess.Popen( # noqa: S603 — intentional self-spawn
511
+ [sys.executable, "-m", "nodes.common.node_cli", "--daemon-internal"],
512
+ stdout=log_fd,
513
+ stderr=log_fd,
514
+ start_new_session=True,
515
+ )
516
+ # Inner process writes the PID file itself; give it a moment.
517
+ for _ in range(50):
518
+ if PID_PATH.exists():
519
+ break
520
+ if proc.poll() is not None:
521
+ print("daemon inner process exited early — see log:", LOG_PATH, file=sys.stderr)
522
+ return 1
523
+ time.sleep(0.1)
524
+ pid = _read_pid() or proc.pid
525
+ print(f"daemon started (pid {pid}); log: {LOG_PATH}")
526
+ return 0
527
+
528
+
529
+ def _daemon_stop(args: argparse.Namespace) -> int: # noqa: ARG001
530
+ _setup_logging(args.log_level)
531
+ pid = _read_pid()
532
+ if pid is None or not _pid_running(pid):
533
+ print("daemon not running")
534
+ if PID_PATH.exists():
535
+ PID_PATH.unlink(missing_ok=True)
536
+ return 0
537
+ try:
538
+ os.kill(pid, signal.SIGTERM)
539
+ except OSError as exc:
540
+ print(f"failed to signal pid {pid}: {exc}", file=sys.stderr)
541
+ return 1
542
+ for _ in range(100):
543
+ if not _pid_running(pid):
544
+ break
545
+ time.sleep(0.1)
546
+ if _pid_running(pid):
547
+ print("daemon did not stop, sending SIGKILL", file=sys.stderr)
548
+ try:
549
+ os.kill(pid, signal.SIGKILL)
550
+ except OSError:
551
+ pass
552
+ PID_PATH.unlink(missing_ok=True)
553
+ print("daemon stopped")
554
+ return 0
555
+
556
+
557
+ def _daemon_status(args: argparse.Namespace) -> int: # noqa: ARG001
558
+ pid = _read_pid()
559
+ running = pid is not None and _pid_running(pid)
560
+ active_profile = current_profile_name()
561
+ status_file: dict[str, Any] = {}
562
+ if STATUS_PATH.exists():
563
+ try:
564
+ status_file = load_json(STATUS_PATH, default={}) or {}
565
+ except Exception:
566
+ status_file = {}
567
+ print(f"pid: {pid if pid is not None else '-'}")
568
+ print(f"running: {running}")
569
+ print(f"active_profile: {active_profile or '-'}")
570
+ print(f"last_heartbeat: {status_file.get('last_heartbeat', '-')}")
571
+ print(f"heartbeat_status: {status_file.get('heartbeat_status', '-')}")
572
+ print(f"tasks_completed: {status_file.get('tasks_completed', 0)}")
573
+ print(f"tasks_failed: {status_file.get('tasks_failed', 0)}")
574
+ failed_tasks = status_file.get("failed_tasks", {})
575
+ if failed_tasks:
576
+ print(f"failed_tasks: {failed_tasks}")
577
+ inflight = status_file.get("in_flight", {})
578
+ if inflight:
579
+ print(f"in_flight: {inflight}")
580
+ return 0 if running else 1
581
+
582
+
583
+ def _daemon_restart(args: argparse.Namespace) -> int:
584
+ _daemon_stop(args)
585
+ return _daemon_start(args)
586
+
587
+
588
+ def _daemon_foreground(args: argparse.Namespace) -> int: # noqa: ARG001
589
+ _setup_logging(os.environ.get("RELAY_LOG_LEVEL"))
590
+ try:
591
+ meta = load_meta()
592
+ cfg = _effective_config()
593
+ client = RelayClient(meta, cfg)
594
+ except SystemExit as exc:
595
+ print(exc, file=sys.stderr)
596
+ return 1
597
+ # Write our own PID file so `status`/`stop` work for foreground too.
598
+ BASE_DIR.mkdir(parents=True, exist_ok=True)
599
+ PID_PATH.write_text(str(os.getpid()) + "\n", encoding="utf-8")
600
+ try:
601
+ Daemon(client, cfg).run()
602
+ finally:
603
+ PID_PATH.unlink(missing_ok=True)
604
+ return 0
605
+
606
+
607
+ def _check_other_daemon(
608
+ other_pid_path: Path | None = None,
609
+ own_name: str = "node-cli daemon",
610
+ other_name: str = "node-daemon",
611
+ ) -> str | None:
612
+ """T-137b-Guard: return an error message if the SSE daemon is running."""
613
+ pid_file = other_pid_path or (BASE_DIR / "node-daemon.pid")
614
+ pid = _nu_read_pid(pid_file)
615
+ if pid is not None and _nu_pid_running(pid):
616
+ return (
617
+ f"{own_name} refuses to start: {other_name} is already running "
618
+ f"(pid {pid}, {pid_file}). Stop it first — running both as the "
619
+ f"same node causes token fights and duplicate claims (T-137b)."
620
+ )
621
+ return None
622
+
623
+
624
+ def _daemon_internal() -> int:
625
+ """Inner entry point for the self-spawned daemon process."""
626
+ _setup_logging(os.environ.get("RELAY_LOG_LEVEL"))
627
+ BASE_DIR.mkdir(parents=True, exist_ok=True)
628
+ # T-137b-Guard: refuse to start while the SSE daemon is running.
629
+ conflict = _check_other_daemon()
630
+ if conflict:
631
+ print(conflict, file=sys.stderr)
632
+ return 1
633
+ try:
634
+ meta = load_meta()
635
+ cfg = _effective_config()
636
+ client = RelayClient(meta, cfg)
637
+ except SystemExit as exc:
638
+ print(exc, file=sys.stderr)
639
+ return 1
640
+ PID_PATH.write_text(str(os.getpid()) + "\n", encoding="utf-8")
641
+ try:
642
+ Daemon(client, cfg).run()
643
+ finally:
644
+ PID_PATH.unlink(missing_ok=True)
645
+ return 0
646
+
647
+
648
+ # ---------------------------------------------------------------------------
649
+ # One-shot subcommands
650
+ # ---------------------------------------------------------------------------
651
+
652
+ def _parse_stage_arg(stage: str) -> tuple[str, dict[str, Any]]:
653
+ """Parse ``<cap>:<json_payload>`` into (capability, payload).
654
+
655
+ Re-exported from ``cli.cli_task`` (T-117 split) so existing tests that
656
+ call ``cli._parse_stage_arg`` keep resolving.
657
+ """
658
+ return cli_task._parse_stage_arg(stage)
659
+
660
+
661
+ @with_client
662
+ def _cmd_heartbeat(client: RelayClient, args: argparse.Namespace) -> int:
663
+ caps = load_active_profile()
664
+ hb = client.heartbeat(caps, {})
665
+ if args.json:
666
+ print(json.dumps(hb, default=str))
667
+ return 0
668
+ print(json.dumps(hb, indent=2, default=str))
669
+ return 0
670
+
671
+
672
+ @with_client
673
+ def _cmd_claim(client: RelayClient, args: argparse.Namespace) -> int:
674
+ stage = client.claim(args.capability)
675
+ if stage is None:
676
+ print(json.dumps({"claimed": False}))
677
+ return 0
678
+ result = {"claimed": True, "stage": stage}
679
+ if args.json:
680
+ print(json.dumps(result, default=str))
681
+ return 0
682
+ print(json.dumps(result, indent=2, default=str))
683
+ cd = stage.get("capability_details")
684
+ if cd:
685
+ print()
686
+ print(f" Capability: {cd.get('name', '?')}")
687
+ if cd.get("description"):
688
+ print(f" Description: {cd['description']}")
689
+ if cd.get("type"):
690
+ print(f" Type: {cd['type']}")
691
+ if cd.get("input_schema"):
692
+ print(f" Input Schema: {json.dumps(cd['input_schema'], indent=2)}")
693
+ return 0
694
+
695
+
696
+ def _cmd_complete(client: RelayClient, args: argparse.Namespace) -> int:
697
+ """Complete a claimed stage (T-179 Task 4: env-defaults for IDs).
698
+
699
+ ``--task``/``stage_id`` fallen back to ``RELAY_TASK_ID``/
700
+ ``RELAY_STAGE_ID`` (set by handler_runner for every handler) —
701
+ handler scripts can complete without repeating their IDs. Explicit
702
+ flags win over env. Without any ID → stderr message + exit 1.
703
+ """
704
+ task_id = getattr(args, "task", None) or os.environ.get("RELAY_TASK_ID")
705
+ stage_id = args.stage_id or os.environ.get("RELAY_STAGE_ID")
706
+ if not task_id or not stage_id:
707
+ print(
708
+ "complete: no --task/--task-id and no RELAY_TASK_ID/"
709
+ "RELAY_STAGE_ID in env",
710
+ file=sys.stderr,
711
+ )
712
+ return 1
713
+ if not Path(args.result_file).exists():
714
+ print(f"result file not found: {args.result_file}", file=sys.stderr)
715
+ return 2
716
+ try:
717
+ result = json.loads(Path(args.result_file).read_text(encoding="utf-8"))
718
+ except json.JSONDecodeError as exc:
719
+ print(f"result file is not valid JSON: {exc}", file=sys.stderr)
720
+ return 2
721
+ resp = client.complete(task_id, stage_id, result)
722
+ if args.json:
723
+ print(json.dumps(resp, default=str))
724
+ return 0
725
+ print(json.dumps(resp, indent=2, default=str))
726
+ return 0
727
+
728
+
729
+ # ---------------------------------------------------------------------------
730
+ # capabilities subcommands — moved to cli/cli_capabilities (T-117)
731
+ # ---------------------------------------------------------------------------
732
+
733
+
734
+ # ---------------------------------------------------------------------------
735
+ # node subcommands — moved to cli/cli_node (T-117)
736
+ # ---------------------------------------------------------------------------
737
+
738
+
739
+ # ---------------------------------------------------------------------------
740
+ # status / reload
741
+ # ---------------------------------------------------------------------------
742
+
743
+ def _cmd_status(args: argparse.Namespace) -> int: # noqa: ARG001
744
+ if not STATUS_PATH.exists():
745
+ print("(no status file — daemon not started yet)")
746
+ return 1
747
+ try:
748
+ data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
749
+ except json.JSONDecodeError as exc:
750
+ print(f"status file is not valid JSON: {exc}", file=sys.stderr)
751
+ return 1
752
+ print(json.dumps(data, indent=2, default=str))
753
+ return 0
754
+
755
+
756
+ def _cmd_reload(args: argparse.Namespace) -> int: # noqa: ARG001
757
+ # t_c214ca25: notify every live daemon pid file (SSE node-daemon.pid
758
+ # first, legacy polling node-cli.pid fallback). The SSE daemon installs
759
+ # a SIGHUP handler (capability-cache invalidation) — probing only the
760
+ # polling pid file left SSE-only nodes without hot-reload.
761
+ from nodes.common import node_daemon as _nd
762
+
763
+ pids = _nu.sighup_pid_files([_nd.PID_PATH, PID_PATH])
764
+ if not pids:
765
+ print("daemon not running", file=sys.stderr)
766
+ return 1
767
+ print(f"SIGHUP sent to daemon (pid {', '.join(str(p) for p in pids)})")
768
+ return 0
769
+
770
+
771
+ # ---------------------------------------------------------------------------
772
+ # Argument parser
773
+ # ---------------------------------------------------------------------------
774
+
775
+ def build_parser() -> argparse.ArgumentParser:
776
+ parser = argparse.ArgumentParser(
777
+ prog="node-cli",
778
+ description="Generic capability-driven daemon & CLI for IOWAP.",
779
+ )
780
+ parser.add_argument(
781
+ "--log-level",
782
+ default=None,
783
+ help="Log level (DEBUG/INFO/WARNING/ERROR). Default: env RELAY_LOG_LEVEL or INFO.",
784
+ )
785
+ parser.add_argument(
786
+ "--json",
787
+ action="store_true",
788
+ help="Output raw JSON instead of formatted text (for scripting).",
789
+ )
790
+ sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")
791
+
792
+ # daemon
793
+ p_daemon = sub.add_parser("daemon", help="Control the background daemon.")
794
+ p_daemon_sub = p_daemon.add_subparsers(dest="daemon_command", required=True, metavar="<action>")
795
+ for action in ("start", "stop", "restart", "foreground"):
796
+ p = p_daemon_sub.add_parser(action, help=f"daemon {action}")
797
+ p.set_defaults(func=_daemon_dispatch)
798
+ p_status = p_daemon_sub.add_parser("status", help="show daemon status")
799
+ p_status.set_defaults(func=_daemon_dispatch)
800
+
801
+ # heartbeat
802
+ p_hb = sub.add_parser("heartbeat", help="Send a single heartbeat and exit.")
803
+ p_hb.set_defaults(func=_cmd_heartbeat)
804
+
805
+ # claim
806
+ p_claim = sub.add_parser("claim", help="Claim one stage for a capability.")
807
+ p_claim.add_argument("capability", help="Capability name to claim.")
808
+ p_claim.set_defaults(func=_cmd_claim)
809
+
810
+ # complete
811
+ p_complete = sub.add_parser("complete", help="Complete a claimed stage.")
812
+ p_complete.add_argument("stage_id", help="Stage ID to complete.")
813
+ p_complete.add_argument(
814
+ "--task",
815
+ required=False,
816
+ default=None,
817
+ help="Task ID of the stage (default: RELAY_TASK_ID env, T-179).",
818
+ )
819
+ p_complete.add_argument(
820
+ "--result-file",
821
+ required=True,
822
+ help="Path to a JSON file containing the result dict.",
823
+ )
824
+ p_complete.set_defaults(func=with_client(_cmd_complete))
825
+
826
+ # task submit
827
+ p_task = sub.add_parser("task", help="Task operations.")
828
+ p_task_sub = p_task.add_subparsers(dest="task_command", required=True, metavar="<action>")
829
+ p_submit = p_task_sub.add_parser("submit", help="Submit a single-stage task.")
830
+ p_submit.add_argument("--name", default="", help="Task name (default: auto).")
831
+ p_submit.add_argument(
832
+ "--stage",
833
+ required=True,
834
+ help="Stage as <capability>:<json-payload>.",
835
+ )
836
+ p_submit.add_argument("--priority", type=int, default=0, help="Task priority 0-10.")
837
+ p_submit.add_argument(
838
+ "--owner",
839
+ default=None,
840
+ help="Node ID that must claim this task (owner_node_id).",
841
+ )
842
+ p_submit.set_defaults(func=with_client(cli_task._cmd_task_submit))
843
+
844
+ p_result = p_task_sub.add_parser("result", help="Show task result (status, stages, artifacts).")
845
+ p_result.add_argument("task_id", help="Task ID to query.")
846
+ p_result.set_defaults(func=with_client(cli_task._cmd_task_result))
847
+
848
+ p_wait = p_task_sub.add_parser("wait", help="Wait for a task to complete and show result.")
849
+ p_wait.add_argument("task_id", help="Task ID to wait for.")
850
+ p_wait.add_argument("--interval", type=int, default=5, help="Poll interval in seconds (default: 5).")
851
+ p_wait.set_defaults(func=with_client(cli_task._cmd_task_wait))
852
+
853
+ p_note = p_task_sub.add_parser("note", help="Append a free-form note to a task (T-052 mini-chat).")
854
+ p_note.add_argument("task_id", help="Task ID to add a note to.")
855
+ p_note.add_argument("message", help="Note text (1..2000 characters).")
856
+ p_note.set_defaults(func=with_client(cli_task._cmd_task_note))
857
+
858
+ # capabilities
859
+ p_caps = sub.add_parser("capabilities", help="Capability profile management.")
860
+ p_caps_sub = p_caps.add_subparsers(
861
+ dest="capabilities_command", required=True, metavar="<action>"
862
+ )
863
+
864
+ p_list = p_caps_sub.add_parser("list", help="List profiles in profiles.d/.")
865
+ p_list.set_defaults(func=cli_capabilities._cmd_capabilities_list)
866
+
867
+ p_validate = p_caps_sub.add_parser("validate", help="Validate a profile (default: active).")
868
+ p_validate.add_argument(
869
+ "profile", nargs="?", default=None, help="Profile name (default: active)."
870
+ )
871
+ p_validate.set_defaults(func=cli_capabilities._cmd_capabilities_validate)
872
+
873
+ p_publish = p_caps_sub.add_parser(
874
+ "publish", help="Validate + atomically copy profile to active."
875
+ )
876
+ p_publish.add_argument("profile", help="Profile name to publish.")
877
+ p_publish.set_defaults(func=cli_capabilities._cmd_capabilities_publish)
878
+
879
+ p_diff = p_caps_sub.add_parser("diff", help="Diff working profile vs active.")
880
+ p_diff.add_argument("profile", nargs="?", default=None, help="Profile name (default: active).")
881
+ p_diff.set_defaults(func=cli_capabilities._cmd_capabilities_diff)
882
+
883
+ p_current = p_caps_sub.add_parser("current", help="Show active profile name.")
884
+ p_current.set_defaults(func=cli_capabilities._cmd_capabilities_current)
885
+
886
+ p_server = p_caps_sub.add_parser(
887
+ "server", help="Query capabilities registered on the relay server (all nodes)."
888
+ )
889
+ p_server.set_defaults(func=with_client(cli_capabilities._cmd_capabilities_server))
890
+
891
+ p_info = p_caps_sub.add_parser(
892
+ "info", help="Show detailed info for a single capability registered on the relay."
893
+ )
894
+ p_info.add_argument("name", help="Capability name to query.")
895
+ p_info.set_defaults(func=with_client(cli_capabilities._cmd_capabilities_info))
896
+
897
+ # node
898
+ p_node = sub.add_parser("node", help="Node operations (list, info, busy/idle/status).")
899
+ p_node_sub = p_node.add_subparsers(dest="node_command", required=True, metavar="<action>")
900
+
901
+ p_node_list = p_node_sub.add_parser("list", help="List all nodes registered on the relay server.")
902
+ p_node_list.set_defaults(func=with_client(cli_node._cmd_node_list))
903
+
904
+ p_node_info = p_node_sub.add_parser("info", help="Show detailed info for a single node.")
905
+ p_node_info.add_argument("node_id", help="Node ID to query.")
906
+ p_node_info.set_defaults(func=with_client(cli_node._cmd_node_info))
907
+
908
+ # T-084: node busy / idle / status / clear-status
909
+ p_node_busy = p_node_sub.add_parser(
910
+ "busy", help="Mark this node as busy (stops it from claiming new stages)."
911
+ )
912
+ p_node_busy.add_argument(
913
+ "--once", action="store_true",
914
+ help="Send a single heartbeat carrying the new status immediately.",
915
+ )
916
+ p_node_busy.set_defaults(func=with_client(cli_node._cmd_node_busy))
917
+
918
+ p_node_idle = p_node_sub.add_parser(
919
+ "idle", help="Mark this node as idle (available for claiming stages again)."
920
+ )
921
+ p_node_idle.add_argument(
922
+ "--once", action="store_true",
923
+ help="Send a single heartbeat carrying the new status immediately.",
924
+ )
925
+ p_node_idle.set_defaults(func=with_client(cli_node._cmd_node_idle))
926
+
927
+ p_node_clear_status = p_node_sub.add_parser(
928
+ "clear-status",
929
+ help="Remove an explicit busy/idle request and restore automatic status handling.",
930
+ )
931
+ p_node_clear_status.add_argument(
932
+ "--once", action="store_true",
933
+ help="Send a single heartbeat after clearing the requested status.",
934
+ )
935
+ p_node_clear_status.set_defaults(func=with_client(cli_node._cmd_node_clear_status))
936
+
937
+ p_node_status = p_node_sub.add_parser(
938
+ "status", help="Show the local + server-side node status."
939
+ )
940
+ p_node_status.set_defaults(func=with_client(cli_node._cmd_node_status))
941
+
942
+ # T-178: node register — first-class registration (no client needed).
943
+ p_node_register = p_node_sub.add_parser(
944
+ "register",
945
+ help="Register this node on a relay server (creates local state files).",
946
+ )
947
+ p_node_register.add_argument(
948
+ "server",
949
+ help="Relay server: IP/host, IP:port, or full URL (default port 8788).",
950
+ )
951
+ p_node_register.add_argument("--name", default=None, help="Node name (default: hostname).")
952
+ p_node_register.add_argument(
953
+ "--force", action="store_true",
954
+ help="Re-register even if local node state already exists.",
955
+ )
956
+ p_node_register.add_argument(
957
+ "--timeout", type=float, default=15.0,
958
+ help="HTTP timeout for the registration request (default: 15s).",
959
+ )
960
+ p_node_register.set_defaults(func=cli_server._cmd_node_register)
961
+
962
+ # T-178: server status commands — unauthenticated health/metrics probe.
963
+ p_server_cmd = sub.add_parser(
964
+ "server", help="Inspect a relay server (health, metrics) without node credentials."
965
+ )
966
+ p_server_sub = p_server_cmd.add_subparsers(dest="server_command", required=True, metavar="<action>")
967
+ p_server_health = p_server_sub.add_parser("health", help="One-shot health probe (/health + /ready).")
968
+ p_server_health.add_argument(
969
+ "server", nargs="?", default=None,
970
+ help="Relay server: IP/host, IP:port, or full URL (default port 8788).",
971
+ )
972
+ p_server_health.set_defaults(func=cli_server._cmd_server_health)
973
+ p_server_metrics = p_server_sub.add_parser(
974
+ "metrics", help="Show Prometheus metrics (gauges) from the relay server."
975
+ )
976
+ p_server_metrics.add_argument(
977
+ "server", nargs="?", default=None,
978
+ help="Relay server: IP/host, IP:port, or full URL (default port 8788).",
979
+ )
980
+ p_server_metrics.set_defaults(func=cli_server._cmd_server_metrics)
981
+
982
+ # T-187: relay set/discover — pin base_url or targeted mDNS discovery
983
+ p_relay = sub.add_parser(
984
+ "relay",
985
+ help="Relay connection management (pin URL or discover via mDNS).",
986
+ )
987
+ p_relay_sub = p_relay.add_subparsers(
988
+ dest="relay_command", required=True, metavar="<action>"
989
+ )
990
+ p_relay_set = p_relay_sub.add_parser(
991
+ "set", help="Pin the relay base_url, or remove the pin (--discover)."
992
+ )
993
+ p_relay_set.add_argument(
994
+ "--server-url", default=None,
995
+ help="Pin this relay URL (discovery disabled).",
996
+ )
997
+ p_relay_set.add_argument(
998
+ "--discover", action="store_true",
999
+ help="Remove the pinned URL and re-enable mDNS discovery.",
1000
+ )
1001
+ p_relay_set.set_defaults(func=_cmd_relay_set)
1002
+
1003
+ p_relay_discover = p_relay_sub.add_parser(
1004
+ "discover", help="Targeted mDNS lookup for the relay service name."
1005
+ )
1006
+ p_relay_discover.add_argument(
1007
+ "--name", default=None,
1008
+ help="Expected service name (default: mdns_service_name from "
1009
+ "relay_config.json, then 'IOWAP Relay Service').",
1010
+ )
1011
+ p_relay_discover.add_argument(
1012
+ "--timeout", type=float, default=2.0,
1013
+ help="Seconds to watch the network (default 2.0).",
1014
+ )
1015
+ p_relay_discover.set_defaults(func=_cmd_relay_discover)
1016
+
1017
+ # T-136: route register/unregister/list — temp bridge route management
1018
+ p_route = sub.add_parser("route", help="Manage temporary bridge routes (T-136).")
1019
+ p_route_sub = p_route.add_subparsers(dest="route_command", required=True, metavar="<action>")
1020
+
1021
+ p_route_register = p_route_sub.add_parser("register", help="Register a temporary bridge route.")
1022
+ p_route_register.add_argument("--path", required=True, help="Route path (e.g. /upload/abc123).")
1023
+ p_route_register.add_argument("--method", required=True, help="HTTP method (GET/POST/PUT/...).")
1024
+ p_route_register.add_argument("--upstream", required=True, help="Upstream URL the relay proxies to.")
1025
+ p_route_register.add_argument("--ttl", type=int, required=True, help="Time-to-live in seconds.")
1026
+ p_route_register.add_argument("--channel", required=True, help="channel_id tying this route to a session.")
1027
+ p_route_register.add_argument("--description", default="", help="Optional human-readable note.")
1028
+ p_route_register.set_defaults(func=with_client(cli_route._cmd_route_register))
1029
+
1030
+ p_route_unregister = p_route_sub.add_parser("unregister", help="Revoke a temp route before its TTL.")
1031
+ p_route_unregister.add_argument("--path", required=True, help="Route path to delete.")
1032
+ p_route_unregister.add_argument("--method", required=True, help="HTTP method of the route.")
1033
+ p_route_unregister.set_defaults(func=with_client(cli_route._cmd_route_unregister))
1034
+
1035
+ p_route_list = p_route_sub.add_parser("list", help="List this node's own routes (temp + permanent).")
1036
+ p_route_list.set_defaults(func=with_client(cli_route._cmd_route_list))
1037
+
1038
+ # T-153: bridge upload/download — stream large files via temp bridge routes
1039
+ p_bridge = sub.add_parser(
1040
+ "bridge", help="Upload/download large files via a temp bridge route (T-129/T-153)."
1041
+ )
1042
+ p_bridge_sub = p_bridge.add_subparsers(dest="bridge_command", required=True, metavar="<action>")
1043
+
1044
+ p_bridge_upload = p_bridge_sub.add_parser(
1045
+ "upload", help="Open a bridge channel and stream a local file TO the storage node."
1046
+ )
1047
+ p_bridge_upload.add_argument("file", type=str, help="Path to the file to upload.")
1048
+ p_bridge_upload.add_argument(
1049
+ "--channel", default=None,
1050
+ help="Channel id (storage.upload_channel); minted when omitted.",
1051
+ )
1052
+ p_bridge_upload.add_argument(
1053
+ "--backup", action="store_true",
1054
+ help="Use backup.create (mode=bridge) instead of storage.upload_channel.",
1055
+ )
1056
+ p_bridge_upload.add_argument("--source", default=None, help="Backup source (default: filename).")
1057
+ p_bridge_upload.add_argument("--type", default="full", choices=["full", "incremental"],
1058
+ help="Backup type (default: full).")
1059
+ p_bridge_upload.add_argument("--name", default="", help="Optional task name.")
1060
+ p_bridge_upload.add_argument("--priority", type=int, default=0, help="Optional task priority.")
1061
+ p_bridge_upload.add_argument("--interval", type=int, default=5,
1062
+ help="Poll interval in seconds (default: 5).")
1063
+ p_bridge_upload.set_defaults(func=with_client(cli_bridge._cmd_bridge_upload))
1064
+
1065
+ p_bridge_download = p_bridge_sub.add_parser(
1066
+ "download", help="Open a bridge channel and stream a large file FROM the storage node."
1067
+ )
1068
+ p_bridge_download.add_argument(
1069
+ "--channel", default=None,
1070
+ help="Channel id (storage.download_channel); required unless --backup is set.",
1071
+ )
1072
+ p_bridge_download.add_argument(
1073
+ "--backup", default=None,
1074
+ help="Backup id (backup.restore). When set, --channel is ignored.",
1075
+ )
1076
+ p_bridge_download.add_argument(
1077
+ "--output", "-o", type=Path, default=None,
1078
+ help="Output path (default: <channel_id> or <backup_id>).",
1079
+ )
1080
+ p_bridge_download.add_argument("--name", default="", help="Optional task name.")
1081
+ p_bridge_download.add_argument("--priority", type=int, default=0, help="Optional task priority.")
1082
+ p_bridge_download.add_argument("--interval", type=int, default=5,
1083
+ help="Poll interval in seconds (default: 5).")
1084
+ p_bridge_download.set_defaults(func=with_client(cli_bridge._cmd_bridge_download))
1085
+
1086
+ # T-164: file send/get — generic transfer-ladder wrapper.
1087
+ p_file = sub.add_parser(
1088
+ "file",
1089
+ help="Send/get files via the generic transfer ladder (T-164).",
1090
+ )
1091
+ p_file_sub = p_file.add_subparsers(dest="file_command", required=True, metavar="<action>")
1092
+
1093
+ p_file_send = p_file_sub.add_parser(
1094
+ "send", help="Send a file via inline/artifact/bridge (auto-chosen)."
1095
+ )
1096
+ p_file_send.add_argument("file", type=str, help="Path to the file to send.")
1097
+ p_file_send.add_argument("--cap", required=True, help="Capability that accepts the file.")
1098
+ p_file_send.add_argument(
1099
+ "--force", choices=["inline", "artifact", "bridge"], default=None,
1100
+ help="Force a transfer mode (must be in the capability's upload_modes).",
1101
+ )
1102
+ p_file_send.add_argument("--name", default="", help="Target filename / task name.")
1103
+ p_file_send.add_argument("--priority", type=int, default=0, help="Task priority.")
1104
+ p_file_send.add_argument("--owner", default=None, help="Node ID that must claim the task.")
1105
+ p_file_send.add_argument("--source", default=None, help="Backup source (backup.create bridge).")
1106
+ p_file_send.add_argument(
1107
+ "--type", default="full", choices=["full", "incremental"],
1108
+ help="Backup type (backup.create bridge).",
1109
+ )
1110
+ p_file_send.add_argument(
1111
+ "--interval", type=int, default=5, help="Poll interval in seconds (bridge mode).",
1112
+ )
1113
+ p_file_send.set_defaults(func=with_client(cli_file._cmd_file_send))
1114
+
1115
+ p_file_get = p_file_sub.add_parser(
1116
+ "get", help="Get a file via inline/bridge (auto-chosen by capability)."
1117
+ )
1118
+ p_file_get.add_argument(
1119
+ "ref", type=str,
1120
+ help="Generic file reference (path for storage.fetch, backup_id for backup.restore, "
1121
+ "channel_id for storage.download_channel).",
1122
+ )
1123
+ p_file_get.add_argument("--cap", required=True, help="Capability that serves the file.")
1124
+ p_file_get.add_argument(
1125
+ "--output", "-o", type=Path, default=None,
1126
+ help="Output path (default: filename from server/channel).",
1127
+ )
1128
+ p_file_get.add_argument("--name", default="", help="Task name.")
1129
+ p_file_get.add_argument("--priority", type=int, default=0, help="Task priority.")
1130
+ p_file_get.add_argument("--owner", default=None, help="Node ID that must claim the task.")
1131
+ p_file_get.add_argument(
1132
+ "--interval", type=int, default=5, help="Poll interval in seconds (bridge mode).",
1133
+ )
1134
+ p_file_get.set_defaults(func=with_client(cli_file._cmd_file_get))
1135
+
1136
+ # T-179: hp put/get — handler primitives for handler scripts.
1137
+ # Scripts talk exclusively to the local node-cli (subprocess
1138
+ # contract); token/ladder/envelope negotiation stays in the CLI
1139
+ # process.
1140
+ p_hp = sub.add_parser(
1141
+ "hp", help="Handler primitives for handler scripts (T-179)."
1142
+ )
1143
+ p_hp_sub = p_hp.add_subparsers(dest="hp_cmd", required=True, metavar="<action>")
1144
+
1145
+ p_hp_put = p_hp_sub.add_parser(
1146
+ "put", help="Wrap a local file as an envelope (ladder-negotiated)."
1147
+ )
1148
+ p_hp_put.add_argument("path", help="Path to the local file to wrap.")
1149
+ p_hp_put.add_argument(
1150
+ "--cap", required=True, help="Receiving capability (decides upload modes)."
1151
+ )
1152
+ p_hp_put.add_argument(
1153
+ "--name", default=None, help="Override filename inside the envelope."
1154
+ )
1155
+ p_hp_put.set_defaults(func=with_client(cli_hp.dispatch_put))
1156
+
1157
+ p_hp_get = p_hp_sub.add_parser(
1158
+ "get", help="Resolve an __iowap_ref__ envelope (inline/artifact) to a local file."
1159
+ )
1160
+ p_hp_get.add_argument(
1161
+ "--file", default=None,
1162
+ help="Read the envelope JSON from this file (default: stdin).",
1163
+ )
1164
+ p_hp_get.add_argument(
1165
+ "--output", "-o", type=Path, default=None,
1166
+ help="Output path (default: <out_dir>/<filename from envelope>).",
1167
+ )
1168
+ p_hp_get.add_argument(
1169
+ "--out-dir", type=Path, default=None,
1170
+ help="Default drop directory (default: ~/.relay/tmp/${RELAY_TASK_ID:-adhoc}/).",
1171
+ )
1172
+ p_hp_get.set_defaults(func=with_client(cli_hp.dispatch_get))
1173
+
1174
+ # status / reload
1175
+ p_status = sub.add_parser("status", help="Print worker_status.json content.")
1176
+ p_status.set_defaults(func=_cmd_status)
1177
+
1178
+ p_reload = sub.add_parser("reload", help="Send SIGHUP to running daemon.")
1179
+ p_reload.set_defaults(func=_cmd_reload)
1180
+
1181
+ # artifact operations
1182
+ p_artifact = sub.add_parser("artifact", help="Artifact operations.")
1183
+ p_artifact_sub = p_artifact.add_subparsers(
1184
+ dest="artifact_command", required=True, metavar="<action>"
1185
+ )
1186
+ p_artifact_download = p_artifact_sub.add_parser(
1187
+ "download", help="Download an artifact by id from the relay."
1188
+ )
1189
+ p_artifact_download.add_argument("artifact_id", help="The artifact ID to download.")
1190
+ p_artifact_download.add_argument(
1191
+ "--output", "-o", type=Path, default=None,
1192
+ help="Output path (default: <artifact name from server>).",
1193
+ )
1194
+ p_artifact_download.set_defaults(func=with_client(cli_artifact._cmd_artifact_download))
1195
+
1196
+ p_artifact_upload = p_artifact_sub.add_parser(
1197
+ "upload", help="Upload a local file as an artifact to the relay."
1198
+ )
1199
+ p_artifact_upload.add_argument("file", type=str, help="Path to the file to upload.")
1200
+ p_artifact_upload.add_argument(
1201
+ "--name", default=None, help="Artifact name (default: filename)."
1202
+ )
1203
+ p_artifact_upload.add_argument(
1204
+ "--task-id", default=None, help="Optional task ID to associate with."
1205
+ )
1206
+ p_artifact_upload.add_argument(
1207
+ "--stage-id", default=None, help="Optional stage ID to associate with."
1208
+ )
1209
+ p_artifact_upload.set_defaults(func=with_client(cli_artifact._cmd_artifact_upload))
1210
+
1211
+ # docs (T-059)
1212
+ p_docs = sub.add_parser(
1213
+ "docs",
1214
+ help="Read relay documentation from the server (list all, or print one).",
1215
+ )
1216
+ p_docs.add_argument(
1217
+ "name",
1218
+ nargs="?",
1219
+ default=None,
1220
+ help="Document name (omit to list all available documents).",
1221
+ )
1222
+ p_docs.set_defaults(func=with_client(cli_docs._cmd_docs))
1223
+
1224
+ # update (wheel-based self-update)
1225
+ p_update = sub.add_parser(
1226
+ "update",
1227
+ help="Check for and apply wheel-based self-updates (GitHub releases).",
1228
+ )
1229
+ p_update_sub = p_update.add_subparsers(
1230
+ dest="update_command", required=True, metavar="<action>"
1231
+ )
1232
+ p_update_check = p_update_sub.add_parser(
1233
+ "check",
1234
+ help="Compare the installed wheel version against the newest GitHub release.",
1235
+ )
1236
+ p_update_check.set_defaults(func=cli_update._cmd_update_check)
1237
+
1238
+ p_update_apply = p_update_sub.add_parser(
1239
+ "apply",
1240
+ help="Download the newest wheel release, reinstall it and restart the service.",
1241
+ )
1242
+ p_update_apply.add_argument(
1243
+ "--service-unit",
1244
+ default=SERVICE_UNIT,
1245
+ help=f"systemd user unit to restart (default: {SERVICE_UNIT}).",
1246
+ )
1247
+ p_update_apply.add_argument(
1248
+ "--restart-command",
1249
+ default=RESTART_COMMAND,
1250
+ help=(
1251
+ "Command to run instead of `systemctl --user restart` (for hosts "
1252
+ "without systemd, e.g. macOS launchd). '{unit}' is replaced with "
1253
+ "the service name. Default: RELAY_RESTART_COMMAND env or systemd."
1254
+ ),
1255
+ )
1256
+ p_update_apply.set_defaults(func=cli_update._cmd_update_apply)
1257
+
1258
+ return parser
1259
+
1260
+
1261
+ def _daemon_dispatch(args: argparse.Namespace) -> int:
1262
+ action = args.daemon_command
1263
+ if action == "start":
1264
+ return _daemon_start(args)
1265
+ if action == "stop":
1266
+ return _daemon_stop(args)
1267
+ if action == "restart":
1268
+ return _daemon_restart(args)
1269
+ if action == "status":
1270
+ return _daemon_status(args)
1271
+ if action == "foreground":
1272
+ return _daemon_foreground(args)
1273
+ print(f"unknown daemon action: {action}", file=sys.stderr)
1274
+ return 2
1275
+
1276
+
1277
+ # ---------------------------------------------------------------------------
1278
+ # Entry point
1279
+ # ---------------------------------------------------------------------------
1280
+
1281
+ def main(argv: list[str] | None = None) -> int:
1282
+ # Hidden internal flag used by the self-spawned daemon process.
1283
+ raw = list(sys.argv[1:] if argv is None else argv)
1284
+ if raw and raw[0] == "--daemon-internal":
1285
+ return _daemon_internal()
1286
+
1287
+ parser = build_parser()
1288
+ args = parser.parse_args(raw)
1289
+ try:
1290
+ # T-226: every invocation records its own absolute path as the
1291
+ # top-level node_cli_path stamp in the active profile — consumers
1292
+ # without PATH knowledge (desktop gateway) resolve the binary from
1293
+ # there. Drift-guarded; never raises.
1294
+ from nodes.common.node_config import ensure_node_cli_stamp
1295
+
1296
+ ensure_node_cli_stamp()
1297
+ return int(args.func(args))
1298
+ except SystemExit as exc:
1299
+ if exc.code is None:
1300
+ return 0
1301
+ print(exc, file=sys.stderr)
1302
+ return int(exc.code) if isinstance(exc.code, int) else 1
1303
+ except KeyboardInterrupt:
1304
+ return 130
1305
+ except httpx.HTTPError as exc:
1306
+ print(f"network error: {exc}", file=sys.stderr)
1307
+ return 1
1308
+
1309
+
1310
+ if __name__ == "__main__":
1311
+ sys.exit(main())