diff --git a/src/dashboard/routes/tasks.py b/src/dashboard/routes/tasks.py index 69f2fc72..4b9c0841 100644 --- a/src/dashboard/routes/tasks.py +++ b/src/dashboard/routes/tasks.py @@ -104,25 +104,29 @@ class _TaskView: @router.get("/tasks", response_class=HTMLResponse) async def tasks_page(request: Request): """Render the main task queue page with 3-column layout.""" - with _get_db() as db: - pending = [ - _TaskView(_row_to_dict(r)) - for r in db.execute( - "SELECT * FROM tasks WHERE status IN ('pending_approval') ORDER BY created_at DESC" - ).fetchall() - ] - active = [ - _TaskView(_row_to_dict(r)) - for r in db.execute( - "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC" - ).fetchall() - ] - completed = [ - _TaskView(_row_to_dict(r)) - for r in db.execute( - "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50" - ).fetchall() - ] + try: + with _get_db() as db: + pending = [ + _TaskView(_row_to_dict(r)) + for r in db.execute( + "SELECT * FROM tasks WHERE status IN ('pending_approval') ORDER BY created_at DESC" + ).fetchall() + ] + active = [ + _TaskView(_row_to_dict(r)) + for r in db.execute( + "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC" + ).fetchall() + ] + completed = [ + _TaskView(_row_to_dict(r)) + for r in db.execute( + "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50" + ).fetchall() + ] + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + pending, active, completed = [], [], [] return templates.TemplateResponse( request, @@ -146,10 +150,14 @@ async def tasks_page(request: Request): @router.get("/tasks/pending", response_class=HTMLResponse) async def tasks_pending(request: Request): """Return HTMX partial for pending approval tasks.""" - with _get_db() as db: - rows = db.execute( - "SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC" - ).fetchall() + try: + with _get_db() as db: + rows = db.execute( + "SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC" + ).fetchall() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + return HTMLResponse('
Database unavailable
') tasks = [_TaskView(_row_to_dict(r)) for r in rows] parts = [] for task in tasks: @@ -166,10 +174,14 @@ async def tasks_pending(request: Request): @router.get("/tasks/active", response_class=HTMLResponse) async def tasks_active(request: Request): """Return HTMX partial for active (approved/running/paused) tasks.""" - with _get_db() as db: - rows = db.execute( - "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC" - ).fetchall() + try: + with _get_db() as db: + rows = db.execute( + "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC" + ).fetchall() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + return HTMLResponse('
Database unavailable
') tasks = [_TaskView(_row_to_dict(r)) for r in rows] parts = [] for task in tasks: @@ -186,10 +198,14 @@ async def tasks_active(request: Request): @router.get("/tasks/completed", response_class=HTMLResponse) async def tasks_completed(request: Request): """Return HTMX partial for completed/vetoed/failed tasks (last 50).""" - with _get_db() as db: - rows = db.execute( - "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50" - ).fetchall() + try: + with _get_db() as db: + rows = db.execute( + "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50" + ).fetchall() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + return HTMLResponse('
Database unavailable
') tasks = [_TaskView(_row_to_dict(r)) for r in rows] parts = [] for task in tasks: @@ -225,13 +241,17 @@ async def create_task_form( now = datetime.now(UTC).isoformat() priority = priority if priority in VALID_PRIORITIES else "normal" - with _get_db() as db: - db.execute( - "INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)", - (task_id, title, description, priority, assigned_to, now), - ) - db.commit() - row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + try: + with _get_db() as db: + db.execute( + "INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (task_id, title, description, priority, assigned_to, now), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + raise HTTPException(status_code=503, detail="Task database unavailable") from exc task = _TaskView(_row_to_dict(row)) return templates.TemplateResponse(request, "partials/task_card.html", {"task": task}) @@ -280,13 +300,17 @@ async def modify_task( description: str = Form(""), ): """Update task title and description.""" - with _get_db() as db: - db.execute( - "UPDATE tasks SET title=?, description=? WHERE id=?", - (title, description, task_id), - ) - db.commit() - row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + try: + with _get_db() as db: + db.execute( + "UPDATE tasks SET title=?, description=? WHERE id=?", + (title, description, task_id), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + raise HTTPException(status_code=503, detail="Task database unavailable") from exc if not row: raise HTTPException(404, "Task not found") task = _TaskView(_row_to_dict(row)) @@ -298,13 +322,17 @@ async def _set_status(request: Request, task_id: str, new_status: str): completed_at = ( datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None ) - with _get_db() as db: - db.execute( - "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?", - (new_status, completed_at, task_id), - ) - db.commit() - row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + try: + with _get_db() as db: + db.execute( + "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?", + (new_status, completed_at, task_id), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + raise HTTPException(status_code=503, detail="Task database unavailable") from exc if not row: raise HTTPException(404, "Task not found") task = _TaskView(_row_to_dict(row)) @@ -330,22 +358,26 @@ async def api_create_task(request: Request): if priority not in VALID_PRIORITIES: priority = "normal" - with _get_db() as db: - db.execute( - "INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - task_id, - title, - body.get("description", ""), - priority, - body.get("assigned_to", ""), - body.get("created_by", "operator"), - now, - ), - ) - db.commit() - row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + try: + with _get_db() as db: + db.execute( + "INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + task_id, + title, + body.get("description", ""), + priority, + body.get("assigned_to", ""), + body.get("created_by", "operator"), + now, + ), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + raise HTTPException(status_code=503, detail="Task database unavailable") from exc return JSONResponse(_row_to_dict(row), status_code=201) @@ -353,8 +385,12 @@ async def api_create_task(request: Request): @router.get("/api/tasks", response_class=JSONResponse) async def api_list_tasks(): """List all tasks as JSON.""" - with _get_db() as db: - rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall() + try: + with _get_db() as db: + rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + return JSONResponse([], status_code=200) return JSONResponse([_row_to_dict(r) for r in rows]) @@ -369,13 +405,17 @@ async def api_update_status(task_id: str, request: Request): completed_at = ( datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None ) - with _get_db() as db: - db.execute( - "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?", - (new_status, completed_at, task_id), - ) - db.commit() - row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + try: + with _get_db() as db: + db.execute( + "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?", + (new_status, completed_at, task_id), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + raise HTTPException(status_code=503, detail="Task database unavailable") from exc if not row: raise HTTPException(404, "Task not found") return JSONResponse(_row_to_dict(row)) @@ -384,9 +424,13 @@ async def api_update_status(task_id: str, request: Request): @router.delete("/api/tasks/{task_id}", response_class=JSONResponse) async def api_delete_task(task_id: str): """Delete a task.""" - with _get_db() as db: - cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,)) - db.commit() + try: + with _get_db() as db: + cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,)) + db.commit() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + raise HTTPException(status_code=503, detail="Task database unavailable") from exc if cursor.rowcount == 0: raise HTTPException(404, "Task not found") return JSONResponse({"success": True, "id": task_id}) @@ -400,15 +444,19 @@ async def api_delete_task(task_id: str): @router.get("/api/queue/status", response_class=JSONResponse) async def queue_status(assigned_to: str = "default"): """Return queue status for the chat panel's agent status indicator.""" - with _get_db() as db: - running = db.execute( - "SELECT * FROM tasks WHERE status='running' AND assigned_to=? LIMIT 1", - (assigned_to,), - ).fetchone() - ahead = db.execute( - "SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved') AND assigned_to=?", - (assigned_to,), - ).fetchone() + try: + with _get_db() as db: + running = db.execute( + "SELECT * FROM tasks WHERE status='running' AND assigned_to=? LIMIT 1", + (assigned_to,), + ).fetchone() + ahead = db.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved') AND assigned_to=?", + (assigned_to,), + ).fetchone() + except sqlite3.OperationalError as exc: + logger.warning("Task DB unavailable: %s", exc) + return JSONResponse({"is_working": False, "current_task": None, "tasks_ahead": 0}) if running: return JSONResponse( diff --git a/tests/dashboard/test_tasks_api.py b/tests/dashboard/test_tasks_api.py index 8afc5a6a..b94f8bfb 100644 --- a/tests/dashboard/test_tasks_api.py +++ b/tests/dashboard/test_tasks_api.py @@ -3,6 +3,99 @@ Verifies task CRUD operations and the dashboard page rendering. """ +import sqlite3 +from unittest.mock import patch + + +# --------------------------------------------------------------------------- +# DB error handling tests +# --------------------------------------------------------------------------- + +_DB_ERROR = sqlite3.OperationalError("database is locked") + + +def test_tasks_page_degrades_on_db_error(client): + """GET /tasks renders empty columns when DB is unavailable.""" + with patch( + "dashboard.routes.tasks._get_db", + side_effect=_DB_ERROR, + ): + response = client.get("/tasks") + assert response.status_code == 200 + assert "TASK QUEUE" in response.text + + +def test_pending_partial_degrades_on_db_error(client): + """GET /tasks/pending returns fallback HTML when DB is unavailable.""" + with patch( + "dashboard.routes.tasks._get_db", + side_effect=_DB_ERROR, + ): + response = client.get("/tasks/pending") + assert response.status_code == 200 + assert "Database unavailable" in response.text + + +def test_active_partial_degrades_on_db_error(client): + """GET /tasks/active returns fallback HTML when DB is unavailable.""" + with patch( + "dashboard.routes.tasks._get_db", + side_effect=_DB_ERROR, + ): + response = client.get("/tasks/active") + assert response.status_code == 200 + assert "Database unavailable" in response.text + + +def test_completed_partial_degrades_on_db_error(client): + """GET /tasks/completed returns fallback HTML when DB is unavailable.""" + with patch( + "dashboard.routes.tasks._get_db", + side_effect=_DB_ERROR, + ): + response = client.get("/tasks/completed") + assert response.status_code == 200 + assert "Database unavailable" in response.text + + +def test_api_create_task_503_on_db_error(client): + """POST /api/tasks returns 503 when DB is unavailable.""" + with patch( + "dashboard.routes.tasks._get_db", + side_effect=_DB_ERROR, + ): + response = client.post("/api/tasks", json={"title": "Test"}) + assert response.status_code == 503 + + +def test_api_list_tasks_empty_on_db_error(client): + """GET /api/tasks returns empty list when DB is unavailable.""" + with patch( + "dashboard.routes.tasks._get_db", + side_effect=_DB_ERROR, + ): + response = client.get("/api/tasks") + assert response.status_code == 200 + assert response.json() == [] + + +def test_queue_status_degrades_on_db_error(client): + """GET /api/queue/status returns idle status when DB is unavailable.""" + with patch( + "dashboard.routes.tasks._get_db", + side_effect=_DB_ERROR, + ): + response = client.get("/api/queue/status") + assert response.status_code == 200 + data = response.json() + assert data["is_working"] is False + assert data["current_task"] is None + + +# --------------------------------------------------------------------------- +# Existing tests +# --------------------------------------------------------------------------- + def test_tasks_page_returns_200(client): response = client.get("/tasks")