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,300 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Publish native records onto the UI envelope bus."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from datetime import UTC, datetime
9
+ from typing import Any
10
+
11
+ from mas.lab.controller.ui_stream.bus import EventBus
12
+ from mas.lab.controller.ui_stream.governance import GovernanceStore, compact_context_record
13
+ from mas.lab.controller.ui_stream.metrics import MetricsStore, coerce_timestamp
14
+ from mas.lab.controller.ui_stream.plot_folds import LivePlotFolds
15
+ from mas.lab.controller.ui_stream.refs import DataSourceRef, envelope
16
+
17
+ _HITL_KINDS = frozenset({"hitl_gate", "hitl_resolve", "hitl_request", "hitl_response", "hitl_timeout"})
18
+ _FLUSH_KINDS = frozenset({"mas_call_end", "execution_end", "run.terminal"})
19
+ _TICK_KINDS = frozenset({"llm_call_end", "tool_call_start", "execution_start", "mas_call_end", "execution_end"})
20
+ _GOV_KINDS = frozenset({"context_assembled", "context_usage"})
21
+ _GOV_FLUSH_KINDS = frozenset({"context_usage", "llm_call_end", "mas_call_end"})
22
+ _SNAPSHOT_INTERVAL_S = 1.0
23
+ _CHART_KEYS = (
24
+ "data",
25
+ "cards",
26
+ "nodes",
27
+ "edges",
28
+ "series",
29
+ "events",
30
+ "diracs",
31
+ "agents",
32
+ "roster",
33
+ "fragments",
34
+ "activity",
35
+ )
36
+
37
+
38
+ def live_tick(record: dict[str, Any]) -> dict[str, Any] | None:
39
+ """Keep JSON chart data; drop HTML shells. Same rule for every plot."""
40
+ if not isinstance(record, dict):
41
+ return None
42
+ html = record.get("html")
43
+ out = {key: value for key, value in record.items() if key != "html"}
44
+ if html and not any(out.get(key) not in (None, "", [], {}) for key in _CHART_KEYS):
45
+ return None
46
+ return out
47
+
48
+
49
+ def _envelope_ts(record: dict[str, Any]) -> str | None:
50
+ ts = coerce_timestamp(record.get("timestamp", record.get("ts", record.get("snapshot_at"))))
51
+ if ts is None:
52
+ return None
53
+ return datetime.fromtimestamp(ts, UTC).isoformat()
54
+
55
+
56
+ class NativeIngest:
57
+ def __init__(self, bus: EventBus, metrics: MetricsStore, governance: GovernanceStore | None = None) -> None:
58
+ self.bus = bus
59
+ self.metrics = metrics
60
+ self.governance = governance or GovernanceStore()
61
+ self._last_snapshot: dict[str, float] = {}
62
+ self._last_gov: dict[str, float] = {}
63
+ self._plots = LivePlotFolds()
64
+ self._skip_plots: set[str] = set()
65
+
66
+ def clear(self) -> None:
67
+ self._last_snapshot.clear()
68
+ self._last_gov.clear()
69
+ self._plots = LivePlotFolds()
70
+ self._skip_plots.clear()
71
+
72
+ def seed_spec(self, ref: DataSourceRef, snap: dict[str, Any], *, replay: bool = False) -> None:
73
+ """Publish topology from a spec snapshot so GET/retry and the plot are not empty."""
74
+ if "custom.topology" in self._skip_plots:
75
+ return
76
+ payload = self._plots.seed_spec(ref, snap)
77
+ if not payload.get("nodes"):
78
+ return
79
+ self.bus.publish(
80
+ envelope("custom.topology.snapshot", ref, "custom", payload, replay=replay, complete=False)
81
+ )
82
+
83
+ def ingest(self, ref: DataSourceRef, record: dict[str, Any], *, replay: bool = False) -> None:
84
+ scoped = ref.merge(
85
+ {
86
+ "scenario": record.get("scenario"),
87
+ "overlay": record.get("overlay"),
88
+ "test": record.get("test") or record.get("item"),
89
+ }
90
+ )
91
+ kind = str(record.get("kind") or "unknown")
92
+ if kind in _GOV_KINDS:
93
+ if not self._wants_context(scoped):
94
+ return
95
+ record = compact_context_record(record)
96
+ event_id = str(record.get("call_id") or record.get("id") or "") or None
97
+ stamped = _envelope_ts(record)
98
+ self.bus.publish(
99
+ envelope(
100
+ f"native.{kind}",
101
+ scoped,
102
+ "native",
103
+ record,
104
+ replay=replay,
105
+ event_id=event_id,
106
+ ts=stamped,
107
+ )
108
+ )
109
+ if kind in _HITL_KINDS:
110
+ self.bus.publish(
111
+ envelope(
112
+ f"control.{kind}",
113
+ scoped,
114
+ "control",
115
+ record,
116
+ replay=replay,
117
+ event_id=event_id,
118
+ ts=stamped,
119
+ )
120
+ )
121
+ if kind == "metric":
122
+ stats = self.metrics.apply_step(scoped, record)
123
+ self.bus.publish(
124
+ envelope("metric.snapshot", scoped, "metric", stats, replay=replay, ts=stamped, complete=False)
125
+ )
126
+ self.bus.publish(
127
+ envelope("artifact.metric", scoped, "artifact", record, replay=replay, ts=stamped)
128
+ )
129
+ return
130
+ if kind.startswith("control."):
131
+ self.bus.publish(
132
+ envelope(kind if kind.startswith("control.") else f"control.{kind}", scoped, "control", record, replay=replay, ts=stamped)
133
+ )
134
+ return
135
+ if kind == "scope.progress":
136
+ self.bus.publish(
137
+ envelope("metric.scope", scoped, "metric", record, replay=replay, ts=stamped)
138
+ )
139
+ self.bus.publish(
140
+ envelope("job.progress", scoped, "job", record, replay=replay, ts=stamped)
141
+ )
142
+ return
143
+ if kind.startswith("artifact."):
144
+ flag = record.get("complete") if isinstance(record.get("complete"), bool) else None
145
+ self.bus.publish(
146
+ envelope(kind, scoped, "artifact", record, replay=replay, ts=stamped, complete=flag)
147
+ )
148
+ if kind.endswith(".sealed") or flag is True:
149
+ self.seal_artefacts(scoped)
150
+ return
151
+ if kind.startswith("custom."):
152
+ record = live_tick(record)
153
+ if record is None:
154
+ return
155
+ flag = record.get("complete") if isinstance(record.get("complete"), bool) else None
156
+ self.bus.publish(
157
+ envelope(kind, scoped, "custom", record, replay=replay, ts=stamped, complete=flag)
158
+ )
159
+ return
160
+ if kind.startswith("series."):
161
+ record = live_tick(record)
162
+ if record is None:
163
+ return
164
+ flag = record.get("complete") if isinstance(record.get("complete"), bool) else None
165
+ self.bus.publish(
166
+ envelope(kind, scoped, "artifact", record, replay=replay, ts=stamped, complete=flag)
167
+ )
168
+ return
169
+ usage = self.metrics.apply_native(scoped, record, wall=not replay)
170
+ updated = bool(usage.pop("_updated", None))
171
+ if updated or kind in _FLUSH_KINDS or kind in _TICK_KINDS:
172
+ payload = usage if updated else self.metrics.snapshot(scoped)
173
+ self._maybe_snapshot(
174
+ scoped,
175
+ payload,
176
+ force=kind in _FLUSH_KINDS or kind in _TICK_KINDS,
177
+ replay=replay,
178
+ ts=stamped,
179
+ )
180
+ if self._wants_context(scoped):
181
+ gov = self.governance.apply(scoped, record)
182
+ gov_updated = bool(gov.pop("_updated", None))
183
+ if gov_updated or kind in _GOV_FLUSH_KINDS:
184
+ self._maybe_gov(
185
+ scoped,
186
+ gov if gov_updated else self.governance.snapshot(scoped),
187
+ force=kind in _GOV_FLUSH_KINDS,
188
+ replay=replay,
189
+ ts=stamped,
190
+ )
191
+ if kind in _GOV_FLUSH_KINDS and scoped.session:
192
+ self._request_inspect("context", scoped)
193
+ def _emit_plot(target: DataSourceRef, rec: dict[str, Any]) -> None:
194
+ plot_kind = str(rec.get("kind") or "")
195
+ source = "custom" if plot_kind.startswith("custom.") else "artifact"
196
+ self.bus.publish(
197
+ envelope(plot_kind, target, source, rec, replay=replay, ts=stamped, complete=False)
198
+ )
199
+
200
+ self._plots.apply(_emit_plot, scoped, record, skip=self._skip_plots)
201
+ if kind in _FLUSH_KINDS:
202
+ self.seal_artefacts(scoped)
203
+
204
+ def seal_artefacts(self, ref: DataSourceRef) -> None:
205
+ """Publish ``{stream}.sealed`` with ``complete=True`` for each produced fold."""
206
+ from mas.lab.controller.ui_stream.artefact_bind import LIVE_GRAPH, is_sealed_topic
207
+
208
+ for name, node in LIVE_GRAPH.items():
209
+ if name == "events":
210
+ continue
211
+ stream = str(node.get("stream") or name)
212
+ ticks = self.bus.snapshot([stream, f"{stream}.*"], ref)
213
+ if not ticks:
214
+ continue
215
+ if any(is_sealed_topic(str(getattr(item, "topic", "") or "")) for item in ticks):
216
+ continue
217
+ last = ticks[-1]
218
+ payload = last.payload if isinstance(last.payload, dict) else {"kind": name}
219
+ self.bus.publish(
220
+ envelope(
221
+ f"{stream}.sealed",
222
+ ref,
223
+ "artifact",
224
+ payload,
225
+ complete=True,
226
+ artefact=name,
227
+ )
228
+ )
229
+
230
+ def flush_snapshot(self, ref: DataSourceRef, *, replay: bool = False, ts: str | None = None) -> None:
231
+ self._publish_snapshot(ref, self.metrics.snapshot(ref), replay=replay, ts=ts)
232
+ if self._wants_context(ref):
233
+ self._publish_gov(ref, self.governance.snapshot(ref), replay=replay, ts=ts)
234
+
235
+ def _wants_context(self, ref: DataSourceRef) -> bool:
236
+ demand = getattr(self, "_demand", None)
237
+ if callable(demand):
238
+ return bool(demand("context", ref))
239
+ demands = getattr(self.bus, "demands_topic", None)
240
+ if not callable(demands):
241
+ return False
242
+ return bool(demands("governance.context", ref))
243
+
244
+ def _request_inspect(self, pull_id: str, ref: DataSourceRef) -> None:
245
+ hook = getattr(self, "_on_inspect", None)
246
+ if callable(hook):
247
+ hook(pull_id, ref)
248
+
249
+ def _maybe_snapshot(
250
+ self,
251
+ ref: DataSourceRef,
252
+ payload: dict[str, Any],
253
+ *,
254
+ force: bool,
255
+ replay: bool,
256
+ ts: str | None = None,
257
+ ) -> None:
258
+ key = ref.coordinate() + "|" + (ref.overlay or "")
259
+ now = time.monotonic()
260
+ last = self._last_snapshot.get(key, 0.0)
261
+ if not force and (now - last) < _SNAPSHOT_INTERVAL_S:
262
+ return
263
+ self._last_snapshot[key] = now
264
+ self._publish_snapshot(ref, payload, replay=replay, ts=ts)
265
+
266
+ def _maybe_gov(
267
+ self,
268
+ ref: DataSourceRef,
269
+ payload: dict[str, Any],
270
+ *,
271
+ force: bool,
272
+ replay: bool,
273
+ ts: str | None = None,
274
+ ) -> None:
275
+ if not payload:
276
+ return
277
+ key = ref.coordinate() + "|" + (ref.overlay or "")
278
+ now = time.monotonic()
279
+ last = self._last_gov.get(key, 0.0)
280
+ if not force and (now - last) < _SNAPSHOT_INTERVAL_S:
281
+ return
282
+ self._last_gov[key] = now
283
+ self._publish_gov(ref, payload, replay=replay, ts=ts)
284
+
285
+ def _publish_snapshot(
286
+ self, ref: DataSourceRef, payload: dict[str, Any], *, replay: bool, ts: str | None = None
287
+ ) -> None:
288
+ self.bus.publish(
289
+ envelope("metric.snapshot", ref, "metric", payload, replay=replay, ts=ts, complete=False)
290
+ )
291
+ self.bus.publish(
292
+ envelope("metric.usage", ref, "metric", payload, replay=replay, ts=ts, complete=False)
293
+ )
294
+
295
+ def _publish_gov(
296
+ self, ref: DataSourceRef, payload: dict[str, Any], *, replay: bool, ts: str | None = None
297
+ ) -> None:
298
+ if not payload:
299
+ return
300
+ self.bus.publish(envelope("governance.context", ref, "governance", payload, replay=replay, ts=ts))
@@ -0,0 +1,55 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Inspect pulls the UI may request. Nothing is sampled unless subscribed."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+ from mas.lab.controller.ui_stream.refs import topic_matches
10
+
11
+ # Canonical topic a plugin subscribes to → which inspect verb / extra native kinds.
12
+ # The space of inspectable session state is large; add a row only when a UI
13
+ # plugin actually declares `inspects: ["…"]` (or subscribes to the topic).
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class InspectPull:
18
+ topic: str
19
+ verb: str
20
+ native_kinds: tuple[str, ...]
21
+
22
+
23
+ PULLS: dict[str, InspectPull] = {
24
+ "context": InspectPull(
25
+ topic="governance.context",
26
+ verb="inspect_context",
27
+ native_kinds=("context_assembled", "context_usage"),
28
+ ),
29
+ }
30
+
31
+
32
+ def infer_inspect(topics: list[str] | tuple[str, ...]) -> list[str]:
33
+ """Map named WS/plugin topic globs onto inspect pulls.
34
+
35
+ Catch-all ``*`` is a viewer glob, not a demand to sample every inspect
36
+ family. Only an explicit topic (``governance.context``, ``governance.*``,
37
+ ``inspect.context``) opts in.
38
+ """
39
+ declared = tuple(glob for glob in (topics or ()) if glob not in {"", "*"})
40
+ if not declared:
41
+ return []
42
+ wanted: list[str] = []
43
+ for pull_id, spec in PULLS.items():
44
+ if topic_matches(declared, spec.topic) or topic_matches(declared, f"inspect.{pull_id}"):
45
+ wanted.append(pull_id)
46
+ return wanted
47
+
48
+
49
+ def extra_native_kinds(inspect: list[str] | tuple[str, ...]) -> frozenset[str]:
50
+ kinds: set[str] = set()
51
+ for pull_id in inspect:
52
+ spec = PULLS.get(pull_id)
53
+ if spec is not None:
54
+ kinds.update(spec.native_kinds)
55
+ return frozenset(kinds)
@@ -0,0 +1,161 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Load YAML layouts from the demo-ui package (include expansion)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+ from mas.lab.controller.ui_stream.refs import DataSourceRef, ref_from_mapping
12
+
13
+
14
+ def load_yaml(path: Path) -> dict[str, Any]:
15
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
16
+ if not isinstance(data, dict):
17
+ raise ValueError(f"layout {path} must be a mapping")
18
+ return data
19
+
20
+
21
+ def _resolve_layout_file(layouts_root: Path | list[Path], name: str) -> Path:
22
+ roots = layouts_root if isinstance(layouts_root, list) else [layouts_root]
23
+ wanted = [name, f"{name}.yaml"] if not str(name).endswith(".yaml") else [name]
24
+ for root in roots:
25
+ for item in wanted:
26
+ for path in (root / item, root / "examples" / item):
27
+ if path.is_file():
28
+ return path
29
+ raise FileNotFoundError(f"layout {name!r} not found")
30
+
31
+
32
+ def _deep_merge(base: Any, extra: Any) -> Any:
33
+ if not isinstance(base, dict) or not isinstance(extra, dict):
34
+ return extra
35
+ out = dict(base)
36
+ for key, value in extra.items():
37
+ if key in out and isinstance(out[key], dict) and isinstance(value, dict):
38
+ out[key] = _deep_merge(out[key], value)
39
+ else:
40
+ out[key] = value
41
+ return out
42
+
43
+
44
+ def expand_lanes(doc: dict[str, Any]) -> dict[str, Any]:
45
+ """Tile ``ref`` / ``pane`` once per ``lanes`` entry (side-by-side compare)."""
46
+ lanes = doc.get("lanes")
47
+ template = doc.get("ref") or doc.get("pane")
48
+ if not lanes or not template:
49
+ return doc
50
+ if not isinstance(lanes, list):
51
+ raise ValueError("lanes must be a list")
52
+ children: list[dict[str, Any]] = []
53
+ for lane in lanes:
54
+ if not isinstance(lane, dict):
55
+ raise ValueError("each lane must be a mapping")
56
+ child: dict[str, Any] = {
57
+ "type": "panel",
58
+ "size": lane.get("size") or "1fr",
59
+ "include": template,
60
+ "dataSource": dict(lane.get("dataSource") or {}),
61
+ }
62
+ if lane.get("title"):
63
+ child["title"] = lane["title"]
64
+ if lane.get("override"):
65
+ child["override"] = lane["override"]
66
+ children.append(child)
67
+ out = dict(doc)
68
+ out.pop("lanes", None)
69
+ out.pop("ref", None)
70
+ out.pop("pane", None)
71
+ out["layout"] = {"type": "hstack", "children": children}
72
+ return out
73
+
74
+
75
+ def expand_includes(node: Any, layouts_root: Path | list[Path], inherited: DataSourceRef) -> Any:
76
+ if isinstance(node, list):
77
+ return [expand_includes(item, layouts_root, inherited) for item in node]
78
+ if not isinstance(node, dict):
79
+ return node
80
+ merged = inherited.merge(ref_from_mapping(node.get("dataSource")))
81
+ if "include" in node:
82
+ included_path = _resolve_layout_file(layouts_root, str(node["include"]))
83
+ included = load_yaml(included_path)
84
+ subtree = included.get("layout") if "layout" in included else included
85
+ override = node.get("override")
86
+ if isinstance(override, dict) and isinstance(subtree, dict):
87
+ subtree = _deep_merge(subtree, override)
88
+ wrapped = dict(node)
89
+ wrapped.pop("include", None)
90
+ wrapped.pop("ref", None)
91
+ wrapped.pop("override", None)
92
+ if isinstance(subtree, dict):
93
+ child = expand_includes(subtree, layouts_root, merged)
94
+ wrapped["children"] = [child]
95
+ wrapped.setdefault("type", "panel")
96
+ wrapped["dataSource"] = merged.as_dict()
97
+ return expand_includes(wrapped, layouts_root, merged)
98
+ wrapped["children"] = expand_includes(subtree, layouts_root, merged)
99
+ wrapped["dataSource"] = merged.as_dict()
100
+ return wrapped
101
+ out = dict(node)
102
+ if "dataSource" in node or any(k in node for k in ("children", "content", "layout")):
103
+ out["dataSource"] = merged.as_dict()
104
+ if "children" in node:
105
+ out["children"] = expand_includes(node["children"], layouts_root, merged)
106
+ if "content" in node:
107
+ out["content"] = expand_includes(node["content"], layouts_root, merged)
108
+ if "layout" in node:
109
+ out["layout"] = expand_includes(node["layout"], layouts_root, merged)
110
+ return out
111
+
112
+
113
+ def layout_group(path: Path, root: Path) -> str:
114
+ try:
115
+ rel = path.resolve().relative_to(root.resolve())
116
+ except ValueError:
117
+ rel = Path(path.name)
118
+ if "examples" in rel.parts:
119
+ return "examples"
120
+ marker = root.resolve()
121
+ if marker.name == "layouts":
122
+ marker = marker.parent
123
+ if marker.name == "demo-ui":
124
+ marker = marker.parent
125
+ name = marker.name.strip() or "layouts"
126
+ return "examples" if name in {".", "mas-lab", "mas-lab-demo-ui"} else name
127
+
128
+
129
+ def list_layout_catalog(roots: list[Path]) -> list[dict[str, Any]]:
130
+ """Title + slug rows for the root gallery and the layout picker."""
131
+ rows: list[dict[str, Any]] = []
132
+ seen: set[str] = set()
133
+ for root in roots:
134
+ for path in list(root.glob("*.yaml")) + list(root.glob("examples/*.yaml")):
135
+ if path.name.startswith("overlay-") or path.name in seen:
136
+ continue
137
+ seen.add(path.name)
138
+ title = path.stem.replace("-", " ")
139
+ try:
140
+ doc = load_yaml(path)
141
+ raw = str(doc.get("title") or "").strip()
142
+ if raw:
143
+ title = raw
144
+ except Exception:
145
+ pass
146
+ rows.append(
147
+ {
148
+ "slug": path.name,
149
+ "title": title,
150
+ "group": layout_group(path, root),
151
+ }
152
+ )
153
+ rows.sort(key=lambda row: (str(row.get("group") or ""), str(row.get("title") or "").lower()))
154
+ return rows
155
+
156
+
157
+ def load_layout(layouts_root: Path | list[Path], name: str) -> dict[str, Any]:
158
+ path = _resolve_layout_file(layouts_root, name)
159
+ doc = expand_lanes(load_yaml(path))
160
+ root_ref = ref_from_mapping(doc.get("dataSource"))
161
+ return expand_includes(doc, layouts_root, root_ref)