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,400 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Build a static demo-ui site that replays envelopes with no backend."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mas.lab.controller.ui_stream.bus import EventBus
|
|
15
|
+
from mas.lab.controller.ui_stream.ingest import NativeIngest
|
|
16
|
+
from mas.lab.controller.ui_stream.layouts import load_layout
|
|
17
|
+
from mas.lab.controller.ui_stream.metrics import MetricsStore
|
|
18
|
+
from mas.lab.controller.ui_stream.refs import DataSourceRef, envelope
|
|
19
|
+
from mas.lab.controller.ui_stream.replay import (
|
|
20
|
+
custom_panel_records,
|
|
21
|
+
demo_records,
|
|
22
|
+
merge_replay_records,
|
|
23
|
+
parse_jsonl,
|
|
24
|
+
trace_metric_records,
|
|
25
|
+
)
|
|
26
|
+
from mas.lab.controller.ui_stream.spec_snapshot import snapshot_from_records
|
|
27
|
+
|
|
28
|
+
BUNDLE_VERSION = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_demo_ui_root(explicit: str | Path | None = None) -> Path:
|
|
32
|
+
if explicit:
|
|
33
|
+
root = Path(explicit)
|
|
34
|
+
if (root / "index.html").is_file():
|
|
35
|
+
return root
|
|
36
|
+
nested = root / "demo-ui"
|
|
37
|
+
if (nested / "index.html").is_file():
|
|
38
|
+
return nested
|
|
39
|
+
raise FileNotFoundError(f"demo-ui not found under {root}")
|
|
40
|
+
env = os.environ.get("MAS_DEMO_UI_ROOT")
|
|
41
|
+
if env:
|
|
42
|
+
return find_demo_ui_root(env)
|
|
43
|
+
here = Path(__file__).resolve()
|
|
44
|
+
for parent in here.parents:
|
|
45
|
+
cand = parent / "demo-ui"
|
|
46
|
+
if (cand / "index.html").is_file():
|
|
47
|
+
return cand
|
|
48
|
+
raise FileNotFoundError("demo-ui/index.html not found; set MAS_DEMO_UI_ROOT")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def default_layouts_root(ui_root: Path | None = None) -> Path:
|
|
52
|
+
override = os.environ.get("MAS_DEMO_UI_LAYOUTS")
|
|
53
|
+
if override:
|
|
54
|
+
return Path(override)
|
|
55
|
+
root = ui_root or find_demo_ui_root()
|
|
56
|
+
return root / "layouts"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sibling(anchor: Path, name: str) -> Path | None:
|
|
60
|
+
"""Look for ``name`` next to ``anchor`` first, then one directory up (the
|
|
61
|
+
same two-level search every other sibling artefact here already uses,
|
|
62
|
+
since ``events.jsonl`` may sit directly in the run dir or under a nested
|
|
63
|
+
``traces/`` subdirectory)."""
|
|
64
|
+
candidate = anchor.parent / name
|
|
65
|
+
if candidate.is_file():
|
|
66
|
+
return candidate
|
|
67
|
+
candidate = anchor.parent.parent / name
|
|
68
|
+
return candidate if candidate.is_file() else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_trace_paths(
|
|
72
|
+
trace: Path,
|
|
73
|
+
) -> tuple[Path, Path | None, Path | None, dict[str, Path]]:
|
|
74
|
+
"""Return (events.jsonl, metrics.json, progress.jsonl, {owner: custom.{owner}.jsonl})
|
|
75
|
+
from a file or directory. The fourth element covers
|
|
76
|
+
``custom.{owner}.jsonl`` sibling traces (same convention as
|
|
77
|
+
metrics.json/progress.jsonl -- see file_tail.append_custom_trace) so an
|
|
78
|
+
offline export carries the same panel data a live run or online replay
|
|
79
|
+
would have."""
|
|
80
|
+
path = Path(trace)
|
|
81
|
+
events: Path | None = None
|
|
82
|
+
if path.is_file() and path.name.endswith(".jsonl"):
|
|
83
|
+
events = path
|
|
84
|
+
elif path.is_dir():
|
|
85
|
+
for candidate in (path / "events.jsonl", path / "traces" / "events.jsonl"):
|
|
86
|
+
if candidate.is_file():
|
|
87
|
+
events = candidate
|
|
88
|
+
break
|
|
89
|
+
if events is None:
|
|
90
|
+
raise FileNotFoundError(f"no events.jsonl under {trace}")
|
|
91
|
+
metrics = _sibling(events, "metrics.json")
|
|
92
|
+
progress = _sibling(events, "progress.jsonl")
|
|
93
|
+
# custom.{owner}.jsonl siblings (file_tail.append_custom_trace) --
|
|
94
|
+
# discovered by glob, not a hardcoded owner list.
|
|
95
|
+
custom: dict[str, Path] = {}
|
|
96
|
+
for folder in (events.parent, events.parent.parent):
|
|
97
|
+
if not folder.is_dir():
|
|
98
|
+
continue
|
|
99
|
+
for found in folder.glob("custom.*.jsonl"):
|
|
100
|
+
owner = found.stem.split(".", 1)[1] if found.stem.count(".") >= 1 else ""
|
|
101
|
+
if owner:
|
|
102
|
+
custom.setdefault(owner, found)
|
|
103
|
+
return events, metrics, progress, custom
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_envelopes(
|
|
107
|
+
records: list[dict[str, Any]],
|
|
108
|
+
ref: DataSourceRef,
|
|
109
|
+
*,
|
|
110
|
+
spec: dict[str, Any] | None = None,
|
|
111
|
+
extra_metrics: list[dict[str, Any]] | None = None,
|
|
112
|
+
) -> list[dict[str, Any]]:
|
|
113
|
+
bus = EventBus(max_history=None)
|
|
114
|
+
ingest = NativeIngest(bus, MetricsStore())
|
|
115
|
+
topology = spec or snapshot_from_records(records)
|
|
116
|
+
bus.publish(envelope("spec.snapshot", ref, "spec", topology, replay=False))
|
|
117
|
+
ordered = merge_replay_records(demo_records(records), extra_metrics or [])
|
|
118
|
+
for record in ordered:
|
|
119
|
+
ingest.ingest(ref, record, replay=False)
|
|
120
|
+
ingest.flush_snapshot(ref)
|
|
121
|
+
return [item.as_dict() for item in bus.snapshot(("*",), DataSourceRef())]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_bundle(
|
|
125
|
+
trace: Path,
|
|
126
|
+
*,
|
|
127
|
+
layout_name: str,
|
|
128
|
+
layouts_root: Path | None = None,
|
|
129
|
+
ref: DataSourceRef | None = None,
|
|
130
|
+
spec: dict[str, Any] | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
events_path, metrics_path, progress_path, custom_paths = resolve_trace_paths(Path(trace))
|
|
133
|
+
records = parse_jsonl(events_path.read_text(encoding="utf-8"))
|
|
134
|
+
extra = trace_metric_records(metrics=metrics_path, progress=progress_path, events=records)
|
|
135
|
+
for owner_path in custom_paths.values():
|
|
136
|
+
extra.extend(custom_panel_records(owner_path))
|
|
137
|
+
scoped = ref or DataSourceRef(mode="replay")
|
|
138
|
+
ui_root = None
|
|
139
|
+
try:
|
|
140
|
+
ui_root = find_demo_ui_root()
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
ui_root = None
|
|
143
|
+
root = layouts_root or (default_layouts_root(ui_root) if ui_root else None)
|
|
144
|
+
layout: dict[str, Any] = {}
|
|
145
|
+
if root is not None:
|
|
146
|
+
layout = load_layout(root, layout_name)
|
|
147
|
+
if layout.get("dataSource"):
|
|
148
|
+
scoped = scoped.merge(layout.get("dataSource"))
|
|
149
|
+
envelopes = build_envelopes(records, scoped, spec=spec, extra_metrics=extra)
|
|
150
|
+
return {
|
|
151
|
+
"version": BUNDLE_VERSION,
|
|
152
|
+
"layoutName": layout_name,
|
|
153
|
+
"layout": layout,
|
|
154
|
+
"ref": scoped.as_dict(),
|
|
155
|
+
"envelopes": envelopes,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_merged_bundle(
|
|
160
|
+
scenario_traces: dict[str, Path],
|
|
161
|
+
*,
|
|
162
|
+
layout_name: str,
|
|
163
|
+
layouts_root: Path | None = None,
|
|
164
|
+
ref: DataSourceRef | None = None,
|
|
165
|
+
spec: dict[str, Any] | None = None,
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
"""Build one bundle covering several scenarios at once -- for a
|
|
168
|
+
multi-panel compare layout where each panel scopes itself to one
|
|
169
|
+
``dataSource.scenario``.
|
|
170
|
+
|
|
171
|
+
``build_bundle`` builds one scenario's envelopes under one
|
|
172
|
+
``DataSourceRef`` (one ``scenario``). The live and online-replay paths
|
|
173
|
+
already support several panels sharing one WS/bundle stream: the
|
|
174
|
+
frontend's own per-panel filter (``demo-ui/src/bus.js``'s
|
|
175
|
+
``ref.scenario !== envelope.ref.scenario`` check) decides which
|
|
176
|
+
envelopes a given panel accepts, keyed off each envelope's own
|
|
177
|
+
``ref.scenario`` -- not off which bundle/stream it came from. So a
|
|
178
|
+
merged *offline* bundle only has to put every scenario's own envelopes
|
|
179
|
+
(each correctly tagged with its own ``ref.scenario``) into one combined
|
|
180
|
+
``envelopes`` list; no frontend change is needed, because the same
|
|
181
|
+
per-panel filter that already runs for live/online-replay runs for an
|
|
182
|
+
offline bundle too.
|
|
183
|
+
|
|
184
|
+
``scenario_traces`` maps each panel's ``scenario`` slug (exactly what
|
|
185
|
+
that panel's ``dataSource.scenario`` says in the layout) to that
|
|
186
|
+
scenario's own trace (an ``events.jsonl`` file or a trace directory,
|
|
187
|
+
same as ``build_bundle``'s single-scenario ``trace`` argument).
|
|
188
|
+
"""
|
|
189
|
+
scoped_base = ref or DataSourceRef(mode="replay")
|
|
190
|
+
ui_root = None
|
|
191
|
+
try:
|
|
192
|
+
ui_root = find_demo_ui_root()
|
|
193
|
+
except FileNotFoundError:
|
|
194
|
+
ui_root = None
|
|
195
|
+
root = layouts_root or (default_layouts_root(ui_root) if ui_root else None)
|
|
196
|
+
layout: dict[str, Any] = {}
|
|
197
|
+
if root is not None:
|
|
198
|
+
layout = load_layout(root, layout_name)
|
|
199
|
+
if layout.get("dataSource"):
|
|
200
|
+
scoped_base = scoped_base.merge(layout.get("dataSource"))
|
|
201
|
+
|
|
202
|
+
merged_envelopes: list[dict[str, Any]] = []
|
|
203
|
+
for scenario, trace in scenario_traces.items():
|
|
204
|
+
events_path, metrics_path, progress_path, custom_paths = resolve_trace_paths(Path(trace))
|
|
205
|
+
records = parse_jsonl(events_path.read_text(encoding="utf-8"))
|
|
206
|
+
extra = trace_metric_records(metrics=metrics_path, progress=progress_path, events=records)
|
|
207
|
+
for owner_path in custom_paths.values():
|
|
208
|
+
extra.extend(custom_panel_records(owner_path))
|
|
209
|
+
scoped = scoped_base.merge(DataSourceRef(scenario=scenario))
|
|
210
|
+
merged_envelopes.extend(build_envelopes(records, scoped, spec=spec, extra_metrics=extra))
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
"version": BUNDLE_VERSION,
|
|
214
|
+
"layoutName": layout_name,
|
|
215
|
+
"layout": layout,
|
|
216
|
+
"ref": scoped_base.as_dict(),
|
|
217
|
+
"envelopes": merged_envelopes,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def export_merged_trace(
|
|
222
|
+
scenario_traces: dict[str, Path],
|
|
223
|
+
out_dir: Path,
|
|
224
|
+
*,
|
|
225
|
+
layout_name: str,
|
|
226
|
+
layouts_root: Path | None = None,
|
|
227
|
+
ref: DataSourceRef | None = None,
|
|
228
|
+
ui_root: Path | None = None,
|
|
229
|
+
dist_dir: Path | None = None,
|
|
230
|
+
build_ui: bool = True,
|
|
231
|
+
) -> Path:
|
|
232
|
+
bundle = build_merged_bundle(
|
|
233
|
+
scenario_traces,
|
|
234
|
+
layout_name=layout_name,
|
|
235
|
+
layouts_root=layouts_root,
|
|
236
|
+
ref=ref,
|
|
237
|
+
)
|
|
238
|
+
return write_site(
|
|
239
|
+
Path(out_dir),
|
|
240
|
+
bundle,
|
|
241
|
+
ui_root=ui_root,
|
|
242
|
+
dist_dir=dist_dir,
|
|
243
|
+
build_ui=build_ui,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _write_bundle_js(path: Path, bundle: dict[str, Any]) -> None:
|
|
248
|
+
payload = json.dumps(bundle, ensure_ascii=False)
|
|
249
|
+
path.write_text(f"window.__DEMO_UI_BUNDLE__ = {payload};\n", encoding="utf-8")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _patch_index(html: str) -> str:
|
|
253
|
+
inject = (
|
|
254
|
+
'<script>window.__DEMO_UI_OFFLINE__=true;</script>\n'
|
|
255
|
+
'<script src="./bundle.js"></script>\n'
|
|
256
|
+
)
|
|
257
|
+
if "window.__DEMO_UI_OFFLINE__" in html:
|
|
258
|
+
return html
|
|
259
|
+
if "<head>" in html:
|
|
260
|
+
return html.replace("<head>", "<head>\n" + inject, 1)
|
|
261
|
+
if "</head>" in html:
|
|
262
|
+
return html.replace("</head>", inject + "</head>", 1)
|
|
263
|
+
return inject + html
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def build_static_ui(ui_root: Path, *, base: str = "./") -> Path:
|
|
267
|
+
dist = ui_root / "dist"
|
|
268
|
+
env = os.environ.copy()
|
|
269
|
+
env["DEMO_UI_BASE"] = base
|
|
270
|
+
subprocess.run(
|
|
271
|
+
["npm", "run", "build"],
|
|
272
|
+
cwd=ui_root,
|
|
273
|
+
env=env,
|
|
274
|
+
check=True,
|
|
275
|
+
)
|
|
276
|
+
if not dist.is_dir():
|
|
277
|
+
raise FileNotFoundError(f"vite dist missing at {dist}")
|
|
278
|
+
return dist
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def write_site(
|
|
282
|
+
out_dir: Path,
|
|
283
|
+
bundle: dict[str, Any],
|
|
284
|
+
*,
|
|
285
|
+
ui_root: Path | None = None,
|
|
286
|
+
dist_dir: Path | None = None,
|
|
287
|
+
build_ui: bool = True,
|
|
288
|
+
) -> Path:
|
|
289
|
+
dest = Path(out_dir)
|
|
290
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
root = ui_root
|
|
292
|
+
if dist_dir is None:
|
|
293
|
+
root = root or find_demo_ui_root()
|
|
294
|
+
existing = root / "dist"
|
|
295
|
+
if build_ui or not existing.is_dir():
|
|
296
|
+
dist_dir = build_static_ui(root)
|
|
297
|
+
else:
|
|
298
|
+
dist_dir = existing
|
|
299
|
+
if dist_dir is not None:
|
|
300
|
+
for item in Path(dist_dir).iterdir():
|
|
301
|
+
target = dest / item.name
|
|
302
|
+
if item.is_dir():
|
|
303
|
+
if target.exists():
|
|
304
|
+
shutil.rmtree(target)
|
|
305
|
+
shutil.copytree(item, target)
|
|
306
|
+
else:
|
|
307
|
+
shutil.copy2(item, target)
|
|
308
|
+
(dest / "bundle.json").write_text(
|
|
309
|
+
json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
310
|
+
)
|
|
311
|
+
_write_bundle_js(dest / "bundle.js", bundle)
|
|
312
|
+
index = dest / "index.html"
|
|
313
|
+
if index.is_file():
|
|
314
|
+
index.write_text(_patch_index(index.read_text(encoding="utf-8")), encoding="utf-8")
|
|
315
|
+
else:
|
|
316
|
+
index.write_text(
|
|
317
|
+
_patch_index(
|
|
318
|
+
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
|
319
|
+
"<title>MAS Lab demo UI</title></head><body><div id='app'></div></body></html>"
|
|
320
|
+
),
|
|
321
|
+
encoding="utf-8",
|
|
322
|
+
)
|
|
323
|
+
(dest / "README.txt").write_text(
|
|
324
|
+
"Offline demo UI. Serve this directory (ES modules need http):\n"
|
|
325
|
+
" python3 -m http.server 8766 --bind 127.0.0.1\n"
|
|
326
|
+
" open http://127.0.0.1:8766/\n",
|
|
327
|
+
encoding="utf-8",
|
|
328
|
+
)
|
|
329
|
+
return dest
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def export_trace(
|
|
333
|
+
trace: Path,
|
|
334
|
+
out_dir: Path,
|
|
335
|
+
*,
|
|
336
|
+
layout_name: str = "run-view.yaml",
|
|
337
|
+
layouts_root: Path | None = None,
|
|
338
|
+
ref: DataSourceRef | None = None,
|
|
339
|
+
ui_root: Path | None = None,
|
|
340
|
+
dist_dir: Path | None = None,
|
|
341
|
+
build_ui: bool = True,
|
|
342
|
+
) -> Path:
|
|
343
|
+
bundle = build_bundle(
|
|
344
|
+
Path(trace),
|
|
345
|
+
layout_name=layout_name,
|
|
346
|
+
layouts_root=layouts_root,
|
|
347
|
+
ref=ref,
|
|
348
|
+
)
|
|
349
|
+
return write_site(
|
|
350
|
+
Path(out_dir),
|
|
351
|
+
bundle,
|
|
352
|
+
ui_root=ui_root,
|
|
353
|
+
dist_dir=dist_dir,
|
|
354
|
+
build_ui=build_ui,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def main(argv: list[str] | None = None) -> int:
|
|
359
|
+
import argparse
|
|
360
|
+
import webbrowser
|
|
361
|
+
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
|
362
|
+
|
|
363
|
+
parser = argparse.ArgumentParser(description="Export a static demo-ui replay from a native trace.")
|
|
364
|
+
parser.add_argument("trace", type=Path, help="events.jsonl or a trace/run directory")
|
|
365
|
+
parser.add_argument("-o", "--output", type=Path, required=True, help="Output directory")
|
|
366
|
+
parser.add_argument("--layout", default="run-view.yaml")
|
|
367
|
+
parser.add_argument("--layouts", type=Path, default=None)
|
|
368
|
+
parser.add_argument("--no-build", action="store_true", help="Reuse demo-ui/dist if present")
|
|
369
|
+
parser.add_argument("--serve", action="store_true", help="Serve the export and open a browser")
|
|
370
|
+
parser.add_argument("--port", type=int, default=8766)
|
|
371
|
+
args = parser.parse_args(argv)
|
|
372
|
+
dest = export_trace(
|
|
373
|
+
args.trace,
|
|
374
|
+
args.output,
|
|
375
|
+
layout_name=args.layout,
|
|
376
|
+
layouts_root=args.layouts,
|
|
377
|
+
build_ui=not args.no_build,
|
|
378
|
+
)
|
|
379
|
+
print(f"Wrote {dest}")
|
|
380
|
+
if not args.serve:
|
|
381
|
+
print(f"python3 -m http.server {args.port} --bind 127.0.0.1 --directory {dest}")
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
385
|
+
def __init__(self, *a: Any, **kw: Any) -> None:
|
|
386
|
+
super().__init__(*a, directory=str(dest), **kw)
|
|
387
|
+
|
|
388
|
+
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
|
389
|
+
url = f"http://127.0.0.1:{args.port}/"
|
|
390
|
+
print(url)
|
|
391
|
+
webbrowser.open(url)
|
|
392
|
+
try:
|
|
393
|
+
server.serve_forever()
|
|
394
|
+
except KeyboardInterrupt:
|
|
395
|
+
return 0
|
|
396
|
+
return 0
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__":
|
|
400
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Fold context size / token counts for the governance UI stream."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mas.lab.controller.ui_stream.metrics import extract_cached_tokens, extract_usage
|
|
10
|
+
from mas.lab.controller.ui_stream.refs import DataSourceRef
|
|
11
|
+
|
|
12
|
+
_DROP_NATIVE = frozenset({"messages", "tools", "content", "output"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def compact_context_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
16
|
+
"""Drop prompt text from context_assembled / context_usage records."""
|
|
17
|
+
out = {key: value for key, value in record.items() if key not in _DROP_NATIVE}
|
|
18
|
+
usage = out.get("context_usage")
|
|
19
|
+
if isinstance(usage, dict):
|
|
20
|
+
out["context_usage"] = {k: v for k, v in usage.items() if k not in _DROP_NATIVE}
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_int(value: Any) -> int | None:
|
|
25
|
+
if value in (None, ""):
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
return int(value)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _as_float(value: Any) -> float | None:
|
|
34
|
+
if value in (None, ""):
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
return float(value)
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _public(bucket: dict[str, Any]) -> dict[str, Any]:
|
|
43
|
+
return {key: value for key, value in bucket.items() if not str(key).startswith("_")}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GovernanceStore:
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._by_key: dict[str, dict[str, Any]] = {}
|
|
49
|
+
|
|
50
|
+
def _key(self, ref: DataSourceRef) -> str:
|
|
51
|
+
return "|".join(
|
|
52
|
+
[
|
|
53
|
+
ref.library or "",
|
|
54
|
+
ref.experiment or "",
|
|
55
|
+
ref.scenario or "",
|
|
56
|
+
ref.overlay or "",
|
|
57
|
+
ref.session or ref.run or "",
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _bucket(self, ref: DataSourceRef) -> dict[str, Any]:
|
|
62
|
+
return self._by_key.setdefault(self._key(ref), {})
|
|
63
|
+
|
|
64
|
+
def apply(self, ref: DataSourceRef, payload: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
current = self._bucket(ref)
|
|
66
|
+
kind = str(payload.get("kind") or "")
|
|
67
|
+
updated = False
|
|
68
|
+
if payload.get("agent_id"):
|
|
69
|
+
current["agent_id"] = payload.get("agent_id")
|
|
70
|
+
updated = True
|
|
71
|
+
if payload.get("model"):
|
|
72
|
+
current["model"] = payload.get("model")
|
|
73
|
+
updated = True
|
|
74
|
+
usage = payload.get("context_usage") if isinstance(payload.get("context_usage"), dict) else {}
|
|
75
|
+
if kind in {"context_assembled", "context_usage"} or usage:
|
|
76
|
+
tokens = _as_int(
|
|
77
|
+
payload.get("context_tokens")
|
|
78
|
+
or payload.get("estimated_prompt_tokens")
|
|
79
|
+
or usage.get("estimated_prompt_tokens")
|
|
80
|
+
or payload.get("total_tokens")
|
|
81
|
+
)
|
|
82
|
+
if tokens is not None:
|
|
83
|
+
current["context_tokens"] = tokens
|
|
84
|
+
updated = True
|
|
85
|
+
window = _as_int(payload.get("context_window") or usage.get("context_window"))
|
|
86
|
+
if window is not None:
|
|
87
|
+
current["context_window"] = window
|
|
88
|
+
updated = True
|
|
89
|
+
fill = _as_float(payload.get("fill_ratio") or usage.get("fill_ratio"))
|
|
90
|
+
if fill is not None:
|
|
91
|
+
current["fill_ratio"] = fill
|
|
92
|
+
updated = True
|
|
93
|
+
segments = _as_int(payload.get("segments") or payload.get("message_count"))
|
|
94
|
+
if segments is not None:
|
|
95
|
+
current["segments"] = segments
|
|
96
|
+
updated = True
|
|
97
|
+
for key in ("cache_hits", "cache_misses"):
|
|
98
|
+
value = _as_int(payload.get(key) or usage.get(key))
|
|
99
|
+
if value is not None:
|
|
100
|
+
current[key] = value
|
|
101
|
+
updated = True
|
|
102
|
+
rate = _as_float(payload.get("cache_hit_rate") or usage.get("cache_hit_rate"))
|
|
103
|
+
if rate is not None:
|
|
104
|
+
current["cache_hit_rate"] = rate
|
|
105
|
+
updated = True
|
|
106
|
+
breakdown = payload.get("token_breakdown") or usage.get("token_breakdown")
|
|
107
|
+
if isinstance(breakdown, dict) and breakdown:
|
|
108
|
+
current["token_breakdown"] = {
|
|
109
|
+
str(k): int(v) for k, v in breakdown.items() if isinstance(v, (int, float))
|
|
110
|
+
}
|
|
111
|
+
updated = True
|
|
112
|
+
if kind == "llm_call_end":
|
|
113
|
+
folded = extract_usage(payload)
|
|
114
|
+
if folded.get("prompt_tokens") or folded.get("total_tokens") or folded.get("cached_tokens"):
|
|
115
|
+
current["prompt_tokens"] = int(folded["prompt_tokens"])
|
|
116
|
+
current["completion_tokens"] = int(folded["completion_tokens"])
|
|
117
|
+
current["cached_tokens"] = int(folded["cached_tokens"])
|
|
118
|
+
if not current.get("context_tokens") and folded["prompt_tokens"]:
|
|
119
|
+
current["context_tokens"] = int(folded["prompt_tokens"])
|
|
120
|
+
updated = True
|
|
121
|
+
extra = extract_cached_tokens(
|
|
122
|
+
payload.get("tokens_used") if isinstance(payload.get("tokens_used"), dict) else {}
|
|
123
|
+
)
|
|
124
|
+
if extra:
|
|
125
|
+
current["cached_tokens"] = extra
|
|
126
|
+
updated = True
|
|
127
|
+
if current.get("context_window") and current.get("context_tokens") and "fill_ratio" not in current:
|
|
128
|
+
current["fill_ratio"] = min(
|
|
129
|
+
float(current["context_tokens"]) / float(current["context_window"]), 1.0
|
|
130
|
+
)
|
|
131
|
+
updated = True
|
|
132
|
+
out = _public(current)
|
|
133
|
+
if updated:
|
|
134
|
+
out["_updated"] = 1
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
def snapshot(self, ref: DataSourceRef) -> dict[str, Any]:
|
|
138
|
+
return _public(self._bucket(ref))
|
|
139
|
+
|
|
140
|
+
def clear(self) -> None:
|
|
141
|
+
self._by_key.clear()
|