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,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