diff --git a/bench_loop/dashboard/api/routes/benchmark.py b/bench_loop/dashboard/api/routes/benchmark.py index 28385db..9690553 100644 --- a/bench_loop/dashboard/api/routes/benchmark.py +++ b/bench_loop/dashboard/api/routes/benchmark.py @@ -3,6 +3,7 @@ import asyncio import json +import shutil import time import uuid from datetime import datetime, timezone @@ -386,3 +387,25 @@ async def get_run(run_id: str): "result": data, "hardware": hw_data, } + + +@router.delete("/benchmark/runs/{run_id}") +async def delete_run(run_id: str): + """Delete a persisted local benchmark run.""" + state = _active_runs.get(run_id) + if state and state.get("status") not in ("completed", "failed", "cancelled"): + raise HTTPException(status_code=409, detail="Cannot delete an active run. Cancel it first.") + + runs_root = RUNS_DIR.resolve() + run_dir = (RUNS_DIR / run_id).resolve() + if run_dir.parent != runs_root: + raise HTTPException(status_code=400, detail="Invalid run id") + + if not run_dir.exists(): + raise HTTPException(status_code=404, detail="Run not found") + if not run_dir.is_dir(): + raise HTTPException(status_code=400, detail="Run path is not a directory") + + shutil.rmtree(run_dir) + _active_runs.pop(run_id, None) + return {"ok": True, "run_id": run_id} diff --git a/tests/test_dashboard_runs.py b/tests/test_dashboard_runs.py new file mode 100644 index 0000000..b6020c7 --- /dev/null +++ b/tests/test_dashboard_runs.py @@ -0,0 +1,52 @@ +import asyncio + +import pytest +from fastapi import HTTPException + +from bench_loop.dashboard.api.routes import benchmark + + +def run(coro): + return asyncio.run(coro) + + +def test_delete_run_removes_persisted_run_directory(tmp_path, monkeypatch): + monkeypatch.setattr(benchmark, "RUNS_DIR", tmp_path) + run_dir = tmp_path / "20260601-120000-model-local-ollama" + run_dir.mkdir() + (run_dir / "run.json").write_text("{}", encoding="utf-8") + + result = run(benchmark.delete_run(run_dir.name)) + + assert result == {"ok": True, "run_id": run_dir.name} + assert not run_dir.exists() + + +def test_delete_run_rejects_path_traversal(tmp_path, monkeypatch): + monkeypatch.setattr(benchmark, "RUNS_DIR", tmp_path) + + with pytest.raises(HTTPException) as exc: + run(benchmark.delete_run("../outside")) + + assert exc.value.status_code == 400 + + +def test_delete_run_rejects_active_run(tmp_path, monkeypatch): + monkeypatch.setattr(benchmark, "RUNS_DIR", tmp_path) + benchmark._active_runs["active123"] = {"status": "running"} + + try: + with pytest.raises(HTTPException) as exc: + run(benchmark.delete_run("active123")) + assert exc.value.status_code == 409 + finally: + benchmark._active_runs.pop("active123", None) + + +def test_delete_run_returns_404_for_missing_run(tmp_path, monkeypatch): + monkeypatch.setattr(benchmark, "RUNS_DIR", tmp_path) + + with pytest.raises(HTTPException) as exc: + run(benchmark.delete_run("missing")) + + assert exc.value.status_code == 404