mas-lab-component-controller 0.2.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.
Files changed (89) hide show
  1. mas/lab/controller/__init__.py +7 -0
  2. mas/lab/controller/__main__.py +5 -0
  3. mas/lab/controller/agent_chat.py +226 -0
  4. mas/lab/controller/api.py +364 -0
  5. mas/lab/controller/artifact_discovery.py +107 -0
  6. mas/lab/controller/artifacts.py +112 -0
  7. mas/lab/controller/cli.py +493 -0
  8. mas/lab/controller/client.py +271 -0
  9. mas/lab/controller/config.py +75 -0
  10. mas/lab/controller/constants.py +30 -0
  11. mas/lab/controller/daemon.py +190 -0
  12. mas/lab/controller/deps.py +172 -0
  13. mas/lab/controller/fastapi_app.py +166 -0
  14. mas/lab/controller/http_server.py +243 -0
  15. mas/lab/controller/io_capture.py +55 -0
  16. mas/lab/controller/item_service.py +625 -0
  17. mas/lab/controller/job_store.py +209 -0
  18. mas/lab/controller/jobs.py +375 -0
  19. mas/lab/controller/kinds.py +149 -0
  20. mas/lab/controller/lab_registry.py +1076 -0
  21. mas/lab/controller/manifest_layout.py +832 -0
  22. mas/lab/controller/manifest_store.py +214 -0
  23. mas/lab/controller/manifest_validation.py +90 -0
  24. mas/lab/controller/models.py +215 -0
  25. mas/lab/controller/path_safety.py +68 -0
  26. mas/lab/controller/pipeline_validation.py +79 -0
  27. mas/lab/controller/registry.py +83 -0
  28. mas/lab/controller/routes/__init__.py +82 -0
  29. mas/lab/controller/routes/_api.py +15 -0
  30. mas/lab/controller/routes/apps.py +190 -0
  31. mas/lab/controller/routes/artifacts.py +37 -0
  32. mas/lab/controller/routes/benchmark.py +307 -0
  33. mas/lab/controller/routes/choices.py +141 -0
  34. mas/lab/controller/routes/datasets.py +91 -0
  35. mas/lab/controller/routes/design_patterns.py +121 -0
  36. mas/lab/controller/routes/eval_output.py +31 -0
  37. mas/lab/controller/routes/experiments.py +194 -0
  38. mas/lab/controller/routes/health.py +178 -0
  39. mas/lab/controller/routes/items.py +187 -0
  40. mas/lab/controller/routes/jobs.py +107 -0
  41. mas/lab/controller/routes/libraries.py +144 -0
  42. mas/lab/controller/routes/overlays.py +164 -0
  43. mas/lab/controller/routes/pipelines.py +139 -0
  44. mas/lab/controller/routes/registry.py +40 -0
  45. mas/lab/controller/routes/run.py +215 -0
  46. mas/lab/controller/routes/runs.py +392 -0
  47. mas/lab/controller/routes/scopes.py +139 -0
  48. mas/lab/controller/routes/uploads.py +24 -0
  49. mas/lab/controller/routes/validate.py +36 -0
  50. mas/lab/controller/run_bridge.py +276 -0
  51. mas/lab/controller/run_store.py +190 -0
  52. mas/lab/controller/schema_registry.py +109 -0
  53. mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
  54. mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
  55. mas/lab/controller/scope_catalog.py +266 -0
  56. mas/lab/controller/scope_service.py +134 -0
  57. mas/lab/controller/sessions.py +63 -0
  58. mas/lab/controller/settings.py +65 -0
  59. mas/lab/controller/ui_stream/__init__.py +13 -0
  60. mas/lab/controller/ui_stream/app.py +2147 -0
  61. mas/lab/controller/ui_stream/artefact_bind.py +308 -0
  62. mas/lab/controller/ui_stream/auth.py +136 -0
  63. mas/lab/controller/ui_stream/bus.py +156 -0
  64. mas/lab/controller/ui_stream/export.py +400 -0
  65. mas/lab/controller/ui_stream/governance.py +141 -0
  66. mas/lab/controller/ui_stream/ingest.py +300 -0
  67. mas/lab/controller/ui_stream/inspect.py +55 -0
  68. mas/lab/controller/ui_stream/layouts.py +161 -0
  69. mas/lab/controller/ui_stream/metrics.py +274 -0
  70. mas/lab/controller/ui_stream/plot_folds.py +609 -0
  71. mas/lab/controller/ui_stream/plugin_registry.py +139 -0
  72. mas/lab/controller/ui_stream/refs.py +259 -0
  73. mas/lab/controller/ui_stream/replay.py +236 -0
  74. mas/lab/controller/ui_stream/replay_cli.py +707 -0
  75. mas/lab/controller/ui_stream/run_index.py +337 -0
  76. mas/lab/controller/ui_stream/spec_load.py +48 -0
  77. mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
  78. mas/lab/controller/ui_stream/stream.py +95 -0
  79. mas/lab/controller/ui_stream/streams.py +42 -0
  80. mas/lab/controller/ui_stream/timeline.py +183 -0
  81. mas/lab/controller/ui_stream/ui_trace.py +258 -0
  82. mas/lab/controller/uploads.py +61 -0
  83. mas/lab/controller/worker_model.py +137 -0
  84. mas/lab/controller/workers.py +296 -0
  85. mas/lab/controller/workspace_library.py +70 -0
  86. mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
  87. mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
  88. mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
  89. mas_lab_component_controller-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,625 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Read/write catalog items through layouts. One implementation for new and deprecated routes."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from mas.ctl.overlay.canvas import (
14
+ apply_ui_canvas_to_overlay_yaml,
15
+ apply_ui_canvas_to_yaml,
16
+ overlay_is_layout_only,
17
+ persist_overlay_canvas_yaml,
18
+ persist_ui_canvas_yaml,
19
+ )
20
+ from mas.lab.controller.kinds import KIND_CATALOG
21
+ from mas.lab.controller.manifest_layout import (
22
+ FileWrite,
23
+ ItemBundle,
24
+ apply_writes,
25
+ item_ref,
26
+ layout_for,
27
+ versions_of,
28
+ )
29
+ from mas.lab.controller.path_safety import (
30
+ UnsafeNameError,
31
+ parse_item_id,
32
+ validate_name,
33
+ validate_version,
34
+ )
35
+ from mas.lab.controller.scope_catalog import Scope, ScopeCatalog, get_scope_catalog
36
+ from mas.version_spec import version_satisfies
37
+
38
+
39
+ def _overlay_is_layout_only(path: Path | None) -> bool:
40
+ if path is None or not path.is_file():
41
+ return False
42
+ try:
43
+ from mas.runtime.spec.source import load_yaml_file
44
+
45
+ return overlay_is_layout_only(load_yaml_file(path))
46
+ except Exception:
47
+ return False
48
+
49
+
50
+ def _registry_for(store: Any | None):
51
+ from mas.lab.controller.lab_registry import LabRegistry, get_lab_registry
52
+
53
+ if store is not None:
54
+ registry = getattr(store, "_registry", None)
55
+ if isinstance(registry, LabRegistry):
56
+ return registry
57
+ return get_lab_registry()
58
+
59
+
60
+ def _manifest_name(yaml_text: str, fallback: str) -> str:
61
+ try:
62
+ doc = yaml.safe_load(yaml_text) or {}
63
+ except Exception:
64
+ return fallback
65
+ if not isinstance(doc, dict):
66
+ return fallback
67
+ return str((doc.get("metadata") or {}).get("name") or fallback).strip() or fallback
68
+
69
+
70
+ def _layout_overlays_for(
71
+ store: Any | None, scope: Scope, *, kind: str, names: list[str]
72
+ ) -> list[tuple[Path, dict[str, Any]]]:
73
+ registry = _registry_for(store)
74
+ seen: set[Path] = set()
75
+ matched: list[tuple[Path, dict[str, Any]]] = []
76
+ for name in names:
77
+ if not name:
78
+ continue
79
+ for path, doc in registry.layout_overlays_targeting(scope.root, kind=kind, name=name):
80
+ key = path.resolve()
81
+ if key in seen:
82
+ continue
83
+ seen.add(key)
84
+ matched.append((path, doc))
85
+ return matched
86
+
87
+
88
+ def _project_application_canvas(
89
+ scope: Scope, bundle: ItemBundle, *, store: Any | None = None
90
+ ) -> None:
91
+ """Project registered layout-only canvas overlays onto a MAS item."""
92
+ mas_name = _manifest_name(bundle.manifest, bundle.name)
93
+ matched = _layout_overlays_for(
94
+ store, scope, kind="MAS", names=[mas_name, bundle.name]
95
+ )
96
+ mas_yaml, agents, _overlay_path = apply_ui_canvas_to_yaml(
97
+ bundle.root or scope.root, bundle.manifest, bundle.children, overlays=matched
98
+ )
99
+ bundle.manifest = mas_yaml
100
+ bundle.children = agents
101
+
102
+
103
+ def _project_overlay_canvas(
104
+ scope: Scope, bundle: ItemBundle, *, store: Any | None = None
105
+ ) -> None:
106
+ """Project a registered layout-only canvas overlay onto a spec overlay."""
107
+ name = _manifest_name(bundle.manifest, bundle.name)
108
+ matched = _layout_overlays_for(store, scope, kind="Overlay", names=[name, bundle.name])
109
+ yaml_out, _path = apply_ui_canvas_to_overlay_yaml(
110
+ bundle.manifest_path.parent if bundle.manifest_path is not None else scope.root,
111
+ bundle.manifest,
112
+ overlays=matched,
113
+ )
114
+ bundle.manifest = yaml_out
115
+
116
+
117
+ class ItemError(Exception):
118
+ def __init__(self, status: int, detail: Any):
119
+ super().__init__(str(detail))
120
+ self.status = status
121
+ self.detail = detail
122
+
123
+
124
+ def _catalog(store: Any | None = None) -> ScopeCatalog:
125
+ return get_scope_catalog(store)
126
+
127
+
128
+ def _scope(scope_id: str, store: Any | None = None) -> Scope:
129
+ try:
130
+ return _catalog(store).get(scope_id)
131
+ except KeyError as exc:
132
+ raise ItemError(404, f"scope {scope_id!r} not found") from exc
133
+
134
+
135
+ def _default_model() -> str | None:
136
+ try:
137
+ from mas.lab.controller.lab_registry import get_lab_registry
138
+
139
+ model = get_lab_registry().default_model()
140
+ return str(model) if model else None
141
+ except Exception:
142
+ return None
143
+
144
+
145
+ def group_items(scope: Scope, kind: str, *, q: str | None = None) -> list[dict[str, Any]]:
146
+ layout = layout_for(kind)
147
+ located = layout.list_located(scope)
148
+ by_name: dict[str, list] = {}
149
+ for item in located:
150
+ by_name.setdefault(item.name, []).append(item)
151
+ groups: list[dict[str, Any]] = []
152
+ needle = (q or "").strip().lower()
153
+ for name, items in sorted(by_name.items()):
154
+ versions, latest = versions_of(items)
155
+ if needle and needle not in name.lower() and not any(
156
+ needle in (i.title or "").lower() or needle in (i.summary or "").lower() for i in items
157
+ ):
158
+ continue
159
+ listed = []
160
+ for i in items:
161
+ row: dict[str, Any] = {
162
+ "id": i.item_id,
163
+ "version": i.version,
164
+ "ref": item_ref(scope, i.item_id),
165
+ "writable": scope.writable,
166
+ }
167
+ if kind == "overlay":
168
+ row["ui_canvas"] = _overlay_is_layout_only(i.manifest_path)
169
+ listed.append(row)
170
+ group: dict[str, Any] = {
171
+ "name": name,
172
+ "kind": kind,
173
+ "title": items[0].title or name,
174
+ "summary": items[0].summary,
175
+ "versions": versions,
176
+ "latest": latest,
177
+ "items": listed,
178
+ }
179
+ if kind == "overlay":
180
+ group["ui_canvas"] = any(row.get("ui_canvas") for row in listed)
181
+ groups.append(group)
182
+ return groups
183
+
184
+
185
+ def _safe_item_id(item: str) -> str:
186
+ try:
187
+ name, version = parse_item_id(item)
188
+ except UnsafeNameError as exc:
189
+ raise ItemError(400, str(exc)) from exc
190
+ return f"{name}@{version}" if version else name
191
+
192
+
193
+ def _next_version(latest: str | None) -> str:
194
+ if not latest:
195
+ return "v1"
196
+ major = latest.lstrip("v").split(".")[0]
197
+ try:
198
+ return f"v{int(major) + 1}"
199
+ except ValueError:
200
+ return "v2"
201
+
202
+
203
+ def get_item(scope_id: str, kind: str, item: str, *, store: Any | None = None) -> dict[str, Any]:
204
+ item = _safe_item_id(item)
205
+ scope = _scope(scope_id, store)
206
+ layout = layout_for(kind)
207
+ bundle = layout.read(scope, item)
208
+ if bundle is None:
209
+ raise ItemError(404, f"{kind} {item!r} not found")
210
+ if kind == "application":
211
+ _project_application_canvas(scope, bundle, store=store)
212
+ elif kind == "overlay":
213
+ _project_overlay_canvas(scope, bundle, store=store)
214
+ payload = bundle.public_dict()
215
+ if kind == "overlay":
216
+ payload["ui_canvas"] = _overlay_is_layout_only(bundle.manifest_path)
217
+ return payload
218
+
219
+
220
+ def check_name(
221
+ scope_id: str, kind: str, name: str, version: str | None = None, *, store: Any | None = None
222
+ ) -> dict[str, Any]:
223
+ try:
224
+ validate_name(name)
225
+ validate_version(version)
226
+ except UnsafeNameError as exc:
227
+ return {"ok": False, "reason": str(exc)}
228
+ scope = _scope(scope_id, store)
229
+ if not scope.writable:
230
+ return {"ok": False, "reason": "read_only", "copy_into": _copy_into_ids(store, kind)}
231
+ item_id = f"{name}@{version}" if version else name
232
+ if layout_for(kind).locate(scope, item_id) is not None:
233
+ return {"ok": False, "reason": "exists"}
234
+ return {"ok": True, "id": item_id, "ref": item_ref(scope, item_id)}
235
+
236
+
237
+ def _copy_into_ids(store: Any | None = None, kind: str | None = None) -> list[str]:
238
+ scopes = [s for s in _catalog(store).all() if s.writable]
239
+ if kind:
240
+ scopes = [s for s in scopes if kind in (s.kinds or [])]
241
+ return [s.id for s in scopes]
242
+
243
+
244
+ def _read_only_error(store: Any | None = None, kind: str | None = None) -> ItemError:
245
+ return ItemError(403, {"reason": "read_only", "copy_into": _copy_into_ids(store, kind)})
246
+
247
+
248
+ def create_item(
249
+ scope_id: str,
250
+ *,
251
+ kind: str,
252
+ name: str,
253
+ version: str | None = None,
254
+ template: str | None = None,
255
+ parent: str | None = None,
256
+ upload_id: str | None = None,
257
+ store: Any | None = None,
258
+ ) -> dict[str, Any]:
259
+ if kind not in KIND_CATALOG:
260
+ raise ItemError(400, f"unknown kind {kind!r}")
261
+ if not KIND_CATALOG[kind].get("creatable"):
262
+ raise ItemError(400, f"{kind} is not creatable")
263
+ try:
264
+ name = validate_name(name)
265
+ version = validate_version(version)
266
+ except UnsafeNameError as exc:
267
+ raise ItemError(400, str(exc)) from exc
268
+ scope = _scope(scope_id, store)
269
+ if not scope.writable:
270
+ raise _read_only_error(store, kind)
271
+ layout = layout_for(kind)
272
+ item_id = f"{name}@{version}" if version else name
273
+ if layout.locate(scope, item_id) is not None:
274
+ raise ItemError(409, f"{kind} {item_id!r} already exists")
275
+ copy_from = None
276
+ if template and template not in ("blank", ""):
277
+ copy_from = layout.read(scope, template)
278
+ if copy_from is None:
279
+ raise ItemError(404, f"template {template!r} not found")
280
+ initial_manifest, extra_writes = _payload_from_upload(scope, layout, name, version, upload_id)
281
+ return _commit_create(
282
+ scope,
283
+ kind,
284
+ name,
285
+ version,
286
+ template=template,
287
+ parent=parent,
288
+ copy_from=copy_from,
289
+ initial_manifest=initial_manifest,
290
+ extra_writes=extra_writes,
291
+ store=store,
292
+ )
293
+
294
+
295
+ def _payload_from_upload(
296
+ scope: Scope,
297
+ layout: Any,
298
+ name: str,
299
+ version: str | None,
300
+ upload_id: str | None,
301
+ ) -> tuple[str | None, list[FileWrite] | None]:
302
+ if not upload_id:
303
+ return None, None
304
+ from mas.lab.controller.uploads import get_upload_store
305
+
306
+ uploaded = get_upload_store().get(upload_id)
307
+ if uploaded is None:
308
+ raise ItemError(404, f"upload {upload_id!r} not found")
309
+ suffix = Path(uploaded.name).suffix.lower()
310
+ if suffix in {".yaml", ".yml"}:
311
+ return uploaded.path.read_text(encoding="utf-8"), None
312
+ dest = layout.default_create_root(scope, name, version) / f"{name}{suffix or '.dat'}"
313
+ return None, [FileWrite(dest, source=uploaded.path)]
314
+
315
+
316
+ def _commit_create(
317
+ scope: Scope,
318
+ kind: str,
319
+ name: str,
320
+ version: str | None,
321
+ *,
322
+ template: str | None = None,
323
+ parent: str | None = None,
324
+ copy_from: ItemBundle | None = None,
325
+ initial_manifest: str | None = None,
326
+ extra_writes: list | None = None,
327
+ store: Any | None = None,
328
+ ) -> dict[str, Any]:
329
+ layout = layout_for(kind)
330
+ item_id = f"{name}@{version}" if version else name
331
+ writes = layout.plan_create(
332
+ scope,
333
+ name=name,
334
+ version=version,
335
+ template=template,
336
+ parent=parent,
337
+ default_model=_default_model(),
338
+ copy_from=copy_from,
339
+ )
340
+ if initial_manifest:
341
+ for write in writes:
342
+ if not write.delete and write.path.suffix in {".yaml", ".yml"}:
343
+ write.content = initial_manifest
344
+ break
345
+ if extra_writes:
346
+ writes = list(writes) + list(extra_writes)
347
+ _validate_writes(scope, kind, writes)
348
+ apply_writes(scope, writes)
349
+ _invalidate(store)
350
+ bundle = layout.read(scope, item_id)
351
+ if bundle is None:
352
+ raise ItemError(500, "write succeeded but the item could not be read back")
353
+ return bundle.public_dict()
354
+
355
+
356
+ def update_item(
357
+ scope_id: str,
358
+ kind: str,
359
+ item: str,
360
+ *,
361
+ manifest: str,
362
+ children: dict[str, str] | None = None,
363
+ revision: str,
364
+ store: Any | None = None,
365
+ ) -> dict[str, Any]:
366
+ item = _safe_item_id(item)
367
+ scope = _scope(scope_id, store)
368
+ if not scope.writable:
369
+ raise _read_only_error(store, kind)
370
+ layout = layout_for(kind)
371
+ current = layout.read(scope, item)
372
+ located = layout.locate(scope, item)
373
+ if current is None or located is None:
374
+ raise ItemError(404, f"{kind} {item!r} not found")
375
+ if current.revision != revision:
376
+ raise ItemError(
377
+ 409,
378
+ {"reason": "stale_revision", "revision": current.revision, "item": current.public_dict()},
379
+ )
380
+ current.manifest = manifest
381
+ current.children = children or {}
382
+ if kind == "application" and current.root is not None:
383
+ mas_yaml, agents = persist_ui_canvas_yaml(
384
+ current.root, current.manifest, current.children, mas_name=current.name
385
+ )
386
+ current.manifest = mas_yaml
387
+ current.children = agents
388
+ elif kind == "overlay" and current.manifest_path is not None:
389
+ current.manifest = persist_overlay_canvas_yaml(
390
+ current.manifest_path.parent, current.manifest, overlay_name=current.name
391
+ )
392
+ writes = layout.plan_update(scope, current, located)
393
+ _validate_writes(scope, kind, writes)
394
+ apply_writes(scope, writes)
395
+ _invalidate(store)
396
+ bundle = layout.read(scope, item)
397
+ if bundle is None:
398
+ raise ItemError(500, "update succeeded but the item could not be read back")
399
+ return bundle.public_dict()
400
+
401
+
402
+ def delete_item(scope_id: str, kind: str, item: str, *, store: Any | None = None) -> None:
403
+ item = _safe_item_id(item)
404
+ scope = _scope(scope_id, store)
405
+ if not scope.writable:
406
+ raise _read_only_error(store, kind)
407
+ layout = layout_for(kind)
408
+ located = layout.locate(scope, item)
409
+ if located is None:
410
+ raise ItemError(404, f"{kind} {item!r} not found")
411
+ apply_writes(scope, layout.plan_delete(scope, located))
412
+ _invalidate(store)
413
+
414
+
415
+ def fork_item(
416
+ scope_id: str,
417
+ kind: str,
418
+ item: str,
419
+ *,
420
+ version: str | None = None,
421
+ name: str | None = None,
422
+ target_scope: str | None = None,
423
+ store: Any | None = None,
424
+ ) -> dict[str, Any]:
425
+ item = _safe_item_id(item)
426
+ source_scope = _scope(scope_id, store)
427
+ dest_scope = _scope(target_scope, store) if target_scope else source_scope
428
+ if not dest_scope.writable:
429
+ raise _read_only_error(store, kind)
430
+ layout = layout_for(kind)
431
+ bundle = layout.read(source_scope, item)
432
+ if bundle is None:
433
+ raise ItemError(404, f"{kind} {item!r} not found")
434
+ try:
435
+ new_name = validate_name(name) if name else bundle.name
436
+ new_version = validate_version(version) if version else bundle.version
437
+ except UnsafeNameError as exc:
438
+ raise ItemError(400, str(exc)) from exc
439
+ if new_version is None and KIND_CATALOG.get(kind, {}).get("versioned"):
440
+ _versions, latest = layout.versions_for(dest_scope, new_name)
441
+ new_version = _next_version(latest)
442
+ if dest_scope.id == source_scope.id:
443
+ return create_item(
444
+ dest_scope.id,
445
+ kind=kind,
446
+ name=new_name,
447
+ version=new_version,
448
+ template=bundle.item_id,
449
+ store=store,
450
+ )
451
+ return _copy_across(dest_scope, kind, new_name, new_version, bundle, store)
452
+
453
+
454
+ def _copy_across(
455
+ dest: Scope, kind: str, name: str, version: str | None, bundle: ItemBundle, store: Any | None
456
+ ) -> dict[str, Any]:
457
+ layout = layout_for(kind)
458
+ item_id = f"{name}@{version}" if version else name
459
+ if layout.locate(dest, item_id) is not None:
460
+ raise ItemError(409, f"{kind} {item_id!r} already exists")
461
+ return _commit_create(dest, kind, name, version, copy_from=bundle, store=store)
462
+
463
+
464
+ def rename_item(
465
+ scope_id: str,
466
+ kind: str,
467
+ item: str,
468
+ *,
469
+ name: str,
470
+ store: Any | None = None,
471
+ ) -> dict[str, Any]:
472
+ item = _safe_item_id(item)
473
+ scope = _scope(scope_id, store)
474
+ if not scope.writable:
475
+ raise _read_only_error(store, kind)
476
+ new_name = validate_name(name)
477
+ layout = layout_for(kind)
478
+ bundle = layout.read(scope, item)
479
+ located = layout.locate(scope, item)
480
+ if bundle is None or located is None:
481
+ raise ItemError(404, f"{kind} {item!r} not found")
482
+ old_ref = bundle.ref
483
+ new_id = f"{new_name}@{bundle.version}" if bundle.version else new_name
484
+ new_ref = item_ref(scope, new_id)
485
+ if layout.locate(scope, new_id) is not None:
486
+ raise ItemError(409, f"{kind} {new_id!r} already exists")
487
+ writes = layout.plan_create(
488
+ scope,
489
+ name=new_name,
490
+ version=bundle.version,
491
+ template=None,
492
+ parent=None,
493
+ default_model=None,
494
+ copy_from=bundle,
495
+ )
496
+ changed = [new_id]
497
+ for other_kind in KIND_CATALOG:
498
+ if other_kind == kind:
499
+ continue
500
+ try:
501
+ other_layout = layout_for(other_kind)
502
+ except KeyError:
503
+ continue
504
+ for other in other_layout.list_located(scope):
505
+ if not other.manifest_path.is_file():
506
+ continue
507
+ text = other.manifest_path.read_text(encoding="utf-8")
508
+ updated = _rewrite_token(text, old_ref, new_ref)
509
+ updated = _rewrite_token(updated, bundle.item_id, new_id)
510
+ if updated == text:
511
+ continue
512
+ writes.append(FileWrite(other.manifest_path, updated))
513
+ changed.append(other.item_id)
514
+ writes.extend(layout.plan_delete(scope, located))
515
+ _validate_writes(scope, kind, writes)
516
+ apply_writes(scope, writes)
517
+ _invalidate(store)
518
+ created = layout.read(scope, new_id)
519
+ if created is None:
520
+ raise ItemError(500, "rename succeeded but the item could not be read back")
521
+ return {"id": created.item_id, "ref": created.ref, "changed_items": changed}
522
+
523
+
524
+ def _rewrite_token(text: str, old: str, new: str) -> str:
525
+ """Replace *old* only when it is not a substring of a longer identifier."""
526
+ if not old or old == new or old not in text:
527
+ return text
528
+ pattern = re.compile(rf"(?<![\w./:@-]){re.escape(old)}(?![\w./:@-])")
529
+ return pattern.sub(new, text)
530
+
531
+
532
+ def resolve_ref(ref: str, *, store: Any | None = None) -> dict[str, Any]:
533
+ from mas.version_spec import split_library_prefix
534
+
535
+ scheme, rest = split_library_prefix(ref)
536
+ catalog = _catalog(store)
537
+ if scheme:
538
+ try:
539
+ scope = catalog.get(f"library:{scheme}")
540
+ except KeyError:
541
+ try:
542
+ scope = catalog.get(f"lab:{scheme}")
543
+ except KeyError as exc:
544
+ raise ItemError(404, f"unknown library {scheme!r} in ref") from exc
545
+ else:
546
+ raise ItemError(400, "ref must be of the form library:item")
547
+ name, spec = _parse_id_or_range(rest)
548
+ # Try application first, then other kinds.
549
+ for kind in ("application", "dataset", "tool", "overlay", "pipeline", "experiment", "agent", "skill"):
550
+ layout = layout_for(kind)
551
+ located = [i for i in layout.list_located(scope) if i.name == name]
552
+ if not located:
553
+ continue
554
+ chosen = _pick_version(located, spec)
555
+ if chosen is None:
556
+ continue
557
+ bundle = layout.read(scope, chosen.item_id)
558
+ if bundle is None:
559
+ continue
560
+ return {
561
+ "ref": ref,
562
+ "resolved_id": bundle.item_id,
563
+ "scope": scope.id,
564
+ "kind": kind,
565
+ "item": bundle.public_dict(),
566
+ }
567
+ raise ItemError(404, f"could not resolve {ref!r}")
568
+
569
+
570
+ def _parse_id_or_range(rest: str) -> tuple[str, str | None]:
571
+ if "@" not in rest:
572
+ return rest, None
573
+ name, _, spec = rest.rpartition("@")
574
+ return name, spec or None
575
+
576
+
577
+ def _pick_version(located: list, spec: str | None):
578
+ if spec is None or spec.lower() == "latest":
579
+ versioned = [i for i in located if i.version]
580
+ if versioned:
581
+ from mas.version_spec import version_sort_key
582
+
583
+ return max(versioned, key=lambda i: version_sort_key(i.version or "v0"))
584
+ return located[0]
585
+ if spec.startswith("^") or any(spec.startswith(op) for op in (">=", "<=", ">", "<", "~=")):
586
+ matches = [i for i in located if i.version and version_satisfies(i.version, spec)]
587
+ if not matches:
588
+ return None
589
+ from mas.version_spec import version_sort_key
590
+
591
+ return max(matches, key=lambda i: version_sort_key(i.version or "v0"))
592
+ for item in located:
593
+ if item.version == spec or item.item_id.endswith(f"@{spec}"):
594
+ return item
595
+ return None
596
+
597
+
598
+ def _validate_writes(scope: Scope, kind: str, writes: list) -> None:
599
+ from mas.lab.controller.manifest_validation import validate_manifest_yaml_content
600
+
601
+ errors: list[str] = []
602
+ for write in writes:
603
+ if write.delete or not write.content.strip():
604
+ continue
605
+ result = validate_manifest_yaml_content(
606
+ write.content,
607
+ base_dir=scope.root,
608
+ resolve_refs=False,
609
+ )
610
+ if not result.get("valid", True):
611
+ errors.append(result.get("stderr") or "invalid manifest")
612
+ if errors:
613
+ raise ItemError(422, {"reason": "invalid_manifest", "errors": errors})
614
+
615
+
616
+ def _invalidate(store: Any | None) -> None:
617
+ if store is not None and hasattr(store, "invalidate"):
618
+ store.invalidate()
619
+ else:
620
+ try:
621
+ from mas.lab.controller.deps import get_manifest_store
622
+
623
+ get_manifest_store().invalidate()
624
+ except Exception:
625
+ pass