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