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