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,139 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Discover demo-ui shell modules and registered plot kinds.
|
|
4
|
+
|
|
5
|
+
Demo-ui has no ``src/plugins`` tree. Chrome and the plot host live next to
|
|
6
|
+
``shell.js``. Plot *kinds* come from ``mas.lab.ui.plots`` providers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
_META = re.compile(
|
|
18
|
+
r"export const pluginMeta\s*=\s*(\{.*?\}|\[.*?\])\s*;",
|
|
19
|
+
re.S,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_SHELL = ("plot-host.js", "chrome.js", "player.js", "layout-picker.js", "notes-overlay.js")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def demo_ui_src() -> Path:
|
|
26
|
+
env = os.environ.get("MAS_DEMO_UI_ROOT")
|
|
27
|
+
if env:
|
|
28
|
+
cand = Path(env).expanduser() / "src"
|
|
29
|
+
if cand.is_dir():
|
|
30
|
+
return cand
|
|
31
|
+
here = Path(__file__).resolve()
|
|
32
|
+
for parent in here.parents:
|
|
33
|
+
cand = parent / "demo-ui" / "src"
|
|
34
|
+
if cand.is_dir():
|
|
35
|
+
return cand
|
|
36
|
+
return Path.cwd() / "demo-ui" / "src"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def plugins_dir() -> Path:
|
|
40
|
+
"""Deprecated alias: there is no plugins folder. Returns ``demo-ui/src``."""
|
|
41
|
+
return demo_ui_src()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_meta(text: str) -> list[dict[str, Any]]:
|
|
45
|
+
match = _META.search(text)
|
|
46
|
+
if not match:
|
|
47
|
+
return []
|
|
48
|
+
blob = match.group(1)
|
|
49
|
+
blob = re.sub(r"([,{]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:", r'\1"\2":', blob)
|
|
50
|
+
blob = blob.replace("'", '"')
|
|
51
|
+
blob = re.sub(r",(\s*[}\]])", r"\1", blob)
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(blob)
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
return []
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
return [data]
|
|
58
|
+
if isinstance(data, list):
|
|
59
|
+
return [row for row in data if isinstance(row, dict)]
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _shell_rows() -> list[dict[str, Any]]:
|
|
64
|
+
root = demo_ui_src()
|
|
65
|
+
rows: list[dict[str, Any]] = []
|
|
66
|
+
for name in _SHELL:
|
|
67
|
+
path = root / name
|
|
68
|
+
if not path.is_file():
|
|
69
|
+
continue
|
|
70
|
+
module = f"/src/{name}"
|
|
71
|
+
metas = _parse_meta(path.read_text(encoding="utf-8"))
|
|
72
|
+
if not metas:
|
|
73
|
+
rows.append({"id": path.stem, "module": module, "kind": "chrome"})
|
|
74
|
+
continue
|
|
75
|
+
for meta in metas:
|
|
76
|
+
row = dict(meta)
|
|
77
|
+
row.setdefault("id", path.stem)
|
|
78
|
+
row["module"] = module
|
|
79
|
+
rows.append(row)
|
|
80
|
+
return rows
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _plot_kind_rows() -> list[dict[str, Any]]:
|
|
84
|
+
rows: list[dict[str, Any]] = []
|
|
85
|
+
try:
|
|
86
|
+
from mas.lab.plots.providers import load_plot_providers
|
|
87
|
+
|
|
88
|
+
for provider in load_plot_providers():
|
|
89
|
+
for kind in sorted(provider.kinds):
|
|
90
|
+
stream = ""
|
|
91
|
+
try:
|
|
92
|
+
stream = provider.stream_for(kind)
|
|
93
|
+
except Exception:
|
|
94
|
+
stream = ""
|
|
95
|
+
rows.append(
|
|
96
|
+
{
|
|
97
|
+
"id": kind,
|
|
98
|
+
"kind": "plot",
|
|
99
|
+
"formats": ["html"],
|
|
100
|
+
"realtime": True,
|
|
101
|
+
"stream": stream,
|
|
102
|
+
"module": "/src/plot-host.js",
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
return rows
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def list_plugins(
|
|
111
|
+
*,
|
|
112
|
+
kind: str | None = None,
|
|
113
|
+
format: str | None = None,
|
|
114
|
+
realtime: bool | None = None,
|
|
115
|
+
) -> list[dict[str, Any]]:
|
|
116
|
+
rows = _shell_rows() + _plot_kind_rows()
|
|
117
|
+
seen: set[tuple[str, str]] = set()
|
|
118
|
+
unique: list[dict[str, Any]] = []
|
|
119
|
+
for row in rows:
|
|
120
|
+
key = (str(row.get("id") or ""), str(row.get("module") or ""))
|
|
121
|
+
if key in seen:
|
|
122
|
+
continue
|
|
123
|
+
seen.add(key)
|
|
124
|
+
unique.append(row)
|
|
125
|
+
out = unique
|
|
126
|
+
if kind:
|
|
127
|
+
out = [row for row in out if str(row.get("kind") or "") == kind]
|
|
128
|
+
if format:
|
|
129
|
+
out = [
|
|
130
|
+
row
|
|
131
|
+
for row in out
|
|
132
|
+
if format in [str(x) for x in (row.get("formats") or [])]
|
|
133
|
+
or str(row.get("format") or "") == format
|
|
134
|
+
]
|
|
135
|
+
if realtime is True:
|
|
136
|
+
out = [row for row in out if row.get("realtime") is True]
|
|
137
|
+
if realtime is False:
|
|
138
|
+
out = [row for row in out if not row.get("realtime")]
|
|
139
|
+
return out
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""ID-only DataSourceRef, topic matching, and event envelopes."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import fnmatch
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
_clock: ContextVar[str | None] = ContextVar("mas_ui_clock", default=None)
|
|
14
|
+
_nonce: ContextVar[str | None] = ContextVar("mas_ui_nonce", default=None)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def use_clock(value: str | None):
|
|
18
|
+
"""Stamp envelopes with a clock owner (``server`` = DES owns timing)."""
|
|
19
|
+
return _clock.set(value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def use_nonce(value: str | None):
|
|
23
|
+
"""Stamp envelopes with the UI registration nonce for this channel."""
|
|
24
|
+
return _nonce.set(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reset_clock(token) -> None:
|
|
28
|
+
_clock.reset(token)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reset_nonce(token) -> None:
|
|
32
|
+
_nonce.reset(token)
|
|
33
|
+
|
|
34
|
+
Mode = Literal["live", "replay", "historical"]
|
|
35
|
+
Source = Literal[
|
|
36
|
+
"command",
|
|
37
|
+
"result",
|
|
38
|
+
"control",
|
|
39
|
+
"native",
|
|
40
|
+
"telemetry",
|
|
41
|
+
"metric",
|
|
42
|
+
"job",
|
|
43
|
+
"catalog",
|
|
44
|
+
"spec",
|
|
45
|
+
"artifact",
|
|
46
|
+
"io",
|
|
47
|
+
"governance",
|
|
48
|
+
"custom",
|
|
49
|
+
"view",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
STREAMS = {
|
|
53
|
+
"spec": "spec",
|
|
54
|
+
"observability": "native",
|
|
55
|
+
"hitl": "control",
|
|
56
|
+
"artifacts": "artifact",
|
|
57
|
+
"metrics": "metric",
|
|
58
|
+
"governance": "governance",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class DataSourceRef:
|
|
64
|
+
library: str | None = None
|
|
65
|
+
experiment: str | None = None
|
|
66
|
+
scenario: str | None = None
|
|
67
|
+
test: str | None = None
|
|
68
|
+
run: str | None = None
|
|
69
|
+
session: str | None = None
|
|
70
|
+
overlay: str | None = None
|
|
71
|
+
batch: str | None = None
|
|
72
|
+
origin: str | None = None
|
|
73
|
+
mode: Mode | None = None
|
|
74
|
+
|
|
75
|
+
def merge(self, override: DataSourceRef | dict[str, Any] | None) -> DataSourceRef:
|
|
76
|
+
if override is None:
|
|
77
|
+
return DataSourceRef(**asdict(self))
|
|
78
|
+
data = asdict(self)
|
|
79
|
+
extra = asdict(override) if isinstance(override, DataSourceRef) else dict(override)
|
|
80
|
+
for key, value in extra.items():
|
|
81
|
+
if key not in data:
|
|
82
|
+
continue
|
|
83
|
+
if value is not None and value != "":
|
|
84
|
+
data[key] = value
|
|
85
|
+
return DataSourceRef(**data)
|
|
86
|
+
|
|
87
|
+
def as_dict(self) -> dict[str, Any]:
|
|
88
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
89
|
+
|
|
90
|
+
def matches(self, other: DataSourceRef) -> bool:
|
|
91
|
+
for key in ("library", "experiment", "test", "run", "session", "overlay", "batch"):
|
|
92
|
+
mine = getattr(self, key)
|
|
93
|
+
theirs = getattr(other, key)
|
|
94
|
+
if mine and theirs and mine != theirs:
|
|
95
|
+
return False
|
|
96
|
+
# A panel that binds a scenario must not also paint unscoped or other-lane events.
|
|
97
|
+
if self.scenario and self.scenario != (other.scenario or ""):
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def missing_for(self, required: tuple[str, ...]) -> list[str]:
|
|
102
|
+
return [name for name in required if not getattr(self, name)]
|
|
103
|
+
|
|
104
|
+
def coordinate(self) -> str:
|
|
105
|
+
return "|".join(
|
|
106
|
+
[
|
|
107
|
+
self.library or "",
|
|
108
|
+
self.experiment or "",
|
|
109
|
+
self.scenario or "",
|
|
110
|
+
self.test or "",
|
|
111
|
+
self.run or "",
|
|
112
|
+
self.session or "",
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def envelope_from_mapping(data: dict[str, Any] | None) -> EventEnvelope:
|
|
118
|
+
row = data or {}
|
|
119
|
+
source = row.get("source") or "native"
|
|
120
|
+
allowed = {
|
|
121
|
+
"command",
|
|
122
|
+
"result",
|
|
123
|
+
"control",
|
|
124
|
+
"native",
|
|
125
|
+
"telemetry",
|
|
126
|
+
"metric",
|
|
127
|
+
"job",
|
|
128
|
+
"catalog",
|
|
129
|
+
"spec",
|
|
130
|
+
"artifact",
|
|
131
|
+
"io",
|
|
132
|
+
"governance",
|
|
133
|
+
"custom",
|
|
134
|
+
"view",
|
|
135
|
+
}
|
|
136
|
+
if source not in allowed:
|
|
137
|
+
source = "custom" if str(row.get("topic") or "").startswith("custom.") else "native"
|
|
138
|
+
return envelope(
|
|
139
|
+
str(row.get("topic") or "native.unknown"),
|
|
140
|
+
ref_from_mapping(row.get("ref") if isinstance(row.get("ref"), dict) else {}),
|
|
141
|
+
source, # type: ignore[arg-type]
|
|
142
|
+
row.get("payload"),
|
|
143
|
+
replay=bool(row.get("replay", True)),
|
|
144
|
+
event_id=row.get("id"),
|
|
145
|
+
ts=row.get("ts") if isinstance(row.get("ts"), str) else None,
|
|
146
|
+
clock=row.get("clock") or "server",
|
|
147
|
+
complete=row.get("complete") if isinstance(row.get("complete"), bool) else None,
|
|
148
|
+
artefact=str(row.get("artefact") or "") or None,
|
|
149
|
+
uri=str(row.get("uri") or "") or None,
|
|
150
|
+
path=str(row.get("path") or "") or None,
|
|
151
|
+
inputs=list(row.get("inputs") or []) or None,
|
|
152
|
+
outputs=list(row.get("outputs") or []) or None,
|
|
153
|
+
nonce=str(row["nonce"]) if row.get("nonce") else None,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ref_from_mapping(data: dict[str, Any] | None) -> DataSourceRef:
|
|
158
|
+
if not data:
|
|
159
|
+
return DataSourceRef()
|
|
160
|
+
allowed = {f.name for f in DataSourceRef.__dataclass_fields__.values()}
|
|
161
|
+
filtered = {k: v for k, v in data.items() if k in allowed and v not in (None, "")}
|
|
162
|
+
return DataSourceRef(**filtered)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class EventEnvelope:
|
|
167
|
+
topic: str
|
|
168
|
+
ts: str
|
|
169
|
+
ref: DataSourceRef
|
|
170
|
+
source: Source
|
|
171
|
+
payload: Any = None
|
|
172
|
+
replay: bool = False
|
|
173
|
+
id: str | None = None
|
|
174
|
+
clock: str | None = None
|
|
175
|
+
complete: bool | None = None
|
|
176
|
+
artefact: str | None = None
|
|
177
|
+
uri: str | None = None
|
|
178
|
+
path: str | None = None
|
|
179
|
+
inputs: list[str] | None = None
|
|
180
|
+
outputs: list[str] | None = None
|
|
181
|
+
nonce: str | None = None
|
|
182
|
+
|
|
183
|
+
def as_dict(self) -> dict[str, Any]:
|
|
184
|
+
data = {
|
|
185
|
+
"topic": self.topic,
|
|
186
|
+
"ts": self.ts,
|
|
187
|
+
"ref": self.ref.as_dict(),
|
|
188
|
+
"source": self.source,
|
|
189
|
+
"payload": self.payload,
|
|
190
|
+
"replay": self.replay,
|
|
191
|
+
"id": self.id,
|
|
192
|
+
}
|
|
193
|
+
if self.clock:
|
|
194
|
+
data["clock"] = self.clock
|
|
195
|
+
if self.artefact:
|
|
196
|
+
data["artefact"] = self.artefact
|
|
197
|
+
if self.uri:
|
|
198
|
+
data["uri"] = self.uri
|
|
199
|
+
if self.path:
|
|
200
|
+
data["path"] = self.path
|
|
201
|
+
if self.inputs:
|
|
202
|
+
data["inputs"] = list(self.inputs)
|
|
203
|
+
if self.outputs:
|
|
204
|
+
data["outputs"] = list(self.outputs)
|
|
205
|
+
if self.complete is not None:
|
|
206
|
+
data["complete"] = self.complete
|
|
207
|
+
if self.nonce:
|
|
208
|
+
data["nonce"] = self.nonce
|
|
209
|
+
return data
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def now_iso() -> str:
|
|
213
|
+
return datetime.now(UTC).isoformat()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def topic_matches(declared: list[str] | tuple[str, ...], topic: str) -> bool:
|
|
217
|
+
if not declared:
|
|
218
|
+
return True
|
|
219
|
+
return any(fnmatch.fnmatchcase(topic, glob) for glob in declared)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def envelope(
|
|
223
|
+
topic: str,
|
|
224
|
+
ref: DataSourceRef,
|
|
225
|
+
source: Source,
|
|
226
|
+
payload: Any,
|
|
227
|
+
*,
|
|
228
|
+
replay: bool = False,
|
|
229
|
+
event_id: str | None = None,
|
|
230
|
+
ts: str | None = None,
|
|
231
|
+
clock: str | None = None,
|
|
232
|
+
complete: bool | None = None,
|
|
233
|
+
artefact: str | None = None,
|
|
234
|
+
uri: str | None = None,
|
|
235
|
+
path: str | None = None,
|
|
236
|
+
inputs: list[str] | None = None,
|
|
237
|
+
outputs: list[str] | None = None,
|
|
238
|
+
nonce: str | None = None,
|
|
239
|
+
) -> EventEnvelope:
|
|
240
|
+
from mas.lab.controller.ui_stream.artefact_bind import link_for_topic
|
|
241
|
+
|
|
242
|
+
link = link_for_topic(topic, ref.as_dict(), complete=complete)
|
|
243
|
+
return EventEnvelope(
|
|
244
|
+
topic=topic,
|
|
245
|
+
ts=ts or now_iso(),
|
|
246
|
+
ref=ref,
|
|
247
|
+
source=source,
|
|
248
|
+
payload=payload,
|
|
249
|
+
replay=replay,
|
|
250
|
+
id=event_id,
|
|
251
|
+
clock=clock or _clock.get(),
|
|
252
|
+
complete=complete if complete is not None else link.complete,
|
|
253
|
+
artefact=artefact or link.name,
|
|
254
|
+
uri=uri or link.uri,
|
|
255
|
+
path=path or link.path,
|
|
256
|
+
inputs=list(inputs) if inputs is not None else list(link.inputs),
|
|
257
|
+
outputs=list(outputs) if outputs is not None else list(link.outputs),
|
|
258
|
+
nonce=nonce if nonce is not None else _nonce.get(),
|
|
259
|
+
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Filter recorded native events for the demo UI bus."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable, Iterator
|
|
10
|
+
|
|
11
|
+
from mas.lab.controller.ui_stream.governance import compact_context_record
|
|
12
|
+
from mas.lab.controller.ui_stream.metrics import coerce_timestamp
|
|
13
|
+
|
|
14
|
+
KEEP_KINDS = frozenset(
|
|
15
|
+
{
|
|
16
|
+
# NOTE: "context_assembled"/"context_usage" are deliberately absent --
|
|
17
|
+
# they carry prompt/message content and are pull-only, demand-driven
|
|
18
|
+
# (see docs/ui/live.md's "Governance / context stream" and
|
|
19
|
+
# ingest.py's NativeIngest._wants_context()). They only reach replay
|
|
20
|
+
# via demo_records(..., extra_kinds=extra_native_kinds(["context"]))
|
|
21
|
+
# when a mounted plugin actually subscribed to governance.context --
|
|
22
|
+
# same gate the live path already enforces. Putting them back here
|
|
23
|
+
# would leak context on every replay regardless of subscription.
|
|
24
|
+
"mas_call_start",
|
|
25
|
+
"mas_call_end",
|
|
26
|
+
"execution_start",
|
|
27
|
+
"execution_end",
|
|
28
|
+
"llm_call_start",
|
|
29
|
+
"llm_call_end",
|
|
30
|
+
"tool_call_start",
|
|
31
|
+
"tool_call_end",
|
|
32
|
+
"user_response",
|
|
33
|
+
"client_response",
|
|
34
|
+
"hitl_gate",
|
|
35
|
+
"hitl_resolve",
|
|
36
|
+
"hitl_request",
|
|
37
|
+
"metric",
|
|
38
|
+
"governance_decision",
|
|
39
|
+
"control.structure",
|
|
40
|
+
"control.context",
|
|
41
|
+
"scope.progress",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_jsonl(text: str) -> list[dict[str, Any]]:
|
|
47
|
+
records: list[dict[str, Any]] = []
|
|
48
|
+
for line in str(text or "").splitlines():
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if not line:
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
item = json.loads(line)
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
continue
|
|
56
|
+
if isinstance(item, dict):
|
|
57
|
+
records.append(item)
|
|
58
|
+
return records
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def demo_records(
|
|
62
|
+
records: Iterable[dict[str, Any]],
|
|
63
|
+
*,
|
|
64
|
+
extra_kinds: Iterable[str] = (),
|
|
65
|
+
) -> Iterator[dict[str, Any]]:
|
|
66
|
+
keep = KEEP_KINDS | frozenset(extra_kinds)
|
|
67
|
+
for record in records:
|
|
68
|
+
kind = str(record.get("kind") or "")
|
|
69
|
+
if kind not in keep:
|
|
70
|
+
continue
|
|
71
|
+
if kind in {"context_assembled", "context_usage"}:
|
|
72
|
+
yield compact_context_record(record)
|
|
73
|
+
else:
|
|
74
|
+
yield record
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def metric_record_from_artefact(path: Path) -> dict[str, Any] | None:
|
|
78
|
+
"""Turn a cached ``metrics.json`` into a bus ``kind: metric`` record."""
|
|
79
|
+
try:
|
|
80
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
if not isinstance(doc, dict):
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
from mas.library.lab.steps.services.metric_event import flatten_mce_document
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
metrics = flatten_mce_document(doc)
|
|
90
|
+
if not metrics:
|
|
91
|
+
return None
|
|
92
|
+
record: dict[str, Any] = {"kind": "metric", "name": "eval_mce", "metrics": metrics}
|
|
93
|
+
if doc.get("scenario"):
|
|
94
|
+
record["scenario"] = doc["scenario"]
|
|
95
|
+
return record
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def metric_records_from_progress(path: Path) -> list[dict[str, Any]]:
|
|
99
|
+
"""Wrap ``progress.jsonl`` rows as ``kind: metric`` records."""
|
|
100
|
+
if not path.is_file():
|
|
101
|
+
return []
|
|
102
|
+
out: list[dict[str, Any]] = []
|
|
103
|
+
for record in parse_jsonl(path.read_text(encoding="utf-8")):
|
|
104
|
+
if not isinstance(record, dict):
|
|
105
|
+
continue
|
|
106
|
+
wrapped = {
|
|
107
|
+
"kind": "metric",
|
|
108
|
+
"name": str(record.get("name") or "eval_progress"),
|
|
109
|
+
"metrics": record,
|
|
110
|
+
}
|
|
111
|
+
ts = record.get("snapshot_at") or record.get("timestamp") or record.get("ts")
|
|
112
|
+
if ts:
|
|
113
|
+
wrapped["timestamp"] = ts
|
|
114
|
+
if record.get("scenario"):
|
|
115
|
+
wrapped["scenario"] = record["scenario"]
|
|
116
|
+
out.append(wrapped)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def custom_panel_records(path: Path | None) -> list[dict[str, Any]]:
|
|
121
|
+
"""Read a persisted ``custom.{owner}.jsonl`` sibling trace for replay.
|
|
122
|
+
|
|
123
|
+
Each line is already a complete ``kind: custom.{owner}`` record (stamped
|
|
124
|
+
with ``ts`` at write time) -- no transform needed, unlike the
|
|
125
|
+
metric/progress readers above.
|
|
126
|
+
"""
|
|
127
|
+
if path is None or not path.is_file():
|
|
128
|
+
return []
|
|
129
|
+
out: list[dict[str, Any]] = []
|
|
130
|
+
for record in parse_jsonl(path.read_text(encoding="utf-8")):
|
|
131
|
+
if isinstance(record, dict) and str(record.get("kind") or "").startswith("custom."):
|
|
132
|
+
out.append(record)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _record_ts(record: dict[str, Any]) -> float:
|
|
137
|
+
return coerce_timestamp(record.get("timestamp", record.get("ts", record.get("snapshot_at")))) or 0.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def metric_anchor(record: dict[str, Any]) -> tuple[str, str]:
|
|
141
|
+
"""Live+cache join uses (kind, agent_id), never call_id or wall clock."""
|
|
142
|
+
return (str(record.get("kind") or ""), str(record.get("agent_id") or ""))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def synthesize_metric_ticks(records: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
146
|
+
"""Replay ticks by folding the same trace-stats counters used live."""
|
|
147
|
+
try:
|
|
148
|
+
from mas.library.lab.steps.extract.trace_stats import (
|
|
149
|
+
empty_trace_stats,
|
|
150
|
+
fold_trace_event,
|
|
151
|
+
public_trace_stats,
|
|
152
|
+
)
|
|
153
|
+
except Exception:
|
|
154
|
+
return []
|
|
155
|
+
stats = empty_trace_stats()
|
|
156
|
+
ticks: list[dict[str, Any]] = []
|
|
157
|
+
last: dict[str, Any] | None = None
|
|
158
|
+
nth: dict[tuple[str, str], int] = {}
|
|
159
|
+
emit_kinds = {"llm_call_end", "tool_call_end", "execution_end", "mas_call_end"}
|
|
160
|
+
for seq, record in enumerate(records or []):
|
|
161
|
+
if not isinstance(record, dict):
|
|
162
|
+
continue
|
|
163
|
+
kind = str(record.get("kind") or "")
|
|
164
|
+
if kind == "metric":
|
|
165
|
+
continue
|
|
166
|
+
fold_trace_event(stats, record)
|
|
167
|
+
pub = public_trace_stats(stats)
|
|
168
|
+
key = metric_anchor(record)
|
|
169
|
+
nth[key] = nth.get(key, 0) + 1
|
|
170
|
+
if kind not in emit_kinds or not pub:
|
|
171
|
+
continue
|
|
172
|
+
if pub == last:
|
|
173
|
+
continue
|
|
174
|
+
last = dict(pub)
|
|
175
|
+
tick: dict[str, Any] = {
|
|
176
|
+
"kind": "metric",
|
|
177
|
+
"name": "trace_stats",
|
|
178
|
+
"metrics": pub,
|
|
179
|
+
"seq": seq,
|
|
180
|
+
"anchor_kind": kind,
|
|
181
|
+
"agent_id": record.get("agent_id"),
|
|
182
|
+
"nth": nth[key],
|
|
183
|
+
}
|
|
184
|
+
ts = record.get("timestamp", record.get("ts"))
|
|
185
|
+
if ts is not None:
|
|
186
|
+
tick["timestamp"] = ts
|
|
187
|
+
if record.get("call_id"):
|
|
188
|
+
tick["call_id"] = record["call_id"]
|
|
189
|
+
if record.get("scenario"):
|
|
190
|
+
tick["scenario"] = record["scenario"]
|
|
191
|
+
ticks.append(tick)
|
|
192
|
+
return ticks
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def stamp_artefact_metrics(record: dict[str, Any] | None, events: Iterable[dict[str, Any]]) -> dict[str, Any] | None:
|
|
196
|
+
"""Put final metrics.json at the end of replay, not at t=0."""
|
|
197
|
+
if not record:
|
|
198
|
+
return None
|
|
199
|
+
if record.get("timestamp") not in (None, ""):
|
|
200
|
+
return record
|
|
201
|
+
last = 0.0
|
|
202
|
+
for item in events or []:
|
|
203
|
+
last = max(last, _record_ts(item))
|
|
204
|
+
if last:
|
|
205
|
+
record = {**record, "timestamp": last}
|
|
206
|
+
return record
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def merge_replay_records(*groups: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
210
|
+
"""Stable time-order merge so progress ticks with native events instead of dumping at the end."""
|
|
211
|
+
merged: list[dict[str, Any]] = []
|
|
212
|
+
for group in groups:
|
|
213
|
+
merged.extend(list(group or []))
|
|
214
|
+
indexed = list(enumerate(merged))
|
|
215
|
+
indexed.sort(key=lambda item: (_record_ts(item[1]), item[0]))
|
|
216
|
+
return [item[1] for item in indexed]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def trace_metric_records(
|
|
220
|
+
*,
|
|
221
|
+
metrics: Path | None = None,
|
|
222
|
+
progress: Path | None = None,
|
|
223
|
+
events: Iterable[dict[str, Any]] | None = None,
|
|
224
|
+
) -> list[dict[str, Any]]:
|
|
225
|
+
extra: list[dict[str, Any]] = []
|
|
226
|
+
native = list(events or [])
|
|
227
|
+
authored = metric_records_from_progress(progress) if progress is not None else []
|
|
228
|
+
if authored:
|
|
229
|
+
extra.extend(authored)
|
|
230
|
+
else:
|
|
231
|
+
extra.extend(synthesize_metric_ticks(native))
|
|
232
|
+
if metrics is not None:
|
|
233
|
+
artefact = stamp_artefact_metrics(metric_record_from_artefact(metrics), native)
|
|
234
|
+
if artefact is not None:
|
|
235
|
+
extra.append(artefact)
|
|
236
|
+
return extra
|