caddforge-gui 0.6.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.
- caddforge_gui/__init__.py +3 -0
- caddforge_gui/app.py +644 -0
- caddforge_gui/artifacts.py +159 -0
- caddforge_gui/config.py +94 -0
- caddforge_gui/plugin_runtime.py +163 -0
- caddforge_gui/static/THIRD_PARTY_LICENSES/NOTICE.txt +18 -0
- caddforge_gui/static/THIRD_PARTY_LICENSES/React-MIT.txt +21 -0
- caddforge_gui/static/assets/index-BTgKXQul.css +1 -0
- caddforge_gui/static/assets/index-KWr4_uhS.js +42 -0
- caddforge_gui/static/assets/molstar_viewer.js +508 -0
- caddforge_gui/static/assets/plugin_uikit.js +500 -0
- caddforge_gui/static/index.html +16 -0
- caddforge_gui-0.6.0.dist-info/METADATA +121 -0
- caddforge_gui-0.6.0.dist-info/RECORD +17 -0
- caddforge_gui-0.6.0.dist-info/WHEEL +4 -0
- caddforge_gui-0.6.0.dist-info/entry_points.txt +2 -0
- caddforge_gui-0.6.0.dist-info/licenses/LICENSE +21 -0
caddforge_gui/app.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""FastAPI app for the CADDForge GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, AsyncGenerator, Iterable
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
|
12
|
+
import typer
|
|
13
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
|
|
17
|
+
from caddforge.cli.main import _restart_recorded_job
|
|
18
|
+
from caddforge.core.job import Job, JobStatus
|
|
19
|
+
from caddforge.core.backends.slurm import SlurmBackend, SlurmJobHandle
|
|
20
|
+
from caddforge.core.job_registry import get_job, initialize_registry, list_jobs, record_job
|
|
21
|
+
from caddforge.core.status_refresh import refresh_job_status
|
|
22
|
+
|
|
23
|
+
try: # Backward-compatible import if core doesn't have delete_job yet.
|
|
24
|
+
from caddforge.core.job_registry import delete_job
|
|
25
|
+
except ImportError: # pragma: no cover - for older core installs
|
|
26
|
+
def delete_job(job_id: str, registry_path: Path | None = None) -> None:
|
|
27
|
+
import sqlite3
|
|
28
|
+
|
|
29
|
+
registry = registry_path or Path("runs") / "job_registry.sqlite"
|
|
30
|
+
with sqlite3.connect(registry) as conn:
|
|
31
|
+
conn.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
|
|
32
|
+
conn.commit()
|
|
33
|
+
from caddforge.gui import discover_gui_extensions
|
|
34
|
+
|
|
35
|
+
from .artifacts import collect_artifacts, resolve_artifact_path, stream_log_events
|
|
36
|
+
from .config import GuiConfig, apply_env_overrides, load_config
|
|
37
|
+
|
|
38
|
+
MAX_INLINE_RESULTS_BYTES = 5 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_config(
|
|
42
|
+
*,
|
|
43
|
+
config_path: Path | None = None,
|
|
44
|
+
host: str | None = None,
|
|
45
|
+
port: int | None = None,
|
|
46
|
+
base_path: str | None = None,
|
|
47
|
+
cors_origins: list[str] | None = None,
|
|
48
|
+
auth_mode: str | None = None,
|
|
49
|
+
auth_token: str | None = None,
|
|
50
|
+
runs_root: Path | None = None,
|
|
51
|
+
registry_path: Path | None = None,
|
|
52
|
+
) -> GuiConfig:
|
|
53
|
+
config = load_config(config_path)
|
|
54
|
+
config = apply_env_overrides(config)
|
|
55
|
+
if runs_root and registry_path is None:
|
|
56
|
+
registry_path = Path(runs_root) / "job_registry.sqlite"
|
|
57
|
+
return config.with_overrides(
|
|
58
|
+
host=host,
|
|
59
|
+
port=port,
|
|
60
|
+
base_path=base_path,
|
|
61
|
+
cors_origins=cors_origins if cors_origins else None,
|
|
62
|
+
auth_mode=auth_mode,
|
|
63
|
+
auth_token=auth_token,
|
|
64
|
+
runs_root=runs_root,
|
|
65
|
+
registry_path=registry_path,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _auth_dependency(config: GuiConfig):
|
|
70
|
+
def _check(request: Request) -> None:
|
|
71
|
+
if config.auth_mode == "none":
|
|
72
|
+
return
|
|
73
|
+
if config.auth_mode == "token":
|
|
74
|
+
token = (request.headers.get("Authorization") or "").replace("Bearer ", "")
|
|
75
|
+
if not token or token != config.auth_token:
|
|
76
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
77
|
+
return
|
|
78
|
+
raise HTTPException(status_code=500, detail="Unsupported auth mode.")
|
|
79
|
+
|
|
80
|
+
return _check
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _job_to_summary(job: Job) -> dict[str, Any]:
|
|
84
|
+
job_name = (job.metadata or {}).get("job_name") if job.metadata else None
|
|
85
|
+
return {
|
|
86
|
+
"job_id": job.id,
|
|
87
|
+
"job_name": job_name,
|
|
88
|
+
"tool_name": job.tool_name,
|
|
89
|
+
"plugin_section": job.metadata.get("section") if job.metadata else None,
|
|
90
|
+
"backend": job.backend,
|
|
91
|
+
"status": job.status.value,
|
|
92
|
+
"created_at": job.created_at.isoformat(),
|
|
93
|
+
"started_at": job.started_at.isoformat() if job.started_at else None,
|
|
94
|
+
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
|
|
95
|
+
"run_directory": str(job.run_directory) if job.run_directory else None,
|
|
96
|
+
"metadata": job.metadata,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load_results(run_directory: Path | None) -> dict[str, Any] | None:
|
|
101
|
+
if not run_directory:
|
|
102
|
+
return None
|
|
103
|
+
results_path = run_directory / "results" / "results.json"
|
|
104
|
+
if not results_path.exists():
|
|
105
|
+
return None
|
|
106
|
+
if results_path.stat().st_size > MAX_INLINE_RESULTS_BYTES:
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(results_path.read_text(encoding="utf-8"))
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _results_metadata(run_directory: Path | None) -> dict[str, Any]:
|
|
115
|
+
if not run_directory:
|
|
116
|
+
return {"available": False, "size_bytes": 0, "inline_loaded": False}
|
|
117
|
+
results_path = run_directory / "results" / "results.json"
|
|
118
|
+
if not results_path.exists():
|
|
119
|
+
return {"available": False, "size_bytes": 0, "inline_loaded": False}
|
|
120
|
+
size_bytes = results_path.stat().st_size
|
|
121
|
+
return {
|
|
122
|
+
"available": True,
|
|
123
|
+
"size_bytes": size_bytes,
|
|
124
|
+
"inline_loaded": size_bytes <= MAX_INLINE_RESULTS_BYTES,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_artifact_payload(job_id: str, run_directory: Path | None) -> list[dict[str, Any]]:
|
|
129
|
+
return _build_artifact_payload_limited(job_id, run_directory, include_tool_specific=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _build_artifact_payload_limited(
|
|
133
|
+
job_id: str,
|
|
134
|
+
run_directory: Path | None,
|
|
135
|
+
*,
|
|
136
|
+
include_tool_specific: bool,
|
|
137
|
+
max_tool_specific_files: int = 200,
|
|
138
|
+
) -> list[dict[str, Any]]:
|
|
139
|
+
if not run_directory:
|
|
140
|
+
return []
|
|
141
|
+
artifacts = collect_artifacts(
|
|
142
|
+
run_directory,
|
|
143
|
+
include_tool_specific=include_tool_specific,
|
|
144
|
+
max_tool_specific_files=max_tool_specific_files,
|
|
145
|
+
)
|
|
146
|
+
return [artifact.to_dict(job_id) for artifact in artifacts]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _resolve_run_directory(job: Job, runs_root: Path) -> Path | None:
|
|
150
|
+
if job.run_directory:
|
|
151
|
+
return Path(job.run_directory)
|
|
152
|
+
if not runs_root.exists():
|
|
153
|
+
return None
|
|
154
|
+
matches = list(runs_root.glob(f"*/{job.id}"))
|
|
155
|
+
if matches:
|
|
156
|
+
return matches[0]
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _delete_run_directory(run_directory: Path | None) -> None:
|
|
161
|
+
if not run_directory or not run_directory.exists():
|
|
162
|
+
return
|
|
163
|
+
import shutil
|
|
164
|
+
|
|
165
|
+
shutil.rmtree(run_directory)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _submission_cwd_from_job(job: Job) -> Path | None:
|
|
169
|
+
if not job.metadata:
|
|
170
|
+
return None
|
|
171
|
+
submission_cwd = job.metadata.get("submission_cwd")
|
|
172
|
+
if not isinstance(submission_cwd, str) or not submission_cwd.strip():
|
|
173
|
+
return None
|
|
174
|
+
return Path(submission_cwd).expanduser().resolve()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _list_jobs(registry_path: Path, statuses: list[JobStatus] | None) -> list[Job]:
|
|
178
|
+
initialize_registry(registry_path)
|
|
179
|
+
return list(list_jobs(registry_path=registry_path, statuses=statuses))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _filter_jobs(
|
|
183
|
+
jobs: Iterable[Job], *, search: str | None, status: list[str] | None
|
|
184
|
+
) -> list[Job]:
|
|
185
|
+
filtered = []
|
|
186
|
+
for job in jobs:
|
|
187
|
+
if status and job.status.value not in status:
|
|
188
|
+
continue
|
|
189
|
+
if search:
|
|
190
|
+
metadata = job.metadata or {}
|
|
191
|
+
haystack = " ".join(
|
|
192
|
+
str(value or "")
|
|
193
|
+
for value in (
|
|
194
|
+
job.id,
|
|
195
|
+
metadata.get("job_name"),
|
|
196
|
+
job.tool_name,
|
|
197
|
+
metadata.get("section"),
|
|
198
|
+
job.backend,
|
|
199
|
+
)
|
|
200
|
+
).lower()
|
|
201
|
+
if search.lower() not in haystack:
|
|
202
|
+
continue
|
|
203
|
+
filtered.append(job)
|
|
204
|
+
return filtered
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _jobs_response(
|
|
208
|
+
registry_path: Path,
|
|
209
|
+
*,
|
|
210
|
+
status: list[str] | None,
|
|
211
|
+
search: str | None,
|
|
212
|
+
limit: int,
|
|
213
|
+
offset: int,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
items = _filter_jobs(_list_jobs(registry_path, None), search=search, status=status)
|
|
216
|
+
total = len(items)
|
|
217
|
+
sliced = items[offset : offset + limit]
|
|
218
|
+
return {
|
|
219
|
+
"items": [_job_to_summary(job) for job in sliced],
|
|
220
|
+
"total": total,
|
|
221
|
+
"limit": limit,
|
|
222
|
+
"offset": offset,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _dashboard_totals(jobs: list[Job]) -> dict[str, int]:
|
|
227
|
+
now = datetime.now().astimezone()
|
|
228
|
+
today = now.date()
|
|
229
|
+
last_24h = now - timedelta(hours=24)
|
|
230
|
+
|
|
231
|
+
active_statuses = {JobStatus.PENDING, JobStatus.RUNNING}
|
|
232
|
+
return {
|
|
233
|
+
"total_jobs": len(jobs),
|
|
234
|
+
"active_jobs": sum(1 for job in jobs if job.status in active_statuses),
|
|
235
|
+
"running_jobs": sum(1 for job in jobs if job.status == JobStatus.RUNNING),
|
|
236
|
+
"pending_jobs": sum(1 for job in jobs if job.status == JobStatus.PENDING),
|
|
237
|
+
"completed_today": sum(
|
|
238
|
+
1
|
|
239
|
+
for job in jobs
|
|
240
|
+
if job.status == JobStatus.COMPLETED
|
|
241
|
+
and job.finished_at
|
|
242
|
+
and job.finished_at.astimezone().date() == today
|
|
243
|
+
),
|
|
244
|
+
"failed_today": sum(
|
|
245
|
+
1
|
|
246
|
+
for job in jobs
|
|
247
|
+
if job.status == JobStatus.FAILED
|
|
248
|
+
and (job.finished_at or job.created_at).astimezone().date() == today
|
|
249
|
+
),
|
|
250
|
+
"created_last_24h": sum(1 for job in jobs if job.created_at >= last_24h),
|
|
251
|
+
"tools_seen": len({job.tool_name for job in jobs}),
|
|
252
|
+
"backends_seen": len({job.backend for job in jobs}),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _dashboard_summary(jobs: list[Job]) -> dict[str, Any]:
|
|
257
|
+
by_status: dict[str, int] = {}
|
|
258
|
+
by_backend: dict[str, int] = {}
|
|
259
|
+
by_tool: dict[str, int] = {}
|
|
260
|
+
for job in jobs:
|
|
261
|
+
by_status[job.status.value] = by_status.get(job.status.value, 0) + 1
|
|
262
|
+
by_backend[job.backend] = by_backend.get(job.backend, 0) + 1
|
|
263
|
+
by_tool[job.tool_name] = by_tool.get(job.tool_name, 0) + 1
|
|
264
|
+
|
|
265
|
+
active_statuses = {JobStatus.PENDING, JobStatus.RUNNING}
|
|
266
|
+
active_jobs = [job for job in jobs if job.status in active_statuses][:6]
|
|
267
|
+
recent_failures = [job for job in jobs if job.status == JobStatus.FAILED][:5]
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"updated_at": datetime.now().astimezone().isoformat(),
|
|
271
|
+
"totals": _dashboard_totals(jobs),
|
|
272
|
+
"status": by_status,
|
|
273
|
+
"backend": by_backend,
|
|
274
|
+
"tool": by_tool,
|
|
275
|
+
"active_jobs": [_job_to_summary(job) for job in active_jobs],
|
|
276
|
+
"recent_jobs": [_job_to_summary(job) for job in jobs[:8]],
|
|
277
|
+
"recent_failures": [_job_to_summary(job) for job in recent_failures],
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def _status_stream(registry_path: Path, interval: float) -> AsyncGenerator[str, None]:
|
|
282
|
+
last_payload: dict[str, str] = {}
|
|
283
|
+
while True:
|
|
284
|
+
jobs = list_jobs(registry_path=registry_path)
|
|
285
|
+
payload = {job.id: job.status.value for job in jobs}
|
|
286
|
+
if payload != last_payload:
|
|
287
|
+
last_payload = payload
|
|
288
|
+
message = {"updated_at": datetime.now().astimezone().isoformat(), "jobs": payload}
|
|
289
|
+
yield f"data: {json.dumps(message)}\n\n"
|
|
290
|
+
await asyncio.sleep(interval)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def create_app(config: GuiConfig) -> FastAPI:
|
|
294
|
+
app = FastAPI(title="CADDForge GUI", root_path=config.base_path)
|
|
295
|
+
|
|
296
|
+
if config.cors_origins:
|
|
297
|
+
app.add_middleware(
|
|
298
|
+
CORSMiddleware,
|
|
299
|
+
allow_origins=config.cors_origins,
|
|
300
|
+
allow_credentials=True,
|
|
301
|
+
allow_methods=["*"],
|
|
302
|
+
allow_headers=["*"],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
auth_dependency = Depends(_auth_dependency(config))
|
|
306
|
+
|
|
307
|
+
extensions = discover_gui_extensions()
|
|
308
|
+
for ext in extensions:
|
|
309
|
+
if ext.router is not None:
|
|
310
|
+
app.include_router(ext.router, prefix=f"/api/v1/ext/{ext.plugin_id}")
|
|
311
|
+
if ext.frontend_dir:
|
|
312
|
+
app.mount(
|
|
313
|
+
f"/static/ext/{ext.plugin_id}",
|
|
314
|
+
StaticFiles(directory=ext.frontend_dir),
|
|
315
|
+
name=f"ext-{ext.plugin_id}",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
static_root = Path(__file__).parent / "static"
|
|
319
|
+
app.mount("/static", StaticFiles(directory=static_root), name="static")
|
|
320
|
+
|
|
321
|
+
@app.get("/api/v1/health", dependencies=[auth_dependency])
|
|
322
|
+
def health() -> dict[str, str]:
|
|
323
|
+
return {"status": "ok"}
|
|
324
|
+
|
|
325
|
+
@app.get("/api/v1/jobs", dependencies=[auth_dependency])
|
|
326
|
+
def jobs(
|
|
327
|
+
status: list[str] | None = Query(None),
|
|
328
|
+
search: str | None = None,
|
|
329
|
+
limit: int = Query(200, ge=1, le=500),
|
|
330
|
+
offset: int = Query(0, ge=0),
|
|
331
|
+
) -> dict[str, Any]:
|
|
332
|
+
return _jobs_response(
|
|
333
|
+
config.registry_path,
|
|
334
|
+
status=status,
|
|
335
|
+
search=search,
|
|
336
|
+
limit=limit,
|
|
337
|
+
offset=offset,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
@app.get("/api/v1/jobs-stream", dependencies=[auth_dependency])
|
|
341
|
+
async def job_stream(interval: float = 2.0) -> StreamingResponse:
|
|
342
|
+
return StreamingResponse(_status_stream(config.registry_path, interval), media_type="text/event-stream")
|
|
343
|
+
|
|
344
|
+
@app.get("/api/v1/jobs/{job_id}", dependencies=[auth_dependency])
|
|
345
|
+
def job_detail(
|
|
346
|
+
job_id: str,
|
|
347
|
+
include_tool_specific_artifacts: bool = False,
|
|
348
|
+
) -> dict[str, Any]:
|
|
349
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
350
|
+
if not job:
|
|
351
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
352
|
+
run_directory = _resolve_run_directory(job, config.runs_root)
|
|
353
|
+
payload = _job_to_summary(job)
|
|
354
|
+
payload["run_directory"] = str(run_directory) if run_directory else None
|
|
355
|
+
payload["artifacts"] = _build_artifact_payload_limited(
|
|
356
|
+
job_id,
|
|
357
|
+
run_directory,
|
|
358
|
+
include_tool_specific=include_tool_specific_artifacts,
|
|
359
|
+
)
|
|
360
|
+
payload["results"] = _load_results(run_directory)
|
|
361
|
+
payload["results_meta"] = _results_metadata(run_directory)
|
|
362
|
+
return payload
|
|
363
|
+
|
|
364
|
+
@app.post("/api/v1/jobs/{job_id}/refresh-status", dependencies=[auth_dependency])
|
|
365
|
+
def job_refresh_status(job_id: str) -> dict[str, Any]:
|
|
366
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
367
|
+
if not job:
|
|
368
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
369
|
+
previous_status = job.status
|
|
370
|
+
refresh_job_status(job, registry_path=config.registry_path)
|
|
371
|
+
refreshed = get_job(job_id, registry_path=config.registry_path) or job
|
|
372
|
+
return {
|
|
373
|
+
"item": _job_to_summary(refreshed),
|
|
374
|
+
"changed": previous_status != refreshed.status,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
@app.post("/api/v1/jobs/refresh-status", dependencies=[auth_dependency])
|
|
378
|
+
def jobs_refresh_status(
|
|
379
|
+
status: list[str] | None = None,
|
|
380
|
+
only_active: bool = True,
|
|
381
|
+
limit: int = 500,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
jobs = _list_jobs(config.registry_path, None)
|
|
384
|
+
if status:
|
|
385
|
+
allowed = {value.upper() for value in status}
|
|
386
|
+
jobs = [job for job in jobs if job.status.value in allowed]
|
|
387
|
+
elif only_active:
|
|
388
|
+
jobs = [job for job in jobs if job.status in {JobStatus.PENDING, JobStatus.RUNNING}]
|
|
389
|
+
|
|
390
|
+
if limit > 0:
|
|
391
|
+
jobs = jobs[:limit]
|
|
392
|
+
|
|
393
|
+
changed = 0
|
|
394
|
+
errors: list[dict[str, str]] = []
|
|
395
|
+
refreshed_items: list[dict[str, Any]] = []
|
|
396
|
+
for job in jobs:
|
|
397
|
+
before = job.status
|
|
398
|
+
try:
|
|
399
|
+
refresh_job_status(job, registry_path=config.registry_path)
|
|
400
|
+
refreshed = get_job(job.id, registry_path=config.registry_path) or job
|
|
401
|
+
if refreshed.status != before:
|
|
402
|
+
changed += 1
|
|
403
|
+
refreshed_items.append(_job_to_summary(refreshed))
|
|
404
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
405
|
+
errors.append({"job_id": job.id, "error": str(exc)})
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
"total": len(jobs),
|
|
409
|
+
"changed": changed,
|
|
410
|
+
"errors": errors,
|
|
411
|
+
"items": refreshed_items,
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
@app.get("/api/v1/jobs/{job_id}/artifacts", dependencies=[auth_dependency])
|
|
415
|
+
def job_artifacts(job_id: str, include_tool_specific: bool = True) -> dict[str, Any]:
|
|
416
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
417
|
+
if not job:
|
|
418
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
419
|
+
run_directory = _resolve_run_directory(job, config.runs_root)
|
|
420
|
+
if not run_directory:
|
|
421
|
+
raise HTTPException(status_code=404, detail="Run directory not found")
|
|
422
|
+
return {
|
|
423
|
+
"items": _build_artifact_payload_limited(
|
|
424
|
+
job_id,
|
|
425
|
+
run_directory,
|
|
426
|
+
include_tool_specific=include_tool_specific,
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
@app.get("/api/v1/jobs/{job_id}/artifacts/{artifact_id}", dependencies=[auth_dependency])
|
|
431
|
+
def job_artifact_download(job_id: str, artifact_id: str) -> Response:
|
|
432
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
433
|
+
if not job:
|
|
434
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
435
|
+
run_directory = _resolve_run_directory(job, config.runs_root)
|
|
436
|
+
if not run_directory:
|
|
437
|
+
raise HTTPException(status_code=404, detail="Run directory not found")
|
|
438
|
+
try:
|
|
439
|
+
path = resolve_artifact_path(run_directory, artifact_id)
|
|
440
|
+
except ValueError as exc:
|
|
441
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
442
|
+
if not path.exists():
|
|
443
|
+
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
444
|
+
return FileResponse(path)
|
|
445
|
+
|
|
446
|
+
@app.get("/api/v1/jobs/{job_id}/results", dependencies=[auth_dependency])
|
|
447
|
+
def job_results(job_id: str) -> Response:
|
|
448
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
449
|
+
if not job:
|
|
450
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
451
|
+
run_directory = _resolve_run_directory(job, config.runs_root)
|
|
452
|
+
if not run_directory:
|
|
453
|
+
raise HTTPException(status_code=404, detail="Run directory not found")
|
|
454
|
+
results = _load_results(run_directory)
|
|
455
|
+
if results is None:
|
|
456
|
+
raise HTTPException(status_code=404, detail="Results not available")
|
|
457
|
+
return JSONResponse(results)
|
|
458
|
+
|
|
459
|
+
@app.post("/api/v1/jobs/{job_id}/restart", dependencies=[auth_dependency])
|
|
460
|
+
def job_restart(job_id: str) -> dict[str, Any]:
|
|
461
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
462
|
+
if not job:
|
|
463
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
464
|
+
if job.status != JobStatus.FAILED:
|
|
465
|
+
raise HTTPException(status_code=400, detail="Only failed jobs can be restarted.")
|
|
466
|
+
try:
|
|
467
|
+
restarted = _restart_recorded_job(
|
|
468
|
+
job,
|
|
469
|
+
registry_path_override=config.registry_path,
|
|
470
|
+
backend_override=job.backend,
|
|
471
|
+
runs_root_override=config.runs_root,
|
|
472
|
+
submission_cwd=_submission_cwd_from_job(job),
|
|
473
|
+
emit=None,
|
|
474
|
+
local_async=job.backend == "local",
|
|
475
|
+
)
|
|
476
|
+
except (LookupError, FileNotFoundError, typer.BadParameter, ValueError) as exc:
|
|
477
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
478
|
+
return {"item": _job_to_summary(restarted)}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@app.post("/api/v1/jobs/{job_id}/cancel", dependencies=[auth_dependency])
|
|
482
|
+
def job_cancel(job_id: str) -> dict[str, Any]:
|
|
483
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
484
|
+
if not job:
|
|
485
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
486
|
+
if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
|
487
|
+
raise HTTPException(status_code=400, detail="Job is not running or pending.")
|
|
488
|
+
if job.backend == "slurm":
|
|
489
|
+
slurm_job_id = None
|
|
490
|
+
if job.metadata:
|
|
491
|
+
slurm_job_id = job.metadata.get("slurm_job_id")
|
|
492
|
+
if not slurm_job_id:
|
|
493
|
+
raise HTTPException(status_code=400, detail="Missing Slurm job id.")
|
|
494
|
+
backend = SlurmBackend(
|
|
495
|
+
runs_root=config.runs_root,
|
|
496
|
+
registry_path=config.registry_path,
|
|
497
|
+
)
|
|
498
|
+
backend.cancel(SlurmJobHandle(job.id, slurm_job_id))
|
|
499
|
+
return {"cancelled": True, "job_id": job.id}
|
|
500
|
+
if job.backend == "local":
|
|
501
|
+
pid = None
|
|
502
|
+
if job.metadata:
|
|
503
|
+
pid = job.metadata.get("local_pid")
|
|
504
|
+
if not pid:
|
|
505
|
+
raise HTTPException(status_code=400, detail="Missing local process id.")
|
|
506
|
+
import os
|
|
507
|
+
import signal
|
|
508
|
+
from datetime import datetime
|
|
509
|
+
from caddforge.core.job_registry import update_status
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
513
|
+
except ProcessLookupError:
|
|
514
|
+
pass
|
|
515
|
+
run_directory = _resolve_run_directory(job, config.runs_root)
|
|
516
|
+
if run_directory:
|
|
517
|
+
job.run_directory = Path(run_directory)
|
|
518
|
+
job.status = JobStatus.CANCELLED
|
|
519
|
+
job.finished_at = datetime.now().astimezone()
|
|
520
|
+
if run_directory:
|
|
521
|
+
job.save_metadata(Path(run_directory) / "metadata.json")
|
|
522
|
+
results_dir = Path(run_directory) / "results"
|
|
523
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
524
|
+
from caddforge.core.result import Result
|
|
525
|
+
|
|
526
|
+
Result(
|
|
527
|
+
job_id=job.id,
|
|
528
|
+
tool_name=job.tool_name,
|
|
529
|
+
status=JobStatus.CANCELLED,
|
|
530
|
+
metrics={},
|
|
531
|
+
paths={},
|
|
532
|
+
extra={"cancelled": True},
|
|
533
|
+
).save(results_dir / "results.json")
|
|
534
|
+
update_status(job.id, JobStatus.CANCELLED, finished_at=job.finished_at, registry_path=config.registry_path)
|
|
535
|
+
record_job(job, registry_path=config.registry_path)
|
|
536
|
+
return {"cancelled": True, "job_id": job.id}
|
|
537
|
+
raise HTTPException(status_code=400, detail="Backend does not support cancellation.")
|
|
538
|
+
|
|
539
|
+
@app.delete("/api/v1/jobs/{job_id}", dependencies=[auth_dependency])
|
|
540
|
+
def job_delete(job_id: str) -> dict[str, Any]:
|
|
541
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
542
|
+
if not job:
|
|
543
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
544
|
+
run_directory = _resolve_run_directory(job, config.runs_root)
|
|
545
|
+
_delete_run_directory(run_directory)
|
|
546
|
+
delete_job(job_id, registry_path=config.registry_path)
|
|
547
|
+
return {"deleted": True, "job_id": job_id}
|
|
548
|
+
|
|
549
|
+
@app.get("/api/v1/jobs/{job_id}/logs/{stream}", dependencies=[auth_dependency])
|
|
550
|
+
async def job_logs(
|
|
551
|
+
request: Request,
|
|
552
|
+
job_id: str,
|
|
553
|
+
stream: str,
|
|
554
|
+
interval: float = 0.5,
|
|
555
|
+
tail_kb: int = 256,
|
|
556
|
+
) -> StreamingResponse:
|
|
557
|
+
job = get_job(job_id, registry_path=config.registry_path)
|
|
558
|
+
if not job:
|
|
559
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
560
|
+
run_directory = _resolve_run_directory(job, config.runs_root)
|
|
561
|
+
if not run_directory:
|
|
562
|
+
raise HTTPException(status_code=404, detail="Run directory not found")
|
|
563
|
+
if stream not in {"stdout", "stderr"}:
|
|
564
|
+
raise HTTPException(status_code=400, detail="stream must be stdout or stderr")
|
|
565
|
+
path = Path(run_directory) / f"{stream}.log"
|
|
566
|
+
clamped_tail_kb = max(0, min(tail_kb, 4096))
|
|
567
|
+
async def _stream() -> AsyncGenerator[str, None]:
|
|
568
|
+
async for event in stream_log_events(path, interval, initial_tail_bytes=clamped_tail_kb * 1024):
|
|
569
|
+
if await request.is_disconnected():
|
|
570
|
+
break
|
|
571
|
+
yield event
|
|
572
|
+
|
|
573
|
+
return StreamingResponse(_stream(), media_type="text/event-stream")
|
|
574
|
+
|
|
575
|
+
@app.get("/api/v1/dashboard", dependencies=[auth_dependency])
|
|
576
|
+
def dashboard() -> dict[str, Any]:
|
|
577
|
+
jobs = _list_jobs(config.registry_path, None)
|
|
578
|
+
return _dashboard_summary(jobs)
|
|
579
|
+
|
|
580
|
+
@app.get("/api/v1/gui/extensions", dependencies=[auth_dependency])
|
|
581
|
+
def gui_extensions() -> dict[str, Any]:
|
|
582
|
+
manifest = []
|
|
583
|
+
for ext in extensions:
|
|
584
|
+
remote_entry = None
|
|
585
|
+
if ext.frontend_dir and ext.remote_entry:
|
|
586
|
+
remote_entry = f"{config.base_path}/static/ext/{ext.plugin_id}/{ext.remote_entry}"
|
|
587
|
+
manifest.append(
|
|
588
|
+
{
|
|
589
|
+
"plugin_id": ext.plugin_id,
|
|
590
|
+
"display_name": ext.display_name,
|
|
591
|
+
"api_version": ext.api_version,
|
|
592
|
+
"ui_compat_version": ext.ui_compat_version,
|
|
593
|
+
"nav_items": [
|
|
594
|
+
{
|
|
595
|
+
"path": item.path,
|
|
596
|
+
"label": item.label,
|
|
597
|
+
"module": item.module,
|
|
598
|
+
"export": item.export,
|
|
599
|
+
"icon": item.icon,
|
|
600
|
+
"order": item.order,
|
|
601
|
+
}
|
|
602
|
+
for item in ext.nav_items
|
|
603
|
+
],
|
|
604
|
+
"remote_entry": remote_entry,
|
|
605
|
+
"remote_modules": [
|
|
606
|
+
{"module": module.module, "export": module.export}
|
|
607
|
+
for module in ext.remote_modules
|
|
608
|
+
],
|
|
609
|
+
}
|
|
610
|
+
)
|
|
611
|
+
return {"items": manifest}
|
|
612
|
+
|
|
613
|
+
def _index_html() -> str:
|
|
614
|
+
index_path = static_root / "index.html"
|
|
615
|
+
html = index_path.read_text(encoding="utf-8")
|
|
616
|
+
if config.base_path:
|
|
617
|
+
html = html.replace('href="/static/', f'href="{config.base_path}/static/')
|
|
618
|
+
html = html.replace('src="/static/', f'src="{config.base_path}/static/')
|
|
619
|
+
html = html.replace('window.__BASE_PATH__ = "";', f'window.__BASE_PATH__ = "{config.base_path}";')
|
|
620
|
+
return html
|
|
621
|
+
|
|
622
|
+
@app.get("/", response_class=HTMLResponse)
|
|
623
|
+
def index() -> HTMLResponse:
|
|
624
|
+
return HTMLResponse(_index_html())
|
|
625
|
+
|
|
626
|
+
@app.get("/{path:path}", response_class=HTMLResponse)
|
|
627
|
+
def spa_fallback(path: str) -> HTMLResponse:
|
|
628
|
+
if path.startswith("api") or path.startswith("static"):
|
|
629
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
630
|
+
return HTMLResponse(_index_html())
|
|
631
|
+
|
|
632
|
+
return app
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def run_gui(config: GuiConfig) -> None:
|
|
636
|
+
import uvicorn
|
|
637
|
+
|
|
638
|
+
app = create_app(config)
|
|
639
|
+
uvicorn.run(app, host=config.host, port=config.port)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def main() -> None:
|
|
643
|
+
config = build_config()
|
|
644
|
+
run_gui(config)
|