Settle WHERE the host-side ANPR service lives and how it joins the build: in this monorepo at apps/vision/ (not a separate repo), still a separate OS process called over localhost HTTP, wired into the Turbo graph via a thin package.json shim whose scripts shell to Python tooling (uv/uvicorn/ruff/pytest). Co-located source honors the vision-service runtime+license isolation decision (AGPL reach is a linking boundary, not a folder); the fast-alpr MIT baseline removes most of the split-repo pressure anyway. New page vision-service-packaging; updates vision-service, opencv-anpr-service, the CLAUDE.md layout, index, log. Not built yet — packaging decision only. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
5.6 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| decision |
|
2026-06-19 | settled |
Decision: the vision service lives in this monorepo (apps/vision/), wired into Turbo via a shim
Taken 2026-06-19, when planning how to implement the host-side [[opencv-anpr-service|vision service]] decided in vision-service. That decision settled WHAT (a separate localhost Python process) and the recognizer baseline (opencv-anpr-service); this one settles WHERE the source lives and how it joins the build.
Decision
- In THIS monorepo, at
apps/vision/— a Python/FastAPI service co-located with the Node backend, not a separate repository. One git history, atomic cross-cutting commits (the/analyzecontract + the Node-side adapter change together), one wiki. - Still a separate OS process — co-location is source-level only. It runs as its own process
(
uvicorn), called over localhost HTTP by the Node backend, with its own failure domain. Nothing about putting it inapps/vision/weakens the runtime isolation vision-service requires. - Wired into the Turbo task graph via a thin
package.jsonshim.pnpm-workspace.yamlalready globsapps/*, so anapps/vision/package.jsonauto-joins the workspace. Itsscriptsshell out to Python tooling, so the existingturbo runtasks cover it:dev→uv run uvicorn app:app --reload(matchesturbo.jsondev: persistent, uncached)lint→ruff check·test→pytest·typecheck→ruff/mypybuild→ no-op or model-fetch (Python has nodist/**; thebuildtask'soutputs: ["dist/**"]simply won't match — fine). If models are fetched/cached at build, point outputs at the model dir. Python dependencies stay managed byuv+pyproject.toml(NOT pnpm) — the shim only exposes tasks, not deps.
- Node talks to it through an interface (
VisionClientbehind a port, the device-adapter-pattern style) so the recognizer/service is swappable without touching business logic — as opencv-anpr-service already specifies.
Why co-located beats a separate repo
- Atomic changes. The service contract (
POST /analyzeshape) and its Node consumer evolve together; one repo = one PR, no two-repo version skew. uvmakes Python-in-monorepo painless — fast, lockfile-based, offline-friendly (fits offline-first); the appliance build pulls a pinned env.- Turbo still orchestrates it. The shim makes
turbo run lint/testinclude the Python service as a first-class node — one command lints front, back, AND vision — even though Turbo can't build Python. Turbo orchestrates tasks, and a task can be a Python command. - One knowledge base. The wiki + CLAUDE.md already describe the whole system; a split repo fragments that.
Why this still honors the isolation decision
The "vision-service" decision is about runtime isolation (own process +
failure domain) and license isolation (AGPL obligations don't reach the Node/React code because
it is not linked — it's a separate program over HTTP). Neither depends on a separate
repository. AGPL's reach is a linking/distribution-boundary question between programs, not a
which-folder question. A Python service in apps/vision/ that Node calls over localhost is exactly as
isolated, license-wise, as one in its own repo.
- With the opencv-anpr-service MIT-end-to-end baseline, the AGPL pressure to split the repo out largely evaporates (pending the weight-provenance caveat). Co-location is the low-friction default.
- If a true-AGPL model (Ultralytics YOLO) is later adopted, its weights live under
apps/vision/— still fine (separate process), and that dir is the natural place to document the license boundary + the[[standing-decisions|scoped exception]].
Rejected
- Separate repo — strongest separation, but loses atomic contract changes and adds coordination overhead; justified only if a different team owns it or the AGPL concern becomes acute. Kept as the fallback if either happens.
- Embed Python in the Node process (opencv4nodejs / a child-process module) — already rejected by vision-service (native-build pain, no process isolation, shares the app's failure + license surface). Unchanged.
- A Python package under
packages/—packages/is for shared JS libraries imported by other workspaces; the vision service is a deployable app, soapps/vision/is the right bucket.
Consequences
- Add
apps/vision/(pyproject.toml+uv.lock, FastAPIapp.py, a thinpackage.jsonshim);apps/*glob picks it up. Update the repo-layout block in the rootCLAUDE.md+ this wiki. - A
Dockerfile/process unit builds the Python service as its own image/process for the appliance; CI runsruff/pytest(via the shim or a dedicated job). - The Node backend gains a
VisionClientadapter (localhost HTTP) + per-camera opt-in wiring (the open item in opencv-anpr-service). - Not built yet — this is the packaging decision; scaffolding follows when the vision work starts (the "scaffold as the work reaches them" rule in CLAUDE.md).
Open
uvvs.pip-tools/poetryfor the Python env (leaninguv— speed + lockfile + offline).- Whether
buildshould fetch/cache model weights (and set Turbooutputsto the model dir) or keep weights out of the build entirely (baked into the Docker image instead). - Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail, defer to the install/hardening pass.