mas-lab-component-controller 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mas/lab/controller/__init__.py +7 -0
- mas/lab/controller/__main__.py +5 -0
- mas/lab/controller/agent_chat.py +226 -0
- mas/lab/controller/api.py +364 -0
- mas/lab/controller/artifact_discovery.py +107 -0
- mas/lab/controller/artifacts.py +112 -0
- mas/lab/controller/cli.py +493 -0
- mas/lab/controller/client.py +271 -0
- mas/lab/controller/config.py +75 -0
- mas/lab/controller/constants.py +30 -0
- mas/lab/controller/daemon.py +190 -0
- mas/lab/controller/deps.py +172 -0
- mas/lab/controller/fastapi_app.py +166 -0
- mas/lab/controller/http_server.py +243 -0
- mas/lab/controller/io_capture.py +55 -0
- mas/lab/controller/item_service.py +625 -0
- mas/lab/controller/job_store.py +209 -0
- mas/lab/controller/jobs.py +375 -0
- mas/lab/controller/kinds.py +149 -0
- mas/lab/controller/lab_registry.py +1076 -0
- mas/lab/controller/manifest_layout.py +832 -0
- mas/lab/controller/manifest_store.py +214 -0
- mas/lab/controller/manifest_validation.py +90 -0
- mas/lab/controller/models.py +215 -0
- mas/lab/controller/path_safety.py +68 -0
- mas/lab/controller/pipeline_validation.py +79 -0
- mas/lab/controller/registry.py +83 -0
- mas/lab/controller/routes/__init__.py +82 -0
- mas/lab/controller/routes/_api.py +15 -0
- mas/lab/controller/routes/apps.py +190 -0
- mas/lab/controller/routes/artifacts.py +37 -0
- mas/lab/controller/routes/benchmark.py +307 -0
- mas/lab/controller/routes/choices.py +141 -0
- mas/lab/controller/routes/datasets.py +91 -0
- mas/lab/controller/routes/design_patterns.py +121 -0
- mas/lab/controller/routes/eval_output.py +31 -0
- mas/lab/controller/routes/experiments.py +194 -0
- mas/lab/controller/routes/health.py +178 -0
- mas/lab/controller/routes/items.py +187 -0
- mas/lab/controller/routes/jobs.py +107 -0
- mas/lab/controller/routes/libraries.py +144 -0
- mas/lab/controller/routes/overlays.py +164 -0
- mas/lab/controller/routes/pipelines.py +139 -0
- mas/lab/controller/routes/registry.py +40 -0
- mas/lab/controller/routes/run.py +215 -0
- mas/lab/controller/routes/runs.py +392 -0
- mas/lab/controller/routes/scopes.py +139 -0
- mas/lab/controller/routes/uploads.py +24 -0
- mas/lab/controller/routes/validate.py +36 -0
- mas/lab/controller/run_bridge.py +276 -0
- mas/lab/controller/run_store.py +190 -0
- mas/lab/controller/schema_registry.py +109 -0
- mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
- mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
- mas/lab/controller/scope_catalog.py +266 -0
- mas/lab/controller/scope_service.py +134 -0
- mas/lab/controller/sessions.py +63 -0
- mas/lab/controller/settings.py +65 -0
- mas/lab/controller/ui_stream/__init__.py +13 -0
- mas/lab/controller/ui_stream/app.py +2147 -0
- mas/lab/controller/ui_stream/artefact_bind.py +308 -0
- mas/lab/controller/ui_stream/auth.py +136 -0
- mas/lab/controller/ui_stream/bus.py +156 -0
- mas/lab/controller/ui_stream/export.py +400 -0
- mas/lab/controller/ui_stream/governance.py +141 -0
- mas/lab/controller/ui_stream/ingest.py +300 -0
- mas/lab/controller/ui_stream/inspect.py +55 -0
- mas/lab/controller/ui_stream/layouts.py +161 -0
- mas/lab/controller/ui_stream/metrics.py +274 -0
- mas/lab/controller/ui_stream/plot_folds.py +609 -0
- mas/lab/controller/ui_stream/plugin_registry.py +139 -0
- mas/lab/controller/ui_stream/refs.py +259 -0
- mas/lab/controller/ui_stream/replay.py +236 -0
- mas/lab/controller/ui_stream/replay_cli.py +707 -0
- mas/lab/controller/ui_stream/run_index.py +337 -0
- mas/lab/controller/ui_stream/spec_load.py +48 -0
- mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
- mas/lab/controller/ui_stream/stream.py +95 -0
- mas/lab/controller/ui_stream/streams.py +42 -0
- mas/lab/controller/ui_stream/timeline.py +183 -0
- mas/lab/controller/ui_stream/ui_trace.py +258 -0
- mas/lab/controller/uploads.py +61 -0
- mas/lab/controller/worker_model.py +137 -0
- mas/lab/controller/workers.py +296 -0
- mas/lab/controller/workspace_library.py +70 -0
- mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
- mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
- mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
- mas_lab_component_controller-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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)
|