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