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