From f67930aefbe9590239dade66e086722a4f922b2b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 09:39:13 +0200 Subject: [PATCH] feat: expose sanitized background job history --- CHANGELOG.md | 2 ++ TODO.md | 6 ++++-- VERSION | 2 +- core/database.py | 15 +++++++++++++++ routes/jobs.py | 32 ++++++++++++++++++++++++++++++++ server.py | 3 ++- tests/test_security.py | 18 ++++++++++++++++++ 7 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 routes/jobs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 284eafe..1761946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi failures with exponential backoff while closing discarded responses. - **Constrained DOM Coverage.** Added a 250-character browser fixture proving character-library rendering stays within the configured 50-row page and expands only on explicit user action. +- **Sanitized Job History API.** Added bounded `/api/jobs` and `/api/jobs/{job_id}` endpoints for recent restart-safe + job metadata, with fingerprints and sensitive/binary payloads excluded. - **4 GiB Container Validation.** Verified the current image serves the application under a hard 4 GiB cgroup limit with no OOM kill; the probe recorded approximately 50.6 MiB idle memory and 0.09% CPU. - **Profile Completeness Coverage.** Added browser regression coverage for empty, partial, and fully populated diff --git a/TODO.md b/TODO.md index 08a6a10..7e26936 100644 --- a/TODO.md +++ b/TODO.md @@ -66,7 +66,8 @@ the first block before investing in architecture or polish. - [ ] **L — Add background jobs and bounded queues.** Image generation already has the full interactive job path, and transcription, voice design, and casting now have optional ID/status/cancel/timeout paths while preserving synchronous callers. Job metadata now persists in SQLite and marks interrupted work after restart; durable binary - result storage remains; outbound TTS and LLM calls now share a bounded transient-failure retry policy. + result storage remains; outbound TTS and LLM calls now share a bounded transient-failure retry policy, and `/api/jobs` + exposes sanitized recent job history. - [ ] **L — Run the Book 2 generalization regression.** Added a neutral multi-speaker fixture and parser regression; a real Book 2 auto-cast/attribution output still needs to be captured and reviewed to confirm rules are not overfit to *Der Sturm*. @@ -191,7 +192,8 @@ the first block before investing in architecture or polish. - [ ] Add structured logs with request and job IDs. - [ ] Report provider, endpoint, duration, and error reason clearly. - [ ] Separate health status from “configured”. -- [ ] Add visible job history and retry controls. +- [ ] Add visible job history and retry controls; the authenticated `/api/jobs` metadata endpoint is available for the + UI, but a rendered history panel and safe retry action remain. - [ ] Avoid logging prompts, API keys, or book contents by default. ## P1 — Resource limits and constrained-device testing diff --git a/VERSION b/VERSION index f9e8384..e4a973f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.1 +1.24.2 diff --git a/core/database.py b/core/database.py index 602eb2a..0417cc5 100644 --- a/core/database.py +++ b/core/database.py @@ -292,6 +292,21 @@ def background_job_get(job_id: str) -> dict | None: return result +def background_job_list(kind: str = "", limit: int = 50) -> list[dict]: + limit = max(1, min(int(limit), 100)) + with _open() as conn: + if kind: + rows = conn.execute( + "SELECT * FROM background_jobs WHERE kind=? ORDER BY updated DESC LIMIT ?", + (str(kind)[:80], limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM background_jobs ORDER BY updated DESC LIMIT ?", (limit,) + ).fetchall() + return [background_job_get(row["job_id"]) for row in rows] + + def _row_to_char(row: sqlite3.Row) -> dict: d = dict(row) d["sheet"] = json.loads(d.get("sheet") or "{}") diff --git a/routes/jobs.py b/routes/jobs.py new file mode 100644 index 0000000..df1146c --- /dev/null +++ b/routes/jobs.py @@ -0,0 +1,32 @@ +"""Read-only status history for restart-safe background jobs.""" +from __future__ import annotations + +import time + +from fastapi import APIRouter, HTTPException + +from core.database import background_job_get, background_job_list + +router = APIRouter() + + +def _public_job(job: dict) -> dict: + payload = dict(job) + payload.pop("fingerprint", None) + payload.pop("created", None) + payload.pop("updated", None) + payload["elapsed"] = round(time.time() - float(job.get("created") or time.time()), 1) + return payload + + +@router.get("/api/jobs") +async def jobs_list(kind: str = "", limit: int = 50): + return {"jobs": [_public_job(job) for job in background_job_list(kind, limit) if job]} + + +@router.get("/api/jobs/{job_id}") +async def jobs_get(job_id: str): + job = background_job_get(job_id) + if job is None: + raise HTTPException(404, "Unknown or expired job") + return _public_job(job) diff --git a/server.py b/server.py index c761823..2896390 100644 --- a/server.py +++ b/server.py @@ -18,7 +18,7 @@ from starlette.datastructures import MutableHeaders from core.constants import STATIC_DIR, _BufferHandler, _MAX_JSON_BYTES, _MAX_UPLOAD_BYTES, _MAX_RAW_REQUEST_BYTES, _REQUIRE_EXTERNAL_API_KEY from core.config import _load_settings, _ensure_external_api_key from core.voice_index import refresh_voice_index_background -from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery, history +from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery, history, jobs logger = logging.getLogger("uvicorn.error") @@ -296,6 +296,7 @@ app.include_router(characters.router) app.include_router(rehearsals_db.router) app.include_router(recovery.router) app.include_router(history.router) +app.include_router(jobs.router) # ── Static files ────────────────────────────────────────────────────────────── diff --git a/tests/test_security.py b/tests/test_security.py index 3e22fa8..976d109 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -241,6 +241,24 @@ class SecurityValidationTests(unittest.TestCase): conn.execute("DELETE FROM background_jobs WHERE job_id LIKE ?", (prefix + "%",)) conn.commit() + def test_job_history_endpoint_is_bounded_and_redacts_fingerprints(self): + from core.database import background_job_create, background_job_update + from routes.jobs import jobs_get, jobs_list + job_id = "public-job-test" + background_job_create(job_id, "test", fingerprint="private-fingerprint") + background_job_update(job_id, stage="done", done=True, result={"id": "safe"}) + try: + single = asyncio.run(jobs_get(job_id)) + listing = asyncio.run(jobs_list(limit=1000)) + self.assertNotIn("fingerprint", single) + self.assertEqual(single["result"], {"id": "safe"}) + self.assertLessEqual(len(listing["jobs"]), 100) + finally: + from core.database import _open + with _open() as conn: + conn.execute("DELETE FROM background_jobs WHERE job_id=?", (job_id,)) + conn.commit() + def test_fish_cache_eviction_is_bounded(self): from pathlib import Path from tempfile import TemporaryDirectory