runrail 0.3.1__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 (43) hide show
  1. runrail/__init__.py +4 -0
  2. runrail/api/__init__.py +1 -0
  3. runrail/api/app.py +52 -0
  4. runrail/api/crud.py +75 -0
  5. runrail/api/routes_resources.py +202 -0
  6. runrail/api/routes_runs.py +193 -0
  7. runrail/api/routes_workflows.py +168 -0
  8. runrail/api/routes_ws.py +80 -0
  9. runrail/api/ws.py +40 -0
  10. runrail/cli.py +305 -0
  11. runrail/config.py +68 -0
  12. runrail/db.py +52 -0
  13. runrail/environments.py +306 -0
  14. runrail/importer.py +132 -0
  15. runrail/maintenance.py +76 -0
  16. runrail/migrations/env.py +25 -0
  17. runrail/migrations/script.py.mako +12 -0
  18. runrail/migrations/versions/b3e7f91a2c04_workflow_project_env.py +18 -0
  19. runrail/migrations/versions/cc9cf8e3d4ad_initial_schema.py +138 -0
  20. runrail/migrations/versions/d7a4b18c2f10_managed_environment_lifecycle.py +47 -0
  21. runrail/migrations/versions/e2f96a3d710b_adopt_legacy_managed_environments.py +74 -0
  22. runrail/migrations/versions/f8b2c41e9d06_managed_notebook_runtime.py +27 -0
  23. runrail/migrations/versions/g3d7a09f42ce_active_environment_packages.py +21 -0
  24. runrail/migrations/versions/h5e2b71c9a3d_workflow_notifications.py +19 -0
  25. runrail/models.py +206 -0
  26. runrail/notify.py +91 -0
  27. runrail/scheduler/__init__.py +1 -0
  28. runrail/scheduler/service.py +102 -0
  29. runrail/schemas.py +192 -0
  30. runrail/web/static/assets/index-CxoNVeSC.css +1 -0
  31. runrail/web/static/assets/index-DVXGj4jA.js +23 -0
  32. runrail/web/static/index.html +21 -0
  33. runrail/worker/__init__.py +1 -0
  34. runrail/worker/queue.py +58 -0
  35. runrail/worker/runners.py +147 -0
  36. runrail/worker/service.py +497 -0
  37. runrail/worker/templating.py +15 -0
  38. runrail/workflow_io.py +163 -0
  39. runrail-0.3.1.dist-info/METADATA +194 -0
  40. runrail-0.3.1.dist-info/RECORD +43 -0
  41. runrail-0.3.1.dist-info/WHEEL +4 -0
  42. runrail-0.3.1.dist-info/entry_points.txt +2 -0
  43. runrail-0.3.1.dist-info/licenses/LICENSE +201 -0
runrail/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """RunRail workflow control plane."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1 @@
1
+
runrail/api/app.py ADDED
@@ -0,0 +1,52 @@
1
+ from contextlib import asynccontextmanager
2
+ from pathlib import Path
3
+
4
+ from fastapi import FastAPI, HTTPException
5
+ from fastapi.responses import FileResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+
8
+ from runrail.api.routes_resources import router as resources_router
9
+ from runrail.api.routes_runs import router as runs_router
10
+ from runrail.api.routes_workflows import router as workflows_router
11
+ from runrail.api.routes_ws import router as ws_router
12
+ from runrail.api.ws import manager as ws_manager
13
+ from runrail.db import init_db
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(app: FastAPI):
18
+ import asyncio
19
+ ws_manager.set_loop(asyncio.get_running_loop())
20
+ init_db()
21
+ yield
22
+
23
+
24
+ def create_app() -> FastAPI:
25
+ app = FastAPI(title="RunRail", version="0.3.1", lifespan=lifespan)
26
+ app.include_router(ws_router)
27
+ app.include_router(resources_router); app.include_router(workflows_router); app.include_router(runs_router)
28
+
29
+ @app.get("/api/health")
30
+ def health(): return {"status": "ok", "service": "runrail"}
31
+
32
+ static = Path(__file__).parents[1] / "web" / "static"
33
+ assets = static / "assets"
34
+ if assets.is_dir(): app.mount("/assets", StaticFiles(directory=assets), name="assets")
35
+
36
+ @app.get("/{path:path}", include_in_schema=False)
37
+ def frontend(path: str):
38
+ # Unknown API paths must not fall through to the SPA index page.
39
+ if path == "api" or path.startswith("api/"):
40
+ raise HTTPException(404, "Not found")
41
+ if path:
42
+ requested = (static / path).resolve()
43
+ # Confine file serving to the static directory (no path traversal).
44
+ if requested.is_relative_to(static.resolve()) and requested.is_file():
45
+ return FileResponse(requested)
46
+ index = static / "index.html"
47
+ if index.is_file(): return FileResponse(index)
48
+ return {"message": "RunRail API is running; frontend assets are not built."}
49
+ return app
50
+
51
+
52
+ app = create_app()
runrail/api/crud.py ADDED
@@ -0,0 +1,75 @@
1
+ from datetime import date, timedelta
2
+ from typing import Any
3
+
4
+ from fastapi import HTTPException
5
+ from sqlalchemy import select
6
+ from sqlalchemy.exc import IntegrityError
7
+ from sqlalchemy.orm import Session
8
+
9
+ from runrail.models import Environment, EnvironmentStatus, TriggerType, Workflow, WorkflowRun
10
+
11
+
12
+ def save(db: Session, obj):
13
+ try:
14
+ db.add(obj)
15
+ db.commit()
16
+ db.refresh(obj)
17
+ return obj
18
+ except IntegrityError as exc:
19
+ db.rollback()
20
+ raise HTTPException(409, "A record with that name already exists") from exc
21
+
22
+
23
+ def get_or_404(db: Session, model, object_id: int):
24
+ obj = db.get(model, object_id)
25
+ if obj is None:
26
+ raise HTTPException(404, f"{model.__name__} not found")
27
+ return obj
28
+
29
+
30
+ def apply_update(obj, data: dict[str, Any]):
31
+ for key, value in data.items():
32
+ setattr(obj, key, value)
33
+ return obj
34
+
35
+
36
+ def ensure_environment_ready(db: Session, environment_id: int | None) -> None:
37
+ if environment_id is None:
38
+ return
39
+ environment = get_or_404(db, Environment, environment_id)
40
+ if environment.status not in (EnvironmentStatus.ready, EnvironmentStatus.degraded):
41
+ raise HTTPException(
42
+ 409,
43
+ f"Environment '{environment.name}' is not ready: "
44
+ f"{environment.last_error or environment.status.value}",
45
+ )
46
+
47
+
48
+ def create_run(db: Session, workflow: Workflow, trigger: TriggerType, params=None, run_key=None):
49
+ return save(db, WorkflowRun(
50
+ workflow_id=workflow.id, status="queued", trigger_type=trigger,
51
+ parameters_json=params or {}, run_key=run_key,
52
+ ))
53
+
54
+
55
+ def create_backfill(db: Session, workflow: Workflow, start: date, end: date, params=None):
56
+ if end < start:
57
+ raise HTTPException(400, "Backfill 'to' date must be on or after 'from' date")
58
+ existing = set(db.scalars(select(WorkflowRun.run_key).where(
59
+ WorkflowRun.run_key.like(f"backfill:{workflow.id}:%")
60
+ )))
61
+ runs = []
62
+ day = start
63
+ while day <= end:
64
+ key = f"backfill:{workflow.id}:{day.isoformat()}"
65
+ if key not in existing:
66
+ values = dict(params or {})
67
+ values["ds"] = day.isoformat()
68
+ runs.append(WorkflowRun(workflow_id=workflow.id, trigger_type=TriggerType.backfill,
69
+ status="queued", parameters_json=values, run_key=key))
70
+ day += timedelta(days=1)
71
+ db.add_all(runs)
72
+ db.commit()
73
+ for run in runs:
74
+ db.refresh(run)
75
+ return runs
@@ -0,0 +1,202 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, Response
4
+ from sqlalchemy import select
5
+ from sqlalchemy.orm import Session
6
+
7
+ from runrail.api.crud import apply_update, ensure_environment_ready, get_or_404, save
8
+ from runrail.config import get_settings
9
+ from runrail.db import get_db
10
+ from runrail.environments import (
11
+ managed_environment_path,
12
+ remove_managed_root,
13
+ validate_external,
14
+ validate_package_specs,
15
+ )
16
+ from runrail.models import Environment, EnvironmentStatus, EnvironmentType, Project
17
+ from runrail.schemas import (
18
+ EnvironmentIn,
19
+ EnvironmentOut,
20
+ EnvironmentRebuild,
21
+ EnvironmentUpdate,
22
+ ProjectIn,
23
+ ProjectOut,
24
+ )
25
+
26
+ router = APIRouter(prefix="/api")
27
+
28
+
29
+ @router.get("/filesystem")
30
+ def browse_filesystem(
31
+ path: str | None = None,
32
+ mode: str = Query("all", pattern="^(all|files|directories)$"),
33
+ ):
34
+ """Browse the configured local root for project and task path selection."""
35
+ root = get_settings().browse_root.expanduser().resolve()
36
+ target = Path(path).expanduser().resolve() if path else root
37
+ if target != root and root not in target.parents:
38
+ raise HTTPException(403, f"Path must be inside {root}")
39
+ if not target.is_dir():
40
+ raise HTTPException(404, "Directory not found")
41
+ try:
42
+ children = sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower()))
43
+ except PermissionError as exc:
44
+ raise HTTPException(403, "Directory cannot be read") from exc
45
+ entries = []
46
+ for item in children:
47
+ try:
48
+ is_dir = item.is_dir()
49
+ except OSError:
50
+ continue
51
+ if item.name.startswith("."):
52
+ continue
53
+ if mode == "directories" and not is_dir:
54
+ continue
55
+ entries.append({"name": item.name, "path": str(item), "is_directory": is_dir})
56
+ return {
57
+ "root": str(root),
58
+ "path": str(target),
59
+ "parent": str(target.parent) if target != root else None,
60
+ "entries": entries,
61
+ }
62
+
63
+
64
+ @router.get("/projects", response_model=list[ProjectOut])
65
+ def list_projects(db: Session = Depends(get_db)):
66
+ return db.scalars(select(Project).order_by(Project.name)).all()
67
+
68
+
69
+ @router.post("/projects", response_model=ProjectOut, status_code=201)
70
+ def create_project(data: ProjectIn, db: Session = Depends(get_db)):
71
+ ensure_environment_ready(db, data.default_environment_id)
72
+ return save(db, Project(**data.model_dump()))
73
+
74
+
75
+ @router.get("/projects/{object_id}", response_model=ProjectOut)
76
+ def get_project(object_id: int, db: Session = Depends(get_db)):
77
+ return get_or_404(db, Project, object_id)
78
+
79
+
80
+ @router.put("/projects/{object_id}", response_model=ProjectOut)
81
+ def update_project(object_id: int, data: ProjectIn, db: Session = Depends(get_db)):
82
+ ensure_environment_ready(db, data.default_environment_id)
83
+ return save(db, apply_update(get_or_404(db, Project, object_id), data.model_dump()))
84
+
85
+
86
+ @router.delete("/projects/{object_id}", status_code=204)
87
+ def delete_project(object_id: int, db: Session = Depends(get_db)):
88
+ db.delete(get_or_404(db, Project, object_id)); db.commit()
89
+ return Response(status_code=204)
90
+
91
+
92
+ @router.get("/environments", response_model=list[EnvironmentOut])
93
+ def list_environments(db: Session = Depends(get_db)):
94
+ return db.scalars(select(Environment).order_by(Environment.name)).all()
95
+
96
+
97
+ @router.post("/environments", response_model=EnvironmentOut, status_code=201)
98
+ def create_environment(data: EnvironmentIn, db: Session = Depends(get_db)):
99
+ if db.scalar(select(Environment.id).where(Environment.name == data.name)) is not None:
100
+ raise HTTPException(409, "An environment with that name already exists")
101
+ if data.create_venv and data.env_type != EnvironmentType.python:
102
+ raise HTTPException(400, "Managed environments must use the Python environment type")
103
+ packages = data.packages
104
+ if data.create_venv:
105
+ try:
106
+ packages = validate_package_specs(data.packages) # fail fast, not mid-build
107
+ except ValueError as exc:
108
+ raise HTTPException(400, str(exc)) from exc
109
+ values = data.model_dump(exclude={"create_venv", "packages", "base_executable"})
110
+ environment = Environment(
111
+ **values,
112
+ managed=data.create_venv,
113
+ status=EnvironmentStatus.creating if data.create_venv else EnvironmentStatus.ready,
114
+ base_executable=data.base_executable,
115
+ packages_json=packages,
116
+ )
117
+ if data.create_venv:
118
+ return save(db, environment)
119
+ try:
120
+ validate_external(environment)
121
+ except ValueError as exc:
122
+ raise HTTPException(400, str(exc)) from exc
123
+ return save(db, environment)
124
+
125
+
126
+ @router.get("/environments/{object_id}", response_model=EnvironmentOut)
127
+ def get_environment(object_id: int, db: Session = Depends(get_db)):
128
+ return get_or_404(db, Environment, object_id)
129
+
130
+
131
+ @router.put("/environments/{object_id}", response_model=EnvironmentOut)
132
+ def update_environment(object_id: int, data: EnvironmentUpdate, db: Session = Depends(get_db)):
133
+ environment = get_or_404(db, Environment, object_id)
134
+ if environment.status in (EnvironmentStatus.creating, EnvironmentStatus.building):
135
+ raise HTTPException(409, "Environment cannot be edited while a build is in progress")
136
+ values = data.model_dump(exclude_unset=True)
137
+ if values.get("env_type") is None:
138
+ values.pop("env_type", None)
139
+ if environment.managed:
140
+ values = {
141
+ key: value for key, value in values.items()
142
+ if key in {"name", "description", "env_vars_json"}
143
+ }
144
+ apply_update(environment, values)
145
+ if environment.managed:
146
+ return save(db, environment)
147
+ try:
148
+ validate_external(environment)
149
+ except ValueError as exc:
150
+ raise HTTPException(400, str(exc)) from exc
151
+ return save(db, environment)
152
+
153
+
154
+ @router.post("/environments/{object_id}/rebuild", response_model=EnvironmentOut)
155
+ def rebuild_environment(
156
+ object_id: int, data: EnvironmentRebuild, db: Session = Depends(get_db)
157
+ ):
158
+ environment = get_or_404(db, Environment, object_id)
159
+ if not environment.managed:
160
+ raise HTTPException(400, "Only managed environments can be rebuilt")
161
+ if environment.status in (EnvironmentStatus.creating, EnvironmentStatus.building):
162
+ raise HTTPException(409, "An environment build is already in progress")
163
+ if data.packages is not None:
164
+ try:
165
+ environment.packages_json = validate_package_specs(data.packages)
166
+ except ValueError as exc:
167
+ raise HTTPException(400, str(exc)) from exc
168
+ if data.base_executable is not None:
169
+ environment.base_executable = data.base_executable
170
+ environment.status = EnvironmentStatus.creating
171
+ environment.last_error = None
172
+ return save(db, environment)
173
+
174
+
175
+ @router.post("/environments/{object_id}/validate", response_model=EnvironmentOut)
176
+ def validate_environment(object_id: int, db: Session = Depends(get_db)):
177
+ environment = get_or_404(db, Environment, object_id)
178
+ if environment.managed:
179
+ if environment.status not in (EnvironmentStatus.ready, EnvironmentStatus.degraded) or not environment.executable:
180
+ raise HTTPException(400, environment.last_error or "Environment is not ready")
181
+ return environment
182
+ try:
183
+ validate_external(environment)
184
+ except ValueError as exc:
185
+ environment.status = EnvironmentStatus.failed
186
+ environment.last_error = str(exc)
187
+ db.commit()
188
+ raise HTTPException(400, str(exc)) from exc
189
+ return save(db, environment)
190
+
191
+
192
+ @router.delete("/environments/{object_id}", status_code=204)
193
+ def delete_environment(object_id: int, db: Session = Depends(get_db)):
194
+ environment = get_or_404(db, Environment, object_id)
195
+ if environment.status in (EnvironmentStatus.creating, EnvironmentStatus.building):
196
+ raise HTTPException(409, "Environment cannot be removed while a build is in progress")
197
+ # Resolve the on-disk location before the ORM object is invalidated by the delete.
198
+ managed_root = managed_environment_path(environment) if environment.managed else None
199
+ db.delete(environment); db.commit()
200
+ if managed_root is not None:
201
+ remove_managed_root(managed_root)
202
+ return Response(status_code=204)
@@ -0,0 +1,193 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from pathlib import Path
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, Query
5
+ from fastapi.responses import FileResponse, PlainTextResponse
6
+ from sqlalchemy import func, select
7
+ from sqlalchemy.orm import Session, selectinload
8
+
9
+ from runrail.api.crud import create_run, get_or_404
10
+ from runrail.api.ws import manager as ws_manager
11
+ from runrail.db import get_db
12
+ from runrail.models import (
13
+ Artifact,
14
+ RunStatus,
15
+ TaskRun,
16
+ TaskRunStatus,
17
+ TriggerType,
18
+ Workflow,
19
+ WorkflowRun,
20
+ now,
21
+ )
22
+ from runrail.schemas import TaskRunOut, WorkflowRunDetail, WorkflowRunOut
23
+
24
+ router = APIRouter(prefix="/api")
25
+
26
+
27
+ @router.get("/runs", response_model=list[WorkflowRunOut])
28
+ def list_runs(status: RunStatus | None = None, workflow_id: int | None = None,
29
+ limit: int = Query(100, ge=1, le=1000), db: Session = Depends(get_db)):
30
+ stmt = select(WorkflowRun).order_by(WorkflowRun.created_at.desc()).limit(limit)
31
+ if status: stmt = stmt.where(WorkflowRun.status == status)
32
+ if workflow_id: stmt = stmt.where(WorkflowRun.workflow_id == workflow_id)
33
+ return db.scalars(stmt).all()
34
+
35
+
36
+ @router.get("/runs/{object_id}", response_model=WorkflowRunDetail)
37
+ def get_run(object_id: int, db: Session = Depends(get_db)):
38
+ obj = db.scalar(select(WorkflowRun).where(WorkflowRun.id == object_id).options(
39
+ selectinload(WorkflowRun.task_runs).selectinload(TaskRun.task)))
40
+ if not obj: raise HTTPException(404, "WorkflowRun not found")
41
+ return obj
42
+
43
+
44
+ @router.post("/runs/{object_id}/cancel", response_model=WorkflowRunOut)
45
+ def cancel_run(object_id: int, db: Session = Depends(get_db)):
46
+ """Cancel a queued run immediately; a running run stops before its next task."""
47
+ run = get_or_404(db, WorkflowRun, object_id)
48
+ if run.status not in (RunStatus.queued, RunStatus.running):
49
+ raise HTTPException(409, f"Run is already {run.status.value}")
50
+ was_queued = run.status == RunStatus.queued
51
+ run.status = RunStatus.cancelled
52
+ if was_queued:
53
+ run.finished_at = now()
54
+ for task_run in db.scalars(select(TaskRun).where(
55
+ TaskRun.workflow_run_id == run.id, TaskRun.status == TaskRunStatus.queued)):
56
+ task_run.status = TaskRunStatus.cancelled
57
+ db.commit()
58
+ db.refresh(run)
59
+ ws_manager.notify({"type": "run_updated", "id": run.id})
60
+ return run
61
+
62
+
63
+ @router.post("/runs/{object_id}/retry", response_model=WorkflowRunOut, status_code=201)
64
+ def retry_run(object_id: int, db: Session = Depends(get_db)):
65
+ """Queue a fresh run of the same workflow with this run's parameters.
66
+
67
+ Allowed even when the workflow is disabled/auto-paused: 'enabled' only
68
+ gates the scheduler, and retrying is how you verify a fix before unpausing.
69
+ """
70
+ source = get_or_404(db, WorkflowRun, object_id)
71
+ if source.status in (RunStatus.queued, RunStatus.running):
72
+ raise HTTPException(409, "Run is still in progress")
73
+ workflow = get_or_404(db, Workflow, source.workflow_id)
74
+ run = create_run(db, workflow, TriggerType.manual, dict(source.parameters_json or {}))
75
+ ws_manager.notify({"type": "run_created", "id": run.id, "workflow_id": run.workflow_id})
76
+ return run
77
+
78
+
79
+ @router.get("/task-runs/{object_id}", response_model=TaskRunOut)
80
+ def get_task_run(object_id: int, db: Session = Depends(get_db)):
81
+ return get_or_404(db, TaskRun, object_id)
82
+
83
+
84
+ def log_response(task_run: TaskRun, attr: str, tail_bytes: int | None = None):
85
+ path = getattr(task_run, attr)
86
+ if not path: return PlainTextResponse("", status_code=200)
87
+ file = Path(path)
88
+ if not file.is_file(): raise HTTPException(404, "Log file not found")
89
+ if tail_bytes is not None:
90
+ # Serve only the end of the file so huge logs never sit in API memory.
91
+ size = file.stat().st_size
92
+ with file.open("rb") as handle:
93
+ if size > tail_bytes:
94
+ handle.seek(size - tail_bytes)
95
+ data = handle.read()
96
+ return PlainTextResponse(data.decode("utf-8", errors="replace"))
97
+ return PlainTextResponse(file.read_text(errors="replace"))
98
+
99
+
100
+ @router.get("/task-runs/{object_id}/stdout")
101
+ def stdout(object_id: int, tail_bytes: int | None = Query(None, ge=1),
102
+ db: Session = Depends(get_db)):
103
+ return log_response(get_or_404(db, TaskRun, object_id), "stdout_log_path", tail_bytes)
104
+
105
+
106
+ @router.get("/task-runs/{object_id}/stderr")
107
+ def stderr(object_id: int, tail_bytes: int | None = Query(None, ge=1),
108
+ db: Session = Depends(get_db)):
109
+ return log_response(get_or_404(db, TaskRun, object_id), "stderr_log_path", tail_bytes)
110
+
111
+
112
+ @router.get("/stats/summary")
113
+ def stats_summary(db: Session = Depends(get_db)):
114
+ """Dashboard headline metrics, aggregated in SQL.
115
+
116
+ The UI previously derived these from a capped /runs fetch, which silently
117
+ under-counts once a workflow runs often enough to overflow the limit (a
118
+ 5-minute schedule is ~288 runs/day). Aggregating in the database keeps the
119
+ numbers correct at any volume.
120
+ """
121
+ now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
122
+ since_24h = now_utc - timedelta(days=1)
123
+ since_7d = now_utc - timedelta(days=7)
124
+
125
+ def count(*conditions) -> int:
126
+ return db.scalar(select(func.count()).select_from(WorkflowRun).where(*conditions)) or 0
127
+
128
+ running = count(WorkflowRun.status == RunStatus.running)
129
+ queued = count(WorkflowRun.status == RunStatus.queued)
130
+ done_7d = count(WorkflowRun.created_at >= since_7d,
131
+ WorkflowRun.status.in_((RunStatus.success, RunStatus.failed)))
132
+ success_7d = count(WorkflowRun.created_at >= since_7d, WorkflowRun.status == RunStatus.success)
133
+ avg_24h = db.scalar(
134
+ select(func.avg(WorkflowRun.duration_seconds)).where(
135
+ WorkflowRun.created_at >= since_24h, WorkflowRun.duration_seconds.is_not(None)))
136
+ return {
137
+ "running": running,
138
+ "queued": queued,
139
+ "live": running + queued,
140
+ "runs_24h": count(WorkflowRun.created_at >= since_24h),
141
+ "succeeded_24h": count(WorkflowRun.created_at >= since_24h,
142
+ WorkflowRun.status == RunStatus.success),
143
+ "failed_24h": count(WorkflowRun.created_at >= since_24h,
144
+ WorkflowRun.status == RunStatus.failed),
145
+ "avg_duration_24h": float(avg_24h) if avg_24h is not None else None,
146
+ "done_7d": done_7d,
147
+ "success_7d": success_7d,
148
+ "success_rate_7d": round(success_7d / done_7d * 100) if done_7d else None,
149
+ }
150
+
151
+
152
+ @router.get("/stats/daily")
153
+ def daily_stats(days: int = Query(112, ge=1, le=366), workflow_id: int | None = None,
154
+ db: Session = Depends(get_db)):
155
+ """Per-day run counts by outcome, for activity heatmaps."""
156
+ since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
157
+ day = func.date(WorkflowRun.created_at)
158
+ stmt = (select(day.label("day"), WorkflowRun.status, func.count())
159
+ .where(WorkflowRun.created_at >= since)
160
+ .group_by(day, WorkflowRun.status))
161
+ if workflow_id:
162
+ stmt = stmt.where(WorkflowRun.workflow_id == workflow_id)
163
+ buckets: dict[str, dict] = {}
164
+ for value, status, count in db.execute(stmt):
165
+ entry = buckets.setdefault(str(value), {"date": str(value), "success": 0, "failed": 0, "other": 0})
166
+ key = getattr(status, "value", str(status))
167
+ if key in ("success", "failed"):
168
+ entry[key] += count
169
+ else:
170
+ entry["other"] += count
171
+ return sorted(buckets.values(), key=lambda item: item["date"])
172
+
173
+
174
+ @router.get("/artifacts")
175
+ def list_artifacts(workflow_run_id: int | None = None, task_run_id: int | None = None,
176
+ limit: int = Query(200, ge=1, le=1000), db: Session = Depends(get_db)):
177
+ stmt = select(Artifact).order_by(Artifact.created_at.desc()).limit(limit)
178
+ if workflow_run_id: stmt = stmt.where(Artifact.workflow_run_id == workflow_run_id)
179
+ if task_run_id: stmt = stmt.where(Artifact.task_run_id == task_run_id)
180
+ return db.scalars(stmt).all()
181
+
182
+
183
+ @router.get("/artifacts/{object_id}")
184
+ def get_artifact(object_id: int, db: Session = Depends(get_db)):
185
+ return get_or_404(db, Artifact, object_id)
186
+
187
+
188
+ @router.get("/artifacts/{object_id}/download")
189
+ def download_artifact(object_id: int, db: Session = Depends(get_db)):
190
+ artifact = get_or_404(db, Artifact, object_id)
191
+ path = Path(artifact.path)
192
+ if not path.is_file(): raise HTTPException(404, "Artifact file not found")
193
+ return FileResponse(path, filename=artifact.name)