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.
- mas/lab/controller/__init__.py +7 -0
- mas/lab/controller/__main__.py +5 -0
- mas/lab/controller/agent_chat.py +226 -0
- mas/lab/controller/api.py +364 -0
- mas/lab/controller/artifact_discovery.py +107 -0
- mas/lab/controller/artifacts.py +112 -0
- mas/lab/controller/cli.py +493 -0
- mas/lab/controller/client.py +271 -0
- mas/lab/controller/config.py +75 -0
- mas/lab/controller/constants.py +30 -0
- mas/lab/controller/daemon.py +190 -0
- mas/lab/controller/deps.py +172 -0
- mas/lab/controller/fastapi_app.py +166 -0
- mas/lab/controller/http_server.py +243 -0
- mas/lab/controller/io_capture.py +55 -0
- mas/lab/controller/item_service.py +625 -0
- mas/lab/controller/job_store.py +209 -0
- mas/lab/controller/jobs.py +375 -0
- mas/lab/controller/kinds.py +149 -0
- mas/lab/controller/lab_registry.py +1076 -0
- mas/lab/controller/manifest_layout.py +832 -0
- mas/lab/controller/manifest_store.py +214 -0
- mas/lab/controller/manifest_validation.py +90 -0
- mas/lab/controller/models.py +215 -0
- mas/lab/controller/path_safety.py +68 -0
- mas/lab/controller/pipeline_validation.py +79 -0
- mas/lab/controller/registry.py +83 -0
- mas/lab/controller/routes/__init__.py +82 -0
- mas/lab/controller/routes/_api.py +15 -0
- mas/lab/controller/routes/apps.py +190 -0
- mas/lab/controller/routes/artifacts.py +37 -0
- mas/lab/controller/routes/benchmark.py +307 -0
- mas/lab/controller/routes/choices.py +141 -0
- mas/lab/controller/routes/datasets.py +91 -0
- mas/lab/controller/routes/design_patterns.py +121 -0
- mas/lab/controller/routes/eval_output.py +31 -0
- mas/lab/controller/routes/experiments.py +194 -0
- mas/lab/controller/routes/health.py +178 -0
- mas/lab/controller/routes/items.py +187 -0
- mas/lab/controller/routes/jobs.py +107 -0
- mas/lab/controller/routes/libraries.py +144 -0
- mas/lab/controller/routes/overlays.py +164 -0
- mas/lab/controller/routes/pipelines.py +139 -0
- mas/lab/controller/routes/registry.py +40 -0
- mas/lab/controller/routes/run.py +215 -0
- mas/lab/controller/routes/runs.py +392 -0
- mas/lab/controller/routes/scopes.py +139 -0
- mas/lab/controller/routes/uploads.py +24 -0
- mas/lab/controller/routes/validate.py +36 -0
- mas/lab/controller/run_bridge.py +276 -0
- mas/lab/controller/run_store.py +190 -0
- mas/lab/controller/schema_registry.py +109 -0
- mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
- mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
- mas/lab/controller/scope_catalog.py +266 -0
- mas/lab/controller/scope_service.py +134 -0
- mas/lab/controller/sessions.py +63 -0
- mas/lab/controller/settings.py +65 -0
- mas/lab/controller/ui_stream/__init__.py +13 -0
- mas/lab/controller/ui_stream/app.py +2147 -0
- mas/lab/controller/ui_stream/artefact_bind.py +308 -0
- mas/lab/controller/ui_stream/auth.py +136 -0
- mas/lab/controller/ui_stream/bus.py +156 -0
- mas/lab/controller/ui_stream/export.py +400 -0
- mas/lab/controller/ui_stream/governance.py +141 -0
- mas/lab/controller/ui_stream/ingest.py +300 -0
- mas/lab/controller/ui_stream/inspect.py +55 -0
- mas/lab/controller/ui_stream/layouts.py +161 -0
- mas/lab/controller/ui_stream/metrics.py +274 -0
- mas/lab/controller/ui_stream/plot_folds.py +609 -0
- mas/lab/controller/ui_stream/plugin_registry.py +139 -0
- mas/lab/controller/ui_stream/refs.py +259 -0
- mas/lab/controller/ui_stream/replay.py +236 -0
- mas/lab/controller/ui_stream/replay_cli.py +707 -0
- mas/lab/controller/ui_stream/run_index.py +337 -0
- mas/lab/controller/ui_stream/spec_load.py +48 -0
- mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
- mas/lab/controller/ui_stream/stream.py +95 -0
- mas/lab/controller/ui_stream/streams.py +42 -0
- mas/lab/controller/ui_stream/timeline.py +183 -0
- mas/lab/controller/ui_stream/ui_trace.py +258 -0
- mas/lab/controller/uploads.py +61 -0
- mas/lab/controller/worker_model.py +137 -0
- mas/lab/controller/workers.py +296 -0
- mas/lab/controller/workspace_library.py +70 -0
- mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
- mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
- mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
- 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
|