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
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared utility functions for node-cli and related tools.
|
|
3
|
+
|
|
4
|
+
Extracted from the legacy poller.py to remove the dependency on the
|
|
5
|
+
old Poller class. These are thin wrappers around file I/O and config
|
|
6
|
+
loading used by node_cli.py and its RelayClient.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.request
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("node-utils")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _utcnow_str() -> str:
|
|
24
|
+
"""Current UTC time as an ISO-8601 string (token cadence anchor)."""
|
|
25
|
+
return datetime.now(UTC).isoformat()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
BASE_DIR = Path.home() / ".relay"
|
|
29
|
+
LEGACY_META_PATH = BASE_DIR / "ai-relay-agent.json"
|
|
30
|
+
LEGACY_TOKEN_PATH = BASE_DIR / "ai-relay-agent.token"
|
|
31
|
+
META_PATH = BASE_DIR / "iowap-agent.json"
|
|
32
|
+
CONFIG_PATH = BASE_DIR / "relay_config.json"
|
|
33
|
+
TOKEN_PATH = BASE_DIR / "iowap-agent.token"
|
|
34
|
+
STATUS_PATH = BASE_DIR / "worker_status.json"
|
|
35
|
+
|
|
36
|
+
DEFAULT_CONFIG = {
|
|
37
|
+
"base_url": None,
|
|
38
|
+
"heartbeat_interval": 8,
|
|
39
|
+
"claim_interval": 5,
|
|
40
|
+
# T-c51219ee: periodic backfill claim sweep for the SSE daemon
|
|
41
|
+
# (rescues tasks whose one-shot task_created event was missed).
|
|
42
|
+
# 0 disables the ticker. Env override: RELAY_BACKFILL_INTERVAL.
|
|
43
|
+
"backfill_interval": 60,
|
|
44
|
+
"status_interval": 7200,
|
|
45
|
+
# T-182: fixed maintenance intervals instead of expiry-margin math.
|
|
46
|
+
# rt (TTL 7d): refresh every 6 days. rs (TTL 7d): rotate on every
|
|
47
|
+
# start + every 24h. Env overrides: RELAY_RS_REFRESH_INTERVAL /
|
|
48
|
+
# RELAY_RT_REFRESH_INTERVAL (seconds).
|
|
49
|
+
"rs_refresh_interval_seconds": 86400,
|
|
50
|
+
"rt_refresh_interval_seconds": 518400,
|
|
51
|
+
"request_timeout": 10,
|
|
52
|
+
"task_timeout": 600,
|
|
53
|
+
"log_level": "INFO",
|
|
54
|
+
"background_heartbeat": True,
|
|
55
|
+
# T-060: how many times the daemon retries a task before refusing
|
|
56
|
+
# to claim more stages for it (3 attempts total with the default of 2).
|
|
57
|
+
"max_retries": 2,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_json(path: Path, default=None):
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return default
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(path.read_text())
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
logger.warning("failed to read %s: %s", path, exc)
|
|
68
|
+
return default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def write_json_atomic(path: Path, data: dict):
|
|
72
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
73
|
+
tmp.write_text(json.dumps(data, indent=2, default=str))
|
|
74
|
+
os.chmod(tmp, 0o600)
|
|
75
|
+
tmp.replace(path)
|
|
76
|
+
os.chmod(path, 0o600)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_config() -> dict:
|
|
80
|
+
cfg = load_json(CONFIG_PATH, default=DEFAULT_CONFIG.copy())
|
|
81
|
+
if cfg is None:
|
|
82
|
+
cfg = DEFAULT_CONFIG.copy()
|
|
83
|
+
for key, value in DEFAULT_CONFIG.items():
|
|
84
|
+
cfg.setdefault(key, value)
|
|
85
|
+
return cfg
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_meta() -> dict:
|
|
89
|
+
path = META_PATH if META_PATH.exists() else LEGACY_META_PATH
|
|
90
|
+
if not path.exists():
|
|
91
|
+
raise FileNotFoundError(f"metadata missing: {META_PATH} (legacy: {LEGACY_META_PATH})")
|
|
92
|
+
return json.loads(path.read_text())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_token() -> dict | None:
|
|
96
|
+
"""Load the persisted runtime token as a dict.
|
|
97
|
+
|
|
98
|
+
Returns ``{"token": "...", "expires_at": "..." | None}`` or ``None``
|
|
99
|
+
when no token file exists. Legacy plaintext token files (pre-T-088)
|
|
100
|
+
are detected — a single non-JSON line is treated as the token value
|
|
101
|
+
with an unknown expiry — and migrated to the JSON format on the next
|
|
102
|
+
``save_token()`` call.
|
|
103
|
+
"""
|
|
104
|
+
if not TOKEN_PATH.exists() and not LEGACY_TOKEN_PATH.exists():
|
|
105
|
+
return None
|
|
106
|
+
token_path = TOKEN_PATH if TOKEN_PATH.exists() else LEGACY_TOKEN_PATH
|
|
107
|
+
raw = token_path.read_text().strip()
|
|
108
|
+
if not raw:
|
|
109
|
+
return None
|
|
110
|
+
# T-088: prefer the JSON envelope. Fall back to the legacy
|
|
111
|
+
# plaintext format so existing installs keep working.
|
|
112
|
+
if raw.lstrip().startswith("{"):
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(raw)
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
logger.warning("token file %s is not valid JSON, treating as plaintext", TOKEN_PATH)
|
|
117
|
+
return {"token": raw, "expires_at": None, "refreshed_at": None}
|
|
118
|
+
if isinstance(data, dict) and data.get("token"):
|
|
119
|
+
return {
|
|
120
|
+
"token": data["token"],
|
|
121
|
+
"expires_at": data.get("expires_at"),
|
|
122
|
+
# T-185: cadence anchor for the 6-day rt interval; None
|
|
123
|
+
# means "unknown" (legacy envelope) — the daemon then
|
|
124
|
+
# stamps it on the next rotation.
|
|
125
|
+
"refreshed_at": data.get("refreshed_at"),
|
|
126
|
+
}
|
|
127
|
+
return None
|
|
128
|
+
return {"token": raw, "expires_at": None, "refreshed_at": None}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def save_token(
|
|
132
|
+
token: str, expires_at: str | None = None, refreshed_at: str | None = None
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Persist the runtime token plus its expiry as a JSON envelope.
|
|
135
|
+
|
|
136
|
+
The envelope is
|
|
137
|
+
``{"token": "...", "expires_at": "...|None", "refreshed_at": "...|None"}``.
|
|
138
|
+
``refreshed_at`` (T-185) is the cadence anchor for the 6-day rt
|
|
139
|
+
refresh interval: it counts from the LAST REFRESH, not from daemon
|
|
140
|
+
start, so the stamp must survive restarts on disk. ``None`` stamps
|
|
141
|
+
the current UTC time — every token write IS a refresh of the
|
|
142
|
+
credential. Writing is atomic (tmp file + rename) so a crash
|
|
143
|
+
mid-write never leaves a truncated token file. The file is chmod
|
|
144
|
+
0o600 so other local users cannot read the token (security
|
|
145
|
+
hardening, T-171).
|
|
146
|
+
"""
|
|
147
|
+
if refreshed_at is None:
|
|
148
|
+
refreshed_at = _utcnow_str()
|
|
149
|
+
tmp = TOKEN_PATH.with_suffix(TOKEN_PATH.suffix + ".tmp")
|
|
150
|
+
tmp.write_text(
|
|
151
|
+
json.dumps(
|
|
152
|
+
{"token": token, "expires_at": expires_at, "refreshed_at": refreshed_at}
|
|
153
|
+
)
|
|
154
|
+
+ "\n"
|
|
155
|
+
)
|
|
156
|
+
os.chmod(tmp, 0o600)
|
|
157
|
+
tmp.replace(TOKEN_PATH)
|
|
158
|
+
os.chmod(TOKEN_PATH, 0o600)
|
|
159
|
+
# Migrate: remove legacy token file after successful write
|
|
160
|
+
if LEGACY_TOKEN_PATH.exists() and LEGACY_TOKEN_PATH != TOKEN_PATH:
|
|
161
|
+
LEGACY_TOKEN_PATH.unlink(missing_ok=True)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def save_meta(meta: dict):
|
|
165
|
+
write_json_atomic(META_PATH, meta)
|
|
166
|
+
# Migrate: remove legacy file after successful write
|
|
167
|
+
if LEGACY_META_PATH.exists() and LEGACY_META_PATH != META_PATH:
|
|
168
|
+
LEGACY_META_PATH.unlink(missing_ok=True)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# PID helpers (T-117): parameterised so node_cli, node_daemon and a future
|
|
173
|
+
# federation_node can share them. Each caller passes its own pid_path.
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def read_pid(pid_path: Path) -> int | None:
|
|
177
|
+
"""Read a PID file and return the pid, or ``None`` if missing/invalid."""
|
|
178
|
+
if not pid_path.exists():
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
return int(pid_path.read_text().strip())
|
|
182
|
+
except (ValueError, OSError):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def pid_running(pid: int) -> bool:
|
|
187
|
+
"""Return True if a process with the given pid is currently alive."""
|
|
188
|
+
try:
|
|
189
|
+
os.kill(pid, 0)
|
|
190
|
+
except OSError:
|
|
191
|
+
return False
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def sighup_pid_files(pid_paths: list[Path]) -> list[int]:
|
|
196
|
+
"""Send SIGHUP to every pid file whose process is alive (t_c214ca25).
|
|
197
|
+
|
|
198
|
+
The node ships two daemons — the SSE daemon (``node-daemon.pid``) and
|
|
199
|
+
the legacy polling daemon (``node-cli.pid``). Both install SIGHUP
|
|
200
|
+
handlers that invalidate the capability cache, so profile publishers
|
|
201
|
+
notify every known pid file instead of guessing which daemon runs.
|
|
202
|
+
Missing, unparsable and stale pid files are skipped silently; OSError
|
|
203
|
+
on the actual kill is swallowed (best-effort notification).
|
|
204
|
+
|
|
205
|
+
Returns the list of pids that received the signal, in the order the
|
|
206
|
+
paths were given (first entry wins as "the" daemon in messages).
|
|
207
|
+
"""
|
|
208
|
+
notified: list[int] = []
|
|
209
|
+
for pid_path in pid_paths:
|
|
210
|
+
pid = read_pid(pid_path)
|
|
211
|
+
if pid is None or not pid_running(pid):
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
os.kill(pid, signal.SIGHUP)
|
|
215
|
+
except OSError:
|
|
216
|
+
continue
|
|
217
|
+
notified.append(pid)
|
|
218
|
+
return notified
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Wheel-based self-update (successor of the T-062 git helpers)
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Source of truth for "is there a newer version" is the GitHub release with
|
|
225
|
+
# tag ``wheel-v<X.Y.Z>`` on iowap-org/iowap-node — the Wheel-CI publishes its
|
|
226
|
+
# built artifact as release asset ``iowap_node-<X.Y.Z>-py3-none-any.whl``.
|
|
227
|
+
# The locally installed distribution version (importlib.metadata) is compared
|
|
228
|
+
# against the newest release tag; apply = download the asset, pip
|
|
229
|
+
# force-reinstall it into the running venv, restart the systemd unit.
|
|
230
|
+
|
|
231
|
+
UPDATE_REPO = os.environ.get("RELAY_UPDATE_REPO", "iowap-org/iowap-node")
|
|
232
|
+
UPDATE_ASSET_RE = re.compile(r"^iowap_node-(\d+\.\d+\.\d+)-py3-none-any\.whl$")
|
|
233
|
+
|
|
234
|
+
# systemd user unit name restarted by `update apply`. Overridable via env
|
|
235
|
+
# RELAY_SERVICE_UNIT so tests can substitute a no-op service name.
|
|
236
|
+
SERVICE_UNIT = os.environ.get("RELAY_SERVICE_UNIT", "iowap-node-daemon.service")
|
|
237
|
+
|
|
238
|
+
# Restart command used by `update apply` instead of the systemd default —
|
|
239
|
+
# needed on hosts without systemd (macOS/launchd, manual setups). When set
|
|
240
|
+
# (env RELAY_RESTART_COMMAND) the value is split with shlex and run in place
|
|
241
|
+
# of `systemctl --user restart <unit>`; the string `{unit}` (if present) is
|
|
242
|
+
# replaced with the unit/service name. Example for launchd:
|
|
243
|
+
# RELAY_RESTART_COMMAND='/usr/bin/launchctl kickstart -k gui/$UID/{unit}'
|
|
244
|
+
RESTART_COMMAND = os.environ.get("RELAY_RESTART_COMMAND")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_local_wheel_version() -> str | None:
|
|
248
|
+
"""Return the installed ``iowap-node`` distribution version (or None)."""
|
|
249
|
+
try:
|
|
250
|
+
from importlib import metadata
|
|
251
|
+
|
|
252
|
+
return metadata.version("iowap-node")
|
|
253
|
+
except Exception: # noqa: BLE001 — not installed / metadata unreadable
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_latest_release_version(repo: str | None = None) -> dict:
|
|
258
|
+
"""Query GitHub for the newest ``wheel-vX.Y.Z`` release.
|
|
259
|
+
|
|
260
|
+
Returns a dict with:
|
|
261
|
+
- ``latest_version``: parsed version str of the newest wheel release
|
|
262
|
+
(None when no matching release exists)
|
|
263
|
+
- ``tag``: full tag name (e.g. ``wheel-v2.3.8``)
|
|
264
|
+
- ``asset_name``: release asset filename of the wheel
|
|
265
|
+
- ``asset_url``: browser_download_url for the asset
|
|
266
|
+
- ``error``: human-readable failure reason (when lookup failed)
|
|
267
|
+
"""
|
|
268
|
+
repo = repo or UPDATE_REPO
|
|
269
|
+
url = f"https://api.github.com/repos/{repo}/releases"
|
|
270
|
+
headers = {"Accept": "application/vnd.github+json"}
|
|
271
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
272
|
+
if token:
|
|
273
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
274
|
+
try:
|
|
275
|
+
req = urllib.request.Request(url, headers=headers)
|
|
276
|
+
with urllib.request.urlopen(req, timeout=30.0) as resp:
|
|
277
|
+
releases = json.loads(resp.read().decode("utf-8"))
|
|
278
|
+
except Exception as exc: # noqa: BLE001 — network / HTTP / parse errors
|
|
279
|
+
return {"latest_version": None, "tag": None, "asset_name": None,
|
|
280
|
+
"asset_url": None, "error": f"release lookup failed: {exc}"}
|
|
281
|
+
for rel in releases:
|
|
282
|
+
tag = rel.get("tag_name") or ""
|
|
283
|
+
m = re.match(r"^wheel-v(\d+\.\d+\.\d+)$", tag)
|
|
284
|
+
if not m:
|
|
285
|
+
continue
|
|
286
|
+
for asset in rel.get("assets") or []:
|
|
287
|
+
name = asset.get("name") or ""
|
|
288
|
+
if UPDATE_ASSET_RE.match(name):
|
|
289
|
+
return {
|
|
290
|
+
"latest_version": m.group(1),
|
|
291
|
+
"tag": tag,
|
|
292
|
+
"asset_name": name,
|
|
293
|
+
"asset_url": asset.get("browser_download_url"),
|
|
294
|
+
"error": None,
|
|
295
|
+
}
|
|
296
|
+
return {"latest_version": None, "tag": None, "asset_name": None,
|
|
297
|
+
"asset_url": None, "error": "no wheel-vX.Y.Z release found"}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_wheel_updates(*, repo: str | None = None) -> dict:
|
|
301
|
+
"""Compare the installed wheel version against the newest GitHub release.
|
|
302
|
+
|
|
303
|
+
Returns get_latest_release_version() plus:
|
|
304
|
+
- ``local_version``: installed version (None if not installed)
|
|
305
|
+
- ``update_available``: bool (True only when both versions parse and
|
|
306
|
+
latest > local)
|
|
307
|
+
"""
|
|
308
|
+
local = get_local_wheel_version()
|
|
309
|
+
info = get_latest_release_version(repo=repo)
|
|
310
|
+
info["local_version"] = local
|
|
311
|
+
latest = info.get("latest_version")
|
|
312
|
+
try:
|
|
313
|
+
info["update_available"] = bool(
|
|
314
|
+
local and latest and tuple(int(x) for x in latest.split(".")) > tuple(int(x) for x in local.split("."))
|
|
315
|
+
)
|
|
316
|
+
except ValueError:
|
|
317
|
+
info["update_available"] = False
|
|
318
|
+
return info
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def apply_wheel_update(
|
|
322
|
+
*,
|
|
323
|
+
repo: str | None = None,
|
|
324
|
+
service_unit: str | None = None,
|
|
325
|
+
wheel_dir: Path | None = None,
|
|
326
|
+
restart_command: str | None = None,
|
|
327
|
+
) -> dict:
|
|
328
|
+
"""Download the newest wheel release, reinstall it and restart the unit.
|
|
329
|
+
|
|
330
|
+
Returns a dict with:
|
|
331
|
+
- ``success``: bool
|
|
332
|
+
- ``message``: human-readable summary
|
|
333
|
+
- ``before_version``: installed version before the update (or None)
|
|
334
|
+
- ``after_version``: installed version after the update (or None)
|
|
335
|
+
- ``restarted``: bool whether the service restart was attempted
|
|
336
|
+
- ``wheel_path``: local path of the downloaded wheel (or None)
|
|
337
|
+
"""
|
|
338
|
+
unit = service_unit or SERVICE_UNIT
|
|
339
|
+
target_dir = wheel_dir or (Path.home() / ".relay" / "wheels")
|
|
340
|
+
before = get_local_wheel_version()
|
|
341
|
+
result: dict = {
|
|
342
|
+
"success": False,
|
|
343
|
+
"message": "",
|
|
344
|
+
"before_version": before,
|
|
345
|
+
"after_version": None,
|
|
346
|
+
"restarted": False,
|
|
347
|
+
"wheel_path": None,
|
|
348
|
+
}
|
|
349
|
+
info = check_wheel_updates(repo=repo)
|
|
350
|
+
if not info.get("update_available"):
|
|
351
|
+
result["after_version"] = before
|
|
352
|
+
result["message"] = (
|
|
353
|
+
f"already up to date ({before}); no wheel release newer than the "
|
|
354
|
+
f"installed version"
|
|
355
|
+
)
|
|
356
|
+
return result
|
|
357
|
+
url = info.get("asset_url")
|
|
358
|
+
name = info.get("asset_name")
|
|
359
|
+
if not url or not name:
|
|
360
|
+
result["message"] = info.get("error") or "no wheel asset in newest release"
|
|
361
|
+
return result
|
|
362
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
363
|
+
wheel_path = target_dir / name
|
|
364
|
+
try:
|
|
365
|
+
req = urllib.request.Request(url, headers={"Accept": "application/octet-stream"})
|
|
366
|
+
with urllib.request.urlopen(req, timeout=120.0) as resp, open(wheel_path, "wb") as fh:
|
|
367
|
+
fh.write(resp.read())
|
|
368
|
+
except Exception as exc: # noqa: BLE001 — network / HTTP errors
|
|
369
|
+
result["message"] = f"wheel download failed: {exc}"
|
|
370
|
+
return result
|
|
371
|
+
result["wheel_path"] = str(wheel_path)
|
|
372
|
+
try:
|
|
373
|
+
subprocess.run(
|
|
374
|
+
[sys.executable, "-m", "pip", "install", "--quiet",
|
|
375
|
+
"--force-reinstall", "--no-deps", str(wheel_path)],
|
|
376
|
+
capture_output=True, text=True, timeout=300.0, check=True,
|
|
377
|
+
)
|
|
378
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
379
|
+
tail = (getattr(exc, "stderr", "") or "").strip().splitlines()[-3:]
|
|
380
|
+
result["message"] = f"pip install failed: {exc}" + (f" | {tail}" if tail else "")
|
|
381
|
+
return result
|
|
382
|
+
after = get_local_wheel_version()
|
|
383
|
+
result["after_version"] = after
|
|
384
|
+
restart_argv: list[str] | None = None
|
|
385
|
+
restart_cmd = restart_command or RESTART_COMMAND
|
|
386
|
+
if restart_cmd:
|
|
387
|
+
import shlex
|
|
388
|
+
|
|
389
|
+
argv = shlex.split(os.path.expanduser(restart_cmd))
|
|
390
|
+
restart_argv = [tok.replace("{unit}", unit) for tok in argv]
|
|
391
|
+
else:
|
|
392
|
+
restart_argv = ["systemctl", "--user", "restart", unit]
|
|
393
|
+
try:
|
|
394
|
+
subprocess.run(
|
|
395
|
+
restart_argv,
|
|
396
|
+
capture_output=True, text=True, timeout=60.0, check=True,
|
|
397
|
+
)
|
|
398
|
+
result["restarted"] = True
|
|
399
|
+
result["success"] = True
|
|
400
|
+
result["message"] = f"updated {before} -> {after}; service restarted"
|
|
401
|
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
402
|
+
result["message"] = (
|
|
403
|
+
f"pip ok ({before} -> {after}) but service restart failed: {exc}"
|
|
404
|
+
)
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def post_result_rescue_note(
|
|
409
|
+
client,
|
|
410
|
+
task_id: str | None,
|
|
411
|
+
stage_id: str,
|
|
412
|
+
result,
|
|
413
|
+
*,
|
|
414
|
+
reason: str = "complete rejected",
|
|
415
|
+
char_cap: int = 4000,
|
|
416
|
+
) -> bool:
|
|
417
|
+
"""T-001c-c: rescue a handler result whose ``complete`` call was rejected.
|
|
418
|
+
|
|
419
|
+
A 404 from ``/scheduler/stages/{id}/complete`` means the stage is gone
|
|
420
|
+
server-side (expired/re-queued/failed while the handler was running).
|
|
421
|
+
The handler had already done the work — posting the result as an info
|
|
422
|
+
note on the task (T-052 note channel, rides in TaskView) keeps it out
|
|
423
|
+
of the bit bucket. Best effort: ``False`` when the task is gone or the
|
|
424
|
+
note call fails; never raises.
|
|
425
|
+
"""
|
|
426
|
+
if not task_id:
|
|
427
|
+
return False
|
|
428
|
+
try:
|
|
429
|
+
payload = json.dumps(result, default=str)
|
|
430
|
+
except (TypeError, ValueError):
|
|
431
|
+
payload = repr(result)
|
|
432
|
+
if len(payload) > char_cap:
|
|
433
|
+
payload = payload[: char_cap - 3] + "..."
|
|
434
|
+
try:
|
|
435
|
+
client.add_task_note(
|
|
436
|
+
str(task_id),
|
|
437
|
+
f"[result-rescue] stage {stage_id} complete failed ({reason}); "
|
|
438
|
+
f"result preserved:\n{payload}",
|
|
439
|
+
kind="info",
|
|
440
|
+
)
|
|
441
|
+
return True
|
|
442
|
+
except Exception: # noqa: BLE001 — best effort, the failure is logged below
|
|
443
|
+
logger.error(
|
|
444
|
+
"result-rescue note for task %s stage %s failed — result not recoverable",
|
|
445
|
+
task_id,
|
|
446
|
+
stage_id,
|
|
447
|
+
)
|
|
448
|
+
return False
|