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,95 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""One stream of envelopes. Backend protocols are adapters that publish or consume frames.
|
|
4
|
+
|
|
5
|
+
A frame is always ``{topic, ts, ref, source, payload, replay, id}``. Topics are
|
|
6
|
+
the dispatch key for plugins (``command.*``, ``native.*``, ``artifact.*``, …).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mas.lab.controller.ui_stream.refs import EventEnvelope, envelope, ref_from_mapping
|
|
14
|
+
|
|
15
|
+
_SOURCES = {
|
|
16
|
+
"command",
|
|
17
|
+
"result",
|
|
18
|
+
"control",
|
|
19
|
+
"native",
|
|
20
|
+
"telemetry",
|
|
21
|
+
"metric",
|
|
22
|
+
"job",
|
|
23
|
+
"catalog",
|
|
24
|
+
"spec",
|
|
25
|
+
"artifact",
|
|
26
|
+
"io",
|
|
27
|
+
"governance",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_frame(data: dict[str, Any] | None) -> EventEnvelope:
|
|
32
|
+
raw = dict(data or {})
|
|
33
|
+
payload = raw.get("payload")
|
|
34
|
+
topic = str(raw.get("topic") or "").strip()
|
|
35
|
+
source = str(raw.get("source") or "").strip()
|
|
36
|
+
if source not in _SOURCES:
|
|
37
|
+
if topic.startswith("command."):
|
|
38
|
+
source = "command"
|
|
39
|
+
elif topic.startswith("result."):
|
|
40
|
+
source = "result"
|
|
41
|
+
elif topic.startswith("native.") or topic.startswith("telemetry."):
|
|
42
|
+
source = "native"
|
|
43
|
+
elif topic.startswith("metric."):
|
|
44
|
+
source = "metric"
|
|
45
|
+
elif topic.startswith("control."):
|
|
46
|
+
source = "control"
|
|
47
|
+
elif topic.startswith("job."):
|
|
48
|
+
source = "job"
|
|
49
|
+
elif topic.startswith("spec."):
|
|
50
|
+
source = "spec"
|
|
51
|
+
elif topic.startswith("io."):
|
|
52
|
+
source = "io"
|
|
53
|
+
elif topic.startswith("governance."):
|
|
54
|
+
source = "governance"
|
|
55
|
+
else:
|
|
56
|
+
source = "artifact"
|
|
57
|
+
if not topic:
|
|
58
|
+
kind = ""
|
|
59
|
+
if isinstance(payload, dict):
|
|
60
|
+
kind = str(payload.get("kind") or payload.get("verb") or source)
|
|
61
|
+
topic = f"{source}.{kind or 'frame'}"
|
|
62
|
+
ref = ref_from_mapping(raw.get("ref") if isinstance(raw.get("ref"), dict) else {})
|
|
63
|
+
if source not in _SOURCES:
|
|
64
|
+
source = "artifact"
|
|
65
|
+
return envelope(
|
|
66
|
+
topic,
|
|
67
|
+
ref,
|
|
68
|
+
source, # type: ignore[arg-type]
|
|
69
|
+
payload,
|
|
70
|
+
replay=bool(raw.get("replay")),
|
|
71
|
+
event_id=str(raw.get("id") or "") or None,
|
|
72
|
+
ts=str(raw.get("ts") or "") or None,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_command_frame(frame: EventEnvelope, raw: dict[str, Any] | None = None) -> bool:
|
|
77
|
+
if frame.source == "command" or frame.topic.startswith("command."):
|
|
78
|
+
return True
|
|
79
|
+
payload = frame.payload if isinstance(frame.payload, dict) else {}
|
|
80
|
+
if payload.get("verb"):
|
|
81
|
+
return True
|
|
82
|
+
return bool(raw and raw.get("verb"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def command_payload(frame: EventEnvelope, raw: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
86
|
+
payload = dict(frame.payload) if isinstance(frame.payload, dict) else {}
|
|
87
|
+
extra = dict(raw or {})
|
|
88
|
+
for key in ("topic", "source", "ts", "replay", "id", "payload"):
|
|
89
|
+
extra.pop(key, None)
|
|
90
|
+
merged = {**extra, **payload}
|
|
91
|
+
if not merged.get("verb") and frame.topic.startswith("command."):
|
|
92
|
+
merged["verb"] = frame.topic.split(".", 1)[1]
|
|
93
|
+
if not merged.get("ref"):
|
|
94
|
+
merged["ref"] = frame.ref.as_dict()
|
|
95
|
+
return merged
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Named telemetry streams plugins get() / subscribe() on."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
# Logical stream → topic globs. Plugins may also pass raw topic globs.
|
|
8
|
+
# Keep in sync with demo-ui/src/streams.js (same keys and topic lists).
|
|
9
|
+
STREAM_TOPICS: dict[str, tuple[str, ...]] = {
|
|
10
|
+
"spec": ("spec.snapshot", "spec.*"),
|
|
11
|
+
"observability": ("native.*",),
|
|
12
|
+
"metrics": ("metrics.snapshot", "metric.*", "native.metric", "metric.scope"),
|
|
13
|
+
"artifacts": ("artifact.*", "artifact.metric"),
|
|
14
|
+
"governance": ("governance.*", "control.context"),
|
|
15
|
+
"control": ("control.*", "control.structure", "control.context"),
|
|
16
|
+
"hitl": ("control.*",),
|
|
17
|
+
"job": ("job.*", "job.progress"),
|
|
18
|
+
"custom": ("custom.*",),
|
|
19
|
+
"series": ("series.*",),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def expand_stream_names(names: list[str] | tuple[str, ...] | None) -> list[str]:
|
|
24
|
+
"""Map ``spec`` / ``observability`` / topic globs onto unique topic globs."""
|
|
25
|
+
out: list[str] = []
|
|
26
|
+
seen: set[str] = set()
|
|
27
|
+
for raw in names or []:
|
|
28
|
+
text = str(raw or "").strip()
|
|
29
|
+
if not text or text == "*":
|
|
30
|
+
continue
|
|
31
|
+
if text in STREAM_TOPICS:
|
|
32
|
+
parts = STREAM_TOPICS[text]
|
|
33
|
+
elif text.startswith(("custom.", "series.")) and "*" not in text:
|
|
34
|
+
parts = (text, f"{text}.*")
|
|
35
|
+
else:
|
|
36
|
+
parts = (text,)
|
|
37
|
+
for item in parts:
|
|
38
|
+
if item in seen:
|
|
39
|
+
continue
|
|
40
|
+
seen.add(item)
|
|
41
|
+
out.append(item)
|
|
42
|
+
return out
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Discrete-event replay clock.
|
|
4
|
+
|
|
5
|
+
All sources are preloaded, merged by timestamp, then emitted on one clock.
|
|
6
|
+
Lookahead is the whole timeline (not a streaming parse of several files).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(order=True)
|
|
19
|
+
class Scheduled:
|
|
20
|
+
ts: float
|
|
21
|
+
seq: int
|
|
22
|
+
tree: str = field(compare=False, default="runtime")
|
|
23
|
+
ref: Any = field(compare=False, default=None)
|
|
24
|
+
record: dict[str, Any] = field(compare=False, default_factory=dict)
|
|
25
|
+
envelope: dict[str, Any] | None = field(compare=False, default=None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def event_ts_s(record: dict[str, Any] | None) -> float | None:
|
|
29
|
+
if not isinstance(record, dict):
|
|
30
|
+
return None
|
|
31
|
+
raw = record.get("timestamp", record.get("ts", record.get("t")))
|
|
32
|
+
if isinstance(raw, (int, float)):
|
|
33
|
+
value = float(raw)
|
|
34
|
+
return value / 1000.0 if value > 1e12 else value
|
|
35
|
+
if isinstance(raw, str) and raw.strip():
|
|
36
|
+
try:
|
|
37
|
+
return float(raw)
|
|
38
|
+
except ValueError:
|
|
39
|
+
from datetime import datetime
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
|
|
43
|
+
except ValueError:
|
|
44
|
+
return None
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def envelope_ts_s(row: dict[str, Any]) -> float | None:
|
|
49
|
+
ts = event_ts_s(row)
|
|
50
|
+
if ts is not None:
|
|
51
|
+
return ts
|
|
52
|
+
payload = row.get("payload")
|
|
53
|
+
if isinstance(payload, dict):
|
|
54
|
+
return event_ts_s(payload)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def merge_timeline(lanes: list[list[Scheduled]]) -> list[Scheduled]:
|
|
59
|
+
"""Stable merge of preloaded lanes ordered by (ts, seq)."""
|
|
60
|
+
merged: list[Scheduled] = []
|
|
61
|
+
seq = 0
|
|
62
|
+
for lane in lanes:
|
|
63
|
+
for item in lane:
|
|
64
|
+
merged.append(
|
|
65
|
+
Scheduled(
|
|
66
|
+
ts=item.ts,
|
|
67
|
+
seq=seq,
|
|
68
|
+
tree=item.tree,
|
|
69
|
+
ref=item.ref,
|
|
70
|
+
record=item.record,
|
|
71
|
+
envelope=item.envelope,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
seq += 1
|
|
75
|
+
merged.sort()
|
|
76
|
+
return merged
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _coherent_fallback(times: list[float | None]) -> float:
|
|
80
|
+
"""Prefer unix event time over epoch-0 placeholders mixed into the same file."""
|
|
81
|
+
unix = [t for t in times if t is not None and t > 1e9]
|
|
82
|
+
if unix:
|
|
83
|
+
return unix[0]
|
|
84
|
+
relative = [t for t in times if t is not None and t != 0.0]
|
|
85
|
+
return relative[0] if relative else 0.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _fill_ts(raw: float | None, fallback: float, *, unix: bool) -> float:
|
|
89
|
+
if raw is None:
|
|
90
|
+
return fallback
|
|
91
|
+
if unix and raw < 1e9:
|
|
92
|
+
return fallback
|
|
93
|
+
return raw
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def records_to_scheduled(
|
|
97
|
+
records: list[dict[str, Any]],
|
|
98
|
+
*,
|
|
99
|
+
ref: Any = None,
|
|
100
|
+
tree: str = "runtime",
|
|
101
|
+
) -> list[Scheduled]:
|
|
102
|
+
times = [event_ts_s(record) for record in records]
|
|
103
|
+
unix = any(t is not None and t > 1e9 for t in times)
|
|
104
|
+
fallback = _coherent_fallback(times)
|
|
105
|
+
out: list[Scheduled] = []
|
|
106
|
+
for i, record in enumerate(records):
|
|
107
|
+
ts = _fill_ts(times[i], fallback, unix=unix)
|
|
108
|
+
fallback = ts
|
|
109
|
+
out.append(Scheduled(ts=ts, seq=i, tree=tree, ref=ref, record=record))
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def envelopes_to_scheduled(
|
|
114
|
+
rows: list[dict[str, Any]],
|
|
115
|
+
*,
|
|
116
|
+
tree: str,
|
|
117
|
+
) -> list[Scheduled]:
|
|
118
|
+
times = [envelope_ts_s(row) if isinstance(row, dict) else None for row in rows]
|
|
119
|
+
unix = any(t is not None and t > 1e9 for t in times)
|
|
120
|
+
fallback = _coherent_fallback(times)
|
|
121
|
+
out: list[Scheduled] = []
|
|
122
|
+
for i, row in enumerate(rows):
|
|
123
|
+
ts = _fill_ts(times[i], fallback, unix=unix)
|
|
124
|
+
fallback = ts
|
|
125
|
+
out.append(Scheduled(ts=ts, seq=i, tree=tree, envelope=row if isinstance(row, dict) else {}))
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def pump_timeline(
|
|
130
|
+
items: list[Scheduled],
|
|
131
|
+
*,
|
|
132
|
+
speed: float,
|
|
133
|
+
emit: Callable[[Scheduled], Awaitable[None] | None],
|
|
134
|
+
cancelled: Callable[[], bool] | None = None,
|
|
135
|
+
on_progress: Callable[[dict[str, Any]], None] | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Emit preloaded events on a shared clock. ``speed`` 0 is instant, 1 realtime."""
|
|
138
|
+
if not items:
|
|
139
|
+
return
|
|
140
|
+
t0 = items[0].ts
|
|
141
|
+
t1 = items[-1].ts
|
|
142
|
+
n_total = len(items)
|
|
143
|
+
tree_totals = Counter(item.tree for item in items)
|
|
144
|
+
tree_done: Counter[str] = Counter()
|
|
145
|
+
clock = asyncio.get_running_loop()
|
|
146
|
+
wall0 = clock.time()
|
|
147
|
+
prev_ts = t0
|
|
148
|
+
jump = 3600.0
|
|
149
|
+
for i, item in enumerate(items, 1):
|
|
150
|
+
if cancelled and cancelled():
|
|
151
|
+
return
|
|
152
|
+
if speed > 0:
|
|
153
|
+
gap = item.ts - prev_ts
|
|
154
|
+
clock_break = gap > jump or gap < -1e-9 or (item.ts > 1e9 and prev_ts < 1e7)
|
|
155
|
+
if clock_break:
|
|
156
|
+
t0 = item.ts - (clock.time() - wall0) * speed
|
|
157
|
+
delay = 0.0
|
|
158
|
+
else:
|
|
159
|
+
delay = (item.ts - t0) / speed - (clock.time() - wall0)
|
|
160
|
+
if delay > 0:
|
|
161
|
+
await asyncio.sleep(delay)
|
|
162
|
+
prev_ts = item.ts
|
|
163
|
+
result = emit(item)
|
|
164
|
+
if asyncio.iscoroutine(result):
|
|
165
|
+
await result
|
|
166
|
+
await asyncio.sleep(0)
|
|
167
|
+
tree_done[item.tree] += 1
|
|
168
|
+
if on_progress is not None:
|
|
169
|
+
on_progress(
|
|
170
|
+
{
|
|
171
|
+
"t": item.ts,
|
|
172
|
+
"t0": t0,
|
|
173
|
+
"t1": t1,
|
|
174
|
+
"n": i,
|
|
175
|
+
"n_total": n_total,
|
|
176
|
+
"tree": item.tree,
|
|
177
|
+
"speed": speed,
|
|
178
|
+
"trees": {
|
|
179
|
+
name: {"n": tree_done[name], "n_total": tree_totals[name]}
|
|
180
|
+
for name in tree_totals
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""On-disk UI trace: the WS envelope stream, split into runtime vs pipeline.
|
|
4
|
+
|
|
5
|
+
A replay directory looks like::
|
|
6
|
+
|
|
7
|
+
ui-trace/
|
|
8
|
+
manifest.json # kind, files[], runtime_digest
|
|
9
|
+
runtime.jsonl # native / spec / job (MAS telemetry)
|
|
10
|
+
pipeline/<artefact>.jsonl # one materialized artefact (chat, series, …)
|
|
11
|
+
|
|
12
|
+
``runtime.jsonl`` is the input log. Each ``pipeline/<artefact>.jsonl`` is the
|
|
13
|
+
realtime counterpart of a pipeline-step fingerprint: a content-addressed
|
|
14
|
+
snapshot keyed by ``hash(artefact, version, runtime_digest)``. Replay skips a
|
|
15
|
+
shard when that key still matches (interrupt/restart, or adding a new fold
|
|
16
|
+
that only misses its own file). ``files[]`` is the extension point.
|
|
17
|
+
|
|
18
|
+
The batch executor keeps the same contract on
|
|
19
|
+
``<output_dir>/.cache/<step>.fingerprint`` + ``artefacts/<aa>/<key>/``.
|
|
20
|
+
This tree is the WS/live view of those artefacts, not a second cache.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
KIND = "mas-lab.ui-trace"
|
|
31
|
+
VERSION = 1
|
|
32
|
+
ARTEFACT_VERSION = "2"
|
|
33
|
+
|
|
34
|
+
RUNTIME_PREFIXES = (
|
|
35
|
+
"native.",
|
|
36
|
+
"spec.",
|
|
37
|
+
"job.",
|
|
38
|
+
"command.",
|
|
39
|
+
"result.",
|
|
40
|
+
"control.",
|
|
41
|
+
"governance.",
|
|
42
|
+
"io.",
|
|
43
|
+
)
|
|
44
|
+
PIPELINE_PREFIXES = ("custom.", "series.", "artifact.", "metric.", "metrics.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def artefact_owner(topic: str) -> str:
|
|
48
|
+
name = str(topic or "")
|
|
49
|
+
parts = name.split(".")
|
|
50
|
+
if name.startswith(("custom.", "series.", "artifact.")) and len(parts) >= 2:
|
|
51
|
+
return ".".join(parts[:2])
|
|
52
|
+
if name.startswith("metric"):
|
|
53
|
+
return "metric"
|
|
54
|
+
return name or "pipeline"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def artefact_filename(owner: str) -> str:
|
|
58
|
+
safe = "".join(ch if ch.isalnum() or ch in ".-" else "." for ch in owner).strip(".")
|
|
59
|
+
return f"pipeline/{safe or 'pipeline'}.jsonl"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def envelopes_digest(rows: list[dict[str, Any]]) -> str:
|
|
63
|
+
digest = hashlib.sha256()
|
|
64
|
+
for row in rows:
|
|
65
|
+
payload = row.get("payload") if isinstance(row, dict) else row
|
|
66
|
+
digest.update(json.dumps(payload, sort_keys=True, default=str).encode("utf-8"))
|
|
67
|
+
digest.update(b"\n")
|
|
68
|
+
return digest.hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def artefact_key(owner: str, runtime_digest: str, *, version: str = ARTEFACT_VERSION) -> str:
|
|
72
|
+
blob = f"{owner}|{version}|{runtime_digest}".encode()
|
|
73
|
+
return hashlib.sha256(blob).hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def tree_of(topic: str) -> str:
|
|
77
|
+
name = str(topic or "")
|
|
78
|
+
if name.startswith(PIPELINE_PREFIXES):
|
|
79
|
+
return "pipeline"
|
|
80
|
+
if name.startswith(RUNTIME_PREFIXES):
|
|
81
|
+
return "runtime"
|
|
82
|
+
if name.startswith("view."):
|
|
83
|
+
return "control"
|
|
84
|
+
return "pipeline" if name else "runtime"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_ui_trace_dir(path: Path) -> bool:
|
|
88
|
+
manifest = path / "manifest.json"
|
|
89
|
+
if not manifest.is_file():
|
|
90
|
+
return False
|
|
91
|
+
try:
|
|
92
|
+
doc = json.loads(manifest.read_text(encoding="utf-8"))
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
return False
|
|
95
|
+
return isinstance(doc, dict) and doc.get("kind") == KIND
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
99
|
+
if not path.is_file():
|
|
100
|
+
return []
|
|
101
|
+
rows: list[dict[str, Any]] = []
|
|
102
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
103
|
+
line = line.strip()
|
|
104
|
+
if not line:
|
|
105
|
+
continue
|
|
106
|
+
row = json.loads(line)
|
|
107
|
+
if isinstance(row, dict):
|
|
108
|
+
rows.append(row)
|
|
109
|
+
return rows
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def split_envelopes(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
113
|
+
out: dict[str, list[dict[str, Any]]] = {"runtime": [], "pipeline": [], "control": []}
|
|
114
|
+
for row in rows:
|
|
115
|
+
tree = tree_of(str(row.get("topic") or ""))
|
|
116
|
+
out.setdefault(tree, []).append(row)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _stamp_rows(rows: list[dict[str, Any]], *, tree: str) -> list[dict[str, Any]]:
|
|
121
|
+
from mas.lab.controller.ui_stream.artefact_bind import link_for_topic
|
|
122
|
+
|
|
123
|
+
stamped: list[dict[str, Any]] = []
|
|
124
|
+
for row in rows:
|
|
125
|
+
item = dict(row)
|
|
126
|
+
topic = str(item.get("topic") or "")
|
|
127
|
+
link = link_for_topic(topic, item.get("ref") if isinstance(item.get("ref"), dict) else {}, complete=item.get("complete") if isinstance(item.get("complete"), bool) else None)
|
|
128
|
+
owner = link.name if tree == "pipeline" else "events"
|
|
129
|
+
item.setdefault("artefact", owner)
|
|
130
|
+
item.setdefault("stream", link.stream)
|
|
131
|
+
item.setdefault("uri", link.uri)
|
|
132
|
+
item.setdefault("path", link.path)
|
|
133
|
+
item.setdefault("inputs", list(link.inputs))
|
|
134
|
+
item.setdefault("outputs", list(link.outputs))
|
|
135
|
+
if item.get("complete") is None and link.complete is not None:
|
|
136
|
+
item["complete"] = link.complete
|
|
137
|
+
stamped.append(item)
|
|
138
|
+
return stamped
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
path.write_text(
|
|
144
|
+
"".join(json.dumps(row, ensure_ascii=False, default=str) + "\n" for row in rows),
|
|
145
|
+
encoding="utf-8",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def write_ui_trace(dest: Path, envelopes: list[dict[str, Any]], *, extra: dict[str, Any] | None = None) -> Path:
|
|
150
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
buckets = split_envelopes(envelopes)
|
|
152
|
+
files = []
|
|
153
|
+
from mas.lab.controller.ui_stream.artefact_bind import graph_from_steps, link_for, shard_is_complete
|
|
154
|
+
|
|
155
|
+
runtime = _stamp_rows(buckets.get("runtime") or [], tree="runtime")
|
|
156
|
+
runtime_digest = envelopes_digest(runtime)
|
|
157
|
+
extra_doc = dict(extra or {})
|
|
158
|
+
ref = extra_doc.get("ref") if isinstance(extra_doc.get("ref"), dict) else {}
|
|
159
|
+
runtime_link = link_for("events", ref, tree="runtime", digest=runtime_digest, complete=shard_is_complete(runtime, tree="runtime"))
|
|
160
|
+
_write_jsonl(dest / "runtime.jsonl", runtime)
|
|
161
|
+
files.append(
|
|
162
|
+
{
|
|
163
|
+
**runtime_link.as_dict(),
|
|
164
|
+
"path": "runtime.jsonl",
|
|
165
|
+
"n": len(runtime),
|
|
166
|
+
"digest": runtime_digest,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
by_owner: dict[str, list[dict[str, Any]]] = {}
|
|
170
|
+
for row in _stamp_rows(buckets.get("pipeline") or [], tree="pipeline"):
|
|
171
|
+
by_owner.setdefault(str(row.get("artefact") or "pipeline"), []).append(row)
|
|
172
|
+
for owner, rows in sorted(by_owner.items()):
|
|
173
|
+
rel = artefact_filename(owner)
|
|
174
|
+
_write_jsonl(dest / rel, rows)
|
|
175
|
+
complete = shard_is_complete(rows, tree="pipeline")
|
|
176
|
+
link = link_for(
|
|
177
|
+
owner,
|
|
178
|
+
ref,
|
|
179
|
+
tree="pipeline",
|
|
180
|
+
complete=complete,
|
|
181
|
+
digest=runtime_digest,
|
|
182
|
+
key=artefact_key(owner, runtime_digest),
|
|
183
|
+
)
|
|
184
|
+
files.append({**link.as_dict(), "path": rel, "n": len(rows), "input_digest": runtime_digest})
|
|
185
|
+
manifest = {
|
|
186
|
+
"kind": KIND,
|
|
187
|
+
"version": VERSION,
|
|
188
|
+
"runtime_digest": runtime_digest,
|
|
189
|
+
"graph": extra_doc.pop("graph", None) or graph_from_steps(extra_doc.pop("steps", None)),
|
|
190
|
+
"files": files,
|
|
191
|
+
**extra_doc,
|
|
192
|
+
}
|
|
193
|
+
(dest / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
194
|
+
return dest
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read_ui_trace(dest: Path, *, trees: tuple[str, ...] | None = None) -> dict[str, list[dict[str, Any]]]:
|
|
198
|
+
dest = dest.resolve()
|
|
199
|
+
if dest.is_file() and dest.name == "manifest.json":
|
|
200
|
+
dest = dest.parent
|
|
201
|
+
if not is_ui_trace_dir(dest):
|
|
202
|
+
raise FileNotFoundError(f"not a ui-trace directory: {dest}")
|
|
203
|
+
wanted = set(trees) if trees else {"runtime", "pipeline"}
|
|
204
|
+
manifest = json.loads((dest / "manifest.json").read_text(encoding="utf-8"))
|
|
205
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
206
|
+
for spec in manifest.get("files") or []:
|
|
207
|
+
tree = str(spec.get("tree") or "")
|
|
208
|
+
if tree not in wanted:
|
|
209
|
+
continue
|
|
210
|
+
rel = str(spec.get("path") or "")
|
|
211
|
+
if not rel:
|
|
212
|
+
continue
|
|
213
|
+
out.setdefault(tree, []).extend(load_jsonl(dest / rel))
|
|
214
|
+
for tree in wanted:
|
|
215
|
+
out.setdefault(tree, [])
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cached_pipeline_files(dest: Path, runtime_digest: str) -> list[dict[str, Any]]:
|
|
220
|
+
"""Pipeline shards whose node key still matches ``runtime_digest``."""
|
|
221
|
+
dest = dest.resolve()
|
|
222
|
+
if dest.is_file() and dest.name == "manifest.json":
|
|
223
|
+
dest = dest.parent
|
|
224
|
+
manifest = json.loads((dest / "manifest.json").read_text(encoding="utf-8"))
|
|
225
|
+
hits: list[dict[str, Any]] = []
|
|
226
|
+
for spec in manifest.get("files") or []:
|
|
227
|
+
if str(spec.get("tree") or "") != "pipeline":
|
|
228
|
+
continue
|
|
229
|
+
owner = str(spec.get("artefact") or "")
|
|
230
|
+
if not owner:
|
|
231
|
+
continue
|
|
232
|
+
if spec.get("complete") is not True:
|
|
233
|
+
continue
|
|
234
|
+
if spec.get("input_digest") != runtime_digest:
|
|
235
|
+
continue
|
|
236
|
+
if spec.get("key") != artefact_key(owner, runtime_digest):
|
|
237
|
+
continue
|
|
238
|
+
hits.append(spec)
|
|
239
|
+
return hits
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def discover_ui_traces(target: Path) -> list[tuple[str, Path]]:
|
|
243
|
+
"""Return (scenario, ui-trace dir) pairs under a lab or a single dir."""
|
|
244
|
+
target = target.expanduser().resolve()
|
|
245
|
+
if is_ui_trace_dir(target):
|
|
246
|
+
return [(target.name, target)]
|
|
247
|
+
if target.is_file():
|
|
248
|
+
return []
|
|
249
|
+
hits: list[tuple[str, Path]] = []
|
|
250
|
+
seen: set[Path] = set()
|
|
251
|
+
for manifest in sorted(target.rglob("manifest.json")):
|
|
252
|
+
parent = manifest.parent
|
|
253
|
+
if parent in seen or not is_ui_trace_dir(parent):
|
|
254
|
+
continue
|
|
255
|
+
seen.add(parent)
|
|
256
|
+
scenario = parent.parent.name if parent.name in {"ui-trace", "trace"} else parent.name
|
|
257
|
+
hits.append((scenario, parent))
|
|
258
|
+
return hits
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Multipart uploads referenced by opaque upload_id (never a client path)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Upload:
|
|
14
|
+
upload_id: str
|
|
15
|
+
name: str
|
|
16
|
+
size: int
|
|
17
|
+
path: Path
|
|
18
|
+
|
|
19
|
+
def public_dict(self) -> dict:
|
|
20
|
+
return {"upload_id": self.upload_id, "name": self.name, "size": self.size}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UploadStore:
|
|
24
|
+
def __init__(self, root: Path):
|
|
25
|
+
self.root = root
|
|
26
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
self._items: dict[str, Upload] = {}
|
|
28
|
+
|
|
29
|
+
def save(self, filename: str, data: bytes) -> Upload:
|
|
30
|
+
from mas.lab.controller.constants import MAX_UPLOAD_BYTES
|
|
31
|
+
|
|
32
|
+
if len(data) > MAX_UPLOAD_BYTES:
|
|
33
|
+
raise ValueError(f"upload exceeds {MAX_UPLOAD_BYTES} bytes")
|
|
34
|
+
upload_id = str(uuid.uuid4())
|
|
35
|
+
safe_name = Path(filename).name or "upload"
|
|
36
|
+
path = self.root / upload_id
|
|
37
|
+
path.write_bytes(data)
|
|
38
|
+
item = Upload(upload_id=upload_id, name=safe_name, size=len(data), path=path)
|
|
39
|
+
self._items[upload_id] = item
|
|
40
|
+
return item
|
|
41
|
+
|
|
42
|
+
def get(self, upload_id: str) -> Upload | None:
|
|
43
|
+
return self._items.get(upload_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_store: UploadStore | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_upload_store() -> UploadStore:
|
|
50
|
+
global _store
|
|
51
|
+
if _store is None:
|
|
52
|
+
from mas.lab.controller.config import controller_dir
|
|
53
|
+
|
|
54
|
+
_store = UploadStore(controller_dir() / "state" / "uploads")
|
|
55
|
+
return _store
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def init_upload_store(root: Path) -> UploadStore:
|
|
59
|
+
global _store
|
|
60
|
+
_store = UploadStore(root)
|
|
61
|
+
return _store
|