perf(ui): cache busting — HTML no-cache + versioned asset URLs (?v=) with immutable 1y asset caching

Phase 33 (story: .agent/user_stories/cache-busting.md).

- app/core/caching.py: asset_version() — git short SHA (a commit is a
  deploy), stable content-hash fallback for non-git checkouts, "dev"
  for a missing static dir; computed once per process. CachingMiddleware
  — the five HTML pages revalidate (no-cache) with ?v=<token> asset refs
  rewritten in flight; /assets/* is public, max-age=31536000, immutable;
  everything else (all /api/*, the SSE chat stream in particular) passes
  through byte-identical.
- tests/e2e/test_cache_busting.py: fresh-Chromium wire assertions —
  document no-cache, versioned CSS/JS request URLs sharing one token,
  immutable asset headers, /api/health baseline headers, SSE chat to
  done (mock LLM).
- README 'Caching / deploys' section + story file.

Also fixed two prod-image defects surfaced by this phase's podman smoke
(the full app would not boot):
- Containerfile: ship the scripts/ package — app/api/sync.py (phase 32)
  imports scripts.git_sync / scripts.import_docs at module level, so the
  container crashed on boot (ModuleNotFoundError: No module named
  'scripts').
- compose.yaml: pass BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET through to
  the app service (:- defaults keep 'podman compose up -d db' working;
  the app's own fail-loud gate still names missing admin auth).

Smoke: podman compose --profile prod up -d on a fresh image + a fresh
Chromium profile — /, /sources.html and /login.html all served
Cache-Control: no-cache; all 8 asset requests versioned with one shared
token (content-hash fallback inside the image — no .git there);
/assets/* immutable for a year.
This commit is contained in:
2026-08-25 22:42:10 -04:00
parent 52136fe307
commit 8fabb7efda
12 changed files with 988 additions and 0 deletions
+38
View File
@@ -325,6 +325,44 @@ git-source refresh in one click, in-process:
pull, hash skip, and the overview is left alone (its regeneration is
change-gated).
## Caching / deploys
A deploy is a commit — and the browser must see it **without a hard
refresh** (the "the pages are too sticky" problem, phase 33). One
Starlette middleware (`app/core/caching.py`) applies the rule at the
transport layer:
- **HTML pages always revalidate.** Every page (`/`, `/sources.html`,
`/document.html`, `/login.html`, `/tuning.html`) ships
`Cache-Control: no-cache`, so each visit re-checks the page with the
server — a page never lingers in the browser's cache unchecked.
- **Assets are versioned and cached for a year.** The pages reference
their CSS/JS with a token (`/assets/styles.css?v=<token>`), and every
`/assets/*` response ships
`Cache-Control: public, max-age=31536000, immutable`. The token is what
identifies the content, so long-term caching is safe: a new token means
a new URL, which the browser fetches fresh.
- **The token is the deploy.** In a git checkout (the normal case) it is
the short SHA of `HEAD` (`git rev-parse --short HEAD`), computed once
per process start — so **every commit/deploy flips the token** and the
versioned asset URLs change with it. A checkout without `.git` (or a git
failure) falls back to a stable content hash of the `frontend/` tree
(sorted path + mtime + size), so dev checkouts still bust; a missing
static dir gets the placeholder token `dev`.
- **The API is untouched.** Nothing under `/api/*` — the SSE chat stream
in particular — gains or loses a header or has its body read; the SSE
endpoint's own `Cache-Control: no-cache` is set by the endpoint itself.
No CDN, no new services, no build-step change: the middleware rewrites
the asset references of the five known pages in flight. The unversioned
asset paths keep working too (the static mount ignores the query string),
so old tabs and direct links to `/assets/…` still resolve.
> **Deploy note:** the very first deploy onto this scheme needs one
> normal page visit, so the browser revalidates the HTML once and starts
> requesting the versioned assets; every commit after that is picked up
> automatically.
## Checking retrieval quality
Ask the *real* pipeline (live aipi embeddings + the current KB) whether a