feat: expose sanitized background job history
This commit is contained in:
parent
b0f475d850
commit
f67930aefb
@ -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
|
||||||
|
|||||||
6
TODO.md
6
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,
|
- [ ] **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
|
||||||
|
|||||||
@ -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
32
routes/jobs.py
Normal 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)
|
||||||
@ -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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user