mas-lab-component-controller 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. mas/lab/controller/__init__.py +7 -0
  2. mas/lab/controller/__main__.py +5 -0
  3. mas/lab/controller/agent_chat.py +226 -0
  4. mas/lab/controller/api.py +364 -0
  5. mas/lab/controller/artifact_discovery.py +107 -0
  6. mas/lab/controller/artifacts.py +112 -0
  7. mas/lab/controller/cli.py +493 -0
  8. mas/lab/controller/client.py +271 -0
  9. mas/lab/controller/config.py +75 -0
  10. mas/lab/controller/constants.py +30 -0
  11. mas/lab/controller/daemon.py +190 -0
  12. mas/lab/controller/deps.py +172 -0
  13. mas/lab/controller/fastapi_app.py +166 -0
  14. mas/lab/controller/http_server.py +243 -0
  15. mas/lab/controller/io_capture.py +55 -0
  16. mas/lab/controller/item_service.py +625 -0
  17. mas/lab/controller/job_store.py +209 -0
  18. mas/lab/controller/jobs.py +375 -0
  19. mas/lab/controller/kinds.py +149 -0
  20. mas/lab/controller/lab_registry.py +1076 -0
  21. mas/lab/controller/manifest_layout.py +832 -0
  22. mas/lab/controller/manifest_store.py +214 -0
  23. mas/lab/controller/manifest_validation.py +90 -0
  24. mas/lab/controller/models.py +215 -0
  25. mas/lab/controller/path_safety.py +68 -0
  26. mas/lab/controller/pipeline_validation.py +79 -0
  27. mas/lab/controller/registry.py +83 -0
  28. mas/lab/controller/routes/__init__.py +82 -0
  29. mas/lab/controller/routes/_api.py +15 -0
  30. mas/lab/controller/routes/apps.py +190 -0
  31. mas/lab/controller/routes/artifacts.py +37 -0
  32. mas/lab/controller/routes/benchmark.py +307 -0
  33. mas/lab/controller/routes/choices.py +141 -0
  34. mas/lab/controller/routes/datasets.py +91 -0
  35. mas/lab/controller/routes/design_patterns.py +121 -0
  36. mas/lab/controller/routes/eval_output.py +31 -0
  37. mas/lab/controller/routes/experiments.py +194 -0
  38. mas/lab/controller/routes/health.py +178 -0
  39. mas/lab/controller/routes/items.py +187 -0
  40. mas/lab/controller/routes/jobs.py +107 -0
  41. mas/lab/controller/routes/libraries.py +144 -0
  42. mas/lab/controller/routes/overlays.py +164 -0
  43. mas/lab/controller/routes/pipelines.py +139 -0
  44. mas/lab/controller/routes/registry.py +40 -0
  45. mas/lab/controller/routes/run.py +215 -0
  46. mas/lab/controller/routes/runs.py +392 -0
  47. mas/lab/controller/routes/scopes.py +139 -0
  48. mas/lab/controller/routes/uploads.py +24 -0
  49. mas/lab/controller/routes/validate.py +36 -0
  50. mas/lab/controller/run_bridge.py +276 -0
  51. mas/lab/controller/run_store.py +190 -0
  52. mas/lab/controller/schema_registry.py +109 -0
  53. mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
  54. mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
  55. mas/lab/controller/scope_catalog.py +266 -0
  56. mas/lab/controller/scope_service.py +134 -0
  57. mas/lab/controller/sessions.py +63 -0
  58. mas/lab/controller/settings.py +65 -0
  59. mas/lab/controller/ui_stream/__init__.py +13 -0
  60. mas/lab/controller/ui_stream/app.py +2147 -0
  61. mas/lab/controller/ui_stream/artefact_bind.py +308 -0
  62. mas/lab/controller/ui_stream/auth.py +136 -0
  63. mas/lab/controller/ui_stream/bus.py +156 -0
  64. mas/lab/controller/ui_stream/export.py +400 -0
  65. mas/lab/controller/ui_stream/governance.py +141 -0
  66. mas/lab/controller/ui_stream/ingest.py +300 -0
  67. mas/lab/controller/ui_stream/inspect.py +55 -0
  68. mas/lab/controller/ui_stream/layouts.py +161 -0
  69. mas/lab/controller/ui_stream/metrics.py +274 -0
  70. mas/lab/controller/ui_stream/plot_folds.py +609 -0
  71. mas/lab/controller/ui_stream/plugin_registry.py +139 -0
  72. mas/lab/controller/ui_stream/refs.py +259 -0
  73. mas/lab/controller/ui_stream/replay.py +236 -0
  74. mas/lab/controller/ui_stream/replay_cli.py +707 -0
  75. mas/lab/controller/ui_stream/run_index.py +337 -0
  76. mas/lab/controller/ui_stream/spec_load.py +48 -0
  77. mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
  78. mas/lab/controller/ui_stream/stream.py +95 -0
  79. mas/lab/controller/ui_stream/streams.py +42 -0
  80. mas/lab/controller/ui_stream/timeline.py +183 -0
  81. mas/lab/controller/ui_stream/ui_trace.py +258 -0
  82. mas/lab/controller/uploads.py +61 -0
  83. mas/lab/controller/worker_model.py +137 -0
  84. mas/lab/controller/workers.py +296 -0
  85. mas/lab/controller/workspace_library.py +70 -0
  86. mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
  87. mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
  88. mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
  89. mas_lab_component_controller-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,139 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Scope listing — libraries and labs as first-class catalogs."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+ from mas.lab.controller.deps import store_from_request
11
+ from mas.lab.controller.item_service import ItemError, group_items
12
+ from mas.lab.controller.kinds import LAB_KINDS, LIBRARY_BASE_KINDS
13
+ from mas.lab.controller.scope_catalog import get_scope_catalog
14
+ from mas.lab.controller.scope_service import create_lab
15
+ from pydantic import BaseModel, Field
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ class CreateScopeRequest(BaseModel):
21
+ kind: str = "lab"
22
+ name: str
23
+ description: Optional[str] = ""
24
+ local_library: bool = Field(default=True)
25
+
26
+
27
+ @router.get("/api/scopes", tags=["Scopes"])
28
+ async def list_scopes(request: Request):
29
+ catalog = get_scope_catalog(store_from_request(request))
30
+ return {"scopes": [_public_scope(scope, counts=True) for scope in catalog.all()]}
31
+
32
+
33
+ @router.post("/api/scopes", tags=["Scopes"], status_code=201)
34
+ async def create_scope(req: CreateScopeRequest, request: Request):
35
+ if req.kind != "lab":
36
+ raise HTTPException(status_code=400, detail=f"cannot create scope kind {req.kind!r}")
37
+ store = store_from_request(request)
38
+ try:
39
+ scope = create_lab(
40
+ name=req.name,
41
+ description=req.description or "",
42
+ local_library=req.local_library,
43
+ store=store,
44
+ )
45
+ except ItemError as exc:
46
+ raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
47
+ return _public_scope(scope, counts=True)
48
+
49
+
50
+ @router.get("/api/scopes/{scope_id}", tags=["Scopes"])
51
+ async def get_scope(scope_id: str, request: Request):
52
+ catalog = get_scope_catalog(store_from_request(request))
53
+ try:
54
+ scope = catalog.get(scope_id)
55
+ except KeyError as exc:
56
+ raise HTTPException(status_code=404, detail=f"scope {scope_id!r} not found") from exc
57
+ return _public_scope(scope, counts=True)
58
+
59
+
60
+ @router.get("/api/scopes/{scope_id}/config", tags=["Scopes"])
61
+ async def scope_config(scope_id: str, request: Request):
62
+ """Infra / flavour / workspace YAML for the control panel (basename keys only)."""
63
+ catalog = get_scope_catalog(store_from_request(request))
64
+ try:
65
+ scope = catalog.get(scope_id)
66
+ except KeyError as exc:
67
+ raise HTTPException(status_code=404, detail=f"scope {scope_id!r} not found") from exc
68
+ return _public_config(scope)
69
+
70
+
71
+ @router.get("/api/scopes/{scope_id}/visible-libraries", tags=["Scopes"])
72
+ async def visible_libraries(scope_id: str, request: Request):
73
+ catalog = get_scope_catalog(store_from_request(request))
74
+ try:
75
+ libraries = catalog.visible_libraries(scope_id)
76
+ except KeyError as exc:
77
+ raise HTTPException(status_code=404, detail=f"scope {scope_id!r} not found") from exc
78
+ return {"libraries": [s.to_public() for s in libraries]}
79
+
80
+
81
+ def _public_scope(scope, *, counts: bool = False) -> dict:
82
+ payload = scope.to_public()
83
+ payload["kinds"] = _kinds_for(scope)
84
+ if not counts:
85
+ return payload
86
+ tallies: dict[str, int] = {}
87
+ for kind in payload["kinds"]:
88
+ try:
89
+ tallies[kind] = len(group_items(scope, kind))
90
+ except KeyError:
91
+ tallies[kind] = 0
92
+ payload["counts"] = tallies
93
+ return payload
94
+
95
+
96
+ def _kinds_for(scope) -> list[str]:
97
+ if scope.kinds:
98
+ kinds = list(scope.kinds)
99
+ elif scope.kind == "lab":
100
+ kinds = list(LAB_KINDS)
101
+ else:
102
+ kinds = list(LIBRARY_BASE_KINDS)
103
+ if scope.kind != "library":
104
+ return kinds
105
+ # Advertise experiments/pipelines when the library actually has them.
106
+ from mas.lab.controller.manifest_layout import layout_for
107
+
108
+ for extra in ("experiment", "pipeline"):
109
+ if extra in kinds:
110
+ continue
111
+ try:
112
+ if layout_for(extra).list_located(scope):
113
+ kinds.append(extra)
114
+ except Exception:
115
+ continue
116
+ return kinds
117
+
118
+
119
+ def _public_config(scope) -> dict[str, dict[str, str]]:
120
+ from pathlib import Path
121
+
122
+ from mas.lab.controller.lab_registry import LabRegistry
123
+ from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME
124
+
125
+ groups: dict[str, dict[str, str]] = {"infra": {}, "flavours": {}, "workspace": {}}
126
+ root = scope.root
127
+ for group, folder_name in (("infra", "infra"), ("flavours", "flavours")):
128
+ folder = root / folder_name
129
+ if not folder.is_dir():
130
+ continue
131
+ for path in sorted(folder.iterdir()):
132
+ if path.is_file() and path.suffix in {".yaml", ".yml"}:
133
+ groups[group][path.name] = path.read_text(encoding="utf-8")
134
+ ws = root / WORKSPACE_CONFIG_FILENAME
135
+ if ws.is_file():
136
+ groups["workspace"][ws.name] = ws.read_text(encoding="utf-8")
137
+ for key, content in LabRegistry.discover_bundled_infra().items():
138
+ groups["infra"].setdefault(Path(key).name, content)
139
+ return groups
@@ -0,0 +1,24 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Upload files and get an opaque upload_id back."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Annotated
8
+
9
+ from fastapi import APIRouter, File, HTTPException, UploadFile
10
+ from mas.lab.controller.uploads import get_upload_store
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.post("/api/uploads", tags=["Uploads"], status_code=201)
16
+ async def upload(file: Annotated[UploadFile, File()]):
17
+ data = await file.read()
18
+ if not data:
19
+ raise HTTPException(status_code=400, detail="empty upload")
20
+ try:
21
+ item = get_upload_store().save(file.filename or "upload", data)
22
+ except ValueError as exc:
23
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
24
+ return item.public_dict()
@@ -0,0 +1,36 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Manifest validation endpoints."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import yaml
8
+ from fastapi import APIRouter
9
+
10
+ from mas.lab.controller.manifest_validation import validate_manifest_yaml_content
11
+ from mas.lab.controller.models import ValidateRequest
12
+ from mas.lab.controller.routes._api import deps
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.post("/api/libraries/{library_name}/validate", tags=["Libraries"])
18
+ async def validate_manifest(library_name: str, req: ValidateRequest):
19
+ """Validate an agent or MAS manifest in-process. Resolves refs relative to the library dir."""
20
+ lib_dir = deps.get_library_path(library_name)
21
+ # Unsaved drafts use __MAS_NAME__ placeholder — skip file-based ref
22
+ # resolution since the agent files don't exist on disk yet.
23
+ resolve = "__MAS_NAME__" not in req.manifest_yaml
24
+
25
+ base_dir = lib_dir
26
+ try:
27
+ data = yaml.safe_load(req.manifest_yaml)
28
+ kind = (data.get("kind") or "").lower() if isinstance(data, dict) else ""
29
+ mas_name = data.get("metadata", {}).get("name", "") if isinstance(data, dict) else ""
30
+ if mas_name:
31
+ app_dir = lib_dir / "apps" / mas_name
32
+ base_dir = app_dir / "agents" if kind == "agent" else app_dir
33
+ except Exception:
34
+ pass
35
+
36
+ return validate_manifest_yaml_content(req.manifest_yaml, base_dir=base_dir, resolve_refs=resolve)
@@ -0,0 +1,276 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Mirror controller workers into the HTTP run store so CLI and UI share one catalog."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import shutil
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from mas.lab.controller.api import peek_controller_api
14
+ from mas.lab.controller.run_store import _now, get_run_store, new_run
15
+
16
+ _TERMINAL = {"completed", "failed", "cancelled"}
17
+ _dropped: set[str] = set()
18
+ _cleanup_by_run: dict[str, list[Path]] = {}
19
+ _lock = threading.RLock()
20
+
21
+ _WORKER_KIND_TO_ITEM = {
22
+ "benchmark": "experiment",
23
+ "experiment": "experiment",
24
+ "pipeline": "pipeline",
25
+ "application": "application",
26
+ }
27
+
28
+
29
+ def submit_run(api, req, path: Path, cwd: Path) -> dict[str, Any]:
30
+ """Submit the same worker the CLI uses for this kind."""
31
+ ref = {
32
+ "origin": "ui",
33
+ "scope": req.scope,
34
+ "kind": req.kind,
35
+ "item": req.item,
36
+ }
37
+ common = {
38
+ "cwd": str(cwd),
39
+ "scope": req.scope,
40
+ "item": req.item,
41
+ "demo_ui_ref": ref,
42
+ }
43
+ if req.kind == "experiment":
44
+ spec = {
45
+ **common,
46
+ "experiment_yaml": str(path),
47
+ "progress": True,
48
+ }
49
+ return api.submit_benchmark(spec)
50
+ if req.kind == "pipeline":
51
+ spec = {**common, "pipeline_yaml": str(path)}
52
+ return api.submit_pipeline(spec)
53
+ spec = {
54
+ **common,
55
+ "spec_path": str(path),
56
+ "prompt": req.query or "",
57
+ "flavour": req.bind,
58
+ "config": {"overlays": list(req.overlays or [])},
59
+ "command": f"mas-ctl run-mas {path}",
60
+ }
61
+ return api.submit_application(spec)
62
+
63
+
64
+ def apply_worker_to_run(run, detail: dict[str, Any], *, cwd: Path | None = None) -> None: # noqa: ARG001
65
+ status = str(detail.get("status") or "running")
66
+ run.status = {
67
+ "pending": "pending",
68
+ "running": "running",
69
+ "completed": "completed",
70
+ "failed": "failed",
71
+ "cancelled": "failed",
72
+ }.get(status, status)
73
+ run.exit_code = detail.get("exit_code")
74
+ if detail.get("started_at"):
75
+ run.started_at = detail["started_at"]
76
+ elif run.status == "running" and not run.started_at:
77
+ run.started_at = _now()
78
+ run.finished_at = detail.get("finished_at")
79
+ lines: list[str] = []
80
+ query = (run.request_summary or {}).get("query")
81
+ if query:
82
+ lines.append(str(query))
83
+ stdout = (detail.get("stdout") or "").rstrip()
84
+ stderr = (detail.get("stderr") or "").rstrip()
85
+ error = (detail.get("error") or "").rstrip()
86
+ if stdout:
87
+ lines.extend(stdout.splitlines())
88
+ if stderr:
89
+ lines.extend(stderr.splitlines())
90
+ if error:
91
+ lines.append(error)
92
+ progress = progress_line(detail.get("request_body") or {})
93
+ if progress:
94
+ lines = [line for line in lines if not line.startswith("ok=")]
95
+ lines.append(progress)
96
+ run.log_lines = lines
97
+ spec = detail.get("request_body") or {}
98
+ output_dir = spec.get("output_dir")
99
+ if output_dir:
100
+ run.result_dir = str(output_dir)
101
+
102
+
103
+ def progress_line(spec: dict[str, Any]) -> str | None:
104
+ """Same tally ``mas-lab benchmark follow`` prints from ``run_info.json``."""
105
+ out = spec.get("output_dir")
106
+ if not out:
107
+ yaml_path = spec.get("experiment_yaml")
108
+ if yaml_path:
109
+ out = str(Path(yaml_path).parent / "output")
110
+ if not out:
111
+ return None
112
+ root = Path(out)
113
+ if not root.is_dir():
114
+ return None
115
+ counts: dict[str, int] = {}
116
+ for path in root.rglob("run_info.json"):
117
+ try:
118
+ info = json.loads(path.read_text(encoding="utf-8"))
119
+ status = str(info.get("status", "unknown"))
120
+ except Exception:
121
+ status = "unknown"
122
+ counts[status] = counts.get(status, 0) + 1
123
+ if not counts:
124
+ return None
125
+ done = counts.get("ok", 0)
126
+ err = counts.get("error", 0)
127
+ return f"ok={done} err={err} done={done + err}"
128
+
129
+
130
+ def locate_scope_item(path: Path, store, kind: str) -> tuple[str, str]:
131
+ from mas.lab.controller.manifest_layout import layout_for
132
+ from mas.lab.controller.scope_catalog import get_scope_catalog
133
+
134
+ try:
135
+ resolved = path.resolve()
136
+ except OSError:
137
+ resolved = path
138
+ try:
139
+ catalog = get_scope_catalog(store)
140
+ layout = layout_for(kind)
141
+ except Exception:
142
+ return "", path.stem
143
+ for scope in catalog.all():
144
+ if kind not in (scope.kinds or []):
145
+ continue
146
+ try:
147
+ located = layout.list_located(scope)
148
+ except Exception:
149
+ continue
150
+ for item in located:
151
+ try:
152
+ if item.manifest_path.resolve() == resolved:
153
+ return scope.id, item.item_id
154
+ except OSError:
155
+ continue
156
+ try:
157
+ resolved.relative_to(scope.root.resolve())
158
+ return scope.id, path.stem
159
+ except ValueError:
160
+ continue
161
+ return "", path.stem
162
+
163
+
164
+ def run_from_worker(detail: dict[str, Any], store) -> Any:
165
+ spec = detail.get("request_body") or {}
166
+ worker_kind = str(detail.get("kind") or spec.get("kind") or "")
167
+ kind = _WORKER_KIND_TO_ITEM.get(worker_kind, worker_kind or "experiment")
168
+ scope = str(spec.get("scope") or "")
169
+ item = str(spec.get("item") or "")
170
+ ref = spec.get("demo_ui_ref") if isinstance(spec.get("demo_ui_ref"), dict) else {}
171
+ if not scope:
172
+ scope = str(ref.get("scope") or "")
173
+ if not item:
174
+ item = str(ref.get("item") or "")
175
+ if ref.get("kind"):
176
+ kind = str(ref["kind"])
177
+ path_key = {
178
+ "experiment": "experiment_yaml",
179
+ "pipeline": "pipeline_yaml",
180
+ "application": "spec_path",
181
+ }.get(kind)
182
+ path = Path(spec[path_key]) if path_key and spec.get(path_key) else None
183
+ if (not scope or not item) and path is not None:
184
+ found_scope, found_item = locate_scope_item(path, store, kind)
185
+ scope = scope or found_scope
186
+ item = item or found_item
187
+ if not item:
188
+ item = path.stem if path is not None else (detail.get("id") or "run")
189
+ if not scope:
190
+ scope = "library:unknown"
191
+ source = "ui" if ref.get("origin") == "ui" else "cli"
192
+ run = new_run(
193
+ scope=scope,
194
+ kind=kind,
195
+ item=item,
196
+ request_summary={
197
+ "source": source,
198
+ "command": detail.get("command") or "",
199
+ "action": "run",
200
+ },
201
+ )
202
+ worker_id = detail.get("id")
203
+ if worker_id:
204
+ run.id = str(worker_id)
205
+ run.job_id = str(worker_id)
206
+ apply_worker_to_run(run, detail, cwd=Path(spec["cwd"]) if spec.get("cwd") else None)
207
+ return run
208
+
209
+
210
+ def refresh_run(run) -> None:
211
+ api = peek_controller_api()
212
+ if api is None or not run.job_id:
213
+ return
214
+ detail = api.get_worker(run.job_id)
215
+ if detail is None:
216
+ return
217
+ cwd = None
218
+ spec = detail.get("request_body") or {}
219
+ if spec.get("cwd"):
220
+ cwd = Path(spec["cwd"])
221
+ apply_worker_to_run(run, detail, cwd=cwd)
222
+ if run.status not in {"pending", "running"}:
223
+ from mas.lab.controller.routes.runs import _attach_artifacts
224
+
225
+ _attach_artifacts(run, cwd)
226
+ _finish_cleanup(run.id)
227
+
228
+
229
+ def sync_workers(store) -> None:
230
+ api = peek_controller_api()
231
+ if api is None:
232
+ return
233
+ runs = get_run_store()
234
+ with _lock:
235
+ dropped = set(_dropped)
236
+ by_job = {row.job_id: row for row in runs.list() if row.job_id}
237
+ for summary in api.list_workers():
238
+ worker_id = summary.get("id")
239
+ if not worker_id or worker_id in dropped:
240
+ continue
241
+ detail = api.get_worker(worker_id) or summary
242
+ # ``list_workers`` summaries omit kind/spec; prefer the detail record.
243
+ if "kind" not in detail and "kind" in summary:
244
+ detail = {**detail, "kind": summary["kind"]}
245
+ run = by_job.get(worker_id)
246
+ if run is None:
247
+ run = run_from_worker(detail, store)
248
+ runs.save(run)
249
+ by_job[worker_id] = run
250
+ else:
251
+ refresh_run(run)
252
+ runs.save(run)
253
+
254
+
255
+ def register_cleanup(run_id: str, paths: list[Path]) -> None:
256
+ if paths:
257
+ with _lock:
258
+ _cleanup_by_run[run_id] = list(paths)
259
+
260
+
261
+ def _finish_cleanup(run_id: str) -> None:
262
+ with _lock:
263
+ paths = _cleanup_by_run.pop(run_id, [])
264
+ for path in paths:
265
+ shutil.rmtree(path, ignore_errors=True)
266
+
267
+
268
+ def forget_worker(worker_id: str) -> None:
269
+ with _lock:
270
+ _dropped.add(worker_id)
271
+
272
+
273
+ def reset_follow_state() -> None:
274
+ with _lock:
275
+ _dropped.clear()
276
+ _cleanup_by_run.clear()
@@ -0,0 +1,190 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Persisted runs (job store rebuilt on the run/artifact model)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import sqlite3
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _CREATE = """\
19
+ CREATE TABLE IF NOT EXISTS runs (
20
+ id TEXT PRIMARY KEY,
21
+ scope TEXT NOT NULL,
22
+ kind TEXT NOT NULL,
23
+ item TEXT NOT NULL,
24
+ status TEXT NOT NULL,
25
+ started_at TEXT,
26
+ finished_at TEXT,
27
+ created_at TEXT NOT NULL,
28
+ exit_code INTEGER,
29
+ request_summary TEXT NOT NULL DEFAULT '{}',
30
+ job_id TEXT,
31
+ result_dir TEXT
32
+ );
33
+ """
34
+
35
+
36
+ def _now() -> str:
37
+ return datetime.now(timezone.utc).isoformat()
38
+
39
+
40
+ @dataclass
41
+ class Run:
42
+ id: str
43
+ scope: str
44
+ kind: str
45
+ item: str
46
+ status: str
47
+ created_at: str
48
+ started_at: Optional[str] = None
49
+ finished_at: Optional[str] = None
50
+ exit_code: Optional[int] = None
51
+ request_summary: dict[str, Any] = field(default_factory=dict)
52
+ job_id: Optional[str] = None
53
+ result_dir: Optional[str] = None
54
+ log_lines: list[str] = field(default_factory=list)
55
+
56
+ def to_public(self) -> dict[str, Any]:
57
+ return {
58
+ "id": self.id,
59
+ "scope": self.scope,
60
+ "kind": self.kind,
61
+ "item": self.item,
62
+ "status": self.status,
63
+ "created_at": self.created_at,
64
+ "started_at": self.started_at,
65
+ "finished_at": self.finished_at,
66
+ "exit_code": self.exit_code,
67
+ "request_summary": self.request_summary,
68
+ "job_id": self.job_id,
69
+ "log": "\n".join(self.log_lines),
70
+ }
71
+
72
+
73
+ class RunStore:
74
+ def __init__(self, db_path: Path | None = None):
75
+ self._memory: dict[str, Run] = {}
76
+ self._conn: sqlite3.Connection | None = None
77
+ if db_path is not None:
78
+ db_path.parent.mkdir(parents=True, exist_ok=True)
79
+ self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
80
+ self._conn.execute("PRAGMA journal_mode=WAL")
81
+ self._conn.execute(_CREATE)
82
+ self._conn.commit()
83
+ self._load()
84
+
85
+ def _load(self) -> None:
86
+ if self._conn is None:
87
+ return
88
+ rows = self._conn.execute(
89
+ "SELECT id, scope, kind, item, status, started_at, finished_at, "
90
+ "created_at, exit_code, request_summary, job_id, result_dir FROM runs"
91
+ ).fetchall()
92
+ for row in rows:
93
+ summary = row[9]
94
+ try:
95
+ summary_obj = json.loads(summary) if summary else {}
96
+ except json.JSONDecodeError:
97
+ summary_obj = {}
98
+ run = Run(
99
+ id=row[0],
100
+ scope=row[1],
101
+ kind=row[2],
102
+ item=row[3],
103
+ status=row[4],
104
+ started_at=row[5],
105
+ finished_at=row[6],
106
+ created_at=row[7],
107
+ exit_code=row[8],
108
+ request_summary=summary_obj,
109
+ job_id=row[10],
110
+ result_dir=row[11],
111
+ )
112
+ self._memory[run.id] = run
113
+
114
+ def save(self, run: Run) -> None:
115
+ self._memory[run.id] = run
116
+ if self._conn is None:
117
+ return
118
+ self._conn.execute(
119
+ "INSERT OR REPLACE INTO runs "
120
+ "(id, scope, kind, item, status, started_at, finished_at, created_at, "
121
+ "exit_code, request_summary, job_id, result_dir) "
122
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
123
+ (
124
+ run.id,
125
+ run.scope,
126
+ run.kind,
127
+ run.item,
128
+ run.status,
129
+ run.started_at,
130
+ run.finished_at,
131
+ run.created_at,
132
+ run.exit_code,
133
+ json.dumps(run.request_summary),
134
+ run.job_id,
135
+ run.result_dir,
136
+ ),
137
+ )
138
+ self._conn.commit()
139
+
140
+ def get(self, run_id: str) -> Run | None:
141
+ return self._memory.get(run_id)
142
+
143
+ def list(self) -> list[Run]:
144
+ return sorted(self._memory.values(), key=lambda r: r.created_at, reverse=True)
145
+
146
+ def delete(self, run_id: str) -> None:
147
+ self._memory.pop(run_id, None)
148
+ if self._conn is not None:
149
+ self._conn.execute("DELETE FROM runs WHERE id = ?", (run_id,))
150
+ self._conn.commit()
151
+
152
+ def close(self) -> None:
153
+ if self._conn is not None:
154
+ self._conn.close()
155
+ self._conn = None
156
+
157
+
158
+ def new_run(*, scope: str, kind: str, item: str, request_summary: dict | None = None) -> Run:
159
+ return Run(
160
+ id=str(uuid.uuid4()),
161
+ scope=scope,
162
+ kind=kind,
163
+ item=item,
164
+ status="pending",
165
+ created_at=_now(),
166
+ request_summary=request_summary or {},
167
+ )
168
+
169
+
170
+ _store: RunStore | None = None
171
+
172
+
173
+ def get_run_store() -> RunStore:
174
+ global _store
175
+ if _store is None:
176
+ _store = RunStore()
177
+ return _store
178
+
179
+
180
+ def init_run_store(db_path: Path | None) -> RunStore:
181
+ global _store
182
+ if _store is not None:
183
+ _store.close()
184
+ _store = RunStore(db_path)
185
+ return _store
186
+
187
+
188
+ def set_run_store(store: RunStore | None) -> None:
189
+ global _store
190
+ _store = store