msdev 0.9.0__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,1085 @@
1
+ """Typed model inventory application service for the msdev CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from ..inventory import SCHEMA_VERSION
12
+ from ..resources import Node
13
+ from ..limits import (
14
+ MAX_INVENTORY_ALIASES_PER_ARTIFACT,
15
+ MAX_INVENTORY_ARTIFACTS,
16
+ MAX_INVENTORY_CONTENT_BYTES,
17
+ MAX_INVENTORY_LINE_BYTES,
18
+ MAX_INVENTORY_REPLICAS_PER_ARTIFACT,
19
+ MAX_INVENTORY_TAGS_PER_ARTIFACT,
20
+ MAX_INVENTORY_TOTAL_ALIASES,
21
+ MAX_INVENTORY_TOTAL_REPLICAS,
22
+ MAX_INVENTORY_TOTAL_TAGS,
23
+ MAX_ROOT_MAP_PATH_BYTES,
24
+ MAX_ROOT_MAPS,
25
+ )
26
+ from ..transport import RpcError, RpcTransport
27
+ from .context import ServiceContext
28
+
29
+
30
+ _MODEL_STATES = frozenset({"ready", "dirty", "missing", "broken"})
31
+ _MODEL_SCOPES = frozenset({"user", "team", "system"})
32
+ _MODEL_MANAGEMENT = frozenset({"external"})
33
+ _VERIFICATION_LEVELS = frozenset(
34
+ {"metadata_verified", "checksum_verified"}
35
+ )
36
+ _NAMESPACE_PATTERN = re.compile(r"[A-Za-z0-9._-]+")
37
+
38
+ _INVENTORY_KEYS = frozenset(
39
+ {"schema_version", "exported_at", "source_node", "artifacts"}
40
+ )
41
+ _JSONL_META_KEYS = frozenset(
42
+ {"type", "schema_version", "exported_at", "source_node"}
43
+ )
44
+ _JSONL_ARTIFACT_RECORD_KEYS = frozenset({"type", "value"})
45
+ _ARTIFACT_KEYS = frozenset(
46
+ {
47
+ "ref",
48
+ "revision",
49
+ "variant",
50
+ "format",
51
+ "manifest_digest",
52
+ "metadata",
53
+ "created_at",
54
+ "updated_at",
55
+ "description",
56
+ "deleted_at",
57
+ "aliases",
58
+ "tags",
59
+ "replicas",
60
+ }
61
+ )
62
+ _REPLICA_KEYS = frozenset(
63
+ {
64
+ "node_id",
65
+ "path",
66
+ "scope",
67
+ "management",
68
+ "state",
69
+ "verification_level",
70
+ "size_bytes",
71
+ "file_count",
72
+ "last_seen",
73
+ }
74
+ )
75
+
76
+
77
+ def _required_string(value: Any, name: str) -> str:
78
+ if not isinstance(value, str) or not value.strip():
79
+ raise ValueError(f"{name} must be a non-empty string")
80
+ return value
81
+
82
+
83
+ def _optional_string(value: Any, name: str) -> str | None:
84
+ if value is None:
85
+ return None
86
+ if not isinstance(value, str):
87
+ raise ValueError(f"{name} must be a string")
88
+ return value
89
+
90
+
91
+ def _boolean(value: Any, name: str) -> bool:
92
+ if type(value) is not bool:
93
+ raise ValueError(f"{name} must be a boolean")
94
+ return value
95
+
96
+
97
+ def _bounded_integer(
98
+ value: Any,
99
+ name: str,
100
+ *,
101
+ minimum: int,
102
+ maximum: int,
103
+ ) -> int:
104
+ if type(value) is not int:
105
+ raise ValueError(f"{name} must be an integer")
106
+ if not minimum <= value <= maximum:
107
+ raise ValueError(f"{name} must be between {minimum} and {maximum}")
108
+ return value
109
+
110
+
111
+ def _string_array(value: Any, name: str) -> tuple[str, ...]:
112
+ if not isinstance(value, (list, tuple)) or isinstance(value, (str, bytes)):
113
+ raise ValueError(f"{name} must be a string array")
114
+ return tuple(_required_string(item, f"{name} item") for item in value)
115
+
116
+
117
+ def _nullable_string(value: Any, name: str) -> str | None:
118
+ if value is not None and not isinstance(value, str):
119
+ raise ValueError(f"{name} must be a string or null")
120
+ return value
121
+
122
+
123
+ def _nonnegative_integer(value: Any, name: str) -> int:
124
+ if type(value) is not int:
125
+ raise ValueError(f"{name} must be an integer")
126
+ if value < 0:
127
+ raise ValueError(f"{name} must be non-negative")
128
+ return value
129
+
130
+
131
+ def _validate_json_value(
132
+ value: Any,
133
+ name: str,
134
+ active_containers: set[int] | None = None,
135
+ ) -> None:
136
+ active = active_containers if active_containers is not None else set()
137
+ if value is None or isinstance(value, (str, bool, int)):
138
+ return
139
+ if isinstance(value, float):
140
+ if not math.isfinite(value):
141
+ raise ValueError(f"{name} must contain only finite numbers")
142
+ return
143
+ if isinstance(value, list):
144
+ identity = id(value)
145
+ if identity in active:
146
+ raise ValueError(f"{name} contains a recursive JSON value")
147
+ active.add(identity)
148
+ try:
149
+ for index, item in enumerate(value):
150
+ _validate_json_value(item, f"{name}[{index}]", active)
151
+ finally:
152
+ active.remove(identity)
153
+ return
154
+ if isinstance(value, dict):
155
+ identity = id(value)
156
+ if identity in active:
157
+ raise ValueError(f"{name} contains a recursive JSON value")
158
+ active.add(identity)
159
+ try:
160
+ for key, item in value.items():
161
+ if not isinstance(key, str):
162
+ raise ValueError(f"{name} object keys must be strings")
163
+ _validate_json_value(item, f"{name}.{key}", active)
164
+ finally:
165
+ active.remove(identity)
166
+ return
167
+ raise ValueError(
168
+ f"{name} contains unsupported JSON value type: "
169
+ f"{type(value).__name__}"
170
+ )
171
+
172
+
173
+ def _ensure_json_value(value: Any, name: str) -> None:
174
+ try:
175
+ _validate_json_value(value, name)
176
+ except RecursionError as exc:
177
+ raise ValueError(f"{name} is too deeply nested") from exc
178
+
179
+
180
+ def _reject_unknown_keys(
181
+ value: dict[str, Any],
182
+ allowed: frozenset[str],
183
+ name: str,
184
+ ) -> None:
185
+ unknown = set(value) - allowed
186
+ if unknown:
187
+ rendered = ", ".join(sorted(repr(item) for item in unknown))
188
+ raise ValueError(f"{name} has unknown field(s): {rendered}")
189
+
190
+
191
+ def _json_text(value: Any, name: str) -> str:
192
+ try:
193
+ return json.dumps(
194
+ value,
195
+ ensure_ascii=False,
196
+ sort_keys=True,
197
+ allow_nan=False,
198
+ )
199
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
200
+ raise ValueError(f"{name} is not valid JSON data: {exc}") from exc
201
+
202
+
203
+ def _reject_json_constant(value: str) -> None:
204
+ raise ValueError(f"non-finite JSON number is not allowed: {value}")
205
+
206
+
207
+ def _load_json(value: str, name: str) -> Any:
208
+ try:
209
+ return json.loads(
210
+ value,
211
+ parse_constant=_reject_json_constant,
212
+ )
213
+ except (json.JSONDecodeError, ValueError, RecursionError) as exc:
214
+ raise ValueError(f"invalid {name} JSON: {exc}") from exc
215
+
216
+
217
+ def _utf8_size(value: str, name: str) -> int:
218
+ try:
219
+ return len(value.encode("utf-8"))
220
+ except UnicodeEncodeError as exc:
221
+ raise ValueError(f"{name} is not valid UTF-8: {exc}") from exc
222
+
223
+
224
+ def _check_size(value: str, name: str, maximum: int) -> None:
225
+ if _utf8_size(value, name) > maximum:
226
+ raise ValueError(f"{name} exceeds {maximum} UTF-8 bytes")
227
+
228
+
229
+ def _root_maps(value: Any) -> tuple[tuple[str, str], ...]:
230
+ if not isinstance(value, (list, tuple)) or isinstance(value, (str, bytes)):
231
+ raise ValueError("root_maps must be an array of [old, new] pairs")
232
+ if len(value) > MAX_ROOT_MAPS:
233
+ raise ValueError(f"root_maps must contain at most {MAX_ROOT_MAPS} pairs")
234
+ result: list[tuple[str, str]] = []
235
+ for item in value:
236
+ if (
237
+ not isinstance(item, (list, tuple))
238
+ or isinstance(item, (str, bytes))
239
+ or len(item) != 2
240
+ ):
241
+ raise ValueError("each root mapping must contain exactly [old, new]")
242
+ old = _required_string(item[0], "root mapping old")
243
+ new = _required_string(item[1], "root mapping new")
244
+ for path, name in ((old, "old"), (new, "new")):
245
+ if _utf8_size(path, f"root mapping {name}") > MAX_ROOT_MAP_PATH_BYTES:
246
+ raise ValueError(
247
+ f"root mapping {name} exceeds "
248
+ f"{MAX_ROOT_MAP_PATH_BYTES} UTF-8 bytes"
249
+ )
250
+ result.append((old, new))
251
+ return tuple(result)
252
+
253
+
254
+ def _validate_artifact(artifact: Any) -> dict[str, Any]:
255
+ if not isinstance(artifact, dict):
256
+ raise ValueError("each inventory artifact must be an object")
257
+ _reject_unknown_keys(
258
+ artifact,
259
+ _ARTIFACT_KEYS,
260
+ "inventory artifact",
261
+ )
262
+ _required_string(artifact.get("ref"), "inventory artifact ref")
263
+
264
+ for name in (
265
+ "manifest_digest",
266
+ "format",
267
+ "revision",
268
+ "variant",
269
+ "description",
270
+ "deleted_at",
271
+ "created_at",
272
+ "updated_at",
273
+ ):
274
+ if name in artifact:
275
+ _nullable_string(artifact[name], f"inventory artifact {name}")
276
+
277
+ if "metadata" in artifact and not isinstance(artifact["metadata"], dict):
278
+ raise ValueError("inventory artifact metadata must be an object")
279
+
280
+ aliases = artifact.get("aliases", [])
281
+ if not isinstance(aliases, list):
282
+ raise ValueError("inventory artifact aliases must be an array")
283
+ if len(aliases) > MAX_INVENTORY_ALIASES_PER_ARTIFACT:
284
+ raise ValueError("inventory artifact has too many aliases")
285
+ _string_array(aliases, "inventory artifact aliases")
286
+
287
+ tags = artifact.get("tags", [])
288
+ if not isinstance(tags, list):
289
+ raise ValueError("inventory artifact tags must be an array")
290
+ if len(tags) > MAX_INVENTORY_TAGS_PER_ARTIFACT:
291
+ raise ValueError("inventory artifact has too many tags")
292
+ _string_array(tags, "inventory artifact tags")
293
+
294
+ replicas = artifact.get("replicas", [])
295
+ if not isinstance(replicas, list):
296
+ raise ValueError("inventory artifact replicas must be an array")
297
+ if len(replicas) > MAX_INVENTORY_REPLICAS_PER_ARTIFACT:
298
+ raise ValueError("inventory artifact has too many replicas")
299
+ for replica in replicas:
300
+ if not isinstance(replica, dict):
301
+ raise ValueError("each inventory replica must be an object")
302
+ _reject_unknown_keys(
303
+ replica,
304
+ _REPLICA_KEYS,
305
+ "inventory replica",
306
+ )
307
+ _required_string(replica.get("path"), "inventory replica path")
308
+ if "node_id" in replica:
309
+ _required_string(replica["node_id"], "inventory replica node_id")
310
+ if "scope" in replica:
311
+ scope = _required_string(replica["scope"], "inventory replica scope")
312
+ if scope not in _MODEL_SCOPES:
313
+ raise ValueError(f"unsupported inventory replica scope: {scope}")
314
+ if "management" in replica:
315
+ management = _required_string(
316
+ replica["management"],
317
+ "inventory replica management",
318
+ )
319
+ if management not in _MODEL_MANAGEMENT:
320
+ raise ValueError(
321
+ f"unsupported inventory replica management: {management}"
322
+ )
323
+ if "state" in replica:
324
+ state = _required_string(replica["state"], "inventory replica state")
325
+ if state not in _MODEL_STATES:
326
+ raise ValueError(f"unsupported inventory replica state: {state}")
327
+ if "verification_level" in replica:
328
+ level = _required_string(
329
+ replica["verification_level"],
330
+ "inventory replica verification_level",
331
+ )
332
+ if level not in _VERIFICATION_LEVELS:
333
+ raise ValueError(
334
+ f"unsupported inventory replica verification_level: {level}"
335
+ )
336
+ for name in ("size_bytes", "file_count"):
337
+ if name in replica:
338
+ _nonnegative_integer(
339
+ replica[name],
340
+ f"inventory replica {name}",
341
+ )
342
+ if "last_seen" in replica:
343
+ _required_string(
344
+ replica["last_seen"],
345
+ "inventory replica last_seen",
346
+ )
347
+ return artifact
348
+
349
+
350
+ def _add_artifact_fanout(
351
+ artifact: dict[str, Any],
352
+ totals: tuple[int, int, int],
353
+ ) -> tuple[int, int, int]:
354
+ total_aliases = totals[0] + len(artifact.get("aliases", []))
355
+ total_tags = totals[1] + len(artifact.get("tags", []))
356
+ total_replicas = totals[2] + len(artifact.get("replicas", []))
357
+ if total_aliases > MAX_INVENTORY_TOTAL_ALIASES:
358
+ raise ValueError("inventory has too many total aliases")
359
+ if total_tags > MAX_INVENTORY_TOTAL_TAGS:
360
+ raise ValueError("inventory has too many total tags")
361
+ if total_replicas > MAX_INVENTORY_TOTAL_REPLICAS:
362
+ raise ValueError("inventory has too many total replicas")
363
+ return total_aliases, total_tags, total_replicas
364
+
365
+
366
+ def _validate_inventory_payload(
367
+ payload: Any,
368
+ *,
369
+ jsonl_lines: list[str] | None = None,
370
+ ) -> dict[str, Any]:
371
+ if not isinstance(payload, dict):
372
+ raise ValueError("inventory payload must be an object")
373
+ _ensure_json_value(payload, "inventory payload")
374
+ _reject_unknown_keys(
375
+ payload,
376
+ _INVENTORY_KEYS,
377
+ "inventory payload",
378
+ )
379
+ schema_version = payload.get("schema_version")
380
+ if type(schema_version) is not int:
381
+ raise ValueError("inventory schema_version must be an integer")
382
+ if schema_version != SCHEMA_VERSION:
383
+ raise ValueError(f"unsupported inventory schema: {schema_version}")
384
+ for name in ("exported_at", "source_node"):
385
+ if name in payload:
386
+ _nullable_string(payload[name], f"inventory {name}")
387
+ artifacts = payload.get("artifacts")
388
+ if not isinstance(artifacts, list):
389
+ raise ValueError("inventory artifacts must be an array")
390
+ if len(artifacts) > MAX_INVENTORY_ARTIFACTS:
391
+ raise ValueError("inventory contains too many artifacts")
392
+
393
+ header = _json_text(
394
+ {
395
+ "type": "meta",
396
+ "schema_version": schema_version,
397
+ "exported_at": payload.get("exported_at"),
398
+ "source_node": payload.get("source_node"),
399
+ },
400
+ "inventory metadata",
401
+ )
402
+ _check_size(header, "inventory metadata line", MAX_INVENTORY_LINE_BYTES)
403
+ if jsonl_lines is not None:
404
+ jsonl_lines.append(header)
405
+ serialized_bytes = _utf8_size(header, "inventory metadata line") + 1
406
+ if serialized_bytes > MAX_INVENTORY_CONTENT_BYTES:
407
+ raise ValueError(
408
+ "inventory JSONL content exceeds "
409
+ f"{MAX_INVENTORY_CONTENT_BYTES} UTF-8 bytes"
410
+ )
411
+
412
+ fanout = (0, 0, 0)
413
+ for artifact in artifacts:
414
+ _validate_artifact(artifact)
415
+ fanout = _add_artifact_fanout(artifact, fanout)
416
+ artifact_line = _json_text(
417
+ {"type": "artifact", "value": artifact},
418
+ "inventory artifact",
419
+ )
420
+ _check_size(
421
+ artifact_line,
422
+ "inventory artifact line",
423
+ MAX_INVENTORY_LINE_BYTES,
424
+ )
425
+ if jsonl_lines is not None:
426
+ jsonl_lines.append(artifact_line)
427
+ serialized_bytes += _utf8_size(
428
+ artifact_line,
429
+ "inventory artifact line",
430
+ ) + 1
431
+ if serialized_bytes > MAX_INVENTORY_CONTENT_BYTES:
432
+ raise ValueError(
433
+ "inventory JSONL content exceeds "
434
+ f"{MAX_INVENTORY_CONTENT_BYTES} UTF-8 bytes"
435
+ )
436
+ return payload
437
+
438
+
439
+ def _model_list_rows(value: Any) -> tuple[dict[str, Any], ...]:
440
+ if not isinstance(value, list):
441
+ raise ValueError("model.list response must be an array")
442
+ if not all(isinstance(item, dict) for item in value):
443
+ raise ValueError("every model.list response item must be an object")
444
+ return tuple(value)
445
+
446
+
447
+ def inventory_to_jsonl(payload: Any) -> str:
448
+ """Serialize structured inventory records using the CLI-compatible JSONL format."""
449
+ lines: list[str] = []
450
+ _validate_inventory_payload(payload, jsonl_lines=lines)
451
+ return "\n".join(lines) + "\n"
452
+
453
+
454
+ def inventory_from_jsonl(content: Any) -> dict[str, Any]:
455
+ """Parse CLI-compatible JSONL into structured inventory records."""
456
+ if not isinstance(content, str):
457
+ raise ValueError("inventory jsonl must be a string")
458
+ _check_size(
459
+ content,
460
+ "inventory JSONL content",
461
+ MAX_INVENTORY_CONTENT_BYTES,
462
+ )
463
+ lines = content.splitlines()
464
+ if not lines:
465
+ raise ValueError("empty inventory export")
466
+ for line in lines:
467
+ _check_size(
468
+ line,
469
+ "inventory JSONL line",
470
+ MAX_INVENTORY_LINE_BYTES,
471
+ )
472
+ meta = _load_json(lines[0], "inventory metadata")
473
+ if not isinstance(meta, dict) or meta.get("type") != "meta":
474
+ raise ValueError("inventory export is missing metadata header")
475
+ _ensure_json_value(meta, "inventory metadata")
476
+ _reject_unknown_keys(
477
+ meta,
478
+ _JSONL_META_KEYS,
479
+ "inventory metadata",
480
+ )
481
+ artifacts: list[dict[str, Any]] = []
482
+ fanout = (0, 0, 0)
483
+ for line in lines[1:]:
484
+ if not line.strip():
485
+ continue
486
+ record = _load_json(line, "inventory record")
487
+ if not isinstance(record, dict):
488
+ raise ValueError("inventory record must be an object")
489
+ _ensure_json_value(record, "inventory record")
490
+ _reject_unknown_keys(
491
+ record,
492
+ _JSONL_ARTIFACT_RECORD_KEYS,
493
+ "inventory artifact record",
494
+ )
495
+ record_type = record.get("type")
496
+ if record_type != "artifact":
497
+ raise ValueError(
498
+ f"unsupported inventory JSONL record type: {record_type!r}"
499
+ )
500
+ if len(artifacts) >= MAX_INVENTORY_ARTIFACTS:
501
+ raise ValueError("inventory contains too many artifacts")
502
+ artifact = _validate_artifact(record.get("value"))
503
+ fanout = _add_artifact_fanout(artifact, fanout)
504
+ artifacts.append(artifact)
505
+ payload = {
506
+ "schema_version": meta.get("schema_version"),
507
+ "exported_at": meta.get("exported_at"),
508
+ "source_node": meta.get("source_node"),
509
+ "artifacts": artifacts,
510
+ }
511
+ return _validate_inventory_payload(payload)
512
+
513
+
514
+ @dataclass(frozen=True)
515
+ class ModelRefRequest:
516
+ node: Any
517
+ ref: Any
518
+
519
+
520
+ @dataclass(frozen=True)
521
+ class ModelRegisterRequest:
522
+ node: Any
523
+ ref: Any
524
+ path: Any
525
+ scope: Any = "user"
526
+ format_name: Any = None
527
+ full_checksum: Any = False
528
+
529
+
530
+ @dataclass(frozen=True)
531
+ class ModelDiscoverRequest:
532
+ node: Any
533
+ root: Any
534
+ max_depth: Any = 5
535
+ register: Any = False
536
+ namespace: Any = "discovered"
537
+
538
+
539
+ @dataclass(frozen=True)
540
+ class ModelListRequest:
541
+ node: Any = None
542
+ all_nodes: Any = False
543
+ format_name: Any = None
544
+ model_type: Any = None
545
+ state: Any = None
546
+ tag: Any = None
547
+ name: Any = None
548
+ include_deleted: Any = False
549
+
550
+
551
+ @dataclass(frozen=True)
552
+ class ModelUpdateRequest:
553
+ node: Any
554
+ ref: Any
555
+ add_aliases: Any = ()
556
+ remove_aliases: Any = ()
557
+ add_tags: Any = ()
558
+ remove_tags: Any = ()
559
+ description: Any = None
560
+ clear_description: Any = False
561
+
562
+
563
+ @dataclass(frozen=True)
564
+ class ModelRefreshRequest:
565
+ node: Any
566
+ ref: Any
567
+ path: Any = None
568
+ full_checksum: Any = False
569
+
570
+
571
+ @dataclass(frozen=True)
572
+ class ModelValidateRequest:
573
+ node: Any
574
+ ref: Any
575
+ path: Any = None
576
+
577
+
578
+ @dataclass(frozen=True)
579
+ class ModelAuditRequest:
580
+ node: Any
581
+ ref: Any = None
582
+ limit: Any = 100
583
+
584
+
585
+ @dataclass(frozen=True)
586
+ class ModelReplicaAddRequest:
587
+ node: Any
588
+ ref: Any
589
+ path: Any
590
+ full_checksum: Any = False
591
+
592
+
593
+ @dataclass(frozen=True)
594
+ class ModelReplicaRemoveRequest:
595
+ node: Any
596
+ ref: Any
597
+ path: Any
598
+
599
+
600
+ @dataclass(frozen=True)
601
+ class ModelVerifyRequest:
602
+ node: Any
603
+ ref: Any
604
+ full_checksum: Any = False
605
+
606
+
607
+ @dataclass(frozen=True)
608
+ class ModelUnregisterRequest:
609
+ node: Any
610
+ ref: Any
611
+ path: Any = None
612
+
613
+
614
+ @dataclass(frozen=True)
615
+ class ModelImportRequest:
616
+ node: Any
617
+ payload: Any = None
618
+ jsonl: Any = None
619
+ root_maps: Any = ()
620
+
621
+
622
+ @dataclass(frozen=True)
623
+ class ModelRebindRequest:
624
+ node: Any
625
+ ref: Any
626
+ path: Any
627
+ scope: Any = "user"
628
+ full_checksum: Any = False
629
+
630
+
631
+ @dataclass(frozen=True)
632
+ class ModelOperationResult:
633
+ node: str
634
+ value: Any
635
+ exit_code: int = 0
636
+
637
+ def to_dict(self) -> dict[str, Any]:
638
+ return {
639
+ "node": self.node,
640
+ "value": self.value,
641
+ "exit_code": self.exit_code,
642
+ }
643
+
644
+
645
+ @dataclass(frozen=True)
646
+ class ModelNodeResult:
647
+ node: str
648
+ ssh_host: str | None
649
+ models: tuple[dict[str, Any], ...]
650
+
651
+ def to_dict(self) -> dict[str, Any]:
652
+ return {
653
+ "node": self.node,
654
+ "ssh_host": self.ssh_host,
655
+ "models": list(self.models),
656
+ }
657
+
658
+
659
+ @dataclass(frozen=True)
660
+ class ModelNodeError:
661
+ node: str
662
+ ssh_host: str
663
+ error: str
664
+
665
+ def to_dict(self) -> dict[str, str]:
666
+ return {
667
+ "node": self.node,
668
+ "ssh_host": self.ssh_host,
669
+ "error": self.error,
670
+ }
671
+
672
+
673
+ @dataclass(frozen=True)
674
+ class ModelListResult:
675
+ nodes: tuple[ModelNodeResult, ...]
676
+ errors: tuple[ModelNodeError, ...] = ()
677
+ exit_code: int = 0
678
+
679
+ def to_dict(self) -> dict[str, Any]:
680
+ return {
681
+ "nodes": [item.to_dict() for item in self.nodes],
682
+ "errors": [item.to_dict() for item in self.errors],
683
+ "exit_code": self.exit_code,
684
+ }
685
+
686
+
687
+ @dataclass(frozen=True)
688
+ class ModelExportResult:
689
+ node: str
690
+ payload: dict[str, Any]
691
+ jsonl: str
692
+ exit_code: int = 0
693
+
694
+ def to_dict(self) -> dict[str, Any]:
695
+ return {
696
+ "node": self.node,
697
+ "payload": self.payload,
698
+ "jsonl": self.jsonl,
699
+ "exit_code": self.exit_code,
700
+ }
701
+
702
+
703
+ class ModelService:
704
+ """Own validation, node routing, and model inventory orchestration."""
705
+
706
+ def __init__(self, context: ServiceContext):
707
+ self.context = context
708
+
709
+ def _transport(self, node_name: Any) -> tuple[str, Node | None, RpcTransport]:
710
+ name = _required_string(node_name, "node")
711
+ node = None if name == "local" else self.context.node_store.get(name)
712
+ return name, node, self.context.create_transport(node)
713
+
714
+ @staticmethod
715
+ def _ref(request: ModelRefRequest) -> str:
716
+ return _required_string(request.ref, "model ref")
717
+
718
+ def _call(
719
+ self,
720
+ node: Any,
721
+ method: str,
722
+ params: dict[str, Any],
723
+ *,
724
+ exit_code: int = 0,
725
+ ) -> ModelOperationResult:
726
+ name, _node, transport = self._transport(node)
727
+ value = transport.call(method, params)
728
+ return ModelOperationResult(name, value, exit_code)
729
+
730
+ def register(self, request: ModelRegisterRequest) -> ModelOperationResult:
731
+ ref = _required_string(request.ref, "model ref")
732
+ path = _required_string(request.path, "model path")
733
+ scope = _required_string(request.scope, "scope")
734
+ if scope not in _MODEL_SCOPES:
735
+ raise ValueError(f"unsupported scope: {scope}")
736
+ format_name = _optional_string(request.format_name, "format")
737
+ full_checksum = _boolean(request.full_checksum, "full_checksum")
738
+ return self._call(
739
+ request.node,
740
+ "model.register",
741
+ {
742
+ "ref": ref,
743
+ "path": path,
744
+ "scope": scope,
745
+ "format": format_name,
746
+ "full_checksum": full_checksum,
747
+ },
748
+ )
749
+
750
+ def discover(self, request: ModelDiscoverRequest) -> ModelOperationResult:
751
+ root = _required_string(request.root, "discovery root")
752
+ max_depth = _bounded_integer(
753
+ request.max_depth,
754
+ "max_depth",
755
+ minimum=0,
756
+ maximum=20,
757
+ )
758
+ register = _boolean(request.register, "register")
759
+ namespace = _required_string(request.namespace, "namespace")
760
+ if _NAMESPACE_PATTERN.fullmatch(namespace) is None:
761
+ raise ValueError(
762
+ "namespace may only contain letters, numbers, dot, underscore, and dash"
763
+ )
764
+ return self._call(
765
+ request.node,
766
+ "model.discover",
767
+ {
768
+ "root": root,
769
+ "max_depth": max_depth,
770
+ "register": register,
771
+ "namespace": namespace,
772
+ },
773
+ )
774
+
775
+ @staticmethod
776
+ def _list_params(request: ModelListRequest) -> dict[str, Any]:
777
+ state = _optional_string(request.state, "state")
778
+ if state is not None and state not in _MODEL_STATES:
779
+ raise ValueError(f"unsupported model state: {state}")
780
+ return {
781
+ "format": _optional_string(request.format_name, "format"),
782
+ "model_type": _optional_string(request.model_type, "model_type"),
783
+ "state": state,
784
+ "tag": _optional_string(request.tag, "tag"),
785
+ "name": _optional_string(request.name, "name"),
786
+ "include_deleted": _boolean(
787
+ request.include_deleted,
788
+ "include_deleted",
789
+ ),
790
+ }
791
+
792
+ def list(self, request: ModelListRequest) -> ModelListResult:
793
+ all_nodes = _boolean(request.all_nodes, "all_nodes")
794
+ if all_nodes:
795
+ if request.node is not None:
796
+ raise ValueError("node and all_nodes cannot be used together")
797
+ elif request.node is None:
798
+ raise ValueError("node or all_nodes is required")
799
+ params = self._list_params(request)
800
+
801
+ if not all_nodes:
802
+ name, node, transport = self._transport(request.node)
803
+ models = _model_list_rows(transport.call("model.list", params))
804
+ return ModelListResult(
805
+ nodes=(
806
+ ModelNodeResult(
807
+ node=name,
808
+ ssh_host=node.ssh_host if node is not None else None,
809
+ models=models,
810
+ ),
811
+ )
812
+ )
813
+
814
+ configured_nodes = self.context.node_store.list()
815
+ if not configured_nodes:
816
+ raise ValueError("no SSH nodes registered")
817
+ nodes: list[ModelNodeResult] = []
818
+ errors: list[ModelNodeError] = []
819
+ for node in configured_nodes:
820
+ try:
821
+ transport = self.context.create_transport(node)
822
+ models = _model_list_rows(
823
+ transport.call("model.list", params)
824
+ )
825
+ nodes.append(
826
+ ModelNodeResult(
827
+ node=node.name,
828
+ ssh_host=node.ssh_host,
829
+ models=models,
830
+ )
831
+ )
832
+ except (
833
+ RpcError,
834
+ OSError,
835
+ ValueError,
836
+ TypeError,
837
+ RuntimeError,
838
+ ) as exc:
839
+ errors.append(
840
+ ModelNodeError(
841
+ node=node.name,
842
+ ssh_host=node.ssh_host,
843
+ error=str(exc),
844
+ )
845
+ )
846
+ return ModelListResult(
847
+ nodes=tuple(nodes),
848
+ errors=tuple(errors),
849
+ exit_code=1 if errors else 0,
850
+ )
851
+
852
+ def inspect(self, request: ModelRefRequest) -> ModelOperationResult:
853
+ return self._call(
854
+ request.node,
855
+ "model.inspect",
856
+ {"ref": self._ref(request)},
857
+ )
858
+
859
+ def update(self, request: ModelUpdateRequest) -> ModelOperationResult:
860
+ description = _optional_string(request.description, "description")
861
+ clear_description = _boolean(
862
+ request.clear_description,
863
+ "clear_description",
864
+ )
865
+ if description is not None and clear_description:
866
+ raise ValueError("description and clear_description cannot be used together")
867
+ return self._call(
868
+ request.node,
869
+ "model.update",
870
+ {
871
+ "ref": _required_string(request.ref, "model ref"),
872
+ "add_aliases": list(
873
+ _string_array(request.add_aliases, "add_aliases")
874
+ ),
875
+ "remove_aliases": list(
876
+ _string_array(request.remove_aliases, "remove_aliases")
877
+ ),
878
+ "add_tags": list(_string_array(request.add_tags, "add_tags")),
879
+ "remove_tags": list(
880
+ _string_array(request.remove_tags, "remove_tags")
881
+ ),
882
+ "description": description,
883
+ "clear_description": clear_description,
884
+ },
885
+ )
886
+
887
+ def delete(self, request: ModelRefRequest) -> ModelOperationResult:
888
+ return self._call(
889
+ request.node,
890
+ "model.delete",
891
+ {"ref": self._ref(request)},
892
+ )
893
+
894
+ def restore(self, request: ModelRefRequest) -> ModelOperationResult:
895
+ return self._call(
896
+ request.node,
897
+ "model.restore",
898
+ {"ref": self._ref(request)},
899
+ )
900
+
901
+ def refresh(self, request: ModelRefreshRequest) -> ModelOperationResult:
902
+ return self._call(
903
+ request.node,
904
+ "model.refresh",
905
+ {
906
+ "ref": _required_string(request.ref, "model ref"),
907
+ "path": _optional_string(request.path, "model path"),
908
+ "full_checksum": _boolean(
909
+ request.full_checksum,
910
+ "full_checksum",
911
+ ),
912
+ },
913
+ )
914
+
915
+ def validate(
916
+ self,
917
+ request: ModelValidateRequest,
918
+ ) -> ModelOperationResult:
919
+ result = self._call(
920
+ request.node,
921
+ "model.validate",
922
+ {
923
+ "ref": _required_string(request.ref, "model ref"),
924
+ "path": _optional_string(request.path, "model path"),
925
+ },
926
+ )
927
+ if not isinstance(result.value, dict) or type(result.value.get("valid")) is not bool:
928
+ raise ValueError("model.validate response must contain boolean valid")
929
+ return ModelOperationResult(
930
+ node=result.node,
931
+ value=result.value,
932
+ exit_code=0 if result.value["valid"] else 1,
933
+ )
934
+
935
+ def audit(self, request: ModelAuditRequest) -> ModelOperationResult:
936
+ ref = _optional_string(request.ref, "model ref")
937
+ limit = _bounded_integer(
938
+ request.limit,
939
+ "audit limit",
940
+ minimum=1,
941
+ maximum=1000,
942
+ )
943
+ return self._call(
944
+ request.node,
945
+ "model.audit",
946
+ {"ref": ref, "limit": limit},
947
+ )
948
+
949
+ def replica_list(self, request: ModelRefRequest) -> ModelOperationResult:
950
+ return self._call(
951
+ request.node,
952
+ "replica.list",
953
+ {"ref": self._ref(request)},
954
+ )
955
+
956
+ def replica_add(
957
+ self,
958
+ request: ModelReplicaAddRequest,
959
+ ) -> ModelOperationResult:
960
+ return self._call(
961
+ request.node,
962
+ "replica.add",
963
+ {
964
+ "ref": _required_string(request.ref, "model ref"),
965
+ "path": _required_string(request.path, "replica path"),
966
+ "full_checksum": _boolean(
967
+ request.full_checksum,
968
+ "full_checksum",
969
+ ),
970
+ },
971
+ )
972
+
973
+ def replica_remove(
974
+ self,
975
+ request: ModelReplicaRemoveRequest,
976
+ ) -> ModelOperationResult:
977
+ return self._call(
978
+ request.node,
979
+ "replica.remove",
980
+ {
981
+ "ref": _required_string(request.ref, "model ref"),
982
+ "path": _required_string(request.path, "replica path"),
983
+ },
984
+ )
985
+
986
+ def verify(self, request: ModelVerifyRequest) -> ModelOperationResult:
987
+ result = self._call(
988
+ request.node,
989
+ "model.verify",
990
+ {
991
+ "ref": _required_string(request.ref, "model ref"),
992
+ "full_checksum": _boolean(
993
+ request.full_checksum,
994
+ "full_checksum",
995
+ ),
996
+ },
997
+ )
998
+ if not isinstance(result.value, dict) or not isinstance(
999
+ result.value.get("results"),
1000
+ list,
1001
+ ):
1002
+ raise ValueError("model.verify response must contain a results array")
1003
+ entries = result.value["results"]
1004
+ if not entries:
1005
+ raise ValueError("model.verify results must not be empty")
1006
+ states: list[str] = []
1007
+ for index, item in enumerate(entries):
1008
+ if not isinstance(item, dict):
1009
+ raise ValueError(
1010
+ f"model.verify result {index} must be an object"
1011
+ )
1012
+ state = item.get("state")
1013
+ if state not in _MODEL_STATES:
1014
+ raise ValueError(
1015
+ f"model.verify result {index} has invalid state: {state!r}"
1016
+ )
1017
+ states.append(state)
1018
+ return ModelOperationResult(
1019
+ node=result.node,
1020
+ value=result.value,
1021
+ exit_code=0 if all(state == "ready" for state in states) else 1,
1022
+ )
1023
+
1024
+ def unregister(
1025
+ self,
1026
+ request: ModelUnregisterRequest,
1027
+ ) -> ModelOperationResult:
1028
+ return self._call(
1029
+ request.node,
1030
+ "model.unregister",
1031
+ {
1032
+ "ref": _required_string(request.ref, "model ref"),
1033
+ "path": _optional_string(request.path, "replica path"),
1034
+ },
1035
+ )
1036
+
1037
+ def export(self, node: Any) -> ModelExportResult:
1038
+ name, _node, transport = self._transport(node)
1039
+ payload = _validate_inventory_payload(transport.call("model.export"))
1040
+ return ModelExportResult(
1041
+ node=name,
1042
+ payload=payload,
1043
+ jsonl=inventory_to_jsonl(payload),
1044
+ )
1045
+
1046
+ def import_records(
1047
+ self,
1048
+ request: ModelImportRequest,
1049
+ ) -> ModelOperationResult:
1050
+ if request.payload is None and request.jsonl is None:
1051
+ raise ValueError("exactly one of payload or jsonl is required")
1052
+ if request.payload is not None and request.jsonl is not None:
1053
+ raise ValueError("payload and jsonl cannot be used together")
1054
+ payload = (
1055
+ _validate_inventory_payload(request.payload)
1056
+ if request.payload is not None
1057
+ else inventory_from_jsonl(request.jsonl)
1058
+ )
1059
+ maps = _root_maps(request.root_maps)
1060
+ return self._call(
1061
+ request.node,
1062
+ "model.import",
1063
+ {
1064
+ "payload": payload,
1065
+ "root_maps": [list(item) for item in maps],
1066
+ },
1067
+ )
1068
+
1069
+ def rebind(self, request: ModelRebindRequest) -> ModelOperationResult:
1070
+ scope = _required_string(request.scope, "scope")
1071
+ if scope not in _MODEL_SCOPES:
1072
+ raise ValueError(f"unsupported scope: {scope}")
1073
+ return self._call(
1074
+ request.node,
1075
+ "model.rebind",
1076
+ {
1077
+ "ref": _required_string(request.ref, "model ref"),
1078
+ "path": _required_string(request.path, "model path"),
1079
+ "scope": scope,
1080
+ "full_checksum": _boolean(
1081
+ request.full_checksum,
1082
+ "full_checksum",
1083
+ ),
1084
+ },
1085
+ )