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,214 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Library-scoped YAML manifest storage for the UI."""
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List
9
+
10
+ from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME
11
+ from mas.lab.controller.lab_registry import LabRegistry, _library_description
12
+ from mas.lab.controller.lab_registry import get_lab_registry
13
+ from mas.ctl.overlay.canvas import overlay_is_layout_only
14
+ from mas.runtime.spec.source import load_yaml_file
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _SUBDIRS = {
19
+ "experiments": "experiments",
20
+ "pipelines": "pipelines",
21
+ "overlays": "overlays",
22
+ "datasets": "datasets",
23
+ "apps": "apps",
24
+ "scenarios": "scenarios",
25
+ }
26
+
27
+
28
+ class ManifestStore:
29
+ """Read/write YAML resources under library roots."""
30
+
31
+ def __init__(self, workspace: Any = None, *, registry: LabRegistry | None = None) -> None:
32
+ """Create a store, optionally injecting a specific ``registry``.
33
+
34
+ By default the store uses the process-wide :func:`get_lab_registry`
35
+ singleton. Pass ``registry`` (e.g. a ``LabRegistry`` built with
36
+ ``MAS_LAB_REGISTRY_EAGER_REFRESH=0`` and manually populated
37
+ ``_libraries``) to use an isolated registry instead — this is how
38
+ tests avoid both the global singleton and expensive discovery,
39
+ without bypassing ``__init__`` via ``__new__``.
40
+ """
41
+ self._workspace = workspace
42
+ self._registry = registry if registry is not None else get_lab_registry(workspace)
43
+ self._libraries: Dict[str, Path] = {}
44
+ self._refreshed = False
45
+
46
+ def _ensure_ready(self) -> None:
47
+ # Many tests override _libraries directly; avoid expensive discovery if already injected.
48
+ if self._libraries:
49
+ return
50
+ if not self._refreshed:
51
+ self.refresh()
52
+
53
+ def refresh(self) -> None:
54
+ self._registry.refresh()
55
+ self._libraries = self._registry.library_paths()
56
+ self._refreshed = True
57
+
58
+ def invalidate(self) -> None:
59
+ """Drop cached manifest-file listings (see LabRegistry.invalidate_manifest_cache)."""
60
+ self._registry.invalidate_manifest_cache()
61
+
62
+ def libraries(self) -> List[Dict[str, str]]:
63
+ self._ensure_ready()
64
+ items: List[Dict[str, str]] = []
65
+ for slug, path in sorted(self._libraries.items()):
66
+ items.append(
67
+ {
68
+ "dir": slug,
69
+ "name": slug.replace("-", " ").replace("_", " ").title(),
70
+ "description": _library_description(path),
71
+ }
72
+ )
73
+ return items
74
+
75
+ def library_root(self, library: str) -> Path:
76
+ self._ensure_ready()
77
+ root = self._libraries.get(library)
78
+ if root is not None:
79
+ return root
80
+ return self._registry.library_root(library)
81
+
82
+ def list_experiments(self, library: str) -> List[Dict[str, Any]]:
83
+ root = self.library_root(library)
84
+ return [
85
+ LabRegistry._parse_experiment_metadata(p, root)
86
+ for p in self._registry._iter_experiment_files(root)
87
+ ]
88
+
89
+ def list_pipelines(self, library: str) -> List[Dict[str, Any]]:
90
+ root = self.library_root(library)
91
+ return [
92
+ LabRegistry._parse_pipeline_metadata(p, root)
93
+ for p in self._registry._iter_pipeline_files(root)
94
+ ]
95
+
96
+ def list_datasets_meta(self, library: str) -> List[Dict[str, str]]:
97
+ root = self.library_root(library)
98
+ return [
99
+ {
100
+ "name": path.name,
101
+ "path": str(path.relative_to(root)),
102
+ "description": LabRegistry._dataset_description(path),
103
+ }
104
+ for path in self._registry._iter_dataset_files(root)
105
+ ]
106
+
107
+ def list_overlays(self, library: str) -> List[Dict[str, Any]]:
108
+ root = self.library_root(library)
109
+ overlays: List[Dict[str, Any]] = []
110
+ for path in self._registry._iter_overlay_files(root):
111
+ rel = str(path.relative_to(root))
112
+ entry: Dict[str, Any] = {
113
+ "name": path.stem,
114
+ "filename": path.name,
115
+ "path": rel,
116
+ "ui_canvas": False,
117
+ }
118
+ try:
119
+ data = load_yaml_file(path)
120
+ meta = data.get("metadata", {})
121
+ entry["description"] = str(meta.get("description", "") or "")
122
+ entry["namespace"] = str((data.get("metadata") or {}).get("namespace", "global") or "global")
123
+ entry["ui_canvas"] = overlay_is_layout_only(data)
124
+ except Exception:
125
+ entry["description"] = ""
126
+ entry["namespace"] = "global"
127
+ overlays.append(entry)
128
+ return overlays
129
+
130
+ def collect_mas_resources(self, library: str) -> Dict[str, Dict[str, Any]]:
131
+ root = self.library_root(library)
132
+ return self._registry._collect_mas_resources(root)
133
+
134
+ def list_yaml_resources(self, library: str, resource: str) -> List[Dict[str, Any]]:
135
+ sub = _SUBDIRS.get(resource, resource)
136
+ root = self.library_root(library) / sub
137
+ if not root.exists():
138
+ return []
139
+ items: List[Dict[str, Any]] = []
140
+ for path in sorted(root.rglob("*.yaml")):
141
+ rel = path.relative_to(root)
142
+ name = path.stem if rel.parent == Path(".") else str(rel.with_suffix(""))
143
+ items.append({"name": name, "path": str(rel), "file": str(path)})
144
+ return items
145
+
146
+ def read_text(self, library: str, resource: str, name: str) -> str:
147
+ path = self._resolve_resource_path(library, resource, name)
148
+ return path.read_text(encoding="utf-8")
149
+
150
+ def write_text(self, library: str, resource: str, name: str, content: str) -> Path:
151
+ path = self._resolve_resource_path(library, resource, name, create=True)
152
+ path.parent.mkdir(parents=True, exist_ok=True)
153
+ path.write_text(content.rstrip() + "\n", encoding="utf-8")
154
+ return path
155
+
156
+ def delete_resource(self, library: str, resource: str, name: str) -> None:
157
+ path = self._resolve_resource_path(library, resource, name)
158
+ if path.exists():
159
+ path.unlink()
160
+
161
+ def config_files(self, library: str) -> Dict[str, Dict[str, str]]:
162
+ """Infra, flavours, and workspace YAML for the control panel."""
163
+ root = self.library_root(library)
164
+ flavours: Dict[str, str] = {}
165
+ infra: Dict[str, str] = {}
166
+
167
+ for path in sorted(p for p in root.rglob("flavours/*") if p.suffix in (".yaml", ".yml")):
168
+ if path.is_file():
169
+ flavours[str(path.relative_to(root))] = path.read_text(encoding="utf-8")
170
+
171
+ for path in sorted(p for p in root.rglob("infra/*") if p.suffix in (".yaml", ".yml")):
172
+ if path.is_file():
173
+ infra[str(path.relative_to(root))] = path.read_text(encoding="utf-8")
174
+
175
+ ws_root = self._registry._workspace_root()
176
+ if ws_root is not None:
177
+ infra.update(LabRegistry.discover_workspace_infra(ws_root))
178
+ infra.update(LabRegistry.discover_bundled_infra())
179
+
180
+ workspace: Dict[str, str] = {}
181
+ if ws_root is not None:
182
+ ws_file = ws_root / WORKSPACE_CONFIG_FILENAME
183
+ if ws_file.is_file():
184
+ workspace[WORKSPACE_CONFIG_FILENAME] = ws_file.read_text(encoding="utf-8")
185
+ if not workspace:
186
+ local_ws = root / WORKSPACE_CONFIG_FILENAME
187
+ if local_ws.is_file():
188
+ workspace[WORKSPACE_CONFIG_FILENAME] = local_ws.read_text(encoding="utf-8")
189
+
190
+ return {
191
+ "infra": infra,
192
+ "flavours": flavours,
193
+ "workspace": workspace,
194
+ }
195
+
196
+ def _resolve_resource_path(
197
+ self,
198
+ library: str,
199
+ resource: str,
200
+ name: str,
201
+ *,
202
+ create: bool = False,
203
+ ) -> Path:
204
+ sub = _SUBDIRS.get(resource, resource)
205
+ root = self.library_root(library) / sub
206
+ candidate = (root / name).with_suffix(".yaml")
207
+ if candidate.exists() or create:
208
+ return candidate
209
+ for path in root.rglob("*.yaml"):
210
+ if path.stem == name or str(path.relative_to(root).with_suffix("")) == name:
211
+ return path
212
+ if create:
213
+ return candidate
214
+ raise FileNotFoundError(f"{resource}/{name} not found in library {library!r}")
@@ -0,0 +1,90 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """In-process manifest validation for controller HTTP routes (no CLI subprocess)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from mas.ctl.validate.schemas import declared_kind, schema_path_for_kind
13
+ from mas.ctl.validate.validator import validate_data
14
+
15
+
16
+ def validate_manifest_yaml_content(
17
+ manifest_yaml: str,
18
+ *,
19
+ base_dir: Path,
20
+ kind: str | None = None,
21
+ resolve_refs: bool = True,
22
+ ) -> dict[str, Any]:
23
+ """Validate inline YAML; return legacy CLI-shaped dict for API compatibility."""
24
+ try:
25
+ data = yaml.safe_load(manifest_yaml)
26
+ except yaml.YAMLError as exc:
27
+ return {
28
+ "exit_code": 1,
29
+ "stdout": "",
30
+ "stderr": f"YAML parse error: {exc}",
31
+ "command": "validate_data",
32
+ "valid": False,
33
+ }
34
+
35
+ if not isinstance(data, dict):
36
+ return {
37
+ "exit_code": 1,
38
+ "stdout": "",
39
+ "stderr": "manifest must be a YAML mapping",
40
+ "command": "validate_data",
41
+ "valid": False,
42
+ }
43
+
44
+ resolved_kind = kind or declared_kind(data)
45
+ if resolved_kind is None or schema_path_for_kind(resolved_kind) is None:
46
+ return {
47
+ "exit_code": 0,
48
+ "stdout": "",
49
+ "stderr": "",
50
+ "command": "validate_data",
51
+ "valid": True,
52
+ }
53
+
54
+ result = validate_data(
55
+ data,
56
+ source="<inline>",
57
+ kind=resolved_kind,
58
+ base_dir=base_dir,
59
+ resolve_refs=resolve_refs,
60
+ )
61
+
62
+ errors = [i for i in result.issues if i.level == "error"]
63
+ warnings = [i for i in result.issues if i.level == "warning"]
64
+ lines = [f"[{i.level}] {i.message}" for i in errors + warnings]
65
+ stderr = "\n".join(lines)
66
+
67
+ return {
68
+ "exit_code": 0 if result.ok else 1,
69
+ "stdout": "",
70
+ "stderr": stderr,
71
+ "command": "validate_data",
72
+ "valid": result.ok,
73
+ "issues": [{"level": i.level, "message": i.message, "path": i.path} for i in result.issues],
74
+ }
75
+
76
+
77
+ def validate_overlay_yaml_content(content: str, *, base_dir: Path) -> list[str] | None:
78
+ """Validate overlay YAML; return error strings or None when valid."""
79
+ outcome = validate_manifest_yaml_content(
80
+ content,
81
+ base_dir=base_dir,
82
+ kind="overlay",
83
+ resolve_refs=False,
84
+ )
85
+ if outcome.get("valid"):
86
+ return None
87
+ stderr = str(outcome.get("stderr") or "").strip()
88
+ if stderr:
89
+ return [line.strip() for line in stderr.splitlines() if line.strip()]
90
+ return ["Validation failed"]
@@ -0,0 +1,215 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pydantic request/response models for the FastAPI controller API."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Optional
8
+
9
+ from pydantic import AliasChoices, BaseModel, Field
10
+
11
+ from mas.lab.controller.constants import MAX_TIMEOUT
12
+
13
+
14
+ class ValidateRequest(BaseModel):
15
+ manifest_yaml: str = Field(
16
+ ...,
17
+ description="Full YAML manifest content to validate (agent or MAS)",
18
+ )
19
+
20
+
21
+ class SaveOverlayRequest(BaseModel):
22
+ name: str = Field(..., description="Overlay name (used as filename, without .yaml)")
23
+ content: str = Field(..., description="Full overlay YAML content")
24
+ run_validation: bool = Field(default=True, description="Validate against schema before saving")
25
+
26
+
27
+ class RunAgentRequest(BaseModel):
28
+ manifest_yaml: str = Field(..., description="Full agent YAML manifest content to run")
29
+ query: str = Field(..., description="The question to send to the agent")
30
+ flavour: Optional[str] = Field(
31
+ default=None,
32
+ description="Flavour name (e.g., 'local', 'local-benchmark')",
33
+ )
34
+ session_id: Optional[str] = Field(
35
+ default=None,
36
+ description="Stable session id for multi-turn chat memory",
37
+ )
38
+ verbose: bool = Field(
39
+ default=False,
40
+ description="Deprecated: chat runs in-process without CLI stdout",
41
+ )
42
+ no_cache: bool = Field(default=True, description="Clear web search cache before running")
43
+ timeout: int = Field(default=60, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
44
+
45
+
46
+ class MultiTurnRequest(BaseModel):
47
+ manifest_yaml: str = Field(..., description="Full agent YAML manifest content to run")
48
+ queries: list[str] = Field(
49
+ ...,
50
+ min_length=1,
51
+ description="Ordered list of queries for a multi-turn conversation",
52
+ )
53
+ overlays: list[str] = Field(
54
+ default_factory=list,
55
+ description="Overlay filenames to apply (relative to library dir)",
56
+ )
57
+ flavour: Optional[str] = Field(default=None, description="Flavour name")
58
+ verbose: bool = Field(default=True, description="Enable verbose output")
59
+ timeout: int = Field(default=90, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
60
+
61
+
62
+ class RunMASRequest(BaseModel):
63
+ manifest_yaml: str = Field(
64
+ default="",
65
+ description="Full MAS YAML. Ignored when mas_path is set.",
66
+ )
67
+ mas_path: Optional[str] = Field(
68
+ default=None,
69
+ description="MAS file relative to the library root (preferred: keeps agent refs valid).",
70
+ )
71
+ query: str = Field(..., description="The question to send to the MAS")
72
+ overlays: list[str] = Field(
73
+ default_factory=list,
74
+ description="Overlay names or paths relative to the library dir",
75
+ )
76
+ infra_refs: list[str] = Field(
77
+ default_factory=list,
78
+ description="If set, only these infra files are passed to mas-ctl (no directory glob).",
79
+ )
80
+ pre: list[dict] = Field(
81
+ default_factory=list,
82
+ description="Inline pipeline-style steps run in the library cwd before mas-ctl (type: shell).",
83
+ )
84
+ demo_ui_ingest_url: Optional[str] = Field(
85
+ default=None,
86
+ description="When set, mas-ctl POSTs live native records to this demo-ui ingest URL.",
87
+ )
88
+ demo_ui_ref: Optional[dict] = Field(
89
+ default=None,
90
+ description="Data-source ref stamped onto live ingest records.",
91
+ )
92
+ flavour: Optional[str] = Field(default=None, description="Flavour name")
93
+ verbose: bool = Field(default=True, description="Enable verbose output")
94
+ timeout: int = Field(default=90, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
95
+
96
+
97
+ class SaveDatasetRequest(BaseModel):
98
+ name: str = Field(..., description="Dataset filename (e.g. my-dataset.yaml)")
99
+ content: str = Field(..., description="Full dataset YAML content")
100
+
101
+
102
+ class ExperimentRunRequest(BaseModel):
103
+ experiment_yaml: str = Field(
104
+ ...,
105
+ description="Experiment filename or path relative to the library root",
106
+ )
107
+ query: str = Field(
108
+ default="",
109
+ description="Optional prompt override shown in the demo-ui selector",
110
+ )
111
+ infra_refs: list[str] = Field(
112
+ default_factory=list,
113
+ description="Extra infra files relative to the library root (LLM cache, model pin)",
114
+ )
115
+ pre: list[dict] = Field(
116
+ default_factory=list,
117
+ description="Inline pipeline-style steps run in the library cwd before the worker starts",
118
+ )
119
+ demo_ui_ingest_url: Optional[str] = Field(
120
+ default=None,
121
+ description="When set, the in-process worker POSTs live native records here",
122
+ )
123
+ demo_ui_ref: Optional[dict] = Field(
124
+ default=None,
125
+ description="Data-source ref stamped onto live ingest records",
126
+ )
127
+ force: bool = Field(default=True, description="Force a new run even if a previous exists")
128
+ max_runs: Optional[int] = Field(default=1, description="Cap n_runs for a live experiment")
129
+ timeout: int = Field(default=MAX_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
130
+
131
+
132
+ class BenchmarkRunRequest(BaseModel):
133
+ experiment_yaml: str = Field(
134
+ ...,
135
+ description="Full experiment YAML content, or a filename relative to the library dir",
136
+ )
137
+ progress: bool = Field(default=True, description="Show progress bar")
138
+ max_runs: Optional[int] = Field(
139
+ default=None,
140
+ validation_alias=AliasChoices("max_runs", "n_runs"),
141
+ description="Override n_runs from config",
142
+ )
143
+ force: bool = Field(default=True, description="Force a new run even if a previous exists")
144
+ flavour: Optional[str] = Field(default=None, description="Flavour name")
145
+ timeout: int = Field(default=MAX_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
146
+
147
+
148
+ class BenchmarkAnalyzeRequest(BaseModel):
149
+ benchmark_id: str = Field(..., description="Benchmark run id (short or full)")
150
+ experiment_yaml: Optional[str] = Field(
151
+ default=None,
152
+ description="Optional experiment YAML path when auto-detect fails",
153
+ )
154
+ timeout: int = Field(default=120, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
155
+
156
+
157
+ class BenchmarkExportRequest(BaseModel):
158
+ benchmark_id: str = Field(..., description="Benchmark run id (short, full, last, or latest)")
159
+ output: Optional[str] = Field(default=None, description="Output .tar.gz path (default: cwd)")
160
+ include_trace_cache: bool = Field(default=True, description="Pack trace-cache entries")
161
+ dry_run: bool = Field(default=False, description="List archive contents without writing")
162
+ timeout: int = Field(default=300, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
163
+
164
+
165
+ class BenchmarkImportRequest(BaseModel):
166
+ tarball: str = Field(..., description="Path to .tar.gz produced by benchmark export")
167
+ output_dir: Optional[str] = Field(default=None, description="Restore benchmark output here")
168
+ trace_cache_dir: Optional[str] = Field(default=None, description="Restore trace-cache entries here")
169
+ dry_run: bool = Field(default=False, description="Inspect archive without writing")
170
+ timeout: int = Field(default=300, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
171
+
172
+
173
+ class PipelineRunRequest(BaseModel):
174
+ pipeline_yaml: str = Field(
175
+ default="pipelines/full-pipeline-topology-comparison.yaml",
176
+ description="Pipeline YAML path (relative to library dir)",
177
+ )
178
+ only: list[str] = Field(
179
+ default_factory=list,
180
+ description="Run only these steps (e.g., ['extract', 'evaluate'])",
181
+ )
182
+ timeout: int = Field(default=MAX_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
183
+
184
+
185
+ class SavePipelineRequest(BaseModel):
186
+ name: str = Field(..., description="Pipeline name (used as filename, without .yaml)")
187
+ content: str = Field(..., description="Full pipeline YAML content")
188
+ run_validation: bool = Field(default=True, description="Validate against schema before saving")
189
+
190
+
191
+ class SaveExperimentRequest(BaseModel):
192
+ name: str = Field(..., description="Experiment name (used as filename, without .yaml)")
193
+ content: str = Field(..., description="Full experiment YAML content")
194
+
195
+
196
+ class EvalOutputRequest(BaseModel):
197
+ events_file: str = Field(
198
+ ...,
199
+ description="Path to events.jsonl (relative to library dir or absolute)",
200
+ )
201
+ metrics: list[str] = Field(
202
+ default=["answer_relevancy"],
203
+ description="Metric names to compute",
204
+ )
205
+ model: str = Field(default="azure/gpt-4o", description="LLM judge model")
206
+ timeout: int = Field(default=90, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
207
+
208
+
209
+ class SaveMASResourceRequest(BaseModel):
210
+ mas_name: str = Field(..., description="MAS name (becomes the app folder name under apps/)")
211
+ mas_yaml: str = Field(..., description="MAS manifest YAML content")
212
+ agents: dict[str, str] = Field(
213
+ default_factory=dict,
214
+ description="Map of agent name → agent YAML content (stored in apps/<mas_name>/agents/)",
215
+ )
@@ -0,0 +1,68 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Name and path guards for every controller write."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from mas.lab.controller.kinds import NAME_PATTERN, VERSION_PATTERN
11
+
12
+ _NAME_RE = re.compile(NAME_PATTERN)
13
+ _VERSION_RE = re.compile(VERSION_PATTERN)
14
+
15
+
16
+ class UnsafeNameError(ValueError):
17
+ """A user-supplied name, version, or item id failed the allow-list."""
18
+
19
+
20
+ def validate_name(name: str) -> str:
21
+ text = str(name or "").strip()
22
+ if not text or not _NAME_RE.fullmatch(text):
23
+ raise UnsafeNameError(f"invalid name {name!r}")
24
+ if text.startswith("."):
25
+ raise UnsafeNameError(f"hidden name {name!r} is not allowed")
26
+ if ".." in text or "/" in text or "\\" in text:
27
+ raise UnsafeNameError(f"path-like name {name!r} is not allowed")
28
+ return text
29
+
30
+
31
+ def validate_version(version: str | None) -> str | None:
32
+ if version is None or str(version).strip() == "":
33
+ return None
34
+ text = str(version).strip()
35
+ if not _VERSION_RE.fullmatch(text):
36
+ raise UnsafeNameError(f"invalid version {version!r}")
37
+ return text
38
+
39
+
40
+ def parse_item_id(item_id: str) -> tuple[str, str | None]:
41
+ """Split ``name`` or ``name@version``. Reject path-like ids."""
42
+ text = str(item_id or "").strip()
43
+ if not text or text.startswith(".") or ".." in text or "/" in text or "\\" in text:
44
+ raise UnsafeNameError(f"invalid item id {item_id!r}")
45
+ if "@" not in text:
46
+ return validate_name(text), None
47
+ name, _, version = text.rpartition("@")
48
+ return validate_name(name), validate_version(version)
49
+
50
+
51
+ def ensure_inside(root: Path, target: Path) -> Path:
52
+ """Resolve *target* and require it to stay under *root* (no symlink escape)."""
53
+ root_resolved = root.resolve()
54
+ target_resolved = target.resolve()
55
+ try:
56
+ target_resolved.relative_to(root_resolved)
57
+ except ValueError as exc:
58
+ raise UnsafeNameError("path is outside the scope root") from exc
59
+ return target_resolved
60
+
61
+
62
+ def is_hidden_under(root: Path, target: Path) -> bool:
63
+ """True when any relative part is a dot-directory (staging leftovers, ``.git``)."""
64
+ try:
65
+ rel = target.resolve().relative_to(root.resolve())
66
+ except ValueError:
67
+ return True
68
+ return any(part.startswith(".") for part in rel.parts)
@@ -0,0 +1,79 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pipeline YAML validation helpers."""
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ def validate_pipeline_yaml(content: str) -> dict:
9
+ """Validate pipeline YAML content against the JSON schema and check DAG integrity.
10
+
11
+ Returns {"valid": True} or {"valid": False, "errors": [...]}.
12
+ """
13
+ import json
14
+
15
+ import jsonschema
16
+ import yaml as _yaml
17
+
18
+ errors: list[str] = []
19
+
20
+ try:
21
+ doc = _yaml.safe_load(content)
22
+ except _yaml.YAMLError as exc:
23
+ return {"valid": False, "errors": [f"YAML parse error: {exc}"]}
24
+
25
+ if not isinstance(doc, dict):
26
+ return {"valid": False, "errors": ["Document must be a YAML mapping"]}
27
+
28
+ # JSON Schema validation (bench package: pipeline-manifest.schema.json)
29
+ try:
30
+ from mas.lab.controller.schema_registry import read_schema_text
31
+
32
+ _, schema_text = read_schema_text("pipeline")
33
+ schema = json.loads(schema_text)
34
+ jsonschema.validate(doc, schema)
35
+ except jsonschema.ValidationError as exc:
36
+ errors.append(f"Schema: {exc.message} (at {'/'.join(str(p) for p in exc.absolute_path)})")
37
+ except Exception as exc:
38
+ errors.append(f"Schema validation error: {exc}")
39
+
40
+ # DAG integrity checks
41
+ steps = (doc.get("spec") or {}).get("steps") or []
42
+ step_names = {s["name"] for s in steps if "name" in s}
43
+ seen: set[str] = set()
44
+ for s in steps:
45
+ name = s.get("name", "<unnamed>")
46
+ if name in seen:
47
+ errors.append(f"Duplicate step name: '{name}'")
48
+ seen.add(name)
49
+ for dep in s.get("depends_on", []):
50
+ if dep not in step_names:
51
+ errors.append(f"Step '{name}' depends on unknown step '{dep}'")
52
+ if dep == name:
53
+ errors.append(f"Step '{name}' depends on itself")
54
+
55
+ # Cycle detection (topological sort)
56
+ if not errors:
57
+ adj: dict[str, list[str]] = {s["name"]: s.get("depends_on", []) for s in steps}
58
+ visited: set[str] = set()
59
+ in_stack: set[str] = set()
60
+
61
+ def _has_cycle(node: str) -> bool:
62
+ if node in in_stack:
63
+ return True
64
+ if node in visited:
65
+ return False
66
+ visited.add(node)
67
+ in_stack.add(node)
68
+ for dep in adj.get(node, []):
69
+ if _has_cycle(dep):
70
+ return True
71
+ in_stack.discard(node)
72
+ return False
73
+
74
+ for step_name in adj:
75
+ if _has_cycle(step_name):
76
+ errors.append("Dependency cycle detected in pipeline steps")
77
+ break
78
+
79
+ return {"valid": len(errors) == 0, "errors": errors}