The group pages were browser-only, so external schedulers had no way to
trigger a group or read its outcome. Adds /api/groups, /api/group-runs and
their run/list endpoints under Basic auth, following the same async contract
as POST /modules/{id}/run.
Cancellation needed more than an endpoint. run_group deliberately continues
past module failures, so killing the in-flight jrunner alone would just let
the loop march on to the next member. A group-level flag in engine/cancel.py
is now checked before each member, and engine.request_group_cancel sets that
flag *and* signals the running module -- it lives in runner.py rather than
cancel.py because finding the live run needs the DB, and cancel.py is
deliberately DB-free.
Group final status gains 'cancelled', ranked above 'error' since the error is
usually the terminated jrunner process. group_run.status already permitted the
value; no migration needed.
Also adds a "cancel group" button to the group run page (web) and .pill
styling for cancelled/dry_run, neither of which had a color rule.
Note: /runs/{id}/cancel keeps its existing meaning -- cancel one member and
let the group continue -- which is now a deliberate contrast to cancel group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
3.3 KiB
Python
85 lines
3.3 KiB
Python
"""Groups + group runs. The JSON counterpart to the web UI's group pages.
|
|
|
|
The web routes (web/app.py) already expose group runs, but behind session-cookie auth and
|
|
returning HTML/redirects, so they are only usable from a browser. External schedulers need
|
|
Basic auth and JSON -- hence these. Same async contract as POST /modules/{id}/run: the
|
|
group_run_id comes back immediately and the work continues in the background, so callers poll
|
|
GET /group-runs/{id} for the terminal status.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
|
|
from ... import engine, repo
|
|
from ..auth import require_auth
|
|
|
|
router = APIRouter(tags=["groups"], dependencies=[Depends(require_auth)])
|
|
|
|
|
|
@router.get("/groups")
|
|
def list_groups() -> list[dict]:
|
|
return repo.list_groups()
|
|
|
|
|
|
@router.get("/groups/{group_id}")
|
|
def get_group(group_id: int) -> dict:
|
|
g = repo.get_group(group_id)
|
|
if g is None:
|
|
raise HTTPException(404, f"group id={group_id} not found")
|
|
return {**g, "members": repo.list_group_members(group_id)}
|
|
|
|
|
|
@router.post("/groups/{group_id}/run")
|
|
def run_group(group_id: int, background: BackgroundTasks,
|
|
dry_run: bool = False) -> dict:
|
|
"""Kick off a group run. Returns group_run_id immediately."""
|
|
g = repo.get_group(group_id)
|
|
if g is None:
|
|
raise HTTPException(404, f"group id={group_id} not found")
|
|
group_run_id = repo.create_group_run(group_id, triggered_by="api")
|
|
background.add_task(_run_in_background, group_id, group_run_id, dry_run)
|
|
return {"group_run_id": group_run_id}
|
|
|
|
|
|
def _run_in_background(group_id: int, group_run_id: int, dry_run: bool) -> None:
|
|
try:
|
|
engine.run_group(group_id, group_run_id=group_run_id, dry_run=dry_run)
|
|
except Exception as e: # noqa: BLE001
|
|
# run_group already records per-module failures; this is the group row itself
|
|
# failing (or a LockBusy escaping), which must not leave it stuck at 'running'.
|
|
repo.finish_group_run(group_run_id, status="error")
|
|
raise e from None
|
|
|
|
|
|
@router.get("/group-runs/{group_run_id}")
|
|
def get_group_run(group_run_id: int) -> dict:
|
|
gr = repo.get_group_run(group_run_id)
|
|
if gr is None:
|
|
raise HTTPException(404, f"group_run id={group_run_id} not found")
|
|
return {**gr, "runs": repo.list_runs_for_group_run(group_run_id)}
|
|
|
|
|
|
@router.post("/group-runs/{group_run_id}/cancel")
|
|
def cancel_group_run(group_run_id: int) -> dict:
|
|
"""Stop an in-flight group run.
|
|
|
|
Kills the module currently running and prevents the remaining members from
|
|
starting. The group run lands with status ``cancelled``; the engine's own
|
|
finally-blocks release the module lock and write the run_log rows, so this
|
|
handler deliberately does not touch either.
|
|
"""
|
|
gr = repo.get_group_run(group_run_id)
|
|
if gr is None:
|
|
raise HTTPException(404, f"group_run id={group_run_id} not found")
|
|
if gr["status"] != "running":
|
|
raise HTTPException(409, f"group run {group_run_id} is {gr['status']}, not running")
|
|
return engine.request_group_cancel(group_run_id)
|
|
|
|
|
|
@router.get("/groups/{group_id}/group-runs")
|
|
def list_group_runs(group_id: int, limit: int = 20) -> list[dict]:
|
|
if repo.get_group(group_id) is None:
|
|
raise HTTPException(404, f"group id={group_id} not found")
|
|
return repo.list_group_runs(group_id, limit=limit)
|