The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small
Fastify + SQLite service in the monorepo (shares the payload contract and the class
vocabulary via @parking/shared), delivered to art-docker-station by its own stack so
nothing booth-side lands there and nothing of it on a booth.
- POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart
meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on
the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row.
- /review + /api/*: the reviewer's screen served by the process (Basic auth, one login):
one pending crop at a time, operator's pick and camera's pick beside it, one button/key
per vocabulary class + unusable + skip; stats per booth and per hashed operator
(agree / disagree / unusable — disagree = the reviewer's class is outside the operator's
category).
- GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are
neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host.
- Booth payload now carries operatorCategory.classes so the comparison needs no site setup.
- Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml
(bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build
step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one
secret per booth referenced from both the collector's token list and the booth's own
stack (park-2 lines templated, commented, DNS name for the URL).
- Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image
built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export).
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
CI already computes <branch>-<short-sha> for image tags but never
surfaced it anywhere reachable from the app, so there was no way to
tell what's actually deployed on a booth without cross-referencing
komodo/resources.toml's TAG by hand.
Thread it through: CI passes BUILD_VERSION as a Docker build-arg,
the Dockerfile captures it as a runtime env var, GET /api/version
(gated by the existing site:read permission) exposes it, and the
Setup page's tab bar shows it right-aligned, muted, absent entirely
on a local/dev build with no CI-supplied value.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
apps/web/.env.production sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
desktop build (which loads from tauri://localhost and needs an absolute backend
origin). But Vite auto-loads .env.production for ANY `vite build`, so the server
image baked 127.0.0.1:3000 into the browser bundle — loading the UI from a real
host (e.g. http://parksystems.msai.al) then made the browser call 127.0.0.1:3000
cross-origin and fail the Same-Origin Policy on /api/auth/login.
Fix: the server Dockerfile writes apps/web/.env.production.local with an empty
VITE_API_BASE before the web build (.local has higher Vite precedence), so the SPA
served by Fastify stays relative/same-origin (/api/...). The desktop build is
unaffected (it doesn't use this Dockerfile). Verified: 127.0.0.1:3000 no longer in
the built bundle; /api/auth/login is relative.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Containerize the two non-desktop apps for the booth appliance. The desktop app stays
on its own tag-only release.yml.
- apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod`
(NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune)
yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds
libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit-
free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a
devDep, pruned from prod.
- apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container
serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never
shadows the backend; a no-op in dev (no dist). Registered last in server.ts.
- apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image
as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env-
selected (VISION_RECOGNIZER stub|fast_alpr).
- Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) +
.prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from
env so a branch deploy pulls that branch's image.
- .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+
test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with
branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED).
- .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked.
Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, /
+ /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr
with 0 runtime downloads; compose stack healthy with server→vision over the private network.
Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V