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,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