diff --git a/.dockerignore b/.dockerignore index a8b1914..95cdf8b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ # Keep the build context to what the image actually needs: pyproject.toml, -# README.md (referenced by pyproject), src/, alembic.ini, docker-entrypoint.sh. +# README.md (referenced by pyproject), src/, frontend/ (source only — the ui stage +# builds it), alembic.ini, docker-entrypoint.sh. .git .github .venv @@ -19,3 +20,10 @@ Dockerfile.control docker-compose.yml .dockerignore .mise.toml +# Frontend artifacts: the ui stage runs npm ci / npm run build itself, and a stale +# local export must never leak in — COPY into src/handler/api/static merges, so any +# checkout copy would ship alongside the fresh build. +frontend/node_modules +frontend/out +frontend/.next +src/handler/api/static diff --git a/.gitignore b/.gitignore index ff72444..9a94144 100644 --- a/.gitignore +++ b/.gitignore @@ -223,3 +223,9 @@ __marimo__/ # Handler runtime artifacts /handler.db + +# Built web UI — a generated artifact, never committed. Local/manual installs produce it +# with `npm run export` (see README "Frontend"); the Docker image builds it in its own +# node stage. Tracking it caused guaranteed merge conflicts: Next's content-hashed chunk +# names churn on every build, so any two branches touching frontend/ collided here. +/src/handler/api/static/ diff --git a/Dockerfile b/Dockerfile index dd59914..511e72b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,17 @@ # syntax=docker/dockerfile:1 +# ---- ui stage: build the dashboard's static export ---- +# The export is a generated artifact (gitignored), so the image builds it here rather +# than trusting the checkout to carry it. package*.json is copied alone first so the +# npm ci layer caches until the lockfile actually changes. +FROM node:20-slim AS ui + +WORKDIR /ui +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY frontend ./ +RUN npm run build + # ---- build stage: install the package + deps into an isolated venv ---- FROM python:3.11-slim AS builder @@ -12,6 +24,10 @@ ENV PATH="/opt/venv/bin:$PATH" WORKDIR /build COPY pyproject.toml README.md ./ COPY src ./src +# Drop the built UI into the packaged tree before pip install: hatchling ships every +# non-.py file under src/handler, so the wheel carries the export and FastAPI serves it +# same-origin — exactly what committing src/handler/api/static used to provide. +COPY --from=ui /ui/out ./src/handler/api/static RUN pip install . # ---- runtime stage ---- diff --git a/README.md b/README.md index b53ff0e..2b5edb2 100644 --- a/README.md +++ b/README.md @@ -498,9 +498,17 @@ the API (same contract as `curl`): the browser prompts for the token once, store `localStorage`, and attaches it to every call. All API values render as React text (never `dangerouslySetInnerHTML`) so agent-authored strings can't inject markup. -The build output is committed to `src/handler/api/static/` so the Python wheel ships it and -FastAPI serves it same-origin — there is no separate frontend server and no node step in the -Docker image. Rebuild after changing the UI: +The build output lands in `src/handler/api/static/`, which FastAPI serves same-origin — +there is no separate frontend server. The export is a **generated artifact and is +gitignored**, not committed: tracked builds guaranteed merge conflicts (Next's +content-hashed chunk names churn on every build) and let the served UI drift from its +source. It gets built in one of two places: + +- **Docker** (the normal path): the [`Dockerfile`](Dockerfile)'s `ui` stage runs + `npm ci && npm run build` and copies the export into the packaged tree, so the image + published by [`docker.yml`](.github/workflows/docker.yml) always carries a UI built + from exactly the source in that commit. +- **Source installs**: build it yourself before (or after) `pip install`: ```bash cd frontend @@ -509,6 +517,9 @@ npm run build # static export → frontend/out/ npm run export # build, then sync frontend/out/ → src/handler/api/static/ ``` +Without that step a source install still works — the API only mounts `static/` when the +directory exists, so it just runs headless (API-only). + `npm run dev` runs the UI against a live API on another origin — set `NEXT_PUBLIC_API_BASE=http://127.0.0.1:8000` and enable `CORS_ORIGINS` on the API. @@ -523,7 +534,7 @@ src/handler/ # forge/gitops seams, credentials, skills_gen, CI poller hooks/ # Stop/SessionEnd, PreToolUse gate (push + approval), Notification migrations/ # Alembic env + versions - api/static/ # built Next.js export (generated — see frontend/) + api/static/ # built Next.js export (generated + gitignored — see frontend/) frontend/ # Next.js dashboard source (builds to api/static/) tests/ # DB, API, hook, and control tests (SQLite) docs/PLAN.md # full design + phased roadmap (the original plan of action) diff --git a/frontend/.gitignore b/frontend/.gitignore index dbc14e7..a18a9f8 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,6 +1,7 @@ -# Frontend build artifacts. The *built* static export is committed under -# src/handler/api/static/ (that is what the Python package ships and FastAPI serves); -# everything below is regenerated by `npm install` / `npm run build`. +# Frontend build artifacts — all regenerated by `npm install` / `npm run build`. The +# static export under src/handler/api/static/ is generated too (gitignored at the repo +# root): `npm run export` produces it for source installs, and the Docker image builds +# it in its own node stage. /node_modules /.next /out diff --git a/pyproject.toml b/pyproject.toml index 776a96c..3d4019b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,15 @@ dev = [ handler = "handler.control.cli:main" [tool.hatch.build.targets.wheel] -# The bundled web UI (src/handler/api/static/*) ships automatically: it lives inside the -# packaged `src/handler` tree, and hatchling includes non-.py files there by default. +# The web UI ships whenever src/handler/api/static/ exists at build time: hatchling +# includes non-.py files in the packaged `src/handler` tree by default. The export is a +# generated artifact (gitignored) — `npm run export` produces it for source installs, +# and the Docker image's ui stage builds it before pip install. Absent the directory, +# the API simply runs headless (it only mounts static/ when present). packages = ["src/handler"] +# Hatchling skips VCS-ignored files; `artifacts` re-includes the (gitignored) export so +# a build that has one still ships it. +artifacts = ["/src/handler/api/static"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/handler/api/static/404.html b/src/handler/api/static/404.html deleted file mode 100644 index 22b0a6a..0000000 --- a/src/handler/api/static/404.html +++ /dev/null @@ -1 +0,0 @@ -
e||125