Files
boehmitools-core/core/jobs.py
T
2026-07-24 21:37:03 +02:00

227 lines
7.9 KiB
Python

# -*- coding: utf-8 -*-
"""
Job-Runner für Plugins, die ein bestehendes Kommandozeilen-Tool kapseln.
Die Original-Skripte bleiben unverändert. Sie werden als Subprozess gestartet,
ihre Ausgabe wird zeilenweise gepuffert und per Server-Sent-Events live in die
Oberfläche gestreamt. Damit sieht ein CLI-Tool im Dashboard genauso aus wie
ein Tool mit eigenem Webinterface.
Sicherheit: Die Argumentliste baut immer das Plugin-Backend aus geprüften
Eingaben. Es gibt bewusst keinen Endpunkt, der beliebige Kommandos annimmt.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
MAX_LINES = 4000
@dataclass
class Job:
id: str
plugin: str
label: str
argv: list[str]
cwd: str
status: str = "running" # running | done | failed | cancelled
returncode: int | None = None
started_at: float = field(default_factory=time.time)
ended_at: float | None = None
lines: list[dict[str, str]] = field(default_factory=list)
artifacts: list[dict[str, str]] = field(default_factory=list)
_process: Any = None
_waiters: list[asyncio.Queue] = field(default_factory=list)
def add(self, stream: str, text: str) -> None:
entry = {"stream": stream, "text": text, "t": round(time.time() - self.started_at, 2)}
self.lines.append(entry)
if len(self.lines) > MAX_LINES:
del self.lines[: len(self.lines) - MAX_LINES]
for queue in list(self._waiters):
queue.put_nowait(entry)
def finish(self, status: str, returncode: int | None) -> None:
self.status = status
self.returncode = returncode
self.ended_at = time.time()
for queue in list(self._waiters):
queue.put_nowait(None)
def info(self) -> dict[str, Any]:
return {
"id": self.id,
"plugin": self.plugin,
"label": self.label,
"command": " ".join(self.argv),
"status": self.status,
"returncode": self.returncode,
"started_at": self.started_at,
"ended_at": self.ended_at,
"duration": round((self.ended_at or time.time()) - self.started_at, 1),
"artifacts": self.artifacts,
}
class JobManager:
"""Hält alle Läufe der Suite im Speicher (bewusst kein Zustand auf Platte)."""
def __init__(self, keep: int = 40) -> None:
self._jobs: dict[str, Job] = {}
self._keep = keep
# ------------------------------------------------------------- Starten
async def start(
self,
*,
plugin: str,
label: str,
argv: Iterable[str],
cwd: Path,
env: dict[str, str] | None = None,
) -> Job:
argv = [str(a) for a in argv]
job = Job(id=uuid.uuid4().hex[:12], plugin=plugin, label=label,
argv=argv, cwd=str(cwd))
self._jobs[job.id] = job
self._prune()
run_env = dict(os.environ)
run_env.update(env or {})
run_env.setdefault("PYTHONUNBUFFERED", "1")
run_env.setdefault("PYTHONIOENCODING", "utf-8")
job.add("meta", "$ " + " ".join(argv))
try:
process = await asyncio.create_subprocess_exec(
*argv,
cwd=str(cwd),
env=run_env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except Exception as exc:
job.add("stderr", f"Start fehlgeschlagen: {exc}")
job.finish("failed", None)
return job
job._process = process
asyncio.create_task(self._pump(job, process))
return job
async def _pump(self, job: Job, process) -> None:
async def read(stream, name):
while True:
raw = await stream.readline()
if not raw:
break
job.add(name, raw.decode("utf-8", "replace").rstrip("\n"))
await asyncio.gather(read(process.stdout, "stdout"), read(process.stderr, "stderr"))
code = await process.wait()
if job.status == "cancelled":
job.finish("cancelled", code)
else:
job.finish("done" if code == 0 else "failed", code)
# -------------------------------------------------------------- Zugriff
def get(self, job_id: str) -> Job | None:
return self._jobs.get(job_id)
def list(self, plugin: str | None = None) -> list[Job]:
jobs = [j for j in self._jobs.values() if plugin is None or j.plugin == plugin]
return sorted(jobs, key=lambda j: j.started_at, reverse=True)
def running(self, plugin: str | None = None) -> list[Job]:
return [j for j in self.list(plugin) if j.status == "running"]
async def cancel(self, job_id: str) -> bool:
job = self._jobs.get(job_id)
if not job or job.status != "running" or job._process is None:
return False
job.status = "cancelled"
job.add("meta", "Abbruch angefordert …")
try:
job._process.terminate()
except ProcessLookupError:
pass
return True
def _prune(self) -> None:
finished = [j for j in self.list() if j.status != "running"]
for job in finished[self._keep:]:
self._jobs.pop(job.id, None)
def job_router(jobs: JobManager, plugin: str) -> APIRouter:
"""Standard-Endpunkte, die jedes CLI-Plugin einbinden kann."""
router = APIRouter()
@router.get("/api/jobs")
def list_jobs() -> dict[str, Any]:
return {"jobs": [j.info() for j in jobs.list(plugin)]}
@router.get("/api/jobs/{job_id}")
def job_detail(job_id: str) -> dict[str, Any]:
job = jobs.get(job_id)
if not job or job.plugin != plugin:
raise HTTPException(404, "Lauf nicht gefunden.")
return {**job.info(), "lines": job.lines}
@router.post("/api/jobs/{job_id}/cancel")
async def cancel_job(job_id: str) -> dict[str, Any]:
job = jobs.get(job_id)
if not job or job.plugin != plugin:
raise HTTPException(404, "Lauf nicht gefunden.")
ok = await jobs.cancel(job_id)
return {"ok": ok, "status": job.status}
@router.get("/api/jobs/{job_id}/events")
async def job_events(job_id: str):
job = jobs.get(job_id)
if not job or job.plugin != plugin:
raise HTTPException(404, "Lauf nicht gefunden.")
async def stream():
queue: asyncio.Queue = asyncio.Queue()
backlog = list(job.lines)
job._waiters.append(queue)
try:
for entry in backlog:
yield "event: line\ndata: " + json.dumps(entry, ensure_ascii=False) + "\n\n"
if job.status != "running":
yield "event: end\ndata: " + json.dumps(job.info(), ensure_ascii=False) + "\n\n"
return
while True:
try:
entry = await asyncio.wait_for(queue.get(), timeout=20)
except asyncio.TimeoutError:
yield ": ping\n\n"
continue
if entry is None:
yield "event: end\ndata: " + json.dumps(job.info(), ensure_ascii=False) + "\n\n"
return
yield "event: line\ndata: " + json.dumps(entry, ensure_ascii=False) + "\n\n"
finally:
if queue in job._waiters:
job._waiters.remove(queue)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
return router