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,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