celery-manager 0.1.0a2__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 (54) hide show
  1. celery_manager/__init__.py +47 -0
  2. celery_manager/api/__init__.py +0 -0
  3. celery_manager/api/app.py +83 -0
  4. celery_manager/api/deps.py +34 -0
  5. celery_manager/api/routes/__init__.py +0 -0
  6. celery_manager/api/routes/audit.py +42 -0
  7. celery_manager/api/routes/auth.py +55 -0
  8. celery_manager/api/routes/schedules.py +72 -0
  9. celery_manager/api/routes/settings.py +83 -0
  10. celery_manager/api/routes/tasks.py +204 -0
  11. celery_manager/api/security.py +143 -0
  12. celery_manager/api/serialization.py +55 -0
  13. celery_manager/api/sse.py +89 -0
  14. celery_manager/auth/__init__.py +0 -0
  15. celery_manager/auth/sessions.py +19 -0
  16. celery_manager/auth/users.py +51 -0
  17. celery_manager/cli.py +46 -0
  18. celery_manager/config.py +38 -0
  19. celery_manager/control/__init__.py +55 -0
  20. celery_manager/control/audit.py +20 -0
  21. celery_manager/control/schedules.py +271 -0
  22. celery_manager/control/settings.py +266 -0
  23. celery_manager/control/tasks.py +319 -0
  24. celery_manager/dashboard/__init__.py +6 -0
  25. celery_manager/dashboard/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  26. celery_manager/dashboard/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  27. celery_manager/dashboard/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  28. celery_manager/dashboard/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  29. celery_manager/dashboard/static/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  30. celery_manager/dashboard/static/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  31. celery_manager/dashboard/static/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  32. celery_manager/dashboard/static/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  33. celery_manager/dashboard/static/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  34. celery_manager/dashboard/static/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  35. celery_manager/dashboard/static/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
  36. celery_manager/dashboard/static/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
  37. celery_manager/dashboard/static/assets/index-AUSbcb6p.js +11 -0
  38. celery_manager/dashboard/static/assets/index-CzsGQV47.css +1 -0
  39. celery_manager/dashboard/static/favicon.svg +4 -0
  40. celery_manager/dashboard/static/index.html +42 -0
  41. celery_manager/db/__init__.py +0 -0
  42. celery_manager/db/models.py +124 -0
  43. celery_manager/db/session.py +34 -0
  44. celery_manager/decorator.py +451 -0
  45. celery_manager/events/__init__.py +0 -0
  46. celery_manager/events/listener.py +104 -0
  47. celery_manager/logcapture.py +19 -0
  48. celery_manager/progress.py +321 -0
  49. celery_manager/runtime.py +54 -0
  50. celery_manager-0.1.0a2.dist-info/METADATA +225 -0
  51. celery_manager-0.1.0a2.dist-info/RECORD +54 -0
  52. celery_manager-0.1.0a2.dist-info/WHEEL +4 -0
  53. celery_manager-0.1.0a2.dist-info/entry_points.txt +2 -0
  54. celery_manager-0.1.0a2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,47 @@
1
+ """celery-manager — a dashboard and control API for Celery background tasks.
2
+
3
+ The public surface spec §4 assumes, importable straight from the package
4
+ root::
5
+
6
+ import celery_manager
7
+ from celery_manager import CeleryManagerConfig, setup, track, report_progress
8
+
9
+ celery_manager.setup(config) # wire this process (worker or API)
10
+
11
+ @app.task(bind=True)
12
+ @celery_manager.track()
13
+ def send_report(self): ...
14
+
15
+ `report_progress(current, total=None, message=None)` is called from *inside*
16
+ a task body to publish live progress to the dashboard while the task is still
17
+ running. It is throttled, tolerant of bad arguments, and — like everything
18
+ else here — never raises into the task that called it::
19
+
20
+ @app.task(bind=True)
21
+ @celery_manager.track()
22
+ def import_rows(self, rows):
23
+ for i, row in enumerate(rows):
24
+ handle(row)
25
+ celery_manager.report_progress(i + 1, len(rows), f"row {i + 1}")
26
+
27
+ `celery_manager.control` (task/schedule/audit operations) is re-exported here
28
+ too, so `celery_manager.control.list_task_runs(...)` works without a separate
29
+ import of the submodule.
30
+ """
31
+
32
+ from celery_manager import control
33
+ from celery_manager.config import CeleryManagerConfig
34
+ from celery_manager.decorator import track
35
+ from celery_manager.progress import report_progress
36
+ from celery_manager.runtime import setup
37
+
38
+ __version__ = "0.1.0a2"
39
+
40
+ __all__ = [
41
+ "CeleryManagerConfig",
42
+ "__version__",
43
+ "control",
44
+ "report_progress",
45
+ "setup",
46
+ "track",
47
+ ]
File without changes
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import Depends, FastAPI, Request
6
+ from fastapi.responses import StreamingResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+
9
+ from celery_manager.api.deps import get_current_user
10
+ from celery_manager.api.routes import (
11
+ audit as audit_routes,
12
+ )
13
+ from celery_manager.api.routes import (
14
+ auth as auth_routes,
15
+ )
16
+ from celery_manager.api.routes import (
17
+ schedules as schedule_routes,
18
+ )
19
+ from celery_manager.api.routes import (
20
+ settings as settings_routes,
21
+ )
22
+ from celery_manager.api.routes import (
23
+ tasks as task_routes,
24
+ )
25
+ from celery_manager.api.security import SecurityMiddleware
26
+ from celery_manager.api.sse import EventBus, sse_stream
27
+ from celery_manager.config import CeleryManagerConfig
28
+ from celery_manager.control.schedules import sync_periodic_schedules
29
+ from celery_manager.runtime import setup as setup_runtime
30
+
31
+ # The placeholder `CeleryManagerConfig.secret_key` ships with. It is public —
32
+ # it's in this file and the docs — so a session cookie signed with it can be
33
+ # forged for any username by anyone.
34
+ _DEFAULT_SECRET_KEY = "change-me"
35
+
36
+
37
+ def create_app(config: CeleryManagerConfig):
38
+ """Build the celery-manager FastAPI app. Returns (app, session_factory)
39
+ — callers that need direct DB access (tests, the events listener CLI)
40
+ use the session_factory; HTTP consumers just use the app."""
41
+ # Checked here rather than in CeleryManagerConfig: worker and listener
42
+ # processes build the same config but never sign or verify a session, so
43
+ # they're free to leave secret_key alone. Only a process serving HTTP is
44
+ # a forgery risk, and it should refuse to start, not start insecurely.
45
+ if not config.secret_key or not config.secret_key.strip() or config.secret_key == _DEFAULT_SECRET_KEY:
46
+ raise ValueError(
47
+ "CeleryManagerConfig.secret_key is unset or still the default "
48
+ f"{_DEFAULT_SECRET_KEY!r}; anyone could forge a dashboard session with it. "
49
+ "Set it to a private random value, e.g. the output of "
50
+ '`python -c "import secrets; print(secrets.token_urlsafe(32))"`.'
51
+ )
52
+
53
+ # Single shared entry point for "wire celery-manager into this process"
54
+ # (init_db + session factory + decorator configure) rather than an inline
55
+ # copy of it, so the API process and the worker process — which must call
56
+ # `celery_manager.setup(config)` itself, see runtime.setup's docstring —
57
+ # can never drift out of sync.
58
+ session_factory = setup_runtime(config)
59
+
60
+ with session_factory() as session:
61
+ sync_periodic_schedules(session, config.celery_app)
62
+
63
+ app = FastAPI(title="celery-manager")
64
+ app.state.cm_config = config
65
+ app.state.cm_session_factory = session_factory
66
+ app.state.cm_event_bus = EventBus()
67
+ app.add_middleware(SecurityMiddleware)
68
+
69
+ app.include_router(auth_routes.router)
70
+ app.include_router(task_routes.router)
71
+ app.include_router(schedule_routes.router)
72
+ app.include_router(audit_routes.router)
73
+ app.include_router(settings_routes.router)
74
+
75
+ @app.get("/api/stream")
76
+ def stream(request: Request, username: str = Depends(get_current_user)):
77
+ return StreamingResponse(sse_stream(app.state.cm_event_bus), media_type="text/event-stream")
78
+
79
+ static_dir = Path(__file__).parent.parent / "dashboard" / "static"
80
+ if static_dir.exists():
81
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="dashboard")
82
+
83
+ return app, session_factory
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Cookie, HTTPException, Request
4
+
5
+ from celery_manager.auth.sessions import verify_session
6
+ from celery_manager.auth.users import user_exists
7
+
8
+ # Not plain `session`: celery-manager is usually mounted inside a host app,
9
+ # and Flask's and Starlette's own session cookies are both called `session`
10
+ # — two cookies of the same name on overlapping paths clobber each other.
11
+ SESSION_COOKIE = "cm_session"
12
+
13
+
14
+ def cookie_path(request: Request) -> str:
15
+ """Scope the session cookie to wherever this app is mounted, so a
16
+ dashboard under `/celery` doesn't send its credential to every other
17
+ path on the host. Must be identical on set and delete — a browser only
18
+ removes a cookie whose path matches the one it was set with."""
19
+ return request.scope.get("root_path") or "/"
20
+
21
+
22
+ def get_current_user(request: Request, session: str | None = Cookie(default=None, alias=SESSION_COOKIE)) -> str:
23
+ config = request.app.state.cm_config
24
+ if session is None:
25
+ raise HTTPException(status_code=401, detail="not authenticated")
26
+ username = verify_session(config.secret_key, session)
27
+ if username is None:
28
+ raise HTTPException(status_code=401, detail="invalid or expired session")
29
+ # Sessions are stateless signed tokens, so a valid signature alone would
30
+ # keep a deleted account working for the rest of its 7 days.
31
+ with request.app.state.cm_session_factory() as db_session:
32
+ if not user_exists(db_session, username):
33
+ raise HTTPException(status_code=401, detail="invalid or expired session")
34
+ return username
File without changes
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, Request
4
+
5
+ from celery_manager.api.deps import get_current_user
6
+ from celery_manager.api.serialization import utc
7
+ from celery_manager.control.tasks import MAX_PAGE_SIZE
8
+ from celery_manager.db.models import AuditLog
9
+
10
+ router = APIRouter(prefix="/api/audit")
11
+
12
+
13
+ @router.get("")
14
+ def list_audit(request: Request, limit: int = 50, username: str = Depends(get_current_user)):
15
+ """The most recent audit entries, newest first.
16
+
17
+ `limit` is clamped the same way `GET /api/tasks` clamps its own — and for
18
+ a sharper reason here: SQLite reads `LIMIT -1` as *no limit*, so
19
+ `?limit=-1` handed back the entire audit log in one response. Same
20
+ reasoning as the task list: clamp rather than reject, because a stale
21
+ bookmark deserves a capped page rather than a 400.
22
+ """
23
+ limit = max(1, min(limit, MAX_PAGE_SIZE))
24
+ session_factory = request.app.state.cm_session_factory
25
+ with session_factory() as session:
26
+ rows = session.query(AuditLog).order_by(AuditLog.id.desc()).limit(limit).all()
27
+ return [
28
+ {
29
+ "username": r.username,
30
+ "action": r.action,
31
+ "target_type": r.target_type,
32
+ "target_id": r.target_id,
33
+ "details": r.details,
34
+ # Marked UTC for the same reason the task routes mark theirs:
35
+ # SQLite hands the value back naive, and the audit screen
36
+ # renders it with `new Date(...).toLocaleString()`, which reads
37
+ # an unmarked timestamp as local time and shifts every entry by
38
+ # the viewer's offset.
39
+ "created_at": utc(r.created_at),
40
+ }
41
+ for r in rows
42
+ ]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
4
+ from pydantic import BaseModel
5
+
6
+ from celery_manager.api.deps import SESSION_COOKIE, cookie_path, get_current_user
7
+ from celery_manager.auth.sessions import sign_session
8
+ from celery_manager.auth.users import verify_password
9
+
10
+ router = APIRouter(prefix="/api")
11
+
12
+
13
+ class LoginRequest(BaseModel):
14
+ username: str
15
+ password: str
16
+
17
+
18
+ @router.post("/auth/login")
19
+ def login(payload: LoginRequest, request: Request, response: Response):
20
+ config = request.app.state.cm_config
21
+ session_factory = request.app.state.cm_session_factory
22
+ with session_factory() as db_session:
23
+ user = verify_password(db_session, payload.username, payload.password)
24
+ if user is None:
25
+ raise HTTPException(status_code=401, detail="invalid credentials")
26
+ token = sign_session(config.secret_key, user.username)
27
+ response.set_cookie(
28
+ SESSION_COOKIE,
29
+ token,
30
+ path=cookie_path(request),
31
+ httponly=True,
32
+ samesite="lax",
33
+ secure=config.cookie_secure,
34
+ )
35
+ return {"username": user.username}
36
+
37
+
38
+ @router.post("/auth/logout")
39
+ def logout(request: Request, response: Response):
40
+ config = request.app.state.cm_config
41
+ # Same attributes as the login cookie: some browsers ignore a deletion
42
+ # whose Secure/SameSite don't match, and a mismatched path deletes nothing.
43
+ response.delete_cookie(
44
+ SESSION_COOKIE,
45
+ path=cookie_path(request),
46
+ httponly=True,
47
+ samesite="lax",
48
+ secure=config.cookie_secure,
49
+ )
50
+ return {"ok": True}
51
+
52
+
53
+ @router.get("/whoami")
54
+ def whoami(username: str = Depends(get_current_user)):
55
+ return {"username": username}
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Literal
5
+
6
+ from fastapi import APIRouter, Depends, Request
7
+ from pydantic import BaseModel
8
+
9
+ from celery_manager.api.deps import get_current_user
10
+ from celery_manager.control.schedules import (
11
+ list_periodic_schedules,
12
+ list_upcoming_with_overrides,
13
+ pause_occurrence,
14
+ )
15
+
16
+ router = APIRouter(prefix="/api/schedules")
17
+
18
+
19
+ class PauseRequest(BaseModel):
20
+ target_time: datetime
21
+ # Deliberately narrower than ScheduleOverride.action's column, which also
22
+ # permits "reschedule". Nothing downstream implements reschedule — the
23
+ # decorator only ever acts on "skip"/"cancel" — so accepting the value
24
+ # here persisted an override that silently did nothing while the
25
+ # dashboard cheerfully displayed the occurrence as paused. Rejecting it
26
+ # at the boundary (Pydantic returns 422) is honest; storing a lie is not.
27
+ # Widen this the day reschedule is actually implemented, not before.
28
+ action: Literal["skip", "cancel"]
29
+
30
+
31
+ @router.post("/{name}/occurrences/pause")
32
+ def pause(name: str, payload: PauseRequest, request: Request, username: str = Depends(get_current_user)):
33
+ session_factory = request.app.state.cm_session_factory
34
+ eta_tolerance_seconds = request.app.state.cm_config.eta_tolerance_seconds
35
+ with session_factory() as session:
36
+ override = pause_occurrence(
37
+ session, name, payload.target_time, payload.action,
38
+ created_by=username, eta_tolerance_seconds=eta_tolerance_seconds,
39
+ )
40
+ return {"id": override.id, "action": override.action, "target_time": override.target_time}
41
+
42
+
43
+ @router.get("/{name}/occurrences")
44
+ def occurrences(name: str, request: Request, username: str = Depends(get_current_user)):
45
+ """Every upcoming occurrence for this schedule (not just already-paused
46
+ ones) so the dashboard has something to act on — see
47
+ list_upcoming_with_overrides in Task 6.
48
+
49
+ `list_upcoming_with_overrides` recomputes occurrence timestamps
50
+ relative to "now" on every call, so a `target_time` a user paused a
51
+ few seconds ago won't be byte-identical to the same logical
52
+ occurrence recomputed on this request. It handles that itself via
53
+ `eta_tolerance_seconds`-window matching (same concept as the
54
+ decorator's own skip-check window) rather than exact equality — we
55
+ just need to pass the configured tolerance through.
56
+ """
57
+ celery_app = request.app.state.cm_config.celery_app
58
+ session_factory = request.app.state.cm_session_factory
59
+ eta_tolerance_seconds = request.app.state.cm_config.eta_tolerance_seconds
60
+ with session_factory() as session:
61
+ return list_upcoming_with_overrides(session, celery_app, name, eta_tolerance_seconds=eta_tolerance_seconds)
62
+
63
+
64
+ @router.get("")
65
+ def list_schedules(request: Request, username: str = Depends(get_current_user)):
66
+ session_factory = request.app.state.cm_session_factory
67
+ with session_factory() as session:
68
+ schedules = list_periodic_schedules(session)
69
+ return [
70
+ {"name": s.name, "task_name": s.task_name, "schedule_spec": s.schedule_spec, "enabled": s.enabled}
71
+ for s in schedules
72
+ ]
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from pydantic import BaseModel, ConfigDict
5
+
6
+ from celery_manager.api.deps import get_current_user
7
+ from celery_manager.control.settings import get_settings, update_settings
8
+
9
+ router = APIRouter(prefix="/api/settings")
10
+
11
+
12
+ @router.get("")
13
+ def read_settings(request: Request):
14
+ """The deployment's branding. **Deliberately unauthenticated.**
15
+
16
+ The login screen has to render this deployment's branding — its wordmark,
17
+ its accent, its logo — and the login screen is by definition pre-auth. An
18
+ authenticated endpoint here would mean the dashboard shows stock branding
19
+ until the moment you sign in, which defeats the feature for the one screen
20
+ an operator's users see most often.
21
+
22
+ That is safe only because the payload is non-sensitive by construction: a
23
+ product name, a hex colour and an image URL, and nothing else, because
24
+ `control.settings.DEFAULT_SETTINGS` is a closed allowlist. It is why that
25
+ allowlist must stay closed — see the comment on it before adding a key.
26
+
27
+ `updated_by` and `updated_at` are deliberately *not* returned. They are
28
+ the one genuinely sensitive thing this table holds: `updated_by` is a
29
+ valid username, and handing an unauthenticated visitor a real account name
30
+ turns a password-guessing attack from two unknowns into one. Nothing in
31
+ the UI needs them, so they are omitted rather than served behind a second
32
+ authenticated route that would exist only to be tidy.
33
+ """
34
+ session_factory = request.app.state.cm_session_factory
35
+ with session_factory() as session:
36
+ return get_settings(session)
37
+
38
+
39
+ class SettingsUpdate(BaseModel):
40
+ """PUT body. Every field optional, so a caller can send just the one it
41
+ is changing rather than read-modify-writing the whole set.
42
+
43
+ `extra="forbid"` so an unrecognized key is a 422 from pydantic, matching
44
+ what `update_settings` does with unknown keys when it is called directly
45
+ from Python. Silently dropping an unknown field would let a typo look like
46
+ a successful save.
47
+ """
48
+
49
+ model_config = ConfigDict(extra="forbid")
50
+
51
+ brand_name: str | None = None
52
+ brand_accent: str | None = None
53
+ brand_logo_url: str | None = None
54
+
55
+
56
+ @router.put("")
57
+ def write_settings(payload: SettingsUpdate, request: Request, username: str = Depends(get_current_user)):
58
+ """Update branding. Requires a session; returns the new effective settings.
59
+
60
+ Any authenticated user may rebrand the dashboard. There is no role system
61
+ in this app — `get_current_user` yields a username and nothing else — so
62
+ "admin only" is not expressible today without inventing one. That is an
63
+ accepted v1 limitation, not an oversight: the blast radius is cosmetic and
64
+ every change is attributed in the audit log, which is how you find out who
65
+ turned the nav pink.
66
+ """
67
+ # `exclude_unset` is what makes a partial update possible: it keeps the
68
+ # difference between "brand_accent was not in the JSON" (leave it alone)
69
+ # and "brand_accent was sent as null" (an explicit value, which the
70
+ # validators reject as a non-string rather than silently ignoring).
71
+ values = payload.model_dump(exclude_unset=True)
72
+
73
+ session_factory = request.app.state.cm_session_factory
74
+ with session_factory() as session:
75
+ try:
76
+ return update_settings(session, values, username)
77
+ except ValueError as exc:
78
+ # 422, not 400: this is a well-formed request whose *content*
79
+ # failed validation, which is the same code FastAPI/pydantic
80
+ # already returns for a bad body — so a client has one error shape
81
+ # to handle, not two. The message is human-readable on purpose;
82
+ # it is rendered straight into the settings form.
83
+ raise HTTPException(status_code=422, detail=str(exc))
@@ -0,0 +1,204 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Request
6
+ from pydantic import BaseModel
7
+
8
+ from celery_manager.api.deps import get_current_user
9
+ from celery_manager.api.serialization import utc, utc_bound
10
+ from celery_manager.control.tasks import (
11
+ MAX_PAGE_SIZE,
12
+ TaskRunNotFound,
13
+ bulk_retry,
14
+ cancel_task,
15
+ count_task_runs,
16
+ get_task_run,
17
+ list_retries_of,
18
+ list_task_runs,
19
+ retry_task,
20
+ start_task,
21
+ )
22
+
23
+ router = APIRouter(prefix="/api/tasks")
24
+
25
+
26
+ @router.get("")
27
+ def list_tasks(
28
+ request: Request,
29
+ status: str | None = None,
30
+ task_name: str | None = None,
31
+ q: str | None = None,
32
+ date_from: datetime | None = None,
33
+ date_to: datetime | None = None,
34
+ limit: int = 50,
35
+ offset: int = 0,
36
+ username: str = Depends(get_current_user),
37
+ ):
38
+ """One page of task runs, plus the total matching the same filters.
39
+
40
+ Returns an envelope (`items`/`total`/`limit`/`offset`) rather than a bare
41
+ array. The array shape had no room for a total, which left the UI unable
42
+ to tell "50 results" from "the first 50 of 1,284" — and with the old hard
43
+ 50-row cap and no offset, older history was simply unreachable. Changing
44
+ the shape is a deliberate break, taken while the package is unreleased at
45
+ 0.1.0 and its only consumers are our own SPA and docs/rest-api.md.
46
+ """
47
+ # Clamp rather than reject: `?limit=10000000` is more often a stale
48
+ # bookmark than an attack, but either way it must not turn one HTTP
49
+ # request into a full-table load, and a 400 here would only give the
50
+ # caller a broken page instead of a capped one. Negative values are
51
+ # meaningless to OFFSET/LIMIT, so floor them too.
52
+ limit = max(1, min(limit, MAX_PAGE_SIZE))
53
+ offset = max(0, offset)
54
+
55
+ session_factory = request.app.state.cm_session_factory
56
+ with session_factory() as session:
57
+ filters = {
58
+ "status": status, "task_name": task_name, "q": q,
59
+ "date_from": utc_bound(date_from), "date_to": utc_bound(date_to),
60
+ }
61
+ # The same filter dict feeds both calls, so `total` can never be a
62
+ # count of some other query than the one `items` came from.
63
+ runs = list_task_runs(session, limit=limit, offset=offset, **filters)
64
+ total = count_task_runs(session, **filters)
65
+ return {
66
+ "items": [
67
+ {
68
+ "task_id": r.task_id,
69
+ "task_name": r.task_name,
70
+ "status": r.status,
71
+ "started_at": utc(r.started_at),
72
+ "finished_at": utc(r.finished_at),
73
+ "runtime_seconds": r.runtime_seconds,
74
+ # Progress is opt-in (only tasks that report it have any),
75
+ # so these are usually null. Shipped raw: the frontend
76
+ # derives percentage and ETA, because it is the side that
77
+ # knows how much space it has to say it in.
78
+ "progress_current": r.progress_current,
79
+ "progress_total": r.progress_total,
80
+ "progress_message": r.progress_message,
81
+ "progress_updated_at": utc(r.progress_updated_at),
82
+ }
83
+ for r in runs
84
+ ],
85
+ "total": total,
86
+ "limit": limit,
87
+ "offset": offset,
88
+ }
89
+
90
+
91
+ @router.get("/{task_id}")
92
+ def get_task(task_id: str, request: Request, username: str = Depends(get_current_user)):
93
+ session_factory = request.app.state.cm_session_factory
94
+ with session_factory() as session:
95
+ run = get_task_run(session, task_id)
96
+ if run is None:
97
+ raise HTTPException(status_code=404, detail="not found")
98
+ return {
99
+ "task_id": run.task_id,
100
+ "task_name": run.task_name,
101
+ "status": run.status,
102
+ "args": run.args,
103
+ "kwargs": run.kwargs,
104
+ "exception": run.exception,
105
+ "traceback": run.traceback,
106
+ "log_output": run.log_output,
107
+ # Operational detail already stored on TaskRun (by the events
108
+ # listener for worker/queue, by the decorator for the timings) but
109
+ # never surfaced here — so the dashboard's detail view could not
110
+ # answer "which worker ran this, off which queue, and how long did
111
+ # it take". The list endpoint returned started_at/finished_at while
112
+ # this one didn't, which also made the frontend's TaskDetail type
113
+ # (extending TaskSummary) claim fields the response never sent.
114
+ "queue": run.queue,
115
+ "worker": run.worker,
116
+ "eta": utc(run.eta),
117
+ "started_at": utc(run.started_at),
118
+ "finished_at": utc(run.finished_at),
119
+ "runtime_seconds": run.runtime_seconds,
120
+ "progress_current": run.progress_current,
121
+ "progress_total": run.progress_total,
122
+ "progress_message": run.progress_message,
123
+ "progress_updated_at": utc(run.progress_updated_at),
124
+ # Both directions of the retry chain: the run this one was
125
+ # retried from, and the runs retried from it. `retried_by` is a
126
+ # list because an operator can retry the same failed run more than
127
+ # once, and each attempt is its own run.
128
+ "retried_from_id": run.retried_from_id,
129
+ "retried_by": [r.task_id for r in list_retries_of(session, task_id)],
130
+ }
131
+
132
+
133
+ class StartRequest(BaseModel):
134
+ task_name: str
135
+ args: list = []
136
+ kwargs: dict = {}
137
+
138
+
139
+ @router.post("/start")
140
+ def start(payload: StartRequest, request: Request, username: str = Depends(get_current_user)):
141
+ celery_app = request.app.state.cm_config.celery_app
142
+ session_factory = request.app.state.cm_session_factory
143
+ # session + username are what make this action land in the audit log,
144
+ # same as the retry route below.
145
+ with session_factory() as session:
146
+ task_id = start_task(
147
+ celery_app, payload.task_name, payload.args, payload.kwargs,
148
+ session=session, username=username,
149
+ )
150
+ return {"task_id": task_id}
151
+
152
+
153
+ @router.post("/{task_id}/cancel")
154
+ def cancel(task_id: str, request: Request, terminate: bool = False, username: str = Depends(get_current_user)):
155
+ celery_app = request.app.state.cm_config.celery_app
156
+ session_factory = request.app.state.cm_session_factory
157
+ with session_factory() as session:
158
+ cancel_task(celery_app, task_id, terminate=terminate, session=session, username=username)
159
+ return {"ok": True}
160
+
161
+
162
+ @router.post("/{task_id}/retry")
163
+ def retry(task_id: str, request: Request, username: str = Depends(get_current_user)):
164
+ """Re-dispatch a stored run. 404 if there is no such run.
165
+
166
+ A retry of an unknown task id used to escape as an unhandled `ValueError`
167
+ and reach the client as a 500 — which reads as "the dashboard is broken"
168
+ rather than "that task id doesn't exist", and is indistinguishable from a
169
+ genuine server fault to anyone reading logs or alerting on 5xx. It is the
170
+ same condition `GET /api/tasks/{task_id}` already answers with a 404, so
171
+ it gets the same status. Only `TaskRunNotFound` is translated: a
172
+ `ValueError` from undecodable stored args is a real server-side problem
173
+ and must keep surfacing as one.
174
+ """
175
+ celery_app = request.app.state.cm_config.celery_app
176
+ session_factory = request.app.state.cm_session_factory
177
+ with session_factory() as session:
178
+ try:
179
+ new_id = retry_task(celery_app, session, task_id, username=username)
180
+ except TaskRunNotFound:
181
+ raise HTTPException(status_code=404, detail="not found")
182
+ return {"new_task_id": new_id}
183
+
184
+
185
+ class BulkRetryRequest(BaseModel):
186
+ task_ids: list[str]
187
+
188
+
189
+ @router.post("/retry-bulk")
190
+ def retry_bulk(payload: BulkRetryRequest, request: Request, username: str = Depends(get_current_user)):
191
+ """Re-dispatch several stored runs. 404 if any of them is unknown.
192
+
193
+ `bulk_retry` stops at the first unknown id (documented limitation — it is
194
+ not partial-failure aware), so an unknown id in the batch is reported the
195
+ same way a single unknown id is, rather than as a 500.
196
+ """
197
+ celery_app = request.app.state.cm_config.celery_app
198
+ session_factory = request.app.state.cm_session_factory
199
+ with session_factory() as session:
200
+ try:
201
+ new_ids = bulk_retry(celery_app, session, payload.task_ids, username=username)
202
+ except TaskRunNotFound as exc:
203
+ raise HTTPException(status_code=404, detail=str(exc))
204
+ return {"new_task_ids": new_ids}