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.
- iowap_node-2.3.20.dist-info/METADATA +126 -0
- iowap_node-2.3.20.dist-info/RECORD +28 -0
- iowap_node-2.3.20.dist-info/WHEEL +4 -0
- iowap_node-2.3.20.dist-info/entry_points.txt +3 -0
- iowap_node-2.3.20.dist-info/licenses/LICENSE +21 -0
- nodes/__init__.py +1 -0
- nodes/common/__init__.py +0 -0
- nodes/common/capability.py +520 -0
- nodes/common/cli/__init__.py +0 -0
- nodes/common/cli/cli_artifact.py +42 -0
- nodes/common/cli/cli_bridge.py +264 -0
- nodes/common/cli/cli_capabilities.py +229 -0
- nodes/common/cli/cli_docs.py +122 -0
- nodes/common/cli/cli_file.py +434 -0
- nodes/common/cli/cli_hp.py +546 -0
- nodes/common/cli/cli_node.py +249 -0
- nodes/common/cli/cli_route.py +92 -0
- nodes/common/cli/cli_server.py +248 -0
- nodes/common/cli/cli_task.py +191 -0
- nodes/common/cli/cli_update.py +48 -0
- nodes/common/envelope.py +168 -0
- nodes/common/file_serve.py +648 -0
- nodes/common/handler_runner.py +497 -0
- nodes/common/node_cli.py +1311 -0
- nodes/common/node_config.py +856 -0
- nodes/common/node_daemon.py +946 -0
- nodes/common/node_utils.py +448 -0
- nodes/common/relay_client.py +1246 -0
nodes/common/node_cli.py
ADDED
|
@@ -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())
|