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,209 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Durable job store — SQLite (default) or ephemeral (no-op).
4
+
5
+ The store persists job metadata so that ``GET /api/jobs`` survives a
6
+ server restart. Stdout/stderr are *not* stored in the DB (they can be
7
+ multi-MB); instead they are teed to a log file whose path is kept in
8
+ the job record.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import sqlite3
16
+ from abc import ABC, abstractmethod
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _CREATE_TABLE = """\
23
+ CREATE TABLE IF NOT EXISTS jobs (
24
+ id TEXT PRIMARY KEY,
25
+ endpoint TEXT NOT NULL,
26
+ command TEXT NOT NULL DEFAULT '',
27
+ status TEXT NOT NULL,
28
+ created_at TEXT NOT NULL,
29
+ started_at TEXT,
30
+ finished_at TEXT,
31
+ pid INTEGER,
32
+ exit_code INTEGER,
33
+ error TEXT,
34
+ request_body TEXT NOT NULL DEFAULT '{}',
35
+ log_path TEXT
36
+ );
37
+ """
38
+
39
+
40
+ class JobStore(ABC):
41
+ """Minimal interface for job persistence."""
42
+
43
+ @abstractmethod
44
+ def save(self, job_dict: dict[str, Any]) -> None:
45
+ """Upsert a job record (called on every state transition)."""
46
+
47
+ @abstractmethod
48
+ def load_all(self) -> list[dict[str, Any]]:
49
+ """Return every persisted job as a dict."""
50
+
51
+ @abstractmethod
52
+ def get(self, job_id: str) -> dict[str, Any] | None:
53
+ """Return a single job by id, or ``None``."""
54
+
55
+ @abstractmethod
56
+ def delete(self, job_id: str) -> None:
57
+ """Remove a job record."""
58
+
59
+ @abstractmethod
60
+ def close(self) -> None:
61
+ """Release resources."""
62
+
63
+
64
+ class EphemeralJobStore(JobStore):
65
+ """No-op store — nothing is persisted. Used with ``--ephemeral``."""
66
+
67
+ def save(self, job_dict: dict[str, Any]) -> None:
68
+ pass
69
+
70
+ def load_all(self) -> list[dict[str, Any]]:
71
+ return []
72
+
73
+ def get(self, job_id: str) -> dict[str, Any] | None:
74
+ return None
75
+
76
+ def delete(self, job_id: str) -> None:
77
+ pass
78
+
79
+ def close(self) -> None:
80
+ pass
81
+
82
+
83
+ _COLUMNS = (
84
+ "id", "endpoint", "command", "status",
85
+ "created_at", "started_at", "finished_at",
86
+ "pid", "exit_code", "error", "request_body", "log_path",
87
+ )
88
+
89
+
90
+ class SqliteJobStore(JobStore):
91
+ """SQLite-backed durable store.
92
+
93
+ Parameters
94
+ ----------
95
+ db_path:
96
+ Filesystem path for the SQLite database file. Parent directories
97
+ are created automatically.
98
+ """
99
+
100
+ def __init__(self, db_path: Path) -> None:
101
+ db_path.parent.mkdir(parents=True, exist_ok=True)
102
+ self._db_path = db_path
103
+ self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
104
+ self._conn.execute("PRAGMA journal_mode=WAL")
105
+ self._conn.execute(_CREATE_TABLE)
106
+ self._conn.commit()
107
+ logger.info("Job store opened: %s", db_path)
108
+
109
+ def save(self, job_dict: dict[str, Any]) -> None:
110
+ request_body = job_dict.get("request_body")
111
+ if isinstance(request_body, dict):
112
+ request_body = json.dumps(request_body)
113
+ elif request_body is None:
114
+ request_body = "{}"
115
+
116
+ values = (
117
+ job_dict.get("id"),
118
+ job_dict.get("endpoint", ""),
119
+ job_dict.get("command", ""),
120
+ job_dict.get("status", "pending"),
121
+ job_dict.get("created_at", ""),
122
+ job_dict.get("started_at"),
123
+ job_dict.get("finished_at"),
124
+ job_dict.get("pid"),
125
+ job_dict.get("exit_code"),
126
+ job_dict.get("error"),
127
+ request_body,
128
+ job_dict.get("log_path"),
129
+ )
130
+
131
+ self._conn.execute(
132
+ "INSERT OR REPLACE INTO jobs "
133
+ "(id, endpoint, command, status, created_at, started_at, "
134
+ "finished_at, pid, exit_code, error, request_body, log_path) "
135
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
136
+ values,
137
+ )
138
+ self._conn.commit()
139
+
140
+ def load_all(self) -> list[dict[str, Any]]:
141
+ cursor = self._conn.execute(
142
+ f"SELECT {', '.join(_COLUMNS)} FROM jobs ORDER BY created_at DESC"
143
+ )
144
+ rows = cursor.fetchall()
145
+ return [self._row_to_dict(row) for row in rows]
146
+
147
+ def get(self, job_id: str) -> dict[str, Any] | None:
148
+ cursor = self._conn.execute(
149
+ f"SELECT {', '.join(_COLUMNS)} FROM jobs WHERE id = ?",
150
+ (job_id,),
151
+ )
152
+ row = cursor.fetchone()
153
+ return self._row_to_dict(row) if row else None
154
+
155
+ def delete(self, job_id: str) -> None:
156
+ self._conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
157
+ self._conn.commit()
158
+
159
+ def close(self) -> None:
160
+ self._conn.close()
161
+
162
+ @staticmethod
163
+ def _row_to_dict(row: tuple) -> dict[str, Any]:
164
+ d: dict[str, Any] = dict(zip(_COLUMNS, row, strict=True))
165
+ rb = d.get("request_body")
166
+ if isinstance(rb, str):
167
+ try:
168
+ d["request_body"] = json.loads(rb)
169
+ except json.JSONDecodeError:
170
+ d["request_body"] = {}
171
+ return d
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Module-level singleton
176
+ # ---------------------------------------------------------------------------
177
+
178
+ _store: JobStore | None = None
179
+
180
+
181
+ def get_job_store() -> JobStore:
182
+ """Return the active store. Raises if ``init_job_store`` hasn't been called."""
183
+ if _store is None:
184
+ return EphemeralJobStore()
185
+ return _store
186
+
187
+
188
+ def init_job_store(*, db_path: Path | None = None) -> JobStore:
189
+ """Initialise the module-level singleton.
190
+
191
+ Parameters
192
+ ----------
193
+ db_path:
194
+ Path to the SQLite file. ``None`` → ephemeral (no-op).
195
+ """
196
+ global _store
197
+ if _store is not None:
198
+ _store.close()
199
+ if db_path is None:
200
+ _store = EphemeralJobStore()
201
+ else:
202
+ _store = SqliteJobStore(db_path)
203
+ return _store
204
+
205
+
206
+ def set_job_store(store: JobStore | None) -> None:
207
+ """Override for tests."""
208
+ global _store
209
+ _store = store
@@ -0,0 +1,375 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Background job tracking and execution."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ import shutil
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from enum import Enum
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class JobStatus(str, Enum):
22
+ PENDING = "pending"
23
+ RUNNING = "running"
24
+ COMPLETED = "completed"
25
+ FAILED = "failed"
26
+ CANCELLED = "cancelled"
27
+ TIMEOUT = "timeout"
28
+ INTERRUPTED = "interrupted"
29
+
30
+
31
+ TERMINAL_STATUSES = {
32
+ JobStatus.COMPLETED,
33
+ JobStatus.FAILED,
34
+ JobStatus.CANCELLED,
35
+ JobStatus.TIMEOUT,
36
+ JobStatus.INTERRUPTED,
37
+ }
38
+
39
+
40
+ def _job_origin(request_body: dict | None) -> str | None:
41
+ body = request_body or {}
42
+ ref = body.get("demo_ui_ref")
43
+ if isinstance(ref, dict):
44
+ origin = ref.get("origin")
45
+ return str(origin) if origin else None
46
+ return None
47
+
48
+
49
+ @dataclass
50
+ class Job:
51
+ id: str
52
+ endpoint: str
53
+ command: str
54
+ status: JobStatus
55
+ created_at: str
56
+ started_at: Optional[str] = None
57
+ finished_at: Optional[str] = None
58
+ pid: Optional[int] = None
59
+ exit_code: Optional[int] = None
60
+ stdout: str = ""
61
+ stderr: str = ""
62
+ error: Optional[str] = None
63
+ response: str = ""
64
+ error_message: str = ""
65
+ error_detail: str = ""
66
+ session_id: str = ""
67
+ request_body: dict = field(default_factory=dict)
68
+ log_path: Optional[str] = None
69
+ _proc: Optional[asyncio.subprocess.Process] = field(default=None, repr=False)
70
+ _task: Optional[asyncio.Task] = field(default=None, repr=False)
71
+
72
+ def to_dict(self) -> dict:
73
+ stdout = self.stdout
74
+ if not stdout and self.log_path:
75
+ try:
76
+ stdout = Path(self.log_path).read_text(encoding="utf-8", errors="replace")
77
+ except OSError:
78
+ stdout = ""
79
+ return {
80
+ "id": self.id,
81
+ "endpoint": self.endpoint,
82
+ "command": self.command,
83
+ "status": self.status.value,
84
+ "created_at": self.created_at,
85
+ "started_at": self.started_at,
86
+ "finished_at": self.finished_at,
87
+ "pid": self.pid,
88
+ "exit_code": self.exit_code,
89
+ "stdout": stdout,
90
+ "stderr": self.stderr,
91
+ "error": self.error,
92
+ "response": self.response,
93
+ "error_message": self.error_message,
94
+ "error_detail": self.error_detail,
95
+ "session_id": self.session_id,
96
+ "request_body": self.request_body,
97
+ "log_path": self.log_path,
98
+ }
99
+
100
+ def to_summary(self) -> dict:
101
+ return {
102
+ "id": self.id,
103
+ "endpoint": self.endpoint,
104
+ "command": self.command,
105
+ "status": self.status.value,
106
+ "created_at": self.created_at,
107
+ "started_at": self.started_at,
108
+ "finished_at": self.finished_at,
109
+ "pid": self.pid,
110
+ "exit_code": self.exit_code,
111
+ "origin": _job_origin(self.request_body),
112
+ }
113
+
114
+
115
+ _jobs: dict[str, Job] = {}
116
+
117
+
118
+ def now_iso() -> str:
119
+ return datetime.now(timezone.utc).isoformat()
120
+
121
+
122
+ async def run_job(
123
+ job: Job,
124
+ cmd: list[str],
125
+ cwd: Path,
126
+ timeout: int,
127
+ env: dict,
128
+ cleanup_paths: list[Path] | None = None,
129
+ ) -> None:
130
+ """Background coroutine that executes the command and updates the job."""
131
+ job.status = JobStatus.RUNNING
132
+ job.started_at = now_iso()
133
+
134
+ try:
135
+ proc = await asyncio.create_subprocess_exec(
136
+ *cmd,
137
+ cwd=str(cwd),
138
+ stdout=asyncio.subprocess.PIPE,
139
+ stderr=asyncio.subprocess.PIPE,
140
+ env=env,
141
+ )
142
+ job.pid = proc.pid
143
+ job._proc = proc
144
+
145
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
146
+
147
+ job.exit_code = proc.returncode
148
+ job.stdout = stdout.decode(errors="replace")
149
+ job.stderr = stderr.decode(errors="replace")
150
+ job.status = JobStatus.COMPLETED if proc.returncode == 0 else JobStatus.FAILED
151
+ if proc.returncode != 0:
152
+ job.error = f"Command failed with exit code {proc.returncode}"
153
+
154
+ except asyncio.TimeoutError:
155
+ if job._proc:
156
+ job._proc.kill()
157
+ await job._proc.communicate()
158
+ job.status = JobStatus.TIMEOUT
159
+ job.error = f"Command timed out after {timeout}s"
160
+
161
+ except asyncio.CancelledError:
162
+ if job._proc and job._proc.returncode is None:
163
+ job._proc.kill()
164
+ await job._proc.communicate()
165
+ job.status = JobStatus.CANCELLED
166
+ job.error = "Job was cancelled"
167
+
168
+ except FileNotFoundError:
169
+ job.status = JobStatus.FAILED
170
+ job.error = f"Command not found: {cmd[0]}. Ensure mas-runtime is on PATH."
171
+
172
+ except Exception as exc:
173
+ job.status = JobStatus.FAILED
174
+ job.error = str(exc)
175
+
176
+ finally:
177
+ job.finished_at = now_iso()
178
+ job._proc = None
179
+ _persist(job)
180
+ if cleanup_paths:
181
+ for p in cleanup_paths:
182
+ if p.is_dir():
183
+ shutil.rmtree(p, ignore_errors=True)
184
+ else:
185
+ p.unlink(missing_ok=True)
186
+
187
+
188
+ async def run_agent_chat_job(
189
+ job: Job,
190
+ manifest_yaml: str,
191
+ query: str,
192
+ lib_dir: Path,
193
+ flavour: Optional[str],
194
+ session_id: str,
195
+ timeout: int,
196
+ ) -> None:
197
+ """Background coroutine: run one agent turn in-process (no subprocess)."""
198
+ from mas.lab.controller.agent_chat import run_agent_turn
199
+
200
+ job.status = JobStatus.RUNNING
201
+ job.started_at = now_iso()
202
+
203
+ try:
204
+ result = await asyncio.wait_for(
205
+ asyncio.to_thread(
206
+ run_agent_turn,
207
+ manifest_yaml,
208
+ query,
209
+ base_dir=lib_dir,
210
+ flavour=flavour,
211
+ session_id=session_id,
212
+ ),
213
+ timeout=timeout,
214
+ )
215
+ job.response = result.response
216
+ job.error_message = result.error_message
217
+ job.error_detail = result.error_detail
218
+ job.session_id = result.session_id
219
+
220
+ if result.status == "ok":
221
+ job.status = JobStatus.COMPLETED
222
+ job.exit_code = 0
223
+ else:
224
+ job.status = JobStatus.FAILED
225
+ job.exit_code = 1
226
+ job.error = result.error_message
227
+
228
+ except asyncio.TimeoutError:
229
+ job.status = JobStatus.TIMEOUT
230
+ job.error = f"Agent timed out after {timeout}s"
231
+ job.error_message = job.error
232
+
233
+ except asyncio.CancelledError:
234
+ job.status = JobStatus.CANCELLED
235
+ job.error = "Job was cancelled"
236
+ job.error_message = job.error
237
+
238
+ except Exception as exc:
239
+ job.status = JobStatus.FAILED
240
+ job.error = str(exc)
241
+ job.error_message = str(exc)
242
+
243
+ finally:
244
+ job.finished_at = now_iso()
245
+ _persist(job)
246
+
247
+
248
+ def submit_agent_chat_job(
249
+ endpoint: str,
250
+ manifest_yaml: str,
251
+ query: str,
252
+ lib_dir: Path,
253
+ flavour: Optional[str] = None,
254
+ session_id: Optional[str] = None,
255
+ timeout: int = 60,
256
+ request_body: dict | None = None,
257
+ ) -> Job:
258
+ """Create a chat job, launch in-process agent turn, return immediately."""
259
+ sid = session_id or str(uuid.uuid4())
260
+
261
+ job = Job(
262
+ id=str(uuid.uuid4()),
263
+ endpoint=endpoint,
264
+ command=f"agent-chat({lib_dir.name})",
265
+ status=JobStatus.PENDING,
266
+ created_at=now_iso(),
267
+ session_id=sid,
268
+ request_body=request_body or {},
269
+ )
270
+ _jobs[job.id] = job
271
+
272
+ task = asyncio.create_task(
273
+ run_agent_chat_job(
274
+ job, manifest_yaml, query, lib_dir, flavour, sid, timeout,
275
+ )
276
+ )
277
+ job._task = task
278
+
279
+ logger.info("Agent chat job %s submitted (session=%s)", job.id, sid)
280
+ _persist(job)
281
+ return job
282
+
283
+
284
+ def submit_job(
285
+ endpoint: str,
286
+ cmd: list[str],
287
+ cwd: Path,
288
+ timeout: int = 60,
289
+ env_override: dict[str, str] | None = None,
290
+ request_body: dict | None = None,
291
+ cleanup_paths: list[Path] | None = None,
292
+ ) -> Job:
293
+ """Create a job, launch it in the background, return immediately."""
294
+ env = os.environ.copy()
295
+ if env_override:
296
+ env.update(env_override)
297
+
298
+ job = Job(
299
+ id=str(uuid.uuid4()),
300
+ endpoint=endpoint,
301
+ command=" ".join(cmd),
302
+ status=JobStatus.PENDING,
303
+ created_at=now_iso(),
304
+ request_body=request_body or {},
305
+ )
306
+ _jobs[job.id] = job
307
+
308
+ task = asyncio.create_task(run_job(job, cmd, cwd, timeout, env, cleanup_paths))
309
+ job._task = task
310
+
311
+ logger.info("Job %s submitted: %s", job.id, job.command)
312
+ _persist(job)
313
+ return job
314
+
315
+
316
+ def get_job(job_id: str) -> Job | None:
317
+ return _jobs.get(job_id)
318
+
319
+
320
+ def _persist(job: Job) -> None:
321
+ try:
322
+ from mas.lab.controller.job_store import get_job_store
323
+
324
+ get_job_store().save(job.to_dict())
325
+ except Exception as exc:
326
+ logger.debug("job persist skipped: %s", exc)
327
+
328
+
329
+ def load_jobs_from_store() -> None:
330
+ """Hydrate the in-memory map from the durable store (controller restart)."""
331
+ from mas.lab.controller.job_store import get_job_store
332
+
333
+ for record in get_job_store().load_all():
334
+ status_raw = str(record.get("status") or "failed")
335
+ try:
336
+ status = JobStatus(status_raw)
337
+ except ValueError:
338
+ status = JobStatus.FAILED
339
+ job = Job(
340
+ id=str(record.get("id") or ""),
341
+ endpoint=str(record.get("endpoint") or ""),
342
+ command=str(record.get("command") or ""),
343
+ status=status,
344
+ created_at=str(record.get("created_at") or now_iso()),
345
+ started_at=record.get("started_at"),
346
+ finished_at=record.get("finished_at"),
347
+ pid=record.get("pid"),
348
+ exit_code=record.get("exit_code"),
349
+ error=record.get("error"),
350
+ request_body=record.get("request_body") or {},
351
+ log_path=record.get("log_path"),
352
+ )
353
+ if job.id:
354
+ _jobs[job.id] = job
355
+
356
+
357
+ def reconcile_jobs() -> None:
358
+ """After a restart, mark in-flight jobs interrupted unless results exist."""
359
+ for job in list(_jobs.values()):
360
+ if job.status in TERMINAL_STATUSES:
361
+ continue
362
+ workspace = (job.request_body or {}).get("workspace")
363
+ completed = False
364
+ if workspace:
365
+ results = Path(workspace) / "out" / "results"
366
+ if results.is_dir() and any(results.iterdir()):
367
+ completed = True
368
+ if completed:
369
+ job.status = JobStatus.COMPLETED
370
+ job.finished_at = now_iso()
371
+ else:
372
+ job.status = JobStatus.INTERRUPTED
373
+ job.error = "Job interrupted because the server restarted"
374
+ job.finished_at = now_iso()
375
+ _persist(job)