feat: expose sanitized background job history

This commit is contained in:
mARTin-B78 2026-09-04 09:39:13 +02:00
parent b0f475d850
commit f67930aefb
7 changed files with 74 additions and 4 deletions

View File

@ -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. failures with exponential backoff while closing discarded responses.
- **Constrained DOM Coverage.** Added a 250-character browser fixture proving character-library rendering stays within - **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. 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 - **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. 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 - **Profile Completeness Coverage.** Added browser regression coverage for empty, partial, and fully populated

View File

@ -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, - [ ] **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 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 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; - [ ] **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 a real Book 2 auto-cast/attribution output still needs to be captured and reviewed to confirm rules are not overfit
to *Der Sturm*. to *Der Sturm*.
@ -191,7 +192,8 @@ the first block before investing in architecture or polish.
- [ ] Add structured logs with request and job IDs. - [ ] Add structured logs with request and job IDs.
- [ ] Report provider, endpoint, duration, and error reason clearly. - [ ] Report provider, endpoint, duration, and error reason clearly.
- [ ] Separate health status from “configured”. - [ ] 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. - [ ] Avoid logging prompts, API keys, or book contents by default.
## P1 — Resource limits and constrained-device testing ## P1 — Resource limits and constrained-device testing

View File

@ -1 +1 @@
1.24.1 1.24.2

View File

@ -292,6 +292,21 @@ def background_job_get(job_id: str) -> dict | None:
return result 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: def _row_to_char(row: sqlite3.Row) -> dict:
d = dict(row) d = dict(row)
d["sheet"] = json.loads(d.get("sheet") or "{}") d["sheet"] = json.loads(d.get("sheet") or "{}")

32
routes/jobs.py Normal file
View File

@ -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)

View File

@ -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.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.config import _load_settings, _ensure_external_api_key
from core.voice_index import refresh_voice_index_background 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") logger = logging.getLogger("uvicorn.error")
@ -296,6 +296,7 @@ app.include_router(characters.router)
app.include_router(rehearsals_db.router) app.include_router(rehearsals_db.router)
app.include_router(recovery.router) app.include_router(recovery.router)
app.include_router(history.router) app.include_router(history.router)
app.include_router(jobs.router)
# ── Static files ────────────────────────────────────────────────────────────── # ── Static files ──────────────────────────────────────────────────────────────

View File

@ -241,6 +241,24 @@ class SecurityValidationTests(unittest.TestCase):
conn.execute("DELETE FROM background_jobs WHERE job_id LIKE ?", (prefix + "%",)) conn.execute("DELETE FROM background_jobs WHERE job_id LIKE ?", (prefix + "%",))
conn.commit() 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): def test_fish_cache_eviction_is_bounded(self):
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory