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,1076 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Unified lab registry — single point of contact for controller + UI discovery.
|
|
4
|
+
|
|
5
|
+
Extends the runtime object registry (``mas.registry``, plugin URN registry) with
|
|
6
|
+
lab-specific resources: libraries, experiments, pipelines, datasets, MAS apps,
|
|
7
|
+
and pipeline step types.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import importlib.resources
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from importlib.metadata import entry_points as _entry_points
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from mas.runtime.constants import (
|
|
22
|
+
LAB_CONFIG_FILENAME,
|
|
23
|
+
LIBRARY_MANIFEST_FILENAME,
|
|
24
|
+
WORKSPACE_CONFIG_FILENAME,
|
|
25
|
+
)
|
|
26
|
+
from mas.ctl.overlay.canvas import overlay_is_layout_only
|
|
27
|
+
from mas.runtime.spec.source import load_yaml_file
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_VERSION_DIR = re.compile(r"^v\d+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _library_description(path: Path) -> str:
|
|
35
|
+
for candidate in (path / LAB_CONFIG_FILENAME, path / "mas.yaml", path / "README.md"):
|
|
36
|
+
if candidate.exists():
|
|
37
|
+
try:
|
|
38
|
+
if candidate.suffix == ".yaml":
|
|
39
|
+
data = load_yaml_file(candidate)
|
|
40
|
+
desc = (
|
|
41
|
+
data.get("description")
|
|
42
|
+
or data.get("metadata", {}).get("description")
|
|
43
|
+
or (data.get("lab") or {}).get("description")
|
|
44
|
+
)
|
|
45
|
+
if desc:
|
|
46
|
+
return str(desc).strip().splitlines()[0][:200]
|
|
47
|
+
else:
|
|
48
|
+
return candidate.read_text(encoding="utf-8").splitlines()[0][:200]
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _manifest_library_names(path: Path) -> list[str]:
|
|
55
|
+
"""Directory stem plus ``library.yaml`` ``name`` / schemes (lookup aliases)."""
|
|
56
|
+
names: list[str] = []
|
|
57
|
+
stem = path.stem if path.name.endswith(".lab") else path.name
|
|
58
|
+
if stem:
|
|
59
|
+
names.append(stem)
|
|
60
|
+
manifest = path / LIBRARY_MANIFEST_FILENAME
|
|
61
|
+
if not manifest.is_file():
|
|
62
|
+
return names
|
|
63
|
+
try:
|
|
64
|
+
data = load_yaml_file(manifest)
|
|
65
|
+
except Exception:
|
|
66
|
+
return names
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
return names
|
|
69
|
+
pkg = str(data.get("name") or "").strip()
|
|
70
|
+
if pkg and pkg not in names:
|
|
71
|
+
names.append(pkg)
|
|
72
|
+
for key in ("schemes", "aliases"):
|
|
73
|
+
raw = data.get(key) or []
|
|
74
|
+
if not isinstance(raw, list):
|
|
75
|
+
continue
|
|
76
|
+
for item in raw:
|
|
77
|
+
text = str(item).strip()
|
|
78
|
+
if text and text not in names:
|
|
79
|
+
names.append(text)
|
|
80
|
+
return names
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
from mas.runtime.agent_defaults import (
|
|
84
|
+
agent_defaults as _agent_defaults,
|
|
85
|
+
resolve_default_model,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_configured_path(entry: str, base: Path | None) -> Path:
|
|
90
|
+
"""Resolve a config.yaml path: absolute stays absolute, else relative to *base*."""
|
|
91
|
+
path = Path(str(entry).strip()).expanduser()
|
|
92
|
+
if not path.is_absolute() and base is not None:
|
|
93
|
+
path = base / path
|
|
94
|
+
return path.resolve()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _iter_lab_dirs(search: Path):
|
|
98
|
+
"""Yield lab roots under *search*.
|
|
99
|
+
|
|
100
|
+
*search* may be a directory of ``*.lab`` folders, a ``*.lab`` folder itself,
|
|
101
|
+
or any directory that contains ``lab-config.yaml``.
|
|
102
|
+
"""
|
|
103
|
+
if not search.exists() or not search.is_dir():
|
|
104
|
+
return
|
|
105
|
+
if search.name.endswith(".lab") or (search / LAB_CONFIG_FILENAME).is_file():
|
|
106
|
+
yield search
|
|
107
|
+
return
|
|
108
|
+
seen: set[Path] = set()
|
|
109
|
+
for path in sorted(search.glob("*.lab")):
|
|
110
|
+
if path.is_dir():
|
|
111
|
+
seen.add(path.resolve())
|
|
112
|
+
yield path
|
|
113
|
+
for path in sorted(search.iterdir()):
|
|
114
|
+
if not path.is_dir() or path.resolve() in seen:
|
|
115
|
+
continue
|
|
116
|
+
if (path / LAB_CONFIG_FILENAME).is_file():
|
|
117
|
+
yield path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_YAML_SUFFIXES = frozenset({".yaml", ".yml"})
|
|
121
|
+
"""Directories that group manifests by role (path convention — filenames are free)."""
|
|
122
|
+
_TYPED_DIRS = frozenset({"datasets", "overlays", "flavours", "infra", "apps"})
|
|
123
|
+
_DISCOVERY_SKIP_DIRS = _TYPED_DIRS | {".git", "node_modules", "__pycache__", ".cache"}
|
|
124
|
+
|
|
125
|
+
_MAS_MANIFEST_NAMES = ("mas.yaml", "mas-bench.yaml")
|
|
126
|
+
|
|
127
|
+
_REGISTRY: Optional["LabRegistry"] = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_yaml_file(path: Path) -> bool:
|
|
131
|
+
return path.is_file() and path.suffix.lower() in _YAML_SUFFIXES
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _iter_library_yaml(lib_dir: Path):
|
|
135
|
+
"""All YAML files under a library root (discovery walks content, not names)."""
|
|
136
|
+
if not lib_dir.is_dir():
|
|
137
|
+
return
|
|
138
|
+
for path in sorted(lib_dir.rglob("*")):
|
|
139
|
+
if _is_yaml_file(path):
|
|
140
|
+
yield path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _iter_manifest_files(lib_dir: Path, expected_kind: str, *, parent_dir: str | None = None):
|
|
144
|
+
"""Yield YAML files whose declared kind matches *expected_kind*."""
|
|
145
|
+
seen: set[Path] = set()
|
|
146
|
+
for path in _iter_library_yaml(lib_dir):
|
|
147
|
+
rel_parts = path.relative_to(lib_dir).parts
|
|
148
|
+
if parent_dir is not None:
|
|
149
|
+
if parent_dir not in rel_parts:
|
|
150
|
+
continue
|
|
151
|
+
elif _DISCOVERY_SKIP_DIRS.intersection(rel_parts):
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
doc = load_yaml_file(path)
|
|
155
|
+
except Exception:
|
|
156
|
+
continue
|
|
157
|
+
from mas.ctl.validate.schemas import declared_kind
|
|
158
|
+
|
|
159
|
+
if declared_kind(doc) != expected_kind:
|
|
160
|
+
continue
|
|
161
|
+
key = path.resolve()
|
|
162
|
+
if key not in seen:
|
|
163
|
+
seen.add(key)
|
|
164
|
+
yield path
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _mas_name_from_overlay_path(path: Path, lib_dir: Path) -> str | None:
|
|
168
|
+
"""Return ``apps/<id>`` when *path* sits in that app's ``overlays/`` folder."""
|
|
169
|
+
try:
|
|
170
|
+
parts = list(path.resolve().relative_to(lib_dir.resolve()).parts)
|
|
171
|
+
except ValueError:
|
|
172
|
+
parts = list(path.parts)
|
|
173
|
+
if "apps" not in parts:
|
|
174
|
+
return None
|
|
175
|
+
rest = parts[parts.index("apps") + 1 :]
|
|
176
|
+
if len(rest) < 2:
|
|
177
|
+
return None
|
|
178
|
+
app_id = rest[0]
|
|
179
|
+
if rest[1] == "overlays":
|
|
180
|
+
return app_id
|
|
181
|
+
if _VERSION_DIR.match(rest[1]) and len(rest) >= 3 and rest[2] == "overlays":
|
|
182
|
+
return app_id
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def infer_overlay_target(
|
|
187
|
+
path: Path, doc: dict[str, Any], *, lib_dir: Path
|
|
188
|
+
) -> tuple[str, str] | None:
|
|
189
|
+
"""``(kind, name)`` this overlay attaches to, filling an empty MAS name from the path."""
|
|
190
|
+
if not isinstance(doc, dict) or str(doc.get("kind") or "") != "Overlay":
|
|
191
|
+
return None
|
|
192
|
+
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
|
|
193
|
+
target = spec.get("target") if isinstance(spec.get("target"), dict) else {}
|
|
194
|
+
kind = str(target.get("kind") or "").strip()
|
|
195
|
+
name = str(target.get("name") or "").strip()
|
|
196
|
+
if not kind:
|
|
197
|
+
inferred = _mas_name_from_overlay_path(path, lib_dir)
|
|
198
|
+
return ("MAS", inferred) if inferred else None
|
|
199
|
+
if name:
|
|
200
|
+
return kind, name
|
|
201
|
+
if kind.lower() == "mas":
|
|
202
|
+
inferred = _mas_name_from_overlay_path(path, lib_dir)
|
|
203
|
+
return ("MAS", inferred) if inferred else None
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class LabRegistry:
|
|
208
|
+
"""Lab-facing registry: runtime objects + workspace libraries + lab artifacts."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, workspace: Any = None) -> None:
|
|
211
|
+
self._workspace = workspace
|
|
212
|
+
self._libraries: Dict[str, Path] = {}
|
|
213
|
+
self._library_sources: Dict[str, str] = {}
|
|
214
|
+
self._refreshed = False
|
|
215
|
+
# Cache of classified manifest paths keyed by (lib_dir, expected_kind,
|
|
216
|
+
# parent_dir): populated lazily on first access per library/kind and
|
|
217
|
+
# reused for the lifetime of the current refresh() cycle. Without this,
|
|
218
|
+
# every list_experiments()/list_pipelines()/list_datasets()/
|
|
219
|
+
# list_overlays() call re-walked the entire library tree and re-parsed
|
|
220
|
+
# every YAML file to reclassify its declared kind, even though the
|
|
221
|
+
# result is invariant until the next explicit refresh().
|
|
222
|
+
self._manifest_file_cache: Dict[tuple[Path, str, str | None], List[Path]] = {}
|
|
223
|
+
self._overlay_record_cache: Dict[Path, List[Dict[str, Any]]] = {}
|
|
224
|
+
eager = os.environ.get("MAS_LAB_REGISTRY_EAGER_REFRESH", "1").strip().lower()
|
|
225
|
+
if eager not in {"0", "false", "no"}:
|
|
226
|
+
self.refresh()
|
|
227
|
+
|
|
228
|
+
def _ensure_ready(self) -> None:
|
|
229
|
+
if self._libraries:
|
|
230
|
+
return
|
|
231
|
+
if not self._refreshed:
|
|
232
|
+
self.refresh()
|
|
233
|
+
|
|
234
|
+
def refresh(self) -> None:
|
|
235
|
+
self._libraries, self._library_sources = self._discover_library_paths()
|
|
236
|
+
self._manifest_file_cache = {}
|
|
237
|
+
self._overlay_record_cache = {}
|
|
238
|
+
self._log_discovery()
|
|
239
|
+
self._refreshed = True
|
|
240
|
+
|
|
241
|
+
def invalidate_manifest_cache(self) -> None:
|
|
242
|
+
"""Drop cached experiment/pipeline/dataset/overlay file listings.
|
|
243
|
+
|
|
244
|
+
The CRUD routes write/delete manifest files directly on the
|
|
245
|
+
filesystem rather than through this registry, so they must call this
|
|
246
|
+
after any such write so the next ``list_*`` call rescans instead of
|
|
247
|
+
returning a listing cached from before the write.
|
|
248
|
+
"""
|
|
249
|
+
self._manifest_file_cache = {}
|
|
250
|
+
self._overlay_record_cache = {}
|
|
251
|
+
|
|
252
|
+
def _cached_manifest_files(
|
|
253
|
+
self, lib_dir: Path, expected_kind: str, *, parent_dir: str | None = None
|
|
254
|
+
) -> List[Path]:
|
|
255
|
+
"""Scan-once wrapper around :func:`_iter_manifest_files`.
|
|
256
|
+
|
|
257
|
+
The underlying scan (full ``rglob`` + YAML-parse-and-classify per file)
|
|
258
|
+
is only ever performed once per (library, kind) pair for the current
|
|
259
|
+
refresh() cycle; subsequent calls reuse the cached path list.
|
|
260
|
+
"""
|
|
261
|
+
cache = self._manifest_file_cache
|
|
262
|
+
key = (lib_dir, expected_kind, parent_dir)
|
|
263
|
+
cached = cache.get(key)
|
|
264
|
+
if cached is None:
|
|
265
|
+
cached = list(_iter_manifest_files(lib_dir, expected_kind, parent_dir=parent_dir))
|
|
266
|
+
cache[key] = cached
|
|
267
|
+
return cached
|
|
268
|
+
|
|
269
|
+
# ── Unified query API (UI / CLI / daemon) ─────────────────────────────
|
|
270
|
+
#
|
|
271
|
+
# ``spec_key`` matches agent manifest ``spec.<key>`` (e.g. design_pattern).
|
|
272
|
+
# Runtime spec keys delegate to :func:`mas.runtime.registry.get_registry`.
|
|
273
|
+
# Lab-only keys (pipeline_step, experiment, …) are resolved here.
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _runtime_spec_keys() -> frozenset[str]:
|
|
277
|
+
from mas.runtime.registry import get_registry
|
|
278
|
+
|
|
279
|
+
return get_registry().runtime_spec_keys()
|
|
280
|
+
|
|
281
|
+
def _runtime_registry(self):
|
|
282
|
+
from mas.runtime.registry import get_registry
|
|
283
|
+
|
|
284
|
+
return get_registry()
|
|
285
|
+
|
|
286
|
+
def resolve(
|
|
287
|
+
self,
|
|
288
|
+
spec_key: str,
|
|
289
|
+
binding: dict | None = None,
|
|
290
|
+
*,
|
|
291
|
+
manifest: dict | None = None,
|
|
292
|
+
) -> Any:
|
|
293
|
+
"""Resolve and instantiate ``spec.<spec_key>`` (runtime) or lab artifact."""
|
|
294
|
+
if spec_key in self._runtime_spec_keys():
|
|
295
|
+
return self._runtime_registry().create(spec_key, binding, manifest=manifest)
|
|
296
|
+
raise KeyError(f"LabRegistry.resolve: unknown spec key {spec_key!r}")
|
|
297
|
+
|
|
298
|
+
def list(self, spec_key: str | None = None) -> Any:
|
|
299
|
+
"""List plugins or artifacts. *spec_key* = manifest ``spec.<key>`` name."""
|
|
300
|
+
if spec_key is None:
|
|
301
|
+
return self.catalog()
|
|
302
|
+
if spec_key in self._runtime_spec_keys():
|
|
303
|
+
return self._runtime_registry().list(spec_key)
|
|
304
|
+
if spec_key == "pipeline_step":
|
|
305
|
+
return self.pipeline_step_types()
|
|
306
|
+
raise KeyError(f"LabRegistry.list: unknown spec key {spec_key!r}")
|
|
307
|
+
|
|
308
|
+
def runtime_objects(self, kind: str) -> Dict[str, Path]:
|
|
309
|
+
"""Objects from ``mas.<kind>s`` entry points (app, dataset, tool).
|
|
310
|
+
|
|
311
|
+
Uses a single discovery pass (``list_objects``) rather than looping
|
|
312
|
+
``get(kind, name)`` per name — each call to ``get``/``list_names``
|
|
313
|
+
independently re-walks every library root and re-parses every
|
|
314
|
+
manifest of that kind, so a per-name loop turns an O(1) scan into an
|
|
315
|
+
O(N) one (this was the actual source of multi-second discovery
|
|
316
|
+
reports, not the directory walk itself).
|
|
317
|
+
"""
|
|
318
|
+
try:
|
|
319
|
+
from mas.registry import list_objects
|
|
320
|
+
|
|
321
|
+
return list_objects(kind)
|
|
322
|
+
except ImportError:
|
|
323
|
+
return {}
|
|
324
|
+
|
|
325
|
+
def list_runtime_object_names(self) -> Dict[str, List[str]]:
|
|
326
|
+
out: Dict[str, List[str]] = {}
|
|
327
|
+
for kind in ("app", "dataset", "tool"):
|
|
328
|
+
out[kind] = sorted(self.runtime_objects(kind))
|
|
329
|
+
return out
|
|
330
|
+
|
|
331
|
+
# ── Agent / infra defaults ────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
def default_model(self) -> str:
|
|
334
|
+
"""Workspace default model (logical default, not infra)."""
|
|
335
|
+
return resolve_default_model(self._workspace)
|
|
336
|
+
|
|
337
|
+
def agent_defaults(self) -> Dict[str, Any]:
|
|
338
|
+
return _agent_defaults(self._workspace)
|
|
339
|
+
|
|
340
|
+
# ── Libraries ───────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def library_paths(self) -> Dict[str, Path]:
|
|
343
|
+
self._ensure_ready()
|
|
344
|
+
return dict(self._libraries)
|
|
345
|
+
|
|
346
|
+
def libraries(self) -> List[Dict[str, str]]:
|
|
347
|
+
self._ensure_ready()
|
|
348
|
+
items: List[Dict[str, str]] = []
|
|
349
|
+
for slug, path in sorted(self._libraries.items()):
|
|
350
|
+
items.append(
|
|
351
|
+
{
|
|
352
|
+
"dir": slug,
|
|
353
|
+
"name": slug.replace("-", " ").replace("_", " ").title(),
|
|
354
|
+
"description": _library_description(path),
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
return items
|
|
358
|
+
|
|
359
|
+
def library_root(self, library: str) -> Path:
|
|
360
|
+
self._ensure_ready()
|
|
361
|
+
root = self._libraries.get(library)
|
|
362
|
+
if root is not None:
|
|
363
|
+
return root
|
|
364
|
+
for path in self._libraries.values():
|
|
365
|
+
if path.name == library:
|
|
366
|
+
return path
|
|
367
|
+
try:
|
|
368
|
+
from mas.library_roots import resolve_named_library_root
|
|
369
|
+
|
|
370
|
+
found = resolve_named_library_root(library)
|
|
371
|
+
if found is not None:
|
|
372
|
+
return found
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
375
|
+
raise KeyError(f"Library {library!r} not found")
|
|
376
|
+
|
|
377
|
+
def catalog(self) -> Dict[str, Any]:
|
|
378
|
+
"""Full registry snapshot for ``GET /api/registry``."""
|
|
379
|
+
runtime = self._runtime_registry()
|
|
380
|
+
spec_catalog = {
|
|
381
|
+
key: runtime.list(key) for key in sorted(self._runtime_spec_keys())
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
"runtime": self.list_runtime_object_names(),
|
|
385
|
+
"spec": spec_catalog,
|
|
386
|
+
"pipeline_steps": self.pipeline_step_types(),
|
|
387
|
+
"libraries": [lib["dir"] for lib in self.libraries()],
|
|
388
|
+
"defaults": self.agent_defaults(),
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
# ── Library artifacts ───────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
def list_experiments(self, library: str) -> List[Dict[str, Any]]:
|
|
394
|
+
root = self.library_root(library)
|
|
395
|
+
return [self._parse_experiment_metadata(p, root) for p in self._iter_experiment_files(root)]
|
|
396
|
+
|
|
397
|
+
def list_pipelines(self, library: str) -> List[Dict[str, Any]]:
|
|
398
|
+
root = self.library_root(library)
|
|
399
|
+
return [self._parse_pipeline_metadata(p, root) for p in self._iter_pipeline_files(root)]
|
|
400
|
+
|
|
401
|
+
def list_datasets(self, library: str) -> List[Dict[str, str]]:
|
|
402
|
+
root = self.library_root(library)
|
|
403
|
+
datasets: List[Dict[str, str]] = []
|
|
404
|
+
for path in self._iter_dataset_files(root):
|
|
405
|
+
rel = str(path.relative_to(root))
|
|
406
|
+
datasets.append(
|
|
407
|
+
{
|
|
408
|
+
"name": path.name,
|
|
409
|
+
"path": rel,
|
|
410
|
+
"description": self._dataset_description(path),
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
return datasets
|
|
414
|
+
|
|
415
|
+
def list_overlays(self, library: str) -> List[Dict[str, Any]]:
|
|
416
|
+
root = self.library_root(library)
|
|
417
|
+
overlays: List[Dict[str, Any]] = []
|
|
418
|
+
for rec in self.overlay_records(root):
|
|
419
|
+
path = rec["path"]
|
|
420
|
+
rel = str(path.relative_to(root))
|
|
421
|
+
data = rec["doc"]
|
|
422
|
+
meta = data.get("metadata") if isinstance(data.get("metadata"), dict) else {}
|
|
423
|
+
overlays.append(
|
|
424
|
+
{
|
|
425
|
+
"name": path.stem,
|
|
426
|
+
"filename": path.name,
|
|
427
|
+
"path": rel,
|
|
428
|
+
"ui_canvas": rec["layout_only"],
|
|
429
|
+
"description": str(meta.get("description", "") or ""),
|
|
430
|
+
"namespace": str(meta.get("namespace", "global") or "global"),
|
|
431
|
+
"target_kind": rec["target_kind"],
|
|
432
|
+
"target_name": rec["target_name"],
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
return overlays
|
|
436
|
+
|
|
437
|
+
def overlay_records(self, lib_dir: Path) -> List[Dict[str, Any]]:
|
|
438
|
+
"""Every Overlay under *lib_dir*, indexed by inferred ``spec.target``.
|
|
439
|
+
|
|
440
|
+
Layout-only canvas overlays are included. Target name is taken from
|
|
441
|
+
``spec.target.name`` when set; otherwise a MAS overlay sitting in
|
|
442
|
+
``apps/<id>/[vN/]overlays/`` inherits *id*. The app does not need to
|
|
443
|
+
exist yet — GET attaches later, once both records are present.
|
|
444
|
+
"""
|
|
445
|
+
cache = getattr(self, "_overlay_record_cache", None)
|
|
446
|
+
if cache is None:
|
|
447
|
+
self._overlay_record_cache = {}
|
|
448
|
+
cache = self._overlay_record_cache
|
|
449
|
+
cached = cache.get(lib_dir)
|
|
450
|
+
if cached is not None:
|
|
451
|
+
return cached
|
|
452
|
+
records: List[Dict[str, Any]] = []
|
|
453
|
+
for path in self._iter_overlay_files(lib_dir):
|
|
454
|
+
try:
|
|
455
|
+
doc = load_yaml_file(path)
|
|
456
|
+
except Exception:
|
|
457
|
+
continue
|
|
458
|
+
if not isinstance(doc, dict):
|
|
459
|
+
continue
|
|
460
|
+
target = infer_overlay_target(path, doc, lib_dir=lib_dir)
|
|
461
|
+
records.append(
|
|
462
|
+
{
|
|
463
|
+
"path": path,
|
|
464
|
+
"doc": doc,
|
|
465
|
+
"layout_only": overlay_is_layout_only(doc),
|
|
466
|
+
"target_kind": target[0] if target else "",
|
|
467
|
+
"target_name": target[1] if target else "",
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
cache[lib_dir] = records
|
|
471
|
+
return records
|
|
472
|
+
|
|
473
|
+
def layout_overlays_targeting(
|
|
474
|
+
self, lib_dir: Path, *, kind: str, name: str
|
|
475
|
+
) -> List[tuple[Path, Dict[str, Any]]]:
|
|
476
|
+
"""Layout-only overlays registered against *kind*/*name*."""
|
|
477
|
+
want_kind = kind.lower()
|
|
478
|
+
matched: List[tuple[Path, Dict[str, Any]]] = []
|
|
479
|
+
for rec in self.overlay_records(lib_dir):
|
|
480
|
+
if not rec["layout_only"]:
|
|
481
|
+
continue
|
|
482
|
+
if str(rec["target_kind"]).lower() != want_kind:
|
|
483
|
+
continue
|
|
484
|
+
if rec["target_name"] != name:
|
|
485
|
+
continue
|
|
486
|
+
matched.append((rec["path"], rec["doc"]))
|
|
487
|
+
return matched
|
|
488
|
+
|
|
489
|
+
def collect_mas_resources(self, library: str) -> Dict[str, Dict[str, Any]]:
|
|
490
|
+
# Only discover apps from the library filesystem. The mas.apps
|
|
491
|
+
# entry-point registry is a separate discovery mechanism used by
|
|
492
|
+
# CLI / experiment resolution; mixing it in here would create
|
|
493
|
+
# duplicates and break CRUD operations that expect filesystem paths.
|
|
494
|
+
root = self.library_root(library)
|
|
495
|
+
return self.collect_apps_in(root)
|
|
496
|
+
|
|
497
|
+
def collect_apps_in(self, root: Path) -> Dict[str, Dict[str, Any]]:
|
|
498
|
+
"""MAS apps under ``root``. Safe to call without a populated registry."""
|
|
499
|
+
return self._collect_mas_resources(root)
|
|
500
|
+
|
|
501
|
+
@classmethod
|
|
502
|
+
def _ephemeral(cls) -> "LabRegistry":
|
|
503
|
+
"""Isolated registry that scans a directory without process discovery."""
|
|
504
|
+
helper = cls.__new__(cls)
|
|
505
|
+
helper._workspace = None
|
|
506
|
+
helper._libraries = {}
|
|
507
|
+
helper._library_sources = {}
|
|
508
|
+
helper._refreshed = True
|
|
509
|
+
helper._manifest_file_cache = {}
|
|
510
|
+
helper._overlay_record_cache = {}
|
|
511
|
+
return helper
|
|
512
|
+
|
|
513
|
+
@classmethod
|
|
514
|
+
def apps_in_directory(cls, root: Path) -> Dict[str, Dict[str, Any]]:
|
|
515
|
+
"""Discover MAS apps under ``root`` without refreshing the process registry."""
|
|
516
|
+
return cls._ephemeral().collect_apps_in(root)
|
|
517
|
+
|
|
518
|
+
@classmethod
|
|
519
|
+
def layout_overlays_in_directory(
|
|
520
|
+
cls, root: Path, *, kind: str, name: str
|
|
521
|
+
) -> List[tuple[Path, Dict[str, Any]]]:
|
|
522
|
+
"""Layout-only overlays under ``root`` whose ``spec.target`` selects *kind*/*name*."""
|
|
523
|
+
return cls._ephemeral().layout_overlays_targeting(root, kind=kind, name=name)
|
|
524
|
+
|
|
525
|
+
def list_all_experiments(self) -> List[Dict[str, Any]]:
|
|
526
|
+
"""All experiment definitions across discovered libraries."""
|
|
527
|
+
self._ensure_ready()
|
|
528
|
+
items: List[Dict[str, Any]] = []
|
|
529
|
+
for slug in sorted(self._libraries):
|
|
530
|
+
for exp in self.list_experiments(slug):
|
|
531
|
+
items.append({**exp, "library": slug})
|
|
532
|
+
return items
|
|
533
|
+
|
|
534
|
+
def discovery_report(self) -> Dict[str, Any]:
|
|
535
|
+
"""Snapshot of paths scanned and libraries resolved (for debugging / UI)."""
|
|
536
|
+
self._ensure_ready()
|
|
537
|
+
ws_root = self._workspace_root()
|
|
538
|
+
return {
|
|
539
|
+
"workspace_root": str(ws_root) if ws_root else None,
|
|
540
|
+
"mas_workspace_root_env": os.environ.get("MAS_WORKSPACE_ROOT") or None,
|
|
541
|
+
"cwd": str(Path.cwd()),
|
|
542
|
+
"labs_search_paths": self._labs_search_paths(),
|
|
543
|
+
"libraries": [
|
|
544
|
+
{
|
|
545
|
+
"slug": slug,
|
|
546
|
+
"path": str(path),
|
|
547
|
+
"source": self._library_sources.get(slug, "unknown"),
|
|
548
|
+
}
|
|
549
|
+
for slug, path in sorted(self._libraries.items())
|
|
550
|
+
],
|
|
551
|
+
"runtime_apps": sorted(self.runtime_objects("app")),
|
|
552
|
+
"runtime_datasets": sorted(self.runtime_objects("dataset")),
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
def pipeline_step_types(self) -> Dict[str, Any]:
|
|
556
|
+
try:
|
|
557
|
+
from mas.runtime.registry import get_registry
|
|
558
|
+
|
|
559
|
+
step_types = []
|
|
560
|
+
categories: Dict[str, dict] = {}
|
|
561
|
+
for item in get_registry().list("step"):
|
|
562
|
+
shortcuts = item.get("shortcuts") or []
|
|
563
|
+
step_id = str(shortcuts[0]) if shortcuts else str(item.get("urn") or "").rsplit(".", 1)[-1]
|
|
564
|
+
if not step_id:
|
|
565
|
+
continue
|
|
566
|
+
attrs = item.get("attributes") or {}
|
|
567
|
+
category = str(attrs.get("category") or "general")
|
|
568
|
+
step_types.append(
|
|
569
|
+
{
|
|
570
|
+
"type": step_id,
|
|
571
|
+
"label": step_id.replace("_", " ").title(),
|
|
572
|
+
"description": str(item.get("description") or ""),
|
|
573
|
+
"phase": str(attrs.get("phase") or "post"),
|
|
574
|
+
"category": category,
|
|
575
|
+
"requires": attrs.get("requires"),
|
|
576
|
+
"config": {},
|
|
577
|
+
}
|
|
578
|
+
)
|
|
579
|
+
categories.setdefault(
|
|
580
|
+
category, {"id": category, "label": category.title(), "color": "#888"}
|
|
581
|
+
)
|
|
582
|
+
return {"step_types": step_types, "categories": list(categories.values())}
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
logger.debug("pipeline step types unavailable: %s", exc)
|
|
585
|
+
return {"step_types": [], "categories": []}
|
|
586
|
+
|
|
587
|
+
# ── Discovery internals ─────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
def _workspace_root(self) -> Optional[Path]:
|
|
590
|
+
if self._workspace is not None:
|
|
591
|
+
base = getattr(self._workspace, "_path", None)
|
|
592
|
+
return Path(base) if base is not None else None
|
|
593
|
+
try:
|
|
594
|
+
from mas.lab.workspace import project_workspace_root
|
|
595
|
+
|
|
596
|
+
return project_workspace_root()
|
|
597
|
+
except Exception:
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
def _workspace_data(self) -> dict:
|
|
601
|
+
if self._workspace is not None:
|
|
602
|
+
data = getattr(self._workspace, "_data", None)
|
|
603
|
+
if isinstance(data, dict):
|
|
604
|
+
return data
|
|
605
|
+
return {}
|
|
606
|
+
root = self._workspace_root()
|
|
607
|
+
if root is None:
|
|
608
|
+
return {}
|
|
609
|
+
config = root / WORKSPACE_CONFIG_FILENAME
|
|
610
|
+
if not config.is_file():
|
|
611
|
+
return {}
|
|
612
|
+
try:
|
|
613
|
+
data = load_yaml_file(config)
|
|
614
|
+
except Exception:
|
|
615
|
+
return {}
|
|
616
|
+
return data if isinstance(data, dict) else {}
|
|
617
|
+
|
|
618
|
+
def _catalog_constrained(self) -> bool:
|
|
619
|
+
"""True when ``mas_lab.labs_search_paths`` is set on the project config.
|
|
620
|
+
|
|
621
|
+
That key is the opt-in exclusive catalog (including ``[]`` = no labs).
|
|
622
|
+
``MAS_WORKSPACE_ROOT`` / ``--workspace`` only chooses *where* those
|
|
623
|
+
paths resolve. Without the key, discovery stays additive: installed
|
|
624
|
+
packages, entry-point apps, and cwd-walk — same as the studio before
|
|
625
|
+
the two use-case pins.
|
|
626
|
+
"""
|
|
627
|
+
return self._configured_labs_search_paths() is not None
|
|
628
|
+
|
|
629
|
+
def _configured_labs_search_paths(self) -> Optional[List[str]]:
|
|
630
|
+
"""Return ``mas_lab.labs_search_paths`` when the key is present.
|
|
631
|
+
|
|
632
|
+
An empty list is intentional (no labs). ``None`` means use the
|
|
633
|
+
default ``labs`` folder under the workspace and keep additive
|
|
634
|
+
discovery.
|
|
635
|
+
"""
|
|
636
|
+
mas_lab = self._workspace_data().get("mas_lab") or {}
|
|
637
|
+
if not isinstance(mas_lab, dict) or "labs_search_paths" not in mas_lab:
|
|
638
|
+
return None
|
|
639
|
+
paths = mas_lab.get("labs_search_paths")
|
|
640
|
+
if paths is None:
|
|
641
|
+
return None
|
|
642
|
+
if isinstance(paths, str):
|
|
643
|
+
return [paths]
|
|
644
|
+
return [str(item) for item in paths]
|
|
645
|
+
|
|
646
|
+
def _labs_search_paths(self) -> List[str]:
|
|
647
|
+
configured = self._configured_labs_search_paths()
|
|
648
|
+
if configured is not None:
|
|
649
|
+
return configured
|
|
650
|
+
return ["labs"]
|
|
651
|
+
|
|
652
|
+
def _raw_manifest_libraries(self) -> Any:
|
|
653
|
+
ws = self._workspace
|
|
654
|
+
if ws is not None and hasattr(ws, "raw_manifest_libraries"):
|
|
655
|
+
return ws.raw_manifest_libraries
|
|
656
|
+
return self._workspace_data().get("manifest_libraries")
|
|
657
|
+
|
|
658
|
+
def _discover_library_paths(self) -> tuple[Dict[str, Path], Dict[str, str]]:
|
|
659
|
+
libraries: Dict[str, Path] = {}
|
|
660
|
+
sources: Dict[str, str] = {}
|
|
661
|
+
constrained = self._catalog_constrained()
|
|
662
|
+
|
|
663
|
+
def _add(slug: str, path: Path, source: str) -> None:
|
|
664
|
+
resolved = path.resolve()
|
|
665
|
+
if not resolved.exists():
|
|
666
|
+
logger.debug("skip missing library %s at %s (%s)", slug, resolved, source)
|
|
667
|
+
return
|
|
668
|
+
if slug not in libraries:
|
|
669
|
+
libraries[slug] = resolved
|
|
670
|
+
sources[slug] = source
|
|
671
|
+
else:
|
|
672
|
+
logger.debug(
|
|
673
|
+
"library %s already mapped to %s; ignoring %s from %s",
|
|
674
|
+
slug,
|
|
675
|
+
libraries[slug],
|
|
676
|
+
resolved,
|
|
677
|
+
source,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
if not constrained:
|
|
681
|
+
for name, path in self.runtime_objects("app").items():
|
|
682
|
+
_add(name, path, f"entry-point:mas.apps:{name}")
|
|
683
|
+
|
|
684
|
+
try:
|
|
685
|
+
from mas.library_roots import discover_library_roots
|
|
686
|
+
|
|
687
|
+
for root in discover_library_roots():
|
|
688
|
+
for slug in _manifest_library_names(root):
|
|
689
|
+
_add(slug, root, "manifest_libraries")
|
|
690
|
+
except Exception as exc:
|
|
691
|
+
logger.debug("manifest library discovery failed: %s", exc)
|
|
692
|
+
|
|
693
|
+
for entry in os.environ.get("MAS_LIBRARY_PATHS", "").split(os.pathsep):
|
|
694
|
+
entry = entry.strip()
|
|
695
|
+
if not entry:
|
|
696
|
+
continue
|
|
697
|
+
path = Path(entry).expanduser()
|
|
698
|
+
if not path.is_dir():
|
|
699
|
+
continue
|
|
700
|
+
if path.name.endswith(".lab") or (path / LAB_CONFIG_FILENAME).is_file():
|
|
701
|
+
for slug in _manifest_library_names(path):
|
|
702
|
+
_add(slug, path, "MAS_LIBRARY_PATHS:lab")
|
|
703
|
+
try:
|
|
704
|
+
from mas.library_roots import iter_libraries_in_search_path
|
|
705
|
+
|
|
706
|
+
libs = iter_libraries_in_search_path(path)
|
|
707
|
+
except Exception:
|
|
708
|
+
libs = [path] if (path / LIBRARY_MANIFEST_FILENAME).is_file() else []
|
|
709
|
+
for lib in libs:
|
|
710
|
+
for slug in _manifest_library_names(lib):
|
|
711
|
+
_add(slug, lib, "MAS_LIBRARY_PATHS")
|
|
712
|
+
|
|
713
|
+
ws_root = self._workspace_root()
|
|
714
|
+
if ws_root is not None:
|
|
715
|
+
from mas.library_roots import iter_libraries_in_search_path
|
|
716
|
+
from mas.runtime.workspace_config import library_search_paths
|
|
717
|
+
|
|
718
|
+
for rel in library_search_paths(self._raw_manifest_libraries()):
|
|
719
|
+
path = Path(rel).expanduser()
|
|
720
|
+
search = path if path.is_absolute() else (ws_root / rel)
|
|
721
|
+
for lib in iter_libraries_in_search_path(search):
|
|
722
|
+
_add(lib.name, lib, f"manifest_libraries:{rel}")
|
|
723
|
+
|
|
724
|
+
for rel in self._labs_search_paths():
|
|
725
|
+
for path in _iter_lab_dirs(_resolve_configured_path(rel, ws_root)):
|
|
726
|
+
_add(path.stem, path, f"labs_search_paths:{rel}")
|
|
727
|
+
|
|
728
|
+
if constrained:
|
|
729
|
+
return libraries, sources
|
|
730
|
+
|
|
731
|
+
try:
|
|
732
|
+
from mas.lab.workspace import find_workspace_root
|
|
733
|
+
|
|
734
|
+
ws = find_workspace_root()
|
|
735
|
+
if ws is not None:
|
|
736
|
+
for rel in self._labs_search_paths():
|
|
737
|
+
for path in _iter_lab_dirs(_resolve_configured_path(rel, ws)):
|
|
738
|
+
_add(path.stem, path, f"workspace.find_workspace_root:{rel}")
|
|
739
|
+
except Exception as exc:
|
|
740
|
+
logger.debug("studio workspace discovery failed: %s", exc)
|
|
741
|
+
|
|
742
|
+
cwd = Path.cwd().resolve()
|
|
743
|
+
for directory in [cwd, *cwd.parents]:
|
|
744
|
+
labs_dir = directory / "labs"
|
|
745
|
+
if labs_dir.is_dir():
|
|
746
|
+
for path in sorted(labs_dir.glob("*.lab")):
|
|
747
|
+
_add(path.stem, path, f"cwd-walk:{directory}")
|
|
748
|
+
if (directory / WORKSPACE_CONFIG_FILENAME).exists():
|
|
749
|
+
break
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
from mas.lab.paths import labs_root
|
|
753
|
+
|
|
754
|
+
labs = labs_root()
|
|
755
|
+
if labs.exists():
|
|
756
|
+
for path in sorted(labs.glob("*.lab")):
|
|
757
|
+
_add(path.stem, path, f"labs_root:{labs}")
|
|
758
|
+
except Exception as exc:
|
|
759
|
+
logger.debug("labs_root discovery failed: %s", exc)
|
|
760
|
+
|
|
761
|
+
return libraries, sources
|
|
762
|
+
|
|
763
|
+
def _log_discovery(self) -> None:
|
|
764
|
+
report = self.discovery_report()
|
|
765
|
+
logger.info(
|
|
766
|
+
"LabRegistry: workspace_root=%s libraries=%d runtime_apps=%s",
|
|
767
|
+
report.get("workspace_root"),
|
|
768
|
+
len(report.get("libraries", [])),
|
|
769
|
+
report.get("runtime_apps"),
|
|
770
|
+
)
|
|
771
|
+
for item in report.get("libraries", []):
|
|
772
|
+
logger.info(
|
|
773
|
+
" library %-20s %s [%s]",
|
|
774
|
+
item["slug"],
|
|
775
|
+
item["path"],
|
|
776
|
+
item["source"],
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
def _iter_experiment_files(self, lib_dir: Path):
|
|
780
|
+
yield from self._cached_manifest_files(lib_dir, "experiment")
|
|
781
|
+
|
|
782
|
+
def _iter_pipeline_files(self, lib_dir: Path):
|
|
783
|
+
yield from self._cached_manifest_files(lib_dir, "pipeline")
|
|
784
|
+
|
|
785
|
+
def _iter_dataset_files(self, lib_dir: Path):
|
|
786
|
+
yield from self._cached_manifest_files(lib_dir, "dataset", parent_dir="datasets")
|
|
787
|
+
|
|
788
|
+
def _iter_overlay_files(self, lib_dir: Path):
|
|
789
|
+
yield from self._cached_manifest_files(lib_dir, "overlay", parent_dir="overlays")
|
|
790
|
+
|
|
791
|
+
def _collect_mas_resources(self, lib_dir: Path) -> Dict[str, Dict[str, Any]]:
|
|
792
|
+
result: Dict[str, Dict[str, Any]] = {}
|
|
793
|
+
seen: set[Path] = set()
|
|
794
|
+
|
|
795
|
+
def _add(catalog_id: str, app_path: Path) -> None:
|
|
796
|
+
if not catalog_id or catalog_id in result:
|
|
797
|
+
return
|
|
798
|
+
resolved = app_path.resolve()
|
|
799
|
+
if resolved in seen:
|
|
800
|
+
return
|
|
801
|
+
mas_file = None
|
|
802
|
+
for name in ("mas.yaml", "mas-bench.yaml"):
|
|
803
|
+
candidate = resolved / name
|
|
804
|
+
if candidate.is_file():
|
|
805
|
+
mas_file = candidate
|
|
806
|
+
break
|
|
807
|
+
if mas_file is None:
|
|
808
|
+
from mas.apps import resolve_app_manifest
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
agent_manifest = resolve_app_manifest(resolved, app_id=resolved.name)
|
|
812
|
+
except Exception:
|
|
813
|
+
return
|
|
814
|
+
try:
|
|
815
|
+
rel = str(agent_manifest.relative_to(lib_dir.resolve()))
|
|
816
|
+
except ValueError:
|
|
817
|
+
rel = str(agent_manifest)
|
|
818
|
+
entry = {
|
|
819
|
+
"agents": {},
|
|
820
|
+
"path": rel,
|
|
821
|
+
}
|
|
822
|
+
if agent_manifest.is_file():
|
|
823
|
+
entry["agents"][agent_manifest.stem] = agent_manifest.read_text(
|
|
824
|
+
encoding="utf-8"
|
|
825
|
+
)
|
|
826
|
+
seen.add(resolved)
|
|
827
|
+
result[catalog_id] = entry
|
|
828
|
+
return
|
|
829
|
+
try:
|
|
830
|
+
doc = load_yaml_file(mas_file)
|
|
831
|
+
except Exception:
|
|
832
|
+
doc = {}
|
|
833
|
+
try:
|
|
834
|
+
rel = str(mas_file.relative_to(lib_dir.resolve()))
|
|
835
|
+
except ValueError:
|
|
836
|
+
rel = str(mas_file)
|
|
837
|
+
seen.add(resolved)
|
|
838
|
+
result[catalog_id] = {
|
|
839
|
+
"mas_yaml": mas_file.read_text(encoding="utf-8"),
|
|
840
|
+
"agents": self._load_agents_from_mas_refs(resolved, doc),
|
|
841
|
+
"path": rel,
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
# Pass 1: canonical catalog discovery (library.yaml `apps:` entries,
|
|
845
|
+
# plus a scan of apps/<name>[/v*] recognised by _is_app_directory).
|
|
846
|
+
# This is the primary source; passes below only add what it misses.
|
|
847
|
+
try:
|
|
848
|
+
from mas.library_catalog import apps_in_library
|
|
849
|
+
|
|
850
|
+
for catalog_id, app_path in apps_in_library(lib_dir).items():
|
|
851
|
+
if "/" in catalog_id:
|
|
852
|
+
continue
|
|
853
|
+
_add(catalog_id, app_path)
|
|
854
|
+
except Exception:
|
|
855
|
+
pass
|
|
856
|
+
|
|
857
|
+
# Pass 2: manual apps/<name> walk. Mostly redundant with pass 1, but
|
|
858
|
+
# its final resolve_app_manifest() fallback also recognises a bare
|
|
859
|
+
# ``<name>/<name>.yaml`` manifest — a convention _is_app_directory()
|
|
860
|
+
# (and so pass 1) does not check for.
|
|
861
|
+
apps_dir = lib_dir / "apps"
|
|
862
|
+
if apps_dir.exists():
|
|
863
|
+
for app_folder in sorted(apps_dir.iterdir()):
|
|
864
|
+
if not app_folder.is_dir():
|
|
865
|
+
continue
|
|
866
|
+
mas_file = app_folder / "mas.yaml"
|
|
867
|
+
if mas_file.is_file():
|
|
868
|
+
_add(app_folder.name, app_folder)
|
|
869
|
+
continue
|
|
870
|
+
version_dirs = [
|
|
871
|
+
d
|
|
872
|
+
for d in sorted(app_folder.iterdir())
|
|
873
|
+
if d.is_dir() and d.name.startswith("v") and (d / "mas.yaml").is_file()
|
|
874
|
+
]
|
|
875
|
+
if version_dirs:
|
|
876
|
+
for ver_dir in version_dirs:
|
|
877
|
+
_add(f"{app_folder.name}@{ver_dir.name}", ver_dir)
|
|
878
|
+
continue
|
|
879
|
+
from mas.apps import resolve_app_manifest
|
|
880
|
+
|
|
881
|
+
try:
|
|
882
|
+
resolve_app_manifest(app_folder, app_id=app_folder.name)
|
|
883
|
+
except Exception:
|
|
884
|
+
continue
|
|
885
|
+
_add(app_folder.name, app_folder)
|
|
886
|
+
|
|
887
|
+
# Pass 3: mas.yaml / mas-bench.yaml anywhere under lib_dir (outside
|
|
888
|
+
# apps/). Required when lib_dir is itself a bare app root rather
|
|
889
|
+
# than a library with an apps/ folder — e.g. an entry-point-
|
|
890
|
+
# registered app (see runtime_objects("app") above) treated as a
|
|
891
|
+
# "library" by collect_mas_resources(); apps_in_library() only
|
|
892
|
+
# looks for library.yaml / an apps/ subfolder, so it never resolves
|
|
893
|
+
# that case (see test_collect_mas_resources_from_app_root).
|
|
894
|
+
for mas_name in _MAS_MANIFEST_NAMES:
|
|
895
|
+
for mas_file in sorted({lib_dir / mas_name, *lib_dir.rglob(mas_name)}):
|
|
896
|
+
if not mas_file.is_file():
|
|
897
|
+
continue
|
|
898
|
+
if "apps" in mas_file.parts and mas_file.name == "mas.yaml":
|
|
899
|
+
continue
|
|
900
|
+
try:
|
|
901
|
+
doc = load_yaml_file(mas_file)
|
|
902
|
+
except Exception:
|
|
903
|
+
doc = {}
|
|
904
|
+
name = (doc.get("metadata") or {}).get("name") or mas_file.stem.replace(
|
|
905
|
+
"-bench", ""
|
|
906
|
+
)
|
|
907
|
+
_add(str(name), mas_file.parent)
|
|
908
|
+
|
|
909
|
+
# Pass 4: apps/<name>/<subdir>/mas.yaml where <subdir> is not a
|
|
910
|
+
# ``v*`` version folder. apps_in_library()'s version scan
|
|
911
|
+
# (_version_dirs) only descends into ``v*``-named children, so a
|
|
912
|
+
# non-version nested layout (e.g. apps/<name>/<variant>/mas.yaml)
|
|
913
|
+
# is invisible to pass 1 and needs this rglob. (A single-level
|
|
914
|
+
# ``apps/*/mas.yaml`` rglob used to run here too, but every path it
|
|
915
|
+
# can match is already an app directory by _is_app_directory()'s
|
|
916
|
+
# own definition, so pass 1 always finds it first — confirmed with
|
|
917
|
+
# apps_in_library() directly, and by this pass's own dead-hit guard
|
|
918
|
+
# in _add(); it was dropped as genuinely redundant.)
|
|
919
|
+
for mas_file in sorted(lib_dir.rglob("apps/*/*/mas.yaml")):
|
|
920
|
+
if not mas_file.is_file():
|
|
921
|
+
continue
|
|
922
|
+
parent = mas_file.parent
|
|
923
|
+
catalog_id = parent.name
|
|
924
|
+
if parent.parent.parent.name == "apps":
|
|
925
|
+
if parent.name.startswith("v"):
|
|
926
|
+
catalog_id = f"{parent.parent.name}@{parent.name}"
|
|
927
|
+
else:
|
|
928
|
+
catalog_id = f"{parent.parent.name}-{parent.name}"
|
|
929
|
+
_add(catalog_id, parent)
|
|
930
|
+
return result
|
|
931
|
+
|
|
932
|
+
@staticmethod
|
|
933
|
+
def _mas_resource_from_app_root(app_path: Path) -> Optional[Dict[str, Any]]:
|
|
934
|
+
"""Build a mas_resources entry from an installed app package directory."""
|
|
935
|
+
for mas_file in (
|
|
936
|
+
app_path / "mas.yaml",
|
|
937
|
+
app_path / "mas-bench.yaml",
|
|
938
|
+
):
|
|
939
|
+
if not mas_file.is_file():
|
|
940
|
+
continue
|
|
941
|
+
try:
|
|
942
|
+
doc = load_yaml_file(mas_file)
|
|
943
|
+
except Exception:
|
|
944
|
+
doc = {}
|
|
945
|
+
name = (doc.get("metadata") or {}).get("name") or mas_file.stem.replace("-bench", "")
|
|
946
|
+
return {
|
|
947
|
+
"mas_yaml": mas_file.read_text(encoding="utf-8"),
|
|
948
|
+
"agents": LabRegistry._load_agents_from_mas_refs(app_path, doc),
|
|
949
|
+
"path": str(mas_file.relative_to(app_path)),
|
|
950
|
+
"source": "runtime-app",
|
|
951
|
+
}
|
|
952
|
+
return None
|
|
953
|
+
|
|
954
|
+
@staticmethod
|
|
955
|
+
@lru_cache(maxsize=1)
|
|
956
|
+
def discover_bundled_infra() -> Dict[str, str]:
|
|
957
|
+
"""Infra bundle YAML from installed ``mas.runtime.manifest_libraries`` packages."""
|
|
958
|
+
bundled: Dict[str, str] = {}
|
|
959
|
+
for ep in _entry_points(group="mas.runtime.manifest_libraries"):
|
|
960
|
+
try:
|
|
961
|
+
pkg_name = ep.value
|
|
962
|
+
libs_path = importlib.resources.files(pkg_name) / "libs" / ep.name
|
|
963
|
+
with importlib.resources.as_file(libs_path) as root:
|
|
964
|
+
if not root.exists():
|
|
965
|
+
continue
|
|
966
|
+
for yaml_file in sorted(root.rglob("*.yaml")):
|
|
967
|
+
key = f"bundles/{ep.name}/{yaml_file.relative_to(root)}"
|
|
968
|
+
bundled[key] = yaml_file.read_text(encoding="utf-8")
|
|
969
|
+
except Exception:
|
|
970
|
+
continue
|
|
971
|
+
return bundled
|
|
972
|
+
|
|
973
|
+
@staticmethod
|
|
974
|
+
def discover_workspace_infra(ws_root: Path) -> Dict[str, str]:
|
|
975
|
+
"""Workspace-level infra manifests (not nested under a single *.lab)."""
|
|
976
|
+
infra: Dict[str, str] = {}
|
|
977
|
+
infra_dir = ws_root / "infra"
|
|
978
|
+
if infra_dir.is_dir():
|
|
979
|
+
for path in sorted(infra_dir.glob("*.yaml")):
|
|
980
|
+
infra[f"workspace/infra/{path.name}"] = path.read_text(encoding="utf-8")
|
|
981
|
+
for path in sorted(infra_dir.glob("*.yml")):
|
|
982
|
+
infra[f"workspace/infra/{path.name}"] = path.read_text(encoding="utf-8")
|
|
983
|
+
return infra
|
|
984
|
+
|
|
985
|
+
@staticmethod
|
|
986
|
+
def _load_agents_from_mas_refs(lib_dir: Path, mas_doc: dict) -> Dict[str, str]:
|
|
987
|
+
agents: Dict[str, str] = {}
|
|
988
|
+
agency = (mas_doc.get("spec") or {}).get("agency") or {}
|
|
989
|
+
for agent in agency.get("agents") or []:
|
|
990
|
+
if not isinstance(agent, dict):
|
|
991
|
+
continue
|
|
992
|
+
agent_id = agent.get("id") or agent.get("name")
|
|
993
|
+
ref = agent.get("ref")
|
|
994
|
+
if not ref:
|
|
995
|
+
continue
|
|
996
|
+
agent_path = (lib_dir / ref).resolve()
|
|
997
|
+
if agent_path.exists():
|
|
998
|
+
agents[str(agent_id or agent_path.stem)] = agent_path.read_text(encoding="utf-8")
|
|
999
|
+
return agents
|
|
1000
|
+
|
|
1001
|
+
@staticmethod
|
|
1002
|
+
def _parse_experiment_metadata(path: Path, lib_dir: Path | None = None) -> Dict[str, Any]:
|
|
1003
|
+
rel = str(path.relative_to(lib_dir)) if lib_dir else str(path)
|
|
1004
|
+
entry: Dict[str, Any] = {"filename": path.name, "path": rel}
|
|
1005
|
+
try:
|
|
1006
|
+
doc = load_yaml_file(path)
|
|
1007
|
+
exp = doc.get("experiment") or doc
|
|
1008
|
+
entry["name"] = exp.get("name", path.stem)
|
|
1009
|
+
entry["description"] = exp.get("description", "")
|
|
1010
|
+
entry["version"] = exp.get("version", "")
|
|
1011
|
+
scenarios = exp.get("scenarios", [])
|
|
1012
|
+
entry["scenarios"] = [s.get("id", "") for s in scenarios if isinstance(s, dict)]
|
|
1013
|
+
dataset = exp.get("dataset", {})
|
|
1014
|
+
entry["dataset"] = dataset.get("path", "") if isinstance(dataset, dict) else ""
|
|
1015
|
+
except Exception:
|
|
1016
|
+
entry.update(
|
|
1017
|
+
name=path.stem,
|
|
1018
|
+
description="",
|
|
1019
|
+
version="",
|
|
1020
|
+
scenarios=[],
|
|
1021
|
+
dataset="",
|
|
1022
|
+
)
|
|
1023
|
+
return entry
|
|
1024
|
+
|
|
1025
|
+
@staticmethod
|
|
1026
|
+
def _parse_pipeline_metadata(path: Path, lib_dir: Path | None = None) -> Dict[str, Any]:
|
|
1027
|
+
rel = str(path.relative_to(lib_dir)) if lib_dir else str(path)
|
|
1028
|
+
entry: Dict[str, Any] = {"filename": path.name, "path": rel}
|
|
1029
|
+
try:
|
|
1030
|
+
doc = load_yaml_file(path)
|
|
1031
|
+
block = doc.get("pipeline") if isinstance(doc.get("pipeline"), dict) else doc
|
|
1032
|
+
meta = block.get("metadata") or doc.get("metadata") or {}
|
|
1033
|
+
entry["name"] = meta.get("name") or block.get("name") or path.stem
|
|
1034
|
+
entry["description"] = meta.get("description") or block.get("description") or ""
|
|
1035
|
+
entry["bind"] = doc.get("x-bind", "")
|
|
1036
|
+
steps = block.get("steps") or (doc.get("spec") or {}).get("steps") or []
|
|
1037
|
+
entry["steps"] = [
|
|
1038
|
+
{
|
|
1039
|
+
"name": s.get("name"),
|
|
1040
|
+
"type": s.get("type"),
|
|
1041
|
+
"depends_on": s.get("depends_on", []),
|
|
1042
|
+
}
|
|
1043
|
+
for s in steps
|
|
1044
|
+
if isinstance(s, dict)
|
|
1045
|
+
]
|
|
1046
|
+
output = block.get("output") or (doc.get("spec") or {}).get("output") or {}
|
|
1047
|
+
entry["experiment"] = output.get("base_dir", "")
|
|
1048
|
+
except Exception:
|
|
1049
|
+
entry.update(name=path.stem, description="", bind="", steps=[], experiment="")
|
|
1050
|
+
return entry
|
|
1051
|
+
|
|
1052
|
+
@staticmethod
|
|
1053
|
+
def _dataset_description(path: Path) -> str:
|
|
1054
|
+
try:
|
|
1055
|
+
data = load_yaml_file(path)
|
|
1056
|
+
return str(
|
|
1057
|
+
data.get("description") or (data.get("metadata") or {}).get("description") or ""
|
|
1058
|
+
)
|
|
1059
|
+
except Exception:
|
|
1060
|
+
return ""
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def get_lab_registry(workspace: Any = None, *, refresh: bool = False) -> LabRegistry:
|
|
1064
|
+
"""Return the process-wide :class:`LabRegistry` singleton."""
|
|
1065
|
+
global _REGISTRY
|
|
1066
|
+
if _REGISTRY is None:
|
|
1067
|
+
_REGISTRY = LabRegistry(workspace)
|
|
1068
|
+
elif refresh:
|
|
1069
|
+
_REGISTRY.refresh()
|
|
1070
|
+
return _REGISTRY
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def reset_lab_registry() -> None:
|
|
1074
|
+
"""Clear singleton (tests)."""
|
|
1075
|
+
global _REGISTRY
|
|
1076
|
+
_REGISTRY = None
|