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,856 @@
1
+ #!/usr/bin/env python3
2
+ """Node configuration & capability profile loader for the node-cli daemon.
3
+
4
+ This module is responsible for loading, validating, publishing, and
5
+ diffing capability profiles stored as YAML files under
6
+ ``~/.relay/profiles.d/``. The daemon only ever reads the *active*
7
+ profile (``~/.relay/node.yaml``); working profiles in ``profiles.d/``
8
+ are never touched by the daemon at runtime.
9
+
10
+ The active file is also the home of node-level configuration: the
11
+ optional ``status`` field (operator-requested busy/idle state, managed
12
+ by ``node-cli node busy``/``idle``/``clear-status``) and the optional
13
+ ``node_name``/``description`` overrides. The ``capabilities`` array
14
+ itself is optional — a node may publish a profile that carries only
15
+ node-level configuration.
16
+
17
+ A *profile* is a YAML document of the form::
18
+
19
+ capabilities:
20
+ - name: chat.ai
21
+ version: "1.0.0"
22
+ auto_publish: true
23
+ claimable: true
24
+ handler: /opt/relay/handlers/chat-ai.sh
25
+ max_parallel: 2
26
+ timeout: 300
27
+
28
+ The loader normalizes each capability into a dict with the keys
29
+ ``name``, ``version``, ``auto_publish``, ``claimable``, ``handler``,
30
+ ``max_parallel`` and ``timeout``. Defaults are applied for missing
31
+ optional fields. Environment-variable overrides
32
+ (``RELAY_CAPABILITY_<NAME>_HANDLER`` / ``..._MAX_PARALLEL``) are applied
33
+ on read so that operators can patch a profile without editing the YAML.
34
+
35
+ For backward compatibility a one-shot migration (``_migrate_old_paths``)
36
+ runs at import time: legacy ``capabilities.active.yaml`` /
37
+ ``capabilities.active.profile`` / ``capabilities.d/`` artifacts are
38
+ copied to their new names if the new files do not yet exist. The old
39
+ files are left in place so a rolling deploy can fall back.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+ import re
47
+ import shutil
48
+ import sys
49
+ import threading
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ import yaml
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # JSON Schema for capability profiles (Draft 2020-12)
57
+ # ---------------------------------------------------------------------------
58
+
59
+ CAPABILITY_SCHEMA: dict[str, Any] = {
60
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
61
+ "type": "object",
62
+ "properties": {
63
+ "status": {"type": "string", "enum": ["busy", "idle", "online"]},
64
+ "node_name": {"type": "string"},
65
+ "description": {"type": "string"},
66
+ "capabilities": {
67
+ "type": "array",
68
+ "items": {
69
+ "type": "object",
70
+ "required": ["name"],
71
+ "properties": {
72
+ "name": {"type": "string", "minLength": 1},
73
+ "version": {"type": "string", "minLength": 1},
74
+ "type": {"type": "string"},
75
+ "description": {"type": "string"},
76
+ "auto_publish": {"type": "boolean"},
77
+ "claimable": {"type": "boolean"},
78
+ "handler": {"type": "string"},
79
+ "max_parallel": {"type": "integer", "minimum": 1},
80
+ "timeout": {"type": "integer", "minimum": 1},
81
+ "input_schema": {"type": "object"},
82
+ },
83
+ "additionalProperties": False,
84
+ },
85
+ }
86
+ },
87
+ "additionalProperties": True,
88
+ }
89
+
90
+
91
+ def validate_with_schema(data: dict[str, Any]) -> list[str]:
92
+ """Validate parsed YAML data against CAPABILITY_SCHEMA.
93
+
94
+ Returns a list of human-readable error messages. An empty list means
95
+ the data is structurally valid. Uses ``jsonschema`` if available,
96
+ otherwise falls back to a basic structural check.
97
+ """
98
+ errors: list[str] = []
99
+
100
+ # Basic structural check (works without jsonschema dependency).
101
+ if not isinstance(data, dict):
102
+ errors.append("profile root must be a mapping")
103
+ return errors
104
+ # 'capabilities' is optional — a profile may carry only node-level
105
+ # configuration (status, node_name, description). When present it
106
+ # must be a list.
107
+ caps = data.get("capabilities")
108
+ if caps is not None:
109
+ if not isinstance(caps, list):
110
+ errors.append("'capabilities' must be a list")
111
+ return errors
112
+ for i, entry in enumerate(caps):
113
+ prefix = f"capabilities[{i}]"
114
+ if not isinstance(entry, dict):
115
+ errors.append(f"{prefix}: must be a mapping, got {type(entry).__name__}")
116
+ continue
117
+ if "name" not in entry or not isinstance(entry.get("name"), str) or not entry["name"].strip():
118
+ errors.append(f"{prefix}: 'name' is required and must be a non-empty string")
119
+ # Check for unknown keys
120
+ allowed = {"name", "version", "type", "description", "input_schema", "auto_publish", "claimable", "handler", "max_parallel", "timeout", "routes", "long_run", "upload_modes", "result_path_hints", "config"}
121
+ extra = set(entry.keys()) - allowed
122
+ if extra:
123
+ errors.append(f"{prefix}: unknown keys: {', '.join(sorted(extra))}")
124
+ # T-005b: per-capability config mapping (carrier of
125
+ # envelope_request_mirror). Must be a mapping when present.
126
+ if "config" in entry and not isinstance(entry.get("config"), dict):
127
+ errors.append(f"{prefix}: 'config' must be a mapping")
128
+ # Type checks for optional fields
129
+ for key, expected_type in [
130
+ ("version", str),
131
+ ("auto_publish", bool),
132
+ ("claimable", bool),
133
+ ("handler", str),
134
+ ("max_parallel", int),
135
+ ("timeout", int),
136
+ ("long_run", bool),
137
+ ]:
138
+ val = entry.get(key)
139
+ if val is not None and not isinstance(val, expected_type):
140
+ errors.append(f"{prefix}.{key}: expected {expected_type.__name__}, got {type(val).__name__}")
141
+ # Range checks
142
+ for key in ("max_parallel", "timeout"):
143
+ val = entry.get(key)
144
+ if isinstance(val, int) and val < 1:
145
+ errors.append(f"{prefix}.{key}: must be >= 1, got {val}")
146
+
147
+ return errors
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Paths
151
+ # ---------------------------------------------------------------------------
152
+
153
+ BASE_DIR = Path.home() / ".relay"
154
+ PROFILES_DIR = Path(
155
+ os.environ.get("RELAY_PROFILES_DIR", str(BASE_DIR / "profiles.d"))
156
+ )
157
+ ACTIVE_PATH = BASE_DIR / "node.yaml"
158
+ ACTIVE_PROFILE_NAME_PATH = BASE_DIR / "node.profile"
159
+
160
+
161
+ def _migrate_old_paths() -> None:
162
+ """Migrate old capability-named files to new node-named files.
163
+
164
+ Called once at module import time. Detects legacy paths and copies
165
+ them to the new locations. Old files are NOT deleted — they are
166
+ left in place for backward compat during a rolling deploy.
167
+ """
168
+ try:
169
+ BASE_DIR.mkdir(parents=True, exist_ok=True)
170
+ except OSError:
171
+ return
172
+ old_active = BASE_DIR / "capabilities.active.yaml"
173
+ new_active = ACTIVE_PATH
174
+ if old_active.exists() and not new_active.exists():
175
+ try:
176
+ shutil.copy2(old_active, new_active)
177
+ except OSError:
178
+ pass
179
+ old_profile = BASE_DIR / "capabilities.active.profile"
180
+ new_profile = ACTIVE_PROFILE_NAME_PATH
181
+ if old_profile.exists() and not new_profile.exists():
182
+ try:
183
+ shutil.copy2(old_profile, new_profile)
184
+ except OSError:
185
+ pass
186
+ old_profiles_dir = BASE_DIR / "capabilities.d"
187
+ new_profiles_dir = PROFILES_DIR
188
+ if old_profiles_dir.exists() and not new_profiles_dir.exists():
189
+ try:
190
+ shutil.copytree(old_profiles_dir, new_profiles_dir, dirs_exist_ok=True)
191
+ except OSError:
192
+ pass
193
+
194
+
195
+ _migrate_old_paths()
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Defaults
199
+ # ---------------------------------------------------------------------------
200
+
201
+ DEFAULT_VERSION = "1.0.0"
202
+ DEFAULT_MAX_PARALLEL = 1
203
+ DEFAULT_TIMEOUT = 300
204
+
205
+ # Required normalized keys in order (for stable output / diffs).
206
+ _NORMALIZED_KEYS = (
207
+ "name",
208
+ "version",
209
+ "description",
210
+ "auto_publish",
211
+ "claimable",
212
+ "handler",
213
+ "max_parallel",
214
+ "timeout",
215
+ "routes",
216
+ )
217
+
218
+
219
+ class CapabilityValidationError(ValueError):
220
+ """Raised when a capability profile fails validation.
221
+
222
+ The message is intended for human consumption and always includes
223
+ enough context (file path, offending capability name) to locate the
224
+ problem. The optional ``file`` and ``line`` attributes allow callers
225
+ to render structured error messages.
226
+ """
227
+
228
+ def __init__(
229
+ self,
230
+ message: str,
231
+ *,
232
+ file: str | None = None,
233
+ line: int | None = None,
234
+ capability: str | None = None,
235
+ ) -> None:
236
+ location = ""
237
+ if file:
238
+ location = file
239
+ if line is not None:
240
+ location += f":{line}"
241
+ if capability:
242
+ location += f" (capability '{capability}')"
243
+ full = f"{location}: {message}" if location else message
244
+ super().__init__(full)
245
+ self.message = message
246
+ self.file = file
247
+ self.line = line
248
+ self.capability = capability
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Env-var override helpers
253
+ # ---------------------------------------------------------------------------
254
+
255
+ def _env_var_name(capability_name: str, suffix: str) -> str:
256
+ """Build the environment-variable name for a capability override.
257
+
258
+ ``<NAME>`` is uppercased with dots and hyphens normalized to
259
+ underscores, e.g. ``chat.ai`` -> ``RELAY_CAPABILITY_CHAT_AI_HANDLER``.
260
+ """
261
+ normalized = re.sub(r"[^A-Za-z0-9]", "_", capability_name).upper()
262
+ return f"RELAY_CAPABILITY_{normalized}_{suffix}"
263
+
264
+
265
+ def _apply_env_overrides(cap: dict[str, Any]) -> dict[str, Any]:
266
+ """Apply per-capability env-var overrides in-place and return the cap."""
267
+ name = cap["name"]
268
+ handler = os.environ.get(_env_var_name(name, "HANDLER"))
269
+ if handler:
270
+ cap["handler"] = handler
271
+ max_parallel_env = os.environ.get(_env_var_name(name, "MAX_PARALLEL"))
272
+ if max_parallel_env is not None:
273
+ try:
274
+ cap["max_parallel"] = int(max_parallel_env)
275
+ except ValueError:
276
+ raise CapabilityValidationError(
277
+ f"env var {_env_var_name(name, 'MAX_PARALLEL')!r}="
278
+ f"{max_parallel_env!r} is not an integer",
279
+ capability=name,
280
+ ) from None
281
+ return cap
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Normalization & validation
286
+ # ---------------------------------------------------------------------------
287
+
288
+ def _normalize_capability(
289
+ raw: Any,
290
+ *,
291
+ file: str | None = None,
292
+ index: int | None = None,
293
+ ) -> dict[str, Any]:
294
+ """Normalize and validate a single capability entry.
295
+
296
+ Raises :class:`CapabilityValidationError` on any structural problem.
297
+ """
298
+ if not isinstance(raw, dict):
299
+ where = f"#{index}" if index is not None else "entry"
300
+ raise CapabilityValidationError(
301
+ f"capability entry {where} is not a mapping",
302
+ file=file,
303
+ )
304
+
305
+ name = raw.get("name")
306
+ if not isinstance(name, str) or not name.strip():
307
+ raise CapabilityValidationError(
308
+ "capability is missing required 'name' (must be non-empty string)",
309
+ file=file,
310
+ line=None,
311
+ capability=name if isinstance(name, str) else None,
312
+ )
313
+
314
+ auto_publish = raw.get("auto_publish", True)
315
+ if not isinstance(auto_publish, bool):
316
+ raise CapabilityValidationError(
317
+ "'auto_publish' must be a boolean",
318
+ file=file,
319
+ capability=name,
320
+ )
321
+
322
+ claimable = raw.get("claimable", False)
323
+ if not isinstance(claimable, bool):
324
+ raise CapabilityValidationError(
325
+ "'claimable' must be a boolean",
326
+ file=file,
327
+ capability=name,
328
+ )
329
+
330
+ handler = raw.get("handler")
331
+ if claimable and (not isinstance(handler, str) or not handler.strip()):
332
+ raise CapabilityValidationError(
333
+ "'handler' is required when claimable is true",
334
+ file=file,
335
+ capability=name,
336
+ )
337
+ if handler is not None and not isinstance(handler, str):
338
+ raise CapabilityValidationError(
339
+ "'handler' must be a string (path or shell command)",
340
+ file=file,
341
+ capability=name,
342
+ )
343
+
344
+ max_parallel = raw.get("max_parallel", DEFAULT_MAX_PARALLEL)
345
+ if not isinstance(max_parallel, int) or isinstance(max_parallel, bool):
346
+ raise CapabilityValidationError(
347
+ "'max_parallel' must be a positive integer",
348
+ file=file,
349
+ capability=name,
350
+ )
351
+ if max_parallel < 1:
352
+ raise CapabilityValidationError(
353
+ "'max_parallel' must be a positive integer (>= 1)",
354
+ file=file,
355
+ capability=name,
356
+ )
357
+
358
+ timeout = raw.get("timeout", DEFAULT_TIMEOUT)
359
+ if not isinstance(timeout, int) or isinstance(timeout, bool):
360
+ raise CapabilityValidationError(
361
+ "'timeout' must be a positive integer",
362
+ file=file,
363
+ capability=name,
364
+ )
365
+ if timeout < 1:
366
+ raise CapabilityValidationError(
367
+ "'timeout' must be a positive integer (>= 1)",
368
+ file=file,
369
+ capability=name,
370
+ )
371
+
372
+ version = raw.get("version", DEFAULT_VERSION)
373
+ if not isinstance(version, str) or not version.strip():
374
+ raise CapabilityValidationError(
375
+ "'version' must be a non-empty string",
376
+ file=file,
377
+ capability=name,
378
+ )
379
+
380
+ cap: dict[str, Any] = {
381
+ "name": name,
382
+ "version": version,
383
+ "auto_publish": auto_publish,
384
+ "claimable": claimable,
385
+ "handler": handler if isinstance(handler, str) else "",
386
+ "max_parallel": max_parallel,
387
+ "timeout": timeout,
388
+ }
389
+ # T-053/T-056: forward optional metadata fields so the heartbeat
390
+ # can populate node_capabilities.{type,description,input_schema}
391
+ # and the server can resolve capability_details on claim/task-view.
392
+ if raw.get("type") is not None:
393
+ cap["type"] = raw["type"]
394
+ if raw.get("description") is not None:
395
+ cap["description"] = raw["description"]
396
+ if raw.get("input_schema") is not None:
397
+ cap["input_schema"] = raw["input_schema"]
398
+ # T-075: forward routes so the heartbeat can register dynamic API endpoints.
399
+ if raw.get("routes") is not None:
400
+ cap["routes"] = raw["routes"]
401
+ # T-154: forward long_run so the daemon can apply the 2h accepted-TTL
402
+ # instead of the default 300s claimed-TTL for long-running capabilities.
403
+ if raw.get("long_run") is not None:
404
+ cap["long_run"] = raw["long_run"]
405
+ # T-164: forward upload_modes so the heartbeat can populate
406
+ # node_capabilities.upload_modes and the server can tell node-cli
407
+ # which transfer modes (inline/artifact/bridge) this capability supports.
408
+ if raw.get("upload_modes") is not None:
409
+ cap["upload_modes"] = raw["upload_modes"]
410
+ # T-004 (iowap-flow): forward result_path_hints so the heartbeat can
411
+ # publish them via node_capabilities and the flow planner can use
412
+ # valid ${ref.result.path} template paths.
413
+ if raw.get("result_path_hints") is not None:
414
+ cap["result_path_hints"] = raw["result_path_hints"]
415
+ # T-005b: per-capability config mapping (carrier of the
416
+ # envelope_request_mirror rollout toggle; design.md §3.2). The
417
+ # daemon's complete_by_script gate already reads cap["config"].
418
+ if raw.get("config") is not None:
419
+ cap["config"] = raw["config"]
420
+ # Apply env-var overrides (may raise CapabilityValidationError).
421
+ _apply_env_overrides(cap)
422
+ # Re-validate handler after overrides: an override could clear it.
423
+ if cap["claimable"] and not cap["handler"].strip():
424
+ raise CapabilityValidationError(
425
+ "'handler' is required when claimable is true "
426
+ "(env override may have cleared it)",
427
+ file=file,
428
+ capability=name,
429
+ )
430
+ return cap
431
+
432
+
433
+ def _normalize_caps_list(
434
+ raw_caps: Any,
435
+ *,
436
+ file: str | None,
437
+ ) -> list[dict[str, Any]]:
438
+ """Validate the ``capabilities`` list and return normalized caps."""
439
+ if not isinstance(raw_caps, list):
440
+ raise CapabilityValidationError(
441
+ "'capabilities' key missing or not a list",
442
+ file=file,
443
+ )
444
+
445
+ normalized: list[dict[str, Any]] = []
446
+ seen: set[str] = set()
447
+ for i, entry in enumerate(raw_caps):
448
+ cap = _normalize_capability(entry, file=file, index=i)
449
+ if cap["name"] in seen:
450
+ raise CapabilityValidationError(
451
+ f"duplicate capability name {cap['name']!r}",
452
+ file=file,
453
+ capability=cap["name"],
454
+ )
455
+ seen.add(cap["name"])
456
+ normalized.append(cap)
457
+ return normalized
458
+
459
+
460
+ def validate_profile(
461
+ source: str | os.PathLike[str] | dict[str, Any] | Path,
462
+ ) -> list[dict[str, Any]]:
463
+ """Validate a profile and return the normalized capabilities list.
464
+
465
+ Accepts either a path to a YAML file or an already-parsed dict.
466
+ Raises :class:`CapabilityValidationError` on any problem.
467
+ """
468
+ if isinstance(source, dict):
469
+ # Schema validation first
470
+ schema_errors = validate_with_schema(source)
471
+ if schema_errors:
472
+ raise CapabilityValidationError(
473
+ "schema validation failed:\n " + "\n ".join(schema_errors)
474
+ )
475
+ if "capabilities" not in source:
476
+ return []
477
+ return _normalize_caps_list(source["capabilities"], file=None)
478
+
479
+ path = Path(source)
480
+ file_label = str(path)
481
+ if not path.exists():
482
+ raise CapabilityValidationError(f"profile file not found: {file_label}")
483
+ try:
484
+ raw_text = path.read_text(encoding="utf-8")
485
+ except OSError as exc:
486
+ raise CapabilityValidationError(f"cannot read profile {file_label}: {exc}") from exc
487
+ try:
488
+ parsed = yaml.safe_load(raw_text)
489
+ except yaml.YAMLError as exc:
490
+ # yaml.MarkedYAMLError carries a problem line; surface it if present.
491
+ line = getattr(exc, "problem_mark", None)
492
+ line_no = line.line + 1 if line is not None else None
493
+ raise CapabilityValidationError(
494
+ f"YAML syntax error: {exc}",
495
+ file=file_label,
496
+ line=line_no,
497
+ ) from exc
498
+
499
+ if parsed is None:
500
+ parsed = {}
501
+ if not isinstance(parsed, dict):
502
+ raise CapabilityValidationError(
503
+ "profile root must be a mapping",
504
+ file=file_label,
505
+ )
506
+
507
+ # Schema validation
508
+ schema_errors = validate_with_schema(parsed)
509
+ if schema_errors:
510
+ raise CapabilityValidationError(
511
+ "schema validation failed:\n " + "\n ".join(schema_errors),
512
+ file=file_label,
513
+ )
514
+
515
+ if "capabilities" not in parsed:
516
+ return []
517
+ return _normalize_caps_list(parsed["capabilities"], file=file_label)
518
+
519
+
520
+ def load_profile(path: str | os.PathLike[str] | Path) -> list[dict[str, Any]]:
521
+ """Load and validate a profile from disk. Alias for :func:`validate_profile`."""
522
+ return validate_profile(path)
523
+
524
+
525
+ # ---------------------------------------------------------------------------
526
+ # Profile discovery / publish / active profile management
527
+ # ---------------------------------------------------------------------------
528
+
529
+ def list_profiles() -> list[Path]:
530
+ """Return sorted list of ``*.yaml`` / ``*.yml`` profiles in PROFILES_DIR."""
531
+ if not PROFILES_DIR.exists():
532
+ return []
533
+ profiles = [
534
+ p for p in PROFILES_DIR.iterdir()
535
+ if p.is_file() and p.suffix.lower() in (".yaml", ".yml")
536
+ ]
537
+ return sorted(profiles, key=lambda p: p.name)
538
+
539
+
540
+ def profile_path(name: str) -> Path:
541
+ """Resolve a profile name to a path inside ``profiles.d/``.
542
+
543
+ Accepts either a bare name (``default``) or a full filename
544
+ (``default.yaml``). Does not check existence.
545
+ """
546
+ if name.endswith((".yaml", ".yml")):
547
+ return PROFILES_DIR / name
548
+ return PROFILES_DIR / f"{name}.yaml"
549
+
550
+
551
+ def current_profile_name() -> str | None:
552
+ """Return the name of the active profile, or ``None`` if unset."""
553
+ if not ACTIVE_PROFILE_NAME_PATH.exists():
554
+ return None
555
+ text = ACTIVE_PROFILE_NAME_PATH.read_text(encoding="utf-8").strip()
556
+ return text or None
557
+
558
+
559
+ def write_current_profile_name(name: str) -> None:
560
+ ACTIVE_PROFILE_NAME_PATH.write_text(name + "\n", encoding="utf-8")
561
+
562
+
563
+ def publish_profile(name: str) -> Path:
564
+ """Validate a working profile then atomically copy it to the active file.
565
+
566
+ Also records the profile name in ``node.profile``.
567
+ Returns the path of the active file. Never touches the active file
568
+ if validation fails.
569
+ """
570
+ src = profile_path(name)
571
+ if not src.exists():
572
+ raise CapabilityValidationError(f"profile not found: {name} ({src})")
573
+ # Validate first — raises on any problem and leaves active untouched.
574
+ validate_profile(src)
575
+ BASE_DIR.mkdir(parents=True, exist_ok=True)
576
+ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
577
+ # Atomic write to active file via temp + rename.
578
+ tmp = ACTIVE_PATH.with_suffix(ACTIVE_PATH.suffix + ".tmp")
579
+ shutil.copyfile(src, tmp)
580
+ os.replace(tmp, ACTIVE_PATH)
581
+ write_current_profile_name(name)
582
+ # T-226: the byte-exact copy replaced the active file — re-stamp the
583
+ # node_cli_path so the key self-heals through validate→publish even
584
+ # when it is not part of the working profile.
585
+ ensure_node_cli_stamp()
586
+ return ACTIVE_PATH
587
+
588
+
589
+ def diff_profiles(
590
+ old: list[dict[str, Any]] | None,
591
+ new: list[dict[str, Any]] | None,
592
+ ) -> dict[str, Any]:
593
+ """Compare two normalized capability lists.
594
+
595
+ Returns ``{"added": [...], "removed": [...], "changed": [...]}``.
596
+ Each ``added``/``changed`` entry is the full normalized cap dict;
597
+ each ``removed`` entry is just the capability name.
598
+ """
599
+ old = old or []
600
+ new = new or []
601
+ old_map = {c["name"]: c for c in old}
602
+ new_map = {c["name"]: c for c in new}
603
+ old_names = set(old_map)
604
+ new_names = set(new_map)
605
+
606
+ added = [new_map[n] for n in sorted(new_names - old_names)]
607
+ removed = sorted(old_names - new_names)
608
+ changed: list[dict[str, Any]] = []
609
+ for n in sorted(old_names & new_names):
610
+ a = old_map[n]
611
+ b = new_map[n]
612
+ # Compare only the canonical keys (ignore key ordering).
613
+ if {k: a.get(k) for k in _NORMALIZED_KEYS} != {
614
+ k: b.get(k) for k in _NORMALIZED_KEYS
615
+ }:
616
+ changed.append({"name": n, "old": a, "new": b})
617
+ return {"added": added, "removed": removed, "changed": changed}
618
+
619
+
620
+ # ---------------------------------------------------------------------------
621
+ # mtime-cached active profile loader
622
+ # ---------------------------------------------------------------------------
623
+
624
+ class ActiveProfileCache:
625
+ """Thread-safe mtime-cached loader for the active profile.
626
+
627
+ The daemon calls :meth:`get` before every heartbeat / claim-loop
628
+ iteration. A changed mtime triggers a re-read and re-validation. A
629
+ SIGHUP handler calls :meth:`invalidate` to force the next read.
630
+
631
+ T-189 (F2): ``invalidate`` is called from a signal handler, which runs
632
+ on the main thread — the same thread the polling daemon runs its claim
633
+ loop on. It must therefore NEVER take the load lock: ``get`` holds it
634
+ across the whole YAML parse, so a signal handler blocking on it would
635
+ self-deadlock the daemon (only SIGKILL). Invalidation is a lock-free
636
+ generation bump; a load that started before the bump refuses to store
637
+ its (now stale) result.
638
+ """
639
+
640
+ def __init__(self, path: Path | None = None) -> None:
641
+ self.path = path or ACTIVE_PATH
642
+ self._lock = threading.Lock()
643
+ self._cached_mtime: float | None = None
644
+ self._cached_caps: list[dict[str, Any]] | None = None
645
+ self._cached_generation = -1
646
+ # Monotonic invalidation counter. Bumped (atomically under the GIL)
647
+ # by invalidate() WITHOUT the lock; a loader captures it before the
648
+ # read and only commits if the generation is unchanged afterwards.
649
+ self._generation = 0
650
+
651
+ def invalidate(self) -> None:
652
+ """Drop the cache and force the next :meth:`get` to re-read disk.
653
+
654
+ Lock-free by contract (T-189): safe to call from a signal handler.
655
+ """
656
+ self._generation += 1
657
+
658
+ def get(self) -> list[dict[str, Any]]:
659
+ """Return the active profile, reloading if mtime changed.
660
+
661
+ If the active file does not exist, returns an empty list (the
662
+ daemon treats this as "no capabilities published yet").
663
+ """
664
+ with self._lock:
665
+ if not self.path.exists():
666
+ self._cached_mtime = None
667
+ self._cached_caps = None
668
+ return []
669
+ try:
670
+ mtime = self.path.stat().st_mtime
671
+ except OSError:
672
+ return self._cached_caps or []
673
+ # A load that raced with an invalidate() must not commit its
674
+ # stale result — re-read whenever the generation moved.
675
+ generation = self._generation
676
+ if (
677
+ self._cached_caps is None
678
+ or mtime != self._cached_mtime
679
+ or generation != self._cached_generation
680
+ ):
681
+ caps = validate_profile(self.path)
682
+ self._cached_caps = caps
683
+ self._cached_mtime = mtime
684
+ self._cached_generation = generation
685
+ return self._cached_caps
686
+
687
+
688
+ # Module-level singleton used by the daemon.
689
+ _active_cache = ActiveProfileCache()
690
+
691
+
692
+ def load_active_profile() -> list[dict[str, Any]]:
693
+ """Convenience accessor for the module-level active profile cache."""
694
+ return _active_cache.get()
695
+
696
+
697
+ def load_active_status() -> str | None:
698
+ """Read the ``status`` field from the active YAML profile.
699
+
700
+ Returns ``None`` when no explicit status is set (the node defaults
701
+ to ``online``). The value is read fresh from disk every call so
702
+ ``node-cli node busy`` / ``idle`` (which write directly into the
703
+ active YAML) are picked up on the next heartbeat without a cache
704
+ invalidation.
705
+ """
706
+ path = ACTIVE_PATH
707
+ if not path.exists():
708
+ return None
709
+ try:
710
+ raw = path.read_text(encoding="utf-8")
711
+ parsed = yaml.safe_load(raw)
712
+ if isinstance(parsed, dict):
713
+ val = parsed.get("status")
714
+ if isinstance(val, str) and val in ("busy", "idle", "online"):
715
+ return val
716
+ except (OSError, yaml.YAMLError):
717
+ pass
718
+ return None
719
+
720
+
721
+ _STATUS_RE = re.compile(r'^(status:\s*).*', re.MULTILINE)
722
+
723
+
724
+ def write_active_status(status: str | None) -> None:
725
+ """Write (or remove) the ``status`` field in the active YAML profile.
726
+
727
+ When ``status`` is ``None`` the field is removed from the YAML so
728
+ the node returns to its default ``online`` state. The file is
729
+ rewritten in place via temp + rename, preserving the rest of the
730
+ document verbatim (the YAML is edited as text so comments, key
731
+ ordering and formatting are kept intact). If the active profile
732
+ does not exist yet, this is a no-op.
733
+ """
734
+ path = ACTIVE_PATH
735
+ if not path.exists():
736
+ return
737
+ try:
738
+ raw = path.read_text(encoding="utf-8")
739
+ if status is None:
740
+ # Remove the status: line.
741
+ new_text = _STATUS_RE.sub("", raw)
742
+ # Clean up double blank lines from removal.
743
+ new_text = re.sub(r'\n{3,}', '\n\n', new_text)
744
+ else:
745
+ if _STATUS_RE.search(raw):
746
+ new_text = _STATUS_RE.sub(rf"\1{status}", raw)
747
+ else:
748
+ # Insert after the first line (usually node_name or the
749
+ # capabilities: key) so the field sits at the top of the
750
+ # document.
751
+ first_newline = raw.index("\n") + 1 if "\n" in raw else len(raw)
752
+ new_text = raw[:first_newline] + f"status: {status}\n" + raw[first_newline:]
753
+ tmp = path.with_suffix(path.suffix + ".tmp")
754
+ tmp.write_text(new_text, encoding="utf-8")
755
+ os.replace(tmp, path)
756
+ except (OSError, yaml.YAMLError) as exc:
757
+ log = __import__("logging").getLogger("relay.node_config")
758
+ log.warning("could not write status to YAML: %s", exc)
759
+
760
+
761
+ def invalidate_active_cache() -> None:
762
+ """Force the next :func:`load_active_profile` call to re-read disk."""
763
+ _active_cache.invalidate()
764
+
765
+
766
+ # ---------------------------------------------------------------------------
767
+ # node_cli_path stamp (T-226)
768
+ # ---------------------------------------------------------------------------
769
+
770
+ _NODE_CLI_STAMP_KEY = "node_cli_path"
771
+ # Top-level only: 'node_cli_path:' at column 0. MULTILINE '^' matches
772
+ # line starts, and capability-level keys are indented — those never match.
773
+ _NODE_CLI_STAMP_RE = re.compile(
774
+ rf"^({_NODE_CLI_STAMP_KEY}):(.*)$", re.MULTILINE
775
+ )
776
+
777
+
778
+ def resolve_own_cli_path() -> str | None:
779
+ """Best-effort absolute path of the node-cli console script.
780
+
781
+ Preference order: the ``node-cli`` sibling of the running interpreter
782
+ (covers ``python -m nodes.common.node_cli`` AND console-script runs —
783
+ both exec from the same venv), then argv[0] when it *is* the cli
784
+ script (symlink-resolved, e.g. pipx), then ``shutil.which``. Returns
785
+ ``None`` when nothing derivable exists so callers stamp nothing.
786
+ """
787
+ exe = str(sys.executable or "")
788
+ if exe:
789
+ cand = Path(exe).with_name("node-cli")
790
+ if cand.is_file() and os.access(cand, os.X_OK):
791
+ return os.path.realpath(cand)
792
+ argv0 = sys.argv[0] if sys.argv else ""
793
+ if os.path.basename(argv0) in ("node-cli", "node_cli.py"):
794
+ try:
795
+ resolved = Path(argv0).resolve()
796
+ except (OSError, ValueError):
797
+ resolved = None
798
+ if resolved and resolved.is_file() and os.access(resolved, os.X_OK):
799
+ return os.path.realpath(resolved)
800
+ which = shutil.which("node-cli")
801
+ if which:
802
+ return os.path.realpath(which)
803
+ return None
804
+
805
+
806
+ def ensure_node_cli_stamp(stamp: str | None = None) -> None:
807
+ """Ensure the active profile carries the node_cli_path stamp (T-226).
808
+
809
+ Consumers of node-cli that cannot rely on PATH (the Hermes desktop
810
+ gateway does not inherit the login-shell PATH) read the top-level
811
+ ``node_cli_path`` key from the active profile. The stamp:
812
+
813
+ * never raises and never creates node.yaml (register-only hosts stay
814
+ untouched — the consumer falls back to PATH/known locations);
815
+ * is drift-guarded: rewrites ONLY when the recorded value differs,
816
+ so the steady state is mtime-silent (the daemon's
817
+ ActiveProfileCache reloads on mtime change);
818
+ * edits the YAML as text — comments, key order and formatting are
819
+ preserved verbatim (same discipline as ``write_active_status``).
820
+
821
+ ``stamp`` overrides the derived path (tests, callers that know
822
+ better); pass an empty string to make the call a no-op.
823
+ """
824
+ value = stamp if stamp is not None else resolve_own_cli_path()
825
+ if not value:
826
+ return
827
+ path = ACTIVE_PATH
828
+ if not path.exists():
829
+ return
830
+ try:
831
+ raw = path.read_text(encoding="utf-8")
832
+ m = _NODE_CLI_STAMP_RE.search(raw)
833
+ current = m.group(2).strip().strip("'\"") if m else None
834
+ if current == value:
835
+ return
836
+ quoted = '"' + value.replace('"', "") + '"'
837
+ if m:
838
+ new_text = raw[: m.start(2)] + " " + quoted + raw[m.end(2):]
839
+ else:
840
+ new_text = f"{_NODE_CLI_STAMP_KEY}: {quoted}\n" + raw
841
+ tmp = path.with_suffix(path.suffix + ".tmp")
842
+ tmp.write_text(new_text, encoding="utf-8")
843
+ os.replace(tmp, path)
844
+ except (OSError, yaml.YAMLError) as exc:
845
+ __import__("logging").getLogger("relay.node_config").warning(
846
+ "could not record node_cli_path in %s: %s", path, exc
847
+ )
848
+
849
+
850
+ # ---------------------------------------------------------------------------
851
+ # Serialization helpers (for `capabilities diff` output)
852
+ # ---------------------------------------------------------------------------
853
+
854
+ def caps_to_json(caps: list[dict[str, Any]]) -> str:
855
+ """Pretty-print normalized caps as JSON with stable key order."""
856
+ return json.dumps(caps, indent=2, sort_keys=False, default=str)