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
+91
View File
@@ -0,0 +1,91 @@
# Story: Cache Busting (Un-stick the Pages)
**Phase:** `33_cache_busting/` · **E2E:** `tests/e2e/test_cache_busting.py`
## Narrative
As **anyone using Brain of Reese**, I want a new deploy to be **visible
without a hard refresh**. Today the five HTML pages reference their CSS/JS
with no version at all, so the browser keeps serving stale assets long
after the app has moved on — the "the pages are too sticky" report. I want
a deploy (a commit) to change what the browser fetches, automatically,
with no new services and no CDN.
- **Given** the app has been redeployed (a new commit)
- **When** I open — or revisit — any page
- **Then** the page itself is always revalidated (never served from cache
unchecked), its assets are fetched from **versioned URLs**
(`?v=<token>` — the git short SHA of the deploy, so each commit changes
them), and the API — the SSE chat stream in particular — is left
byte-for-byte alone.
## Acceptance criteria
1. **HTML always revalidates.** All five pages (`/`, `/index.html`,
`/sources.html`, `/document.html`, `/login.html`, `/tuning.html`) ship
`Cache-Control: no-cache` (the page is the cheap redirector to the
long-cached assets).
2. **Assets are versioned.** Every local `href`/`src` asset reference on
those pages carries `?v=<token>` — rewritten in flight by one
middleware (a small regex over `href`/`src` `assets/…` refs, leading
slash optional, idempotent: an existing query string or fragment is
never double-tagged, and non-asset references pass through). The token
is non-empty and **stable across requests** within a process (one token
per process, computed once).
3. **Immutable assets.** `/assets/*` responses ship
`Cache-Control: public, max-age=31536000, immutable` — safe precisely
because the URL carries the token (a deploy changes the token, hence
the URL). The unversioned path keeps resolving (the static mount
ignores the query string), so old tabs and direct links still work.
4. **The token is the deploy.** `app/core/caching.py::asset_version()`:
a git checkout (the project root next to `frontend/` has a `.git` —
the homelab reality) → `git rev-parse --short HEAD` with a 5 s timeout
(a deploy must not hang a boot); no `.git`, git missing, non-zero
exit, timeout, or empty output → the first **12 hex chars** of SHA-256
over the sorted `relpath:mtime_ns:size` of every regular file under
`frontend/`; a missing/empty static dir → `"dev"`. Cached with
`functools.cache` (== `lru_cache(maxsize=None)`) — zero per-request
git/file cost. A new commit or a frontend content change flips the
token on the next process start.
5. **The API is untouched.** The middleware touches exactly two response
shapes (the page paths: body rewrite + `no-cache`; `/assets/*`:
header only). Everything else — all of `/api/*`, including the SSE
chat stream — passes through with no header changes and no body read
(SSE keeps the `no-cache` its endpoint sets itself). The no-CDN
integration test stays green (the rewritten references are
same-origin).
6. **Quality gates.** Unit (`tests/unit/test_caching.py`): the token's
git path, fallback path (stable across an unchanged tree, flips on a
touch/modify + `cache_clear()`), failure path (git raising → content
hash, no exception), empty-dir `"dev"`; the pure `rewrite_asset_refs`
(versioned `href`/`src`, no-leading-slash refs, module scripts,
idempotency, `#fragment`/existing-query left alone, non-asset refs
untouched). Integration (`tests/integration/test_api.py`): every page
`no-cache` + versioned references, asset headers, no `cache-control`
injected on `/api/health`, SSE chat tests unchanged and green.
Coverage: `app/core/caching.py` 100 %, `app/` > 90 % (TOTAL ≥
pre-change).
7. **E2E (this story's gate).** `tests/e2e/test_cache_busting.py`, run in
isolation: a real (fresh-profile) Chromium asserts the **wire truth**
— the document responses are `no-cache`, the CSS/JS request URLs the
browser actually makes carry one shared token (matching this
checkout's `asset_version()`), the asset responses are immutable for a
year, and `/api/*` — including a live SSE chat turn that streams
deltas and completes with `done` — is unaffected (mock LLM, no live
aipi; `podman compose up -d db` for the chat check).
## Playwright Mapping Rule
`tests/e2e/test_cache_busting.py` — run in isolation (Chromium +
`podman compose up -d db`; mock LLM via the shared `conftest.py` session
app; no per-module overrides needed):
1. `test_html_pages_are_no_cache_and_versioned` → AC 1 + 2 + 3 (the `/`
document response is `no-cache`; the `styles.css` request URL carries
`?v=<token>` and its response is `immutable` + `max-age=31536000`;
the `app.js` request URL carries the **same** token; the served HTML
carries no unversioned `"/assets/styles.css"` reference).
2. `test_other_pages_share_the_token` → AC 1 + 2 (the `/sources.html` and
`/login.html` document responses are `no-cache`; both pages'
stylesheet requests carry the same token).
3. `test_api_responses_unaffected` → AC 5 (`/api/health` has no
`cache-control` injected — the endpoint's baseline headers only; the
SSE chat POST still streams deltas and completes with `done`).