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,308 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Canonical artefact ↔ pipeline I/O ↔ WS stream binder.
4
+
5
+ One name, four projections:
6
+
7
+ * ``name`` / ``stream`` — WS topic owner (``custom.chat``, ``native``)
8
+ * ``uri`` — ``artifact://experiment/scenario/item/run/name#attempt``
9
+ * ``address`` — ``/api/artefacts/{library}/…/{name}``
10
+ * ``path`` — ui-trace relative file (``pipeline/custom.chat.jsonl``)
11
+
12
+ ``inputs`` / ``outputs`` / ``producers`` / ``consumers`` are the DAG edges
13
+ in both directions. ``complete`` is sealed: a cache hit is only legal on a
14
+ complete artefact whose input digest still matches.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Iterable
21
+
22
+ from mas.lab.lab.config.artifact_types import artefact_address, artefact_uri
23
+
24
+ # Catalog shortcuts (`chat`) map onto the stream-owner identity (`custom.chat`).
25
+ SHORTCUTS = {
26
+ "chat": "custom.chat",
27
+ "topology": "custom.topology",
28
+ "loop": "series",
29
+ "trace": "events",
30
+ "events": "events",
31
+ "metrics": "metric",
32
+ }
33
+
34
+ # Live folds consume the events artefact and produce one named snapshot each.
35
+ # YAML pipeline steps merge on top via :func:`graph_from_steps`.
36
+ LIVE_GRAPH: dict[str, dict[str, Any]] = {
37
+ "events": {
38
+ "stream": "native",
39
+ "inputs": [],
40
+ "outputs": ["events"],
41
+ "producers": ["runtime"],
42
+ "consumers": ["fold.chat", "fold.topology"],
43
+ },
44
+ "custom.chat": {
45
+ "stream": "custom.chat",
46
+ "plot": "chat",
47
+ "inputs": ["events"],
48
+ "outputs": ["custom.chat"],
49
+ "producers": ["fold.chat"],
50
+ "consumers": ["plot.chat"],
51
+ },
52
+ "custom.topology": {
53
+ "stream": "custom.topology",
54
+ "plot": "topology",
55
+ "inputs": ["events"],
56
+ "outputs": ["custom.topology"],
57
+ "producers": ["fold.topology"],
58
+ "consumers": ["plot.topology"],
59
+ },
60
+ "series": {
61
+ "stream": "series",
62
+ "plot": "loop",
63
+ "inputs": ["events"],
64
+ "outputs": ["series"],
65
+ "producers": ["fold.series"],
66
+ "consumers": ["plot.loop"],
67
+ },
68
+ "metric": {
69
+ "stream": "metric",
70
+ "plot": "metrics",
71
+ "inputs": ["events"],
72
+ "outputs": ["metric"],
73
+ "producers": ["fold.metrics"],
74
+ "consumers": ["plot.metrics"],
75
+ },
76
+ }
77
+
78
+
79
+ def canonical_artefact(name: str) -> str:
80
+ """Shortcut (`chat`) or topic (`custom.chat.snapshot`) → stream owner."""
81
+ key = str(name or "").strip()
82
+ if key in SHORTCUTS:
83
+ return SHORTCUTS[key]
84
+ if key in LIVE_GRAPH:
85
+ return key
86
+ return name_of_stream(key) or key
87
+
88
+
89
+ def _node(name: str) -> dict[str, Any]:
90
+ declared = LIVE_GRAPH.get(name) or {}
91
+ return {
92
+ "stream": str(declared.get("stream") or name),
93
+ "plot": declared.get("plot"),
94
+ "inputs": list(declared.get("inputs") or ([] if name == "events" else ["events"])),
95
+ "outputs": list(declared.get("outputs") or [name]),
96
+ "producers": list(declared.get("producers") or []),
97
+ "consumers": list(declared.get("consumers") or []),
98
+ }
99
+
100
+
101
+ def stream_of(name: str) -> str:
102
+ return str(_node(name).get("stream") or name)
103
+
104
+
105
+ _STREAM_TICK_SUFFIXES = frozenset({"snapshot", "plot", "tick", "sealed", "html", "complete"})
106
+
107
+
108
+ def _stream_matches(declared: str, owner: str) -> bool:
109
+ """Exact owner, or ``declared.snapshot`` / ``.sealed`` — not ``declared.foo``."""
110
+ if not declared or owner == declared:
111
+ return bool(declared) and owner == declared
112
+ prefix = declared + "."
113
+ if not owner.startswith(prefix):
114
+ return False
115
+ rest = owner[len(prefix) :]
116
+ first = rest.split(".", 1)[0]
117
+ return first in _STREAM_TICK_SUFFIXES
118
+
119
+
120
+ def name_of_stream(stream: str) -> str:
121
+ owner = str(stream or "").strip()
122
+ if not owner:
123
+ return ""
124
+ for name, node in LIVE_GRAPH.items():
125
+ declared = str(node.get("stream") or name)
126
+ if _stream_matches(declared, owner):
127
+ return name
128
+ for prefix in ("custom.", "series.", "artifact."):
129
+ if owner.startswith(prefix):
130
+ parts = owner.split(".")
131
+ return ".".join(parts[:2]) if len(parts) >= 2 else owner
132
+ if owner.startswith("metric"):
133
+ return "metric"
134
+ if owner.startswith(("native.", "spec.", "job.", "command.", "result.", "control.", "governance.", "io.")) or owner in {
135
+ "native",
136
+ "job",
137
+ "spec",
138
+ }:
139
+ return "events"
140
+ return owner
141
+
142
+
143
+ def is_sealed_topic(topic: str) -> bool:
144
+ name = str(topic or "")
145
+ return name.endswith(".sealed") or name.endswith(".complete") or name in {"job.completed"}
146
+
147
+
148
+ @dataclass
149
+ class ArtefactLink:
150
+ name: str
151
+ stream: str
152
+ uri: str = ""
153
+ address: str = ""
154
+ path: str = ""
155
+ complete: bool | None = None
156
+ digest: str = ""
157
+ key: str = ""
158
+ inputs: list[str] = field(default_factory=list)
159
+ outputs: list[str] = field(default_factory=list)
160
+ producers: list[str] = field(default_factory=list)
161
+ consumers: list[str] = field(default_factory=list)
162
+ plot: str | None = None
163
+ tree: str = "pipeline"
164
+
165
+ def as_dict(self) -> dict[str, Any]:
166
+ data: dict[str, Any] = {
167
+ "artefact": self.name,
168
+ "stream": self.stream,
169
+ "uri": self.uri,
170
+ "address": self.address,
171
+ "path": self.path,
172
+ "inputs": list(self.inputs),
173
+ "outputs": list(self.outputs),
174
+ "producers": list(self.producers),
175
+ "consumers": list(self.consumers),
176
+ "tree": self.tree,
177
+ }
178
+ if self.complete is not None:
179
+ data["complete"] = self.complete
180
+ if self.digest:
181
+ data["digest"] = self.digest
182
+ if self.key:
183
+ data["key"] = self.key
184
+ if self.plot:
185
+ data["plot"] = self.plot
186
+ return data
187
+
188
+ def envelope_fields(self) -> dict[str, Any]:
189
+ data = {
190
+ "artefact": self.name,
191
+ "stream": self.stream,
192
+ "uri": self.uri,
193
+ "path": self.path,
194
+ "inputs": list(self.inputs),
195
+ "outputs": list(self.outputs),
196
+ }
197
+ if self.complete is not None:
198
+ data["complete"] = self.complete
199
+ return data
200
+
201
+
202
+ def link_for(
203
+ name: str,
204
+ ref: dict[str, Any] | None = None,
205
+ *,
206
+ complete: bool | None = None,
207
+ tree: str | None = None,
208
+ digest: str = "",
209
+ key: str = "",
210
+ ) -> ArtefactLink:
211
+ name = canonical_artefact(name)
212
+ node = _node(name)
213
+ stream = str(node.get("stream") or name)
214
+ resolved_tree = tree or ("runtime" if name == "events" else "pipeline")
215
+ if resolved_tree == "runtime":
216
+ rel = "runtime.jsonl"
217
+ else:
218
+ rel = f"pipeline/{name}.jsonl"
219
+ data = dict(ref or {})
220
+ return ArtefactLink(
221
+ name=name,
222
+ stream=stream,
223
+ uri=artefact_uri(name, data),
224
+ address=artefact_address(name, data),
225
+ path=rel,
226
+ complete=complete,
227
+ digest=digest,
228
+ key=key,
229
+ inputs=list(node.get("inputs") or []),
230
+ outputs=list(node.get("outputs") or [name]),
231
+ producers=list(node.get("producers") or []),
232
+ consumers=list(node.get("consumers") or []),
233
+ plot=node.get("plot"),
234
+ tree=resolved_tree,
235
+ )
236
+
237
+
238
+ def link_for_topic(
239
+ topic: str,
240
+ ref: dict[str, Any] | None = None,
241
+ *,
242
+ complete: bool | None = None,
243
+ ) -> ArtefactLink:
244
+ name = canonical_artefact(name_of_stream(topic))
245
+ sealed = is_sealed_topic(topic)
246
+ flag = True if sealed else complete
247
+ tree = "runtime" if name == "events" else "pipeline"
248
+ return link_for(name, ref, complete=flag, tree=tree)
249
+
250
+
251
+ def _step_io(spec: Any) -> tuple[str, list[str], list[str]]:
252
+ step_name = str(getattr(spec, "name", None) or getattr(spec, "type", "") or "")
253
+ inputs = list(getattr(spec, "inputs", None) or [])
254
+ outputs = list(getattr(spec, "outputs", None) or [])
255
+ if isinstance(spec, dict):
256
+ if not step_name:
257
+ step_name = str(spec.get("name") or spec.get("type") or "")
258
+ if not inputs:
259
+ raw = spec.get("inputs") or spec.get("in")
260
+ if raw:
261
+ inputs = [raw] if isinstance(raw, str) else list(raw)
262
+ if not outputs:
263
+ raw = spec.get("outputs") or spec.get("out")
264
+ if raw:
265
+ outputs = [raw] if isinstance(raw, str) else list(raw)
266
+ return (
267
+ step_name,
268
+ [canonical_artefact(str(item)) for item in inputs if str(item).strip()],
269
+ [canonical_artefact(str(item)) for item in outputs if str(item).strip()],
270
+ )
271
+
272
+
273
+ def graph_from_steps(steps: Iterable[Any] | None = None) -> dict[str, dict[str, Any]]:
274
+ """Merge YAML ``in:`` / ``out:`` onto the live-fold graph (bidirectional)."""
275
+ graph: dict[str, dict[str, Any]] = {name: dict(node) for name, node in LIVE_GRAPH.items()}
276
+ for spec in steps or []:
277
+ step_name, inputs, outputs = _step_io(spec)
278
+ for artefact in outputs:
279
+ node = graph.setdefault(artefact, _node(artefact))
280
+ producers = list(node.get("producers") or [])
281
+ if step_name and step_name not in producers:
282
+ producers.append(step_name)
283
+ node["producers"] = producers
284
+ node["outputs"] = list(dict.fromkeys([*(node.get("outputs") or []), artefact]))
285
+ node["inputs"] = list(dict.fromkeys([*(node.get("inputs") or []), *inputs]))
286
+ for artefact in inputs:
287
+ node = graph.setdefault(artefact, _node(artefact))
288
+ consumers = list(node.get("consumers") or [])
289
+ if step_name and step_name not in consumers:
290
+ consumers.append(step_name)
291
+ node["consumers"] = consumers
292
+ node.setdefault("outputs", [artefact])
293
+ node.setdefault("inputs", [])
294
+ node.setdefault("producers", [])
295
+ node.setdefault("stream", _node(artefact).get("stream"))
296
+ return graph
297
+
298
+
299
+ def shard_is_complete(rows: list[dict[str, Any]], *, tree: str) -> bool:
300
+ if not rows:
301
+ return False
302
+ if any(row.get("complete") is True or is_sealed_topic(str(row.get("topic") or "")) for row in rows):
303
+ return True
304
+ if tree == "runtime":
305
+ kinds = {str((row.get("payload") or {}).get("kind") if isinstance(row.get("payload"), dict) else "") for row in rows}
306
+ kinds.update(str(row.get("topic") or "").rsplit(".", 1)[-1] for row in rows)
307
+ return bool(kinds & {"mas_call_end", "execution_end", "job.completed"})
308
+ return False
@@ -0,0 +1,136 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Token + TLS gate for the demo-ui stream plane."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hmac
8
+ import os
9
+ from typing import Any
10
+
11
+ from fastapi import HTTPException, Request, WebSocket
12
+ from starlette.datastructures import Headers
13
+
14
+ _LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1", "testclient", "testserver"})
15
+ _PROTECTED_PREFIXES = (
16
+ "/api/ingest",
17
+ "/api/commands",
18
+ "/api/stream",
19
+ "/api/plots",
20
+ "/ws",
21
+ )
22
+ CSP = (
23
+ "default-src 'self'; "
24
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
25
+ "style-src 'self' 'unsafe-inline'; "
26
+ "img-src 'self' data:; "
27
+ "connect-src 'self' ws: wss:; "
28
+ "object-src 'none'; "
29
+ "frame-src 'none'; "
30
+ "frame-ancestors 'none'; "
31
+ "base-uri 'self'"
32
+ )
33
+
34
+
35
+ def ui_token() -> str:
36
+ return str(os.environ.get("MAS_DEMO_UI_TOKEN") or "").strip()
37
+
38
+
39
+ def is_loopback(host: str) -> bool:
40
+ name = str(host or "").split("%", 1)[0].strip().lower().strip("[]")
41
+ if name.startswith("127."):
42
+ return True
43
+ return name in _LOOPBACK
44
+
45
+
46
+ def ssl_enabled() -> bool:
47
+ cert = str(os.environ.get("MAS_DEMO_UI_SSL_CERTFILE") or "").strip()
48
+ key = str(os.environ.get("MAS_DEMO_UI_SSL_KEYFILE") or "").strip()
49
+ return bool(cert and key)
50
+
51
+
52
+ def ssl_uvicorn_kwargs() -> dict[str, str]:
53
+ if not ssl_enabled():
54
+ return {}
55
+ return {
56
+ "ssl_certfile": str(os.environ.get("MAS_DEMO_UI_SSL_CERTFILE") or "").strip(),
57
+ "ssl_keyfile": str(os.environ.get("MAS_DEMO_UI_SSL_KEYFILE") or "").strip(),
58
+ }
59
+
60
+
61
+ def assert_ui_bind(host: str) -> None:
62
+ """Refuse a non-loopback bind that has no TLS or no token."""
63
+ if is_loopback(host):
64
+ return
65
+ if not ssl_enabled():
66
+ raise RuntimeError("TLS is required for a non-loopback mas-lab serve bind")
67
+ if not ui_token():
68
+ raise RuntimeError("MAS_DEMO_UI_TOKEN is required for a non-loopback mas-lab serve bind")
69
+
70
+
71
+ def token_from_headers(headers: Headers | dict[str, str] | None, extra: str | None = None) -> str:
72
+ if extra:
73
+ return str(extra)
74
+ if headers is None:
75
+ return ""
76
+ raw = str(headers.get("authorization") or headers.get("Authorization") or "")
77
+ if raw.lower().startswith("bearer "):
78
+ raw = raw[7:]
79
+ raw = raw.strip()
80
+ if raw:
81
+ return raw
82
+ return str(headers.get("x-mas-token") or headers.get("X-Mas-Token") or "").strip()
83
+
84
+
85
+ def token_from_request(request: Request) -> str:
86
+ query = str(request.query_params.get("token") or "").strip()
87
+ return token_from_headers(request.headers, query or None)
88
+
89
+
90
+ def check_token(provided: str, expected: str) -> bool:
91
+ if not expected:
92
+ return False
93
+ return hmac.compare_digest(str(provided or ""), str(expected))
94
+
95
+
96
+ def authorize_http(request: Request) -> None:
97
+ expected = ui_token()
98
+ if not expected:
99
+ return
100
+ if not check_token(token_from_request(request), expected):
101
+ raise HTTPException(status_code=401, detail="unauthorized")
102
+
103
+
104
+ def authorize_ws(ws: WebSocket, hello: dict[str, Any] | None = None) -> None:
105
+ expected = ui_token()
106
+ if not expected:
107
+ return
108
+ query = str(ws.query_params.get("token") or "").strip()
109
+ provided = token_from_headers(ws.headers, query or None)
110
+ if hello and not provided:
111
+ provided = str(hello.get("token") or hello.get("authorization") or "").strip()
112
+ if provided.lower().startswith("bearer "):
113
+ provided = provided[7:].strip()
114
+ if not check_token(provided, expected):
115
+ raise HTTPException(status_code=401, detail="unauthorized")
116
+
117
+
118
+ def path_is_protected(path: str) -> bool:
119
+ raw = str(path or "")
120
+ return any(raw == prefix or raw.startswith(prefix + "/") or raw.startswith(prefix + "?") for prefix in _PROTECTED_PREFIXES)
121
+
122
+
123
+ def ingest_headers() -> dict[str, str]:
124
+ headers = {"Content-Type": "application/json"}
125
+ token = ui_token()
126
+ if token:
127
+ headers["Authorization"] = f"Bearer {token}"
128
+ return headers
129
+
130
+
131
+ def security_headers() -> dict[str, str]:
132
+ return {
133
+ "Content-Security-Policy": CSP,
134
+ "X-Content-Type-Options": "nosniff",
135
+ "Referrer-Policy": "no-referrer",
136
+ }
@@ -0,0 +1,156 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Topic-scoped in-memory pub/sub for one gateway process."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass, field
10
+
11
+ from mas.lab.controller.ui_stream.refs import DataSourceRef, EventEnvelope, topic_matches
12
+
13
+
14
+ @dataclass
15
+ class Subscription:
16
+ topics: tuple[str, ...]
17
+ ref: DataSourceRef
18
+ nonce: str | None = None
19
+ queue: asyncio.Queue[EventEnvelope] = field(default_factory=lambda: asyncio.Queue(maxsize=2000))
20
+
21
+
22
+ class EventBus:
23
+ def __init__(self, *, max_history: int | None = 4000) -> None:
24
+ self._subs: list[Subscription] = []
25
+ self._history: list[EventEnvelope] = []
26
+ self._max_history = max_history
27
+
28
+ def publish(self, envelope: EventEnvelope) -> None:
29
+ self._history.append(envelope)
30
+ if self._max_history and len(self._history) > self._max_history:
31
+ self._history = self._history[-self._max_history :]
32
+ for sub in list(self._subs):
33
+ if not topic_matches(sub.topics, envelope.topic):
34
+ continue
35
+ if not sub.ref.matches(envelope.ref):
36
+ continue
37
+ if envelope.nonce and sub.nonce and envelope.nonce != sub.nonce:
38
+ if not envelope.replay and envelope.topic != "view.reset":
39
+ continue
40
+ try:
41
+ sub.queue.put_nowait(envelope)
42
+ except asyncio.QueueFull:
43
+ try:
44
+ sub.queue.get_nowait()
45
+ except Exception:
46
+ pass
47
+ try:
48
+ sub.queue.put_nowait(envelope)
49
+ except Exception:
50
+ pass
51
+
52
+ def subscribe(
53
+ self, topics: Iterable[str], ref: DataSourceRef, *, nonce: str | None = None
54
+ ) -> Subscription:
55
+ sub = Subscription(topics=tuple(topics), ref=ref, nonce=str(nonce) if nonce else None)
56
+ self._subs.append(sub)
57
+ return sub
58
+
59
+ def unsubscribe(self, sub: Subscription) -> None:
60
+ try:
61
+ self._subs.remove(sub)
62
+ except ValueError:
63
+ return
64
+
65
+ def drop(self, *, run: str | None = None, batch: str | None = None, origin: str | None = None) -> int:
66
+ kept: list[EventEnvelope] = []
67
+ removed = 0
68
+ for item in self._history:
69
+ ref = item.ref
70
+ match = False
71
+ if run and ref.run == run:
72
+ match = True
73
+ if batch and ref.batch == batch:
74
+ match = True
75
+ if origin and ref.origin == origin:
76
+ match = True
77
+ if match:
78
+ removed += 1
79
+ continue
80
+ kept.append(item)
81
+ self._history = kept
82
+ return removed
83
+
84
+ def clear(self) -> None:
85
+ self._history.clear()
86
+ for sub in list(self._subs):
87
+ while True:
88
+ try:
89
+ sub.queue.get_nowait()
90
+ except Exception:
91
+ break
92
+
93
+ def wants_topic(self, topic: str, ref: DataSourceRef) -> bool:
94
+ """True when a live WS/plugin subscription would receive ``topic`` for ``ref``."""
95
+ for sub in self._subs:
96
+ if not topic_matches(sub.topics, topic):
97
+ continue
98
+ if sub.ref.matches(ref):
99
+ return True
100
+ return False
101
+
102
+ def demands_topic(self, topic: str, ref: DataSourceRef) -> bool:
103
+ """True when a subscription named this topic (not a catch-all ``*``)."""
104
+ for sub in self._subs:
105
+ globs = tuple(glob for glob in sub.topics if glob not in {"", "*"})
106
+ if not globs:
107
+ continue
108
+ if not topic_matches(globs, topic):
109
+ continue
110
+ if sub.ref.matches(ref):
111
+ return True
112
+ return False
113
+
114
+ def snapshot(self, topics: Iterable[str], ref: DataSourceRef) -> list[EventEnvelope]:
115
+ globs = tuple(topics)
116
+ out: list[EventEnvelope] = []
117
+ for item in self._history:
118
+ if not topic_matches(globs, item.topic):
119
+ continue
120
+ if not ref.matches(item.ref):
121
+ continue
122
+ out.append(item)
123
+ return out
124
+
125
+ def catchup(
126
+ self,
127
+ topics: Iterable[str],
128
+ ref: DataSourceRef,
129
+ *,
130
+ nonce: str | None = None,
131
+ ) -> list[EventEnvelope]:
132
+ """Latest envelope per topic after the last ``view.reset``.
133
+
134
+ A tab that connects after ingest has finished still paints from this
135
+ list. Empty ``topics`` means nothing (do not dump the whole bus).
136
+ """
137
+ globs = tuple(str(item) for item in topics if str(item or "").strip())
138
+ if not globs:
139
+ return []
140
+ items = self.snapshot(globs, ref)
141
+ last_reset: EventEnvelope | None = None
142
+ latest: dict[str, EventEnvelope] = {}
143
+ for item in items:
144
+ if item.nonce and nonce and item.nonce != nonce:
145
+ if not item.replay and item.topic != "view.reset":
146
+ continue
147
+ if item.topic == "view.reset":
148
+ last_reset = item
149
+ latest.clear()
150
+ continue
151
+ latest[item.topic] = item
152
+ out: list[EventEnvelope] = []
153
+ if last_reset is not None:
154
+ out.append(last_reset)
155
+ out.extend(latest.values())
156
+ return out