Files
brain-of-reese/.agent/user_stories/containerfile-build.md
T

132 lines
6.8 KiB
Markdown

# Story: Containerfile Builds the Whole App Again
**Phase:** `23_containerfile_build` · **Gate:** hermetic integration
`tests/integration/test_containerfile_assets.py` + real `podman build`
+ image smoke (no Playwright suite — build/infrastructure phase, per
the phase plan's A16 note)
## Narrative
As **the owner**, I reported (2026-08-24, TODO.md L6) that the
Containerfile build "is not working". I want
`podman build -f Containerfile .` to succeed again — and, more
importantly, for the resulting image to serve the **whole app**: all
four pages (chat, sources, document viewer, login) with their bundled,
minified, local-only assets (No CDN rule, A11), the shared header
module evaluated exactly once per page, and a build contract that
fails loudly when a future page/script/asset outgrows the Containerfile.
- **Given** the 3-stage `Containerfile` (esbuild frontend bundle → uv
python deps → slim runtime serving `/app/static`)
- **When** `podman build -f Containerfile .` runs
- **Then** the build is green and the image serves `/`,
`/sources.html`, `/document.html`, `/login.html` with 200 and every
referenced asset local and minified — with `header.js` inlined into
each page bundle exactly once.
## Bug report (verbatim, TODO.md L6)
> Fix Containerfile build not working
## Verified diagnosis (2026-08-24, phase 23 — not a guess)
1. **Root cause of the build failure:** phase 19 switched the page
scripts to `import … from "/assets/header.js"` (an absolute URL).
esbuild resolves that as the *filesystem* path `/assets/header.js`
and the stage-1 bundle dies:
`✘ [ERROR] Could not resolve "/assets/header.js"`
(reproduced with esbuild **0.25.5**, the exact pinned version, on a
copy of `frontend/` — `repro_esbuild_0.25.5.log`).
2. **Secondary gap (image would be broken even if it built):** stage 1
bundled only `app.js` + `sources.js` and copied only `index.html` +
`sources.html`. Missing from the image: `document.html` +
`login.html` (phases 10/16), `document.js` + `login.js`, and
`markdown.js` (classic script loaded by `index.html` +
`document.html`).
3. **Latent double-evaluation trap:** all four HTML pages also load
`<script type="module" src="/assets/header.js">` directly while the
page script imports it. In dev the browser dedupes (same module
URL) — but in the image the bundled page script already contains the
header code, so shipping a raw `header.js` too would evaluate the
module **twice** (duplicate sign-out listener, double init). The
direct tags are redundant: the page script's `import` is hoisted and
guarantees `header.js` evaluates before the page script's body calls
`initSharedHeader()`, in dev and in the bundle alike.
4. **Second build blocker (found in task 01):** a *local* `npm install`
of esbuild left the binary in `node_modules/.bin`, invisible to the
next `RUN` — `esbuild: not found`. The stage must install globally.
## Owner-confirmed (2026-08-24, roadmap A4)
1. **Relative imports** (`from "./header.js"`) over an esbuild alias —
simpler, verified working, dev-server behavior unchanged (files are
side-by-side).
2. **Remove the four redundant direct `header.js` script tags** (the
design above) rather than ship a raw `header.js` into the image —
single module evaluation, no duplicate listeners.
3. The image must cover **all four pages + all local assets** they
reference — the integration test (task 02) enforces this coverage so
the gap cannot silently reappear.
## Fix
| piece | before | after |
|---|---|---|
| page-script header import | `from "/assets/header.js"` (absolute — esbuild fatal) | **`from "./header.js"`** (relative, bundles cleanly) |
| direct header.js `<script>` tags | 4 (one per page — double-eval trap) | **0** (the hoisted import covers evaluation order) |
| esbuild install | local (binary off the next RUN's PATH) | **`npm install -g esbuild@0.25.5`** (pinned, on PATH) |
| stage-1 bundles | `app.js`, `sources.js` | **`app.js`, `sources.js`, `document.js`, `login.js`** |
| stage-1 minifies | `styles.css` | **`styles.css`**, **`markdown.js`** (no `--bundle` — classic script) |
| stage-1 copies | `index.html`, `sources.html` | **all four pages** into `/out` |
## Acceptance criteria
1. Local esbuild 0.25.5 bundles all four page scripts cleanly.
2. `podman build -f Containerfile .` green (log excerpt in
`.agent/reports/23_containerfile_build/`).
3. Image smoke: container runs (throwaway Postgres 17 + pgvector);
`GET /`, `/sources.html`, `/document.html`, `/login.html` → 200;
`/assets/app.js` minified and contains the header code;
`/assets/markdown.js` 200; no `http(s)://` asset reference in any
served page (No CDN rule).
4. Dev server unchanged in behavior: `test_smoke.py`,
`test_shared_header.py`, `test_chat_persistence.py` green in
isolation.
5. `test_containerfile_assets.py` 6/6 within the full suite;
`uv run pytest` green; `app/` coverage ≥ today's number (>90% gate);
ruff + pyright clean.
## Test mapping table
**Test Scenario → `tests/integration/test_containerfile_assets.py`**
(hermetic — parses `Containerfile` + `frontend/` as text; no podman,
no network, no DB):
1. `test_every_html_page_is_copied_into_stage1` — every
`frontend/*.html` is copied by a stage-1 `cp` into `/out`, and the
copied set is exactly the pages on disk (new page without cp entry
→ fail; cp of a deleted page → fail).
2. `test_every_local_asset_reference_is_produced` — every local
`assets/`/`/assets/` `src=`/`href=` in the pages is produced by a
stage-1 line (esbuild `--outfile=/out/assets/<name>` or a `cp`) —
the missing-`markdown.js`-style gap cannot reappear.
3. `test_page_module_scripts_are_bundled` — the `type="module"` page
scripts referenced by the HTML (basenames) equal the esbuild
`--bundle` inputs in stage 1 (today: `app.js`, `sources.js`,
`document.js`, `login.js`).
4. `test_header_module_is_imported_not_directly_loaded` — no HTML
carries a direct `<script … src="…/header.js">` tag (single-evaluation
design pin, owner-confirmed A4-2) and every page script imports it
relatively (`from "./header.js"` — no absolute form).
5. `test_markdown_js_is_a_produced_classic_script` — `markdown.js` has
a stage-1 minify line **without** `--bundle` and no top-level
`import`/`export` in the source (both sides of the classic-script
assumption pinned).
6. `test_esbuild_stays_pinned` — the frontend stage pins a concrete
`esbuild@X.Y.Z` (no `latest`/`^`/`~`/bare spec — the exact pinned
0.25.5 is what the diagnosis reproduced against).
Complementary source pins live in `tests/unit/test_shared_header.py::
test_header_module_loads_before_the_page_script` (unit) and the real
build + image smoke are recorded in
`.agent/reports/23_containerfile_build/` (task 01).