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,520 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Node-side capability data model.
|
|
4
|
+
|
|
5
|
+
Nodes define their capabilities locally, typically in a YAML file
|
|
6
|
+
(e.g. nodes/worker/capabilities.yaml). This module loads those definitions
|
|
7
|
+
and maps them to the schema expected by the relay server's API.
|
|
8
|
+
|
|
9
|
+
This model is used for:
|
|
10
|
+
- Loading capability definitions from YAML config files
|
|
11
|
+
- Serializing capabilities for registration and heartbeat payloads
|
|
12
|
+
- Schema validation BEFORE sending to the server (fail fast locally)
|
|
13
|
+
|
|
14
|
+
The server has its own copy in src/relay_server/models/capability.py for
|
|
15
|
+
inbound validation. The two models are intentionally separate: the node
|
|
16
|
+
owns the definition, the server only mediates and routes.
|
|
17
|
+
The ``node_config.py`` module bridges this gap by loading YAML and
|
|
18
|
+
mapping to the server-expected JSON schema.
|
|
19
|
+
|
|
20
|
+
Provided API:
|
|
21
|
+
|
|
22
|
+
* ``CapabilityType`` - Enum of available capability types.
|
|
23
|
+
* ``CapabilityInputField`` - Description of a single input field.
|
|
24
|
+
* ``CapabilityInputSchema`` - Schema over all input fields with
|
|
25
|
+
payload validation.
|
|
26
|
+
* ``Capability`` - A single capability of a node.
|
|
27
|
+
* ``CapabilitySet`` - Management of a node's full
|
|
28
|
+
capability inventory.
|
|
29
|
+
* ``load_capabilities_from_yaml`` - Loads a CapabilitySet from YAML.
|
|
30
|
+
* ``diff_capabilities`` - Compares two CapabilitySets.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import copy
|
|
36
|
+
from dataclasses import asdict, dataclass, field
|
|
37
|
+
from enum import Enum
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Optional
|
|
40
|
+
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CapabilityType(str, Enum):
|
|
45
|
+
"""Type of a capability - determines routing in the scheduler."""
|
|
46
|
+
|
|
47
|
+
AI = "ai"
|
|
48
|
+
TOOL = "tool"
|
|
49
|
+
SCRIPT = "script"
|
|
50
|
+
WORKFLOW = "workflow"
|
|
51
|
+
RESOURCE = "resource"
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
"""Return the string value of this enum member."""
|
|
55
|
+
return self.value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Input Schema
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CapabilityInputField:
|
|
65
|
+
"""
|
|
66
|
+
Describes a single input field of a capability.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
name: Identifier of the field.
|
|
70
|
+
type: Type identifier as string (e.g. ``"string"``,
|
|
71
|
+
``"integer"``, ``"number"``, ``"boolean"``,
|
|
72
|
+
``"object"``, ``"array"``). Default is ``"string"``.
|
|
73
|
+
required: Whether the field is mandatory.
|
|
74
|
+
default: Default value used when the field is missing from
|
|
75
|
+
the payload.
|
|
76
|
+
enum: Optional list of allowed values.
|
|
77
|
+
ge: Optional numeric lower bound (>=).
|
|
78
|
+
le: Optional numeric upper bound (<=).
|
|
79
|
+
description: Human-readable description of the field.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
type: str = "string"
|
|
84
|
+
required: bool = False
|
|
85
|
+
default: Any = None
|
|
86
|
+
enum: Optional[list[Any]] = None
|
|
87
|
+
ge: Optional[float] = None
|
|
88
|
+
le: Optional[float] = None
|
|
89
|
+
description: str = ""
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict[str, Any]:
|
|
92
|
+
"""Serialize this field to a dictionary."""
|
|
93
|
+
d: dict[str, Any] = {
|
|
94
|
+
"name": self.name,
|
|
95
|
+
"type": self.type,
|
|
96
|
+
"required": self.required,
|
|
97
|
+
"default": self.default,
|
|
98
|
+
"enum": self.enum,
|
|
99
|
+
"ge": self.ge,
|
|
100
|
+
"le": self.le,
|
|
101
|
+
"description": self.description,
|
|
102
|
+
}
|
|
103
|
+
return d
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_dict(cls, data: dict[str, Any]) -> CapabilityInputField:
|
|
107
|
+
"""Create a field from a dictionary."""
|
|
108
|
+
return cls(
|
|
109
|
+
name=data["name"],
|
|
110
|
+
type=data.get("type", "string"),
|
|
111
|
+
required=data.get("required", False),
|
|
112
|
+
default=data.get("default", None),
|
|
113
|
+
enum=data.get("enum"),
|
|
114
|
+
ge=data.get("ge"),
|
|
115
|
+
le=data.get("le"),
|
|
116
|
+
description=data.get("description", ""),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def validate(self, value: Any) -> list[str]:
|
|
120
|
+
"""
|
|
121
|
+
Validate a single value against this field schema.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of error messages (empty if the value is valid).
|
|
125
|
+
"""
|
|
126
|
+
errors: list[str] = []
|
|
127
|
+
|
|
128
|
+
if value is None:
|
|
129
|
+
if self.required and self.default is None:
|
|
130
|
+
errors.append(f"Field '{self.name}' is required.")
|
|
131
|
+
return errors
|
|
132
|
+
|
|
133
|
+
# Enum check
|
|
134
|
+
if self.enum is not None and value not in self.enum:
|
|
135
|
+
errors.append(
|
|
136
|
+
f"Field '{self.name}': value {value!r} is not in "
|
|
137
|
+
f"allowed values {self.enum!r}."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Numeric bounds
|
|
141
|
+
if self.ge is not None or self.le is not None:
|
|
142
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
143
|
+
errors.append(
|
|
144
|
+
f"Field '{self.name}': value {value!r} must be numeric "
|
|
145
|
+
f"for ge/le validation."
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
if self.ge is not None and value < self.ge:
|
|
149
|
+
errors.append(
|
|
150
|
+
f"Field '{self.name}': value {value!r} must >= {self.ge}."
|
|
151
|
+
)
|
|
152
|
+
if self.le is not None and value > self.le:
|
|
153
|
+
errors.append(
|
|
154
|
+
f"Field '{self.name}': value {value!r} must <= {self.le}."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return errors
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class CapabilityInputSchema:
|
|
162
|
+
"""
|
|
163
|
+
Schema over all input fields of a capability.
|
|
164
|
+
|
|
165
|
+
Attributes:
|
|
166
|
+
fields: Mapping of field name to the corresponding \
|
|
167
|
+
``CapabilityInputField``.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
fields: dict[str, CapabilityInputField] = field(default_factory=dict)
|
|
171
|
+
|
|
172
|
+
def to_dict(self) -> dict[str, Any]:
|
|
173
|
+
"""Serialize this schema to a dictionary."""
|
|
174
|
+
return {
|
|
175
|
+
"fields": {name: f.to_dict() for name, f in self.fields.items()}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_dict(cls, data: dict[str, Any]) -> CapabilityInputSchema:
|
|
180
|
+
"""Create a schema from a dictionary."""
|
|
181
|
+
if not data:
|
|
182
|
+
return cls()
|
|
183
|
+
raw_fields = data.get("fields", data) if isinstance(data, dict) else {}
|
|
184
|
+
fields: dict[str, CapabilityInputField] = {}
|
|
185
|
+
for raw in raw_fields.values() if isinstance(raw_fields, dict) else raw_fields:
|
|
186
|
+
fld = CapabilityInputField.from_dict(raw)
|
|
187
|
+
fields[fld.name] = fld
|
|
188
|
+
return cls(fields=fields)
|
|
189
|
+
|
|
190
|
+
def validate_payload(self, payload: dict[str, Any]) -> tuple[bool, list[str]]:
|
|
191
|
+
"""
|
|
192
|
+
Validate a payload against this schema.
|
|
193
|
+
|
|
194
|
+
Rules checked:
|
|
195
|
+
* All fields marked ``required`` must be present.
|
|
196
|
+
* Fields without a value receive their ``default``.
|
|
197
|
+
* Enum constraints are enforced.
|
|
198
|
+
* Numeric bounds (``ge`` / ``le``) are enforced.
|
|
199
|
+
* Unexpected fields not defined in the schema are reported as
|
|
200
|
+
errors.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Tuple ``(is_valid, error_messages)``.
|
|
204
|
+
"""
|
|
205
|
+
errors: list[str] = []
|
|
206
|
+
if not isinstance(payload, dict):
|
|
207
|
+
return False, ["Payload must be a dictionary."]
|
|
208
|
+
|
|
209
|
+
# Check for unexpected fields
|
|
210
|
+
for key in payload:
|
|
211
|
+
if key not in self.fields:
|
|
212
|
+
errors.append(f"Unexpected field '{key}' in payload.")
|
|
213
|
+
|
|
214
|
+
# Check expected fields
|
|
215
|
+
for name, fld in self.fields.items():
|
|
216
|
+
if name not in payload or payload[name] is None:
|
|
217
|
+
if fld.required and fld.default is None:
|
|
218
|
+
errors.append(f"Field '{name}' is required.")
|
|
219
|
+
continue
|
|
220
|
+
# default is not written back into the payload -
|
|
221
|
+
# validation uses the default if present.
|
|
222
|
+
value = fld.default
|
|
223
|
+
else:
|
|
224
|
+
value = payload[name]
|
|
225
|
+
|
|
226
|
+
if value is None:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
errors.extend(fld.validate(value))
|
|
230
|
+
return (len(errors) == 0), errors
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Capability
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class Capability:
|
|
240
|
+
"""
|
|
241
|
+
A single capability of a worker node.
|
|
242
|
+
|
|
243
|
+
Attributes:
|
|
244
|
+
name: Unique identifier of the capability.
|
|
245
|
+
type: Type of capability (see ``CapabilityType``).
|
|
246
|
+
description: Human-readable description.
|
|
247
|
+
version: Semantic version of the capability.
|
|
248
|
+
available: Whether the capability is currently usable.
|
|
249
|
+
input_schema: Optional input schema for validating call payloads.
|
|
250
|
+
config: Free-form configuration values of the capability.
|
|
251
|
+
metadata: Free-form metadata of the capability.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
name: str
|
|
255
|
+
type: CapabilityType
|
|
256
|
+
description: str = ""
|
|
257
|
+
version: str = "1.0.0"
|
|
258
|
+
available: bool = True
|
|
259
|
+
input_schema: Optional[CapabilityInputSchema] = None
|
|
260
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
261
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
262
|
+
# T-004 (iowap-flow): dot-paths into the task result this capability
|
|
263
|
+
# produces. T-005c (Envelope-Contract, design.md T-005a section 4.2):
|
|
264
|
+
# paths are RELATIVE TO THE INNER result of the handler result envelope
|
|
265
|
+
# ({"status": "completed", "result": X}) — e.g. ["answer"], NOT
|
|
266
|
+
# ["result.answer"]: flow unwraps the envelope exactly once, so the
|
|
267
|
+
# template ${ref.result.<hint>} navigates the unwrapped object directly.
|
|
268
|
+
# Pure metadata — consumers (flow planner) use it for template paths;
|
|
269
|
+
# routing ignores it.
|
|
270
|
+
result_path_hints: Optional[list[str]] = None
|
|
271
|
+
|
|
272
|
+
def to_dict(self) -> dict[str, Any]:
|
|
273
|
+
"""Serialize this capability to a dictionary."""
|
|
274
|
+
d: dict[str, Any] = asdict(self)
|
|
275
|
+
d["type"] = self.type.value if isinstance(self.type, CapabilityType) else self.type
|
|
276
|
+
if self.input_schema is not None:
|
|
277
|
+
d["input_schema"] = self.input_schema.to_dict()
|
|
278
|
+
else:
|
|
279
|
+
d["input_schema"] = None
|
|
280
|
+
return d
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def from_dict(cls, data: dict[str, Any]) -> Capability:
|
|
284
|
+
"""Create a capability from a dictionary."""
|
|
285
|
+
raw_type = data.get("type", "tool")
|
|
286
|
+
raw_schema = data.get("input_schema")
|
|
287
|
+
input_schema: Optional[CapabilityInputSchema] = None
|
|
288
|
+
if raw_schema:
|
|
289
|
+
input_schema = CapabilityInputSchema.from_dict(raw_schema)
|
|
290
|
+
return cls(
|
|
291
|
+
name=data["name"],
|
|
292
|
+
type=CapabilityType(raw_type) if isinstance(raw_type, str) else raw_type,
|
|
293
|
+
description=data.get("description", ""),
|
|
294
|
+
version=data.get("version", "1.0.0"),
|
|
295
|
+
available=data.get("available", True),
|
|
296
|
+
input_schema=input_schema,
|
|
297
|
+
config=data.get("config", {}) or {},
|
|
298
|
+
metadata=data.get("metadata", {}) or {},
|
|
299
|
+
result_path_hints=data.get("result_path_hints"),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def merge(self, other: Capability) -> Capability:
|
|
303
|
+
"""
|
|
304
|
+
Merge fields from ``other`` into this capability.
|
|
305
|
+
|
|
306
|
+
Fields that are None or empty in ``other`` are skipped.
|
|
307
|
+
``name`` and ``type`` are always taken from ``self``.
|
|
308
|
+
``config`` and ``metadata`` are merged (values from ``other``
|
|
309
|
+
override same-named keys from ``self``).
|
|
310
|
+
"""
|
|
311
|
+
merged = copy.deepcopy(self)
|
|
312
|
+
if other.description:
|
|
313
|
+
merged.description = other.description
|
|
314
|
+
if other.version:
|
|
315
|
+
merged.version = other.version
|
|
316
|
+
# ``available`` is also taken when False - otherwise there
|
|
317
|
+
# would be no way to intentionally disable a capability.
|
|
318
|
+
merged.available = other.available
|
|
319
|
+
if other.input_schema is not None:
|
|
320
|
+
merged.input_schema = copy.deepcopy(other.input_schema)
|
|
321
|
+
merged.config = {**merged.config, **(other.config or {})}
|
|
322
|
+
merged.metadata = {**merged.metadata, **(other.metadata or {})}
|
|
323
|
+
if other.result_path_hints:
|
|
324
|
+
merged.result_path_hints = list(other.result_path_hints)
|
|
325
|
+
return merged
|
|
326
|
+
|
|
327
|
+
def patch_config(self, updates: dict[str, Any]) -> None:
|
|
328
|
+
"""Patch individual config values without replacing the entire config."""
|
|
329
|
+
self.config.update(updates)
|
|
330
|
+
|
|
331
|
+
def matches(
|
|
332
|
+
self,
|
|
333
|
+
name: Optional[str] = None,
|
|
334
|
+
ctype: Optional[CapabilityType | str] = None,
|
|
335
|
+
available_only: bool = True,
|
|
336
|
+
) -> bool:
|
|
337
|
+
"""Check if this capability matches the given filter criteria."""
|
|
338
|
+
if name is not None and self.name != name:
|
|
339
|
+
return False
|
|
340
|
+
if ctype is not None:
|
|
341
|
+
check = ctype.value if isinstance(ctype, CapabilityType) else str(ctype)
|
|
342
|
+
self_val = self.type.value if isinstance(self.type, CapabilityType) else str(self.type)
|
|
343
|
+
if self_val != check:
|
|
344
|
+
return False
|
|
345
|
+
if available_only and not self.available:
|
|
346
|
+
return False
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# CapabilitySet
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@dataclass
|
|
356
|
+
class CapabilitySet:
|
|
357
|
+
"""
|
|
358
|
+
Manage the full inventory of capabilities for a node.
|
|
359
|
+
|
|
360
|
+
Attributes:
|
|
361
|
+
_caps: Internal mapping of capability name to ``Capability``.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
_caps: dict[str, Capability] = field(default_factory=dict)
|
|
365
|
+
|
|
366
|
+
def __post_init__(self) -> None:
|
|
367
|
+
"""Ensure ``_caps`` is a dictionary."""
|
|
368
|
+
if self._caps is None:
|
|
369
|
+
self._caps = {}
|
|
370
|
+
|
|
371
|
+
# ---- Registration ----------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def register(self, cap: Capability) -> None:
|
|
374
|
+
"""Register a capability. Existing entries are overwritten."""
|
|
375
|
+
self._caps[cap.name] = cap
|
|
376
|
+
|
|
377
|
+
def deregister(self, name: str) -> bool:
|
|
378
|
+
"""Remove a capability by name. Returns True if removed."""
|
|
379
|
+
if name in self._caps:
|
|
380
|
+
del self._caps[name]
|
|
381
|
+
return True
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
# ---- Access -----------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def get(self, name: str) -> Optional[Capability]:
|
|
387
|
+
"""Get the capability with the given name or ``None``."""
|
|
388
|
+
return self._caps.get(name)
|
|
389
|
+
|
|
390
|
+
def update(self, name: str, **kwargs: Any) -> Optional[Capability]:
|
|
391
|
+
"""Update individual fields of a registered capability."""
|
|
392
|
+
cap = self._caps.get(name)
|
|
393
|
+
if cap is None:
|
|
394
|
+
return None
|
|
395
|
+
for key in ("description", "version", "available", "input_schema",
|
|
396
|
+
"config", "metadata", "result_path_hints"):
|
|
397
|
+
if key in kwargs and kwargs[key] is not None:
|
|
398
|
+
setattr(cap, key, kwargs[key])
|
|
399
|
+
return cap
|
|
400
|
+
|
|
401
|
+
def filter(
|
|
402
|
+
self,
|
|
403
|
+
name: Optional[str] = None,
|
|
404
|
+
ctype: Optional[CapabilityType | str] = None,
|
|
405
|
+
available_only: bool = True,
|
|
406
|
+
) -> list[Capability]:
|
|
407
|
+
"""Return all capabilities matching the filter criteria."""
|
|
408
|
+
return [
|
|
409
|
+
c for c in self._caps.values()
|
|
410
|
+
if c.matches(name, ctype, available_only)
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def names(self) -> list[str]:
|
|
415
|
+
"""Return the list of all registered capability names."""
|
|
416
|
+
return list(self._caps.keys())
|
|
417
|
+
|
|
418
|
+
# ---- Serialization ----------------------------------------------------
|
|
419
|
+
|
|
420
|
+
def to_list(self) -> list[dict[str, Any]]:
|
|
421
|
+
"""Serialize all capabilities to a list of dictionaries."""
|
|
422
|
+
return [c.to_dict() for c in self._caps.values()]
|
|
423
|
+
|
|
424
|
+
@classmethod
|
|
425
|
+
def from_list(cls, data: list[dict[str, Any]]) -> CapabilitySet:
|
|
426
|
+
"""Create a CapabilitySet from a list of dictionaries."""
|
|
427
|
+
return cls(
|
|
428
|
+
_caps={c["name"]: Capability.from_dict(c) for c in data}
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
# YAML Loader
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def load_capabilities_from_yaml(path: Path) -> CapabilitySet:
|
|
438
|
+
"""
|
|
439
|
+
Load capabilities from a YAML file.
|
|
440
|
+
|
|
441
|
+
Expected YAML structure::
|
|
442
|
+
|
|
443
|
+
capabilities:
|
|
444
|
+
- name: vault
|
|
445
|
+
type: tool
|
|
446
|
+
description: "..."
|
|
447
|
+
version: "1.2.0"
|
|
448
|
+
available: true
|
|
449
|
+
input_schema:
|
|
450
|
+
fields:
|
|
451
|
+
query:
|
|
452
|
+
name: query
|
|
453
|
+
type: string
|
|
454
|
+
required: true
|
|
455
|
+
config: {}
|
|
456
|
+
metadata: {}
|
|
457
|
+
|
|
458
|
+
If the file does not exist, an empty ``CapabilitySet`` is returned.
|
|
459
|
+
"""
|
|
460
|
+
path = Path(path)
|
|
461
|
+
if not path.exists():
|
|
462
|
+
return CapabilitySet()
|
|
463
|
+
|
|
464
|
+
with open(path, encoding="utf-8") as f:
|
|
465
|
+
raw = yaml.safe_load(f) or {}
|
|
466
|
+
|
|
467
|
+
raw_caps = raw.get("capabilities", []) if isinstance(raw, dict) else []
|
|
468
|
+
caps: dict[str, Capability] = {}
|
|
469
|
+
for entry in raw_caps:
|
|
470
|
+
if not isinstance(entry, dict) or "name" not in entry:
|
|
471
|
+
continue
|
|
472
|
+
cap = Capability.from_dict(entry)
|
|
473
|
+
caps[cap.name] = cap
|
|
474
|
+
return CapabilitySet(_caps=caps)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# Diff Function
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def diff_capabilities(old: CapabilitySet, new: CapabilitySet) -> dict[str, Any]:
|
|
483
|
+
"""
|
|
484
|
+
Compare two CapabilitySets and return the differences.
|
|
485
|
+
|
|
486
|
+
Return structure::
|
|
487
|
+
|
|
488
|
+
{
|
|
489
|
+
"added": [<Capability.to_dict>, ...],
|
|
490
|
+
"removed": [<name:str>, ...],
|
|
491
|
+
"changed": [
|
|
492
|
+
{"name": <str>, "old": <dict>, "new": <dict>},
|
|
493
|
+
...
|
|
494
|
+
],
|
|
495
|
+
}
|
|
496
|
+
"""
|
|
497
|
+
result: dict[str, Any] = {"added": [], "removed": [], "changed": []}
|
|
498
|
+
old_names = set(old.names)
|
|
499
|
+
new_names = set(new.names)
|
|
500
|
+
|
|
501
|
+
for name in sorted(new_names - old_names):
|
|
502
|
+
cap = new.get(name)
|
|
503
|
+
if cap is not None:
|
|
504
|
+
result["added"].append(cap.to_dict())
|
|
505
|
+
|
|
506
|
+
for name in sorted(old_names - new_names):
|
|
507
|
+
result["removed"].append(name)
|
|
508
|
+
|
|
509
|
+
for name in sorted(old_names & new_names):
|
|
510
|
+
old_cap = old.get(name)
|
|
511
|
+
new_cap = new.get(name)
|
|
512
|
+
if old_cap is not None and new_cap is not None:
|
|
513
|
+
if old_cap.to_dict() != new_cap.to_dict():
|
|
514
|
+
result["changed"].append({
|
|
515
|
+
"name": name,
|
|
516
|
+
"old": old_cap.to_dict(),
|
|
517
|
+
"new": new_cap.to_dict(),
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""CLI artifact subcommands — download / upload (T-117 split).
|
|
2
|
+
|
|
3
|
+
Handler signatures are plain ``(client, args) -> int``; the
|
|
4
|
+
``node_cli.with_client`` decorator is applied at parser-registration time
|
|
5
|
+
in the facade.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from nodes.common.relay_client import RelayClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cmd_artifact_download(client: RelayClient, args) -> int:
|
|
18
|
+
target = client.download_artifact(args.artifact_id, output_path=args.output)
|
|
19
|
+
size = target.stat().st_size if target.exists() else 0
|
|
20
|
+
if args.json:
|
|
21
|
+
print(json.dumps({"path": str(target), "size_bytes": size}, default=str))
|
|
22
|
+
return 0
|
|
23
|
+
print(f"Downloaded {size} bytes to {target}")
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cmd_artifact_upload(client: RelayClient, args) -> int:
|
|
28
|
+
file_path = Path(args.file)
|
|
29
|
+
if not file_path.exists():
|
|
30
|
+
print(f"file not found: {file_path}", file=sys.stderr)
|
|
31
|
+
return 2
|
|
32
|
+
result = client.upload_artifact(
|
|
33
|
+
file_path,
|
|
34
|
+
name=args.name,
|
|
35
|
+
task_id=args.task_id,
|
|
36
|
+
stage_id=args.stage_id,
|
|
37
|
+
)
|
|
38
|
+
if args.json:
|
|
39
|
+
print(json.dumps(result, default=str))
|
|
40
|
+
return 0
|
|
41
|
+
print(json.dumps(result, indent=2, default=str))
|
|
42
|
+
return 0
|