Files
brain-of-reese/.agent/user_stories/cache-busting.md
T
ducoterra 8fabb7efda 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.
2026-08-25 22:42:10 -04:00

5.2 KiB

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