Skip to content

Commit 3872cae

Browse files
authored
fix(devcontainer): harden worktree startup with devrouter (#5169)
1 parent 212a924 commit 3872cae

19 files changed

Lines changed: 686 additions & 166 deletions

.agents/skills/devrouter/SKILL.md

Lines changed: 254 additions & 0 deletions
Large diffs are not rendered by default.

.agents/skills/klicker-environment-doctor/SKILL.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: Diagnose and repair a broken KlickerUZH development environment. Us
77

88
Run the checks **in order** — later checks assume earlier ones pass. Background facts live in the wiki: [docs/getting-started.md](../../../docs/getting-started.md).
99

10-
Provenance: checks 1–6 were executed and verified on macOS (2026-07-07). Steps marked **config-derived** were read from config, not executed — treat their exact output as unconfirmed.
10+
Provenance: checks 1–5 and 7–9 were executed and verified on macOS (2026-07-07). Check 6's process reconciler was executed in the Linux devcontainer image, and its host-side devrouter lifecycle was verified from a clean linked worktree on macOS (2026-07-13). Other steps marked **config-derived** were read from config, not executed — treat their exact output as unconfirmed.
1111

1212
## Agent ground rules
1313

@@ -32,7 +32,7 @@ Wrong major (e.g. 9.x from a stale Volta shim; `VOLTA_FEATURE_PNPM` unset) **sil
3232
| `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` under `CI=true` | restore lockfile (check 1), install without `CI=true` |
3333
| ~19 packages fail `pnpm run check` | `pnpm run build` once (generates Prisma client, codegen, dists), then re-check |
3434

35-
Healthy sequence from scratch: `pnpm install``pnpm run build``pnpm run check` (verified ~20s / ~1.5min / clean).
35+
Healthy sequence from scratch inside the devcontainer: `pnpm install``pnpm run build``pnpm run check` (verified ~20s / ~1.5min / clean). The root build script forces `NODE_ENV=production`, including when the devcontainer exports `NODE_ENV=development` for its live apps. A production build can replace Next.js dev output; run `devrouter ensure .` afterward so the exact checkout runtime is health-checked and recovered when needed.
3636

3737
## Check 3 — stale GraphQL codegen
3838

@@ -53,22 +53,25 @@ If `apps/analytics` complains about schema drift or a schema edit isn't visible:
5353
lsof -nP -iTCP:5432 -sTCP:LISTEN # repeat for 6379 6380 6381 7077 8888 80 443
5454
```
5555

56-
`Bind for :::5432 failed: port is already allocated` means another stack holds the port — stop it or don't start the colliding service. Plain localhost and legacy host-based paths publish fixed ports, so only one such stack runs per machine. For parallel devcontainer worktrees, keep the base `.devcontainer/docker-compose.yml` port-free and use `.devcontainer/docker-compose.devrouter.yml`, which exposes `${WORKSPACE:-klicker-uzh}-app` / `${WORKSPACE:-klicker-uzh}-db` aliases on `devnet`. Start/register linked worktrees with the same token, e.g. `WORKSPACE=<slug> devpod up .` and `devrouter app run <app> --workspace <slug>`. Use `.devcontainer/docker-compose.localhost.yml` only for the one-at-a-time localhost fallback. If manage media uploads fail with an Azure Blob CORS error while GraphQL auth still works, check the storage account before changing app CORS. The media library uploads directly from the browser to Azure Blob Storage via SAS, so the Blob service CORS rule must allow the actual devrouter origin (`https://manage.klicker.localhost` or `https://manage.klicker.<workspace>.localhost`). Use exact origins for production/staging accounts; for a dedicated dev storage account, a dev-only `https://*.localhost` rule keeps parallel worktrees usable.
56+
`Bind for :::5432 failed: port is already allocated` means another stack holds the port — stop it or don't start the colliding service. Plain localhost and legacy host-based paths publish fixed ports, so only one such stack runs per machine. Parallel devcontainer worktrees use the port-free base compose file plus `.devcontainer/docker-compose.devrouter.yml`; the one-at-a-time fallback uses `.devcontainer/docker-compose.localhost.yml`. If manage media uploads fail with an Azure Blob CORS error while GraphQL auth still works, check the storage account before changing app CORS. The media library uploads directly from the browser to Azure Blob Storage via SAS, so its CORS rule must allow the actual local origin. Use exact origins for production/staging accounts and dev-only localhost rules for a dedicated dev storage account.
5757

5858
## Check 6 — infra bring-up / server status (headless-safe)
5959

6060
Depending on your environment path:
6161

62-
### Path A: Inside Devcontainer
62+
### Path A: Managed devcontainer
6363

64-
The container manages infra services and app servers automatically in the background. Check logs and process status:
64+
Run the ownership-aware lifecycle check from the host, then inspect the exact container through devrouter:
6565

6666
```bash
67-
pgrep -f "turbo run dev" >/dev/null && echo "Dev servers running" || echo "Dev servers NOT running"
68-
tail -n 50 /tmp/dev.log # inspect server startup logs
67+
devrouter ensure .
68+
devrouter exec . -- cat /tmp/devrouter-process-klicker-dev.state
69+
devrouter exec . -- tail -n 50 /tmp/dev.log
6970
```
7071

71-
If servers are down, restart them: `bash .devcontainer/post-start.sh`.
72+
`devrouter ensure` delivers its matching process helper to the exact validated container. Released `0.0.35` fingerprints the workspace, command, adapter bytes, and declared non-secret origin allowlist. The helper replaces a stale owned process group and leaves unknown processes untouched. Host-side ensure checks all routes and can recreate one stale or unhealthy exact-path DevPod once.
73+
74+
`devrouter doctor --repo .` provides static diagnostics. `devrouter ensure .` resolves the checkout-specific overlay and is the authoritative runtime proof.
7275

7376
### Path B: Host-based Setup
7477

.devcontainer/Dockerfile

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@ FROM node:24.16.0-bookworm-slim
55

66
ENV PNPM_HOME="/pnpm" \
77
PATH="/pnpm:$PATH" \
8-
DEBIAN_FRONTEND=noninteractive
8+
UV_PYTHON="3.12"
9+
10+
ARG DEBIAN_FRONTEND=noninteractive
911

1012
# Runtime tools: git (repo ops), openssl/ca-certificates (Prisma + TLS),
11-
# procps (pgrep for the post-start dev-server guard), curl (healthchecks).
13+
# procps (process matching), util-linux (flock/setsid), curl (healthchecks).
1214
RUN apt-get update \
1315
&& apt-get install -y --no-install-recommends \
14-
git openssl ca-certificates procps curl \
16+
git openssl ca-certificates procps util-linux curl \
1517
&& rm -rf /var/lib/apt/lists/*
1618

19+
# Match the analytics image and CI so the repository-wide lint gate works in
20+
# the self-contained development environment.
21+
COPY --from=ghcr.io/astral-sh/uv:0.11.12 /uv /uvx /bin/
22+
1723
# Install pnpm with npm (not corepack — signature verification can fail on a
1824
# pinned Node). Mirror the repo's packageManager pin. (GOTCHAS #8)
1925
RUN npm install -g pnpm@11.5.0

.devcontainer/README.md

Lines changed: 56 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
# Dev container (Phase 1 + Phase 2 Tier 1)
1+
# Self-contained dev container
22

33
Self-contained local environment for `klicker-uzh`. No Infisical/Doppler, no
44
external EduID, no `/etc/hosts` edits — clone, route through devrouter, and run.
55
The devcontainer owns the whole stack (toolchain, Postgres, 3× Redis, MailHog,
66
Hatchet, install + build + seed, `turbo dev`);
77
[devrouter](https://github.com/rschlaefli/devrouter) fronts it on a shared
8-
`:443` / `:5432` so many projects coexist with **zero host-port collisions**
9-
(nothing is published on the host).
8+
`:443` / `:5432`. Linked worktrees publish no host ports and can coexist;
9+
the primary checkout intentionally keeps fixed localhost ports and is
10+
one-at-a-time.
1011

1112
> **Scope:** all runnable apps — **backend, auth, frontend-pwa, frontend-manage,
1213
> frontend-control, olat-api, response-api, lti-service, chat**, and the **two
@@ -19,12 +20,12 @@ Hatchet, install + build + seed, `turbo dev`);
1920

2021
You can run the devcontainer in two modes:
2122

22-
### Mode 1: Plain localhost (Fallback)
23+
### Mode 1: Primary checkout
2324

24-
Use this if you are running in a headless cloud server or want to avoid installing Traefik/mkcert on your host:
25+
The primary checkout keeps fixed localhost ports and receives stable unnamespaced devrouter routes:
2526

26-
1. No action required; the default setup dynamically falls back to the localhost overlay. (If you need to force it, ensure `DEVCONTAINER_COMPOSE_OVERLAY` is unset or explicitly set to `docker-compose.localhost.yml`).
27-
2. Start the devcontainer (`devpod up .` or via VS Code).
27+
1. Run one-time setup: `devrouter setup --yes`.
28+
2. Start and prove the checkout: `devrouter ensure .`.
2829
3. The applications are exposed directly on your host's ports:
2930
- Student PWA: `http://localhost:3001`
3031
- Lecturer UI (Manage): `http://localhost:3002` (login: `lecturer` / `abcd`)
@@ -36,63 +37,56 @@ Use this if you are running in a headless cloud server or want to avoid installi
3637
- Hatchet Dashboard: `http://localhost:8888`
3738
- Postgres DB: `localhost:5432`
3839

39-
### Mode 2: devrouter (Default Routed HTTPS domains)
40+
### Mode 2: Linked checkout
4041

4142
Use this to mirror production domain behaviors, test cookie-sharing over HTTPS, and enable parallel workspaces:
4243

43-
1. **Host prerequisite**: Install [devrouter](https://github.com/rschlaefli/devrouter) (≥ 0.0.23 recommended; ≥ 0.0.21 required) and start it:
44+
1. **Host prerequisite**: Install [devrouter](https://github.com/rschlaefli/devrouter) ≥ 0.0.35 and set it up:
4445
```bash
45-
devrouter up && devrouter tls install # Traefik + the shared `devnet` + mkcert CA
46+
devrouter setup --yes # Traefik + the shared `devnet` + mkcert CA
4647
```
47-
2. Start the devcontainer via devrouter workspace commands:
48+
2. Reconcile this existing linked worktree with one command:
4849
```bash
49-
dev workspace up <branch-name>
50+
devrouter ensure .
5051
```
51-
_Note: This automatically provisions a git worktree, sets `WORKSPACE` and `DEVCONTAINER_COMPOSE_OVERLAY=docker-compose.devrouter.yml` inside the environment, and runs `devpod up` under the hood._
52+
To create a new worktree instead, run `devrouter workspace up <branch-name>` from the main repository. Both commands persist one identity, select the devrouter overlay, mount linked Git metadata, start or attach the exact DevPod, prove the runtime, and reconcile routes.
5253
3. Open `https://manage.klicker.<workspace>.localhost` (credentials: `lecturer` / `abcd`).
5354

54-
For a manual `devpod up` configuration (without using `dev workspace up`), you must supply the environment variables yourself:
55+
Do not use bare `devpod up`, manual `WORKSPACE`, or per-app `--workspace` route loops for this managed devcontainer. Those paths bypass the identity and runtime proof.
5556

56-
```bash
57-
WORKSPACE=my-branch DEVCONTAINER_COMPOSE_OVERLAY=docker-compose.devrouter.yml devpod up . --ide none
58-
for a in api auth pwa manage control olat-api response-api lti chat db; do
59-
devrouter app run "$a" --workspace my-branch
60-
done
61-
```
62-
63-
## Run with DevPod
57+
## Checkout lifecycle
6458

6559
```bash
66-
devpod up . --ide none # builds image, starts infra, installs, builds, seeds
67-
for a in api auth pwa manage control olat-api response-api lti chat db; do devrouter app run "$a"; done # register routes
60+
devrouter ensure .
61+
devrouter exec . -- <command...>
62+
devrouter stop .
6863
```
6964

70-
Open <https://manage.klicker.localhost> and log in as **`lecturer` / `abcd`**
71-
(accept the terms checkbox). The dev servers auto-start in the background
72-
(`tail -f /tmp/dev.log`; first compile can take a minute).
65+
Open the Manage URL printed by `ensure` and log in as **`lecturer` / `abcd`**
66+
(accept the terms checkbox). The dev servers run in the background; inspect
67+
`/tmp/dev.log` through `devrouter exec` or an exact DevPod shell.
7368

7469
## How routing works
7570

7671
The monorepo runs **all apps in one container** via `turbo dev`;
7772
devrouter's Traefik (on `devnet`) routes each hostname to that container's
78-
internal port — no published host ports. The devrouter overlay exposes
79-
`${WORKSPACE:-klicker-uzh}-app` and `${WORKSPACE:-klicker-uzh}-db` aliases.
80-
`.devrouter.yml` uses `${WORKSPACE}` in each proxy upstream, so the primary
81-
checkout routes to `klicker-uzh-app` / `klicker-uzh-db`, while a worktree with
82-
`WORKSPACE=my-branch` routes to `my-branch-app` / `my-branch-db`.
83-
84-
| What | Host | Upstream (devnet) |
85-
| ----------------- | ------------------------------------------------------ | ----------------------- |
86-
| API (GraphQL) | `https://api.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:3000` |
87-
| Auth | `https://auth.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:3010` |
88-
| PWA (student) | `https://pwa.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:3001` |
89-
| Manage (lecturer) | `https://manage.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:3002` |
90-
| Control | `https://control.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:3003` |
91-
| OLAT API | `https://olat-api.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:3030` |
92-
| Response API | `https://response-api.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:7078` |
93-
| LTI Service | `https://lti.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:4000` |
94-
| Chat App | `https://chat.klicker[.<workspace>].localhost` | `${WORKSPACE}-app:3004` |
95-
| Postgres | `db.klicker[.<workspace>].localhost:5432` | `${WORKSPACE}-db:5432` |
73+
internal port. The linked-worktree overlay publishes no host ports and exposes
74+
`${WORKSPACE}-app` and `${WORKSPACE}-db` aliases. The primary overlay exposes
75+
stable unnamespaced aliases plus fixed localhost ports. `.devrouter.yml` uses
76+
the selected checkout identity in every proxy upstream.
77+
78+
| What | Host | Upstream (devnet) |
79+
| ----------------- | ---------------------------------------------------- | ----------------------- |
80+
| API (GraphQL) | `https://api.klicker.<workspace>.localhost` | `${WORKSPACE}-app:3000` |
81+
| Auth | `https://auth.klicker.<workspace>.localhost` | `${WORKSPACE}-app:3010` |
82+
| PWA (student) | `https://pwa.klicker.<workspace>.localhost` | `${WORKSPACE}-app:3001` |
83+
| Manage (lecturer) | `https://manage.klicker.<workspace>.localhost` | `${WORKSPACE}-app:3002` |
84+
| Control | `https://control.klicker.<workspace>.localhost` | `${WORKSPACE}-app:3003` |
85+
| OLAT API | `https://olat-api.klicker.<workspace>.localhost` | `${WORKSPACE}-app:3030` |
86+
| Response API | `https://response-api.klicker.<workspace>.localhost` | `${WORKSPACE}-app:7078` |
87+
| LTI Service | `https://lti.klicker.<workspace>.localhost` | `${WORKSPACE}-app:4000` |
88+
| Chat App | `https://chat.klicker.<workspace>.localhost` | `${WORKSPACE}-app:3004` |
89+
| Postgres | `db.klicker.<workspace>.localhost:5432` | `${WORKSPACE}-db:5432` |
9690

9791
The two Hatchet workers (`hatchet-worker-general`, `hatchet-worker-response-processor`)
9892
also run in the `app` container but have **no port/route** — they consume the
@@ -103,7 +97,7 @@ compose DNS (`redis_exec`, `redis_cache`, `redis_assessment`, `mailhog`,
10397
`hatchet:7077`). Connect to the DB from the host with direct-SSL:
10498

10599
```bash
106-
psql "host=db.klicker.localhost port=5432 user=klicker-prod password=klicker \
100+
psql "host=db.klicker.<workspace>.localhost port=5432 user=klicker-prod password=klicker \
107101
dbname=klicker-prod sslmode=require sslnegotiation=direct"
108102
```
109103

@@ -112,9 +106,8 @@ psql "host=db.klicker.localhost port=5432 user=klicker-prod password=klicker \
112106
EduID is replaced by klicker's own **credentials login** (no OIDC mock needed).
113107
Seeded users (`packages/prisma-data`): `lecturer`/`abcd` (ADMIN), `free`/`abcd`,
114108
`pro1..3`/`abcd`, and `testuser1..50`/`abcdabcd`. Cross-app sessions work because
115-
every app is served under the same `klicker*.localhost` parent and the cookie
116-
domain resolves to that parent (`klicker.localhost` for the primary checkout,
117-
`klicker.<workspace>.localhost` for linked worktrees). `post-start.sh` rewrites
109+
linked-worktree apps are served under the same `klicker.<workspace>.localhost`
110+
parent and the cookie domain resolves to that parent. `post-start.sh` rewrites
118111
the public origins and `AUTH_*_ALLOWED_HOSTS` when `WORKSPACE` is set, because
119112
the hardcoded defaults only know `klicker.com`.
120113

@@ -138,7 +131,7 @@ hatchet DB migrations finishing. If the API is down, check
138131

139132
| Service | Image | Purpose |
140133
| ----------------------------------- | ------------------------------------------ | ------------------------------------------------------ |
141-
| `app` | local `Dockerfile` (Node 24 + pnpm 11.5.0) | runs `turbo dev` for the core + Tier-1 apps + workers |
134+
| `app` | local `Dockerfile` (Node 24 + pnpm 11.5.0) | runs every routed app plus the two Hatchet workers |
142135
| `postgres` | `postgres:15` | DB (klicker-prod + shadow/lti/qa/hatchet via init.sql) |
143136
| `redis_exec`/`_assessment`/`_cache` | `redis:7` | live-quiz exec / assessment / cache + pub/sub |
144137
| `mailhog` | `mailhog/mailhog` | dev SMTP sink |
@@ -148,7 +141,19 @@ hatchet DB migrations finishing. If the API is down, check
148141

149142
Environment lives in `devcontainer.env` (committed, dev-only). Lifecycle:
150143
`post-create.sh` (install + build packages + prisma reset/push/seed + token) then
151-
`post-start.sh` (launch `turbo dev`).
144+
host-side `devrouter ensure` delivers its matching process helper and invokes
145+
`post-start.sh` (set Klicker origins and call that helper). Runtime state is
146+
`/tmp/devrouter-process-klicker-dev.state`: exact workspace, command, adapter
147+
bytes, and declared non-secret runtime-origin values are fingerprinted for
148+
reuse; stale owned groups are replaced boundedly, and unknown processes are
149+
never killed. HTTP readiness remains in
150+
`devrouter ensure .`; the root build script forces production mode even though
151+
the live container exports `NODE_ENV=development`. Rerun ensure after
152+
`pnpm run build` so stale Next.js dev output can trigger the single
153+
container-recreate budget.
154+
155+
The image also carries uv `0.11.12` and selects Python 3.12, matching the
156+
analytics image and lint CI so the root quality gate runs inside the container.
152157

153158
## Notes
154159

.devcontainer/devcontainer.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# NOTE: lifecycle hooks re-source this file (set -a; . devcontainer.env; set +a)
77
# because DevPod truncates env_file values at '='. (GOTCHAS #1)
88
#
9-
# Scope: core apps (backend + auth + frontend-pwa/manage/control) + Phase 2 Tier 1
9+
# Scope: every routed app plus the two Hatchet workers
1010
# (olat-api, response-api, 2 hatchet workers). Infra is reached by COMPOSE DNS
1111
# (postgres / redis_* / hatchet / mailhog). Browser-facing URLs are the devrouter
1212
# https hosts; server-side app->app calls use localhost:<port> (all apps share

.devcontainer/devcontainer.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
"hatchet_token"
2323
],
2424
"postCreateCommand": "bash .devcontainer/post-create.sh",
25-
"postStartCommand": "bash .devcontainer/post-start.sh",
2625
"customizations": {
2726
"vscode": {
2827
"extensions": [

0 commit comments

Comments
 (0)