From 3c2e7eae3dc0a2ca62b201f6669eb57eb0a5a4ae Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 30 Oct 2026 10:36:45 +0000 Subject: [PATCH 1/7] Make local Supabase verification use two isolated slots --- .claude/skills/verify-debatelab/SKILL.md | 20 +++---- .../skills/verify-debatelab/bin/demo-user.mjs | 2 +- .claude/skills/verify-debatelab/bin/verify.sh | 25 ++++----- .../verify-debatelab/features/README.md | 4 +- .../features/auth-demo-login.md | 2 +- .../features/workspace-autosave.md | 2 +- .../playwright.verify.config.ts | 2 +- .../verify-debatelab/recipes/auth.spec.ts | 6 ++- .superset/db-gate.sh | 33 +++++------- .superset/heavy.sh | 8 +++ .superset/lib/lock.sh | 14 +++++ .superset/lib/slot.sh | 48 +++++++++++++++++ AGENTS.md | 4 +- docs/development.md | 4 +- docs/testing.md | 6 +-- package.json | 2 +- scripts/verify.mjs | 2 +- scripts/workspace-test-env.mjs | 9 +++- src/test/scripts/db-gate.test.ts | 54 ++++++++++++++++++- supabase/functions/_shared/cors.test.ts | 22 ++++++++ supabase/functions/_shared/cors.ts | 4 +- 21 files changed, 210 insertions(+), 63 deletions(-) create mode 100644 .superset/heavy.sh create mode 100644 .superset/lib/slot.sh diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index b231a9b37..2fac342de 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -25,9 +25,9 @@ $H down # stop everything `up` started; evidence stays ## Safety rules, read first -- **Plain `npm run dev` talks to the PRODUCTION Supabase project** (`src/lib/supabase/env.ts` falls back to production). Never sign in or submit forms on a dev server you started yourself. Drive only the `up` instance. It sets `VITE_LOCAL_PREVIEW=true`, and `doctor` proves the client resolved to `http://127.0.0.1:54321`. +- **Plain `npm run dev` talks to the PRODUCTION Supabase project** (`src/lib/supabase/env.ts` falls back to production). Never sign in or submit forms on a dev server you started yourself. Drive only the `up` instance. It sets `VITE_LOCAL_PREVIEW=true`, and `doctor` proves the client resolved to its allocated local API URL (`54321` or `54421`). - The Docker container `debatelab` on `127.0.0.1:8080` is Adam's deployed app. Never drive it, restart it, or point tests at it. -- The local Supabase stack uses fixed ports (54321+) and shared container names, so only one worktree can run it at a time. `up` takes `~/.superset/debatelab-db.lock` (the lock `.superset/db-gate.sh` uses) and refuses to start if the lock is held, if any `supabase_*` container is running, or if port 18081 or 18090 is busy. Don't work around a refusal. Another worktree owns the stack. +- Local verification shares two slots with `.superset/db-gate.sh`. `up` takes the first free slot or waits up to 600 seconds for either. Slot 1 keeps the default ports and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock`. Set `DEBATELAB_STACK_SLOTS=1` to restrict allocation to slot 1. The port table is `.superset/lib/slot.sh`. Containers and networks are named per worktree; the provider name is per slot. `up` refuses a stack already running for this worktree or busy slot ports. Never bypass an ownership check. Plain lock files, dead owners, and empty lock directories older than 120 seconds are reclaimed. - Recipes abort any request to `*.supabase.co` and fail the test if one is attempted. - Never print values from `.verify/state/` (`drive.env`, `function.env`). They are local test keys, but treat them as secrets. @@ -50,11 +50,11 @@ It prints nothing about the values. Ask Adam before the first live run: it spend 1. `supabase start`, which replays `supabase/migrations/`. 2. Writes the function env from `.superset/workspace-test.env`. -3. Starts the fake provider container `debatelab-fake-openrouter` (`127.0.0.1:18090`) on the stack network. +3. Starts the slot's fake provider container (`debatelab-fake-openrouter` on `18090`, or `debatelab-fake-openrouter-s2` on `18190`) on the stack network. 4. `supabase functions serve --no-verify-jwt`. -5. Starts Vite on `http://127.0.0.1:18081` in local-preview mode. +5. Starts Vite on the allocated app origin (`http://127.0.0.1:18081` or `http://127.0.0.1:18181`) in local-preview mode. -It is ready when it prints `READY http://127.0.0.1:18081`. Port 18081 is fixed: the Edge Function CORS list (`supabase/functions/_shared/cors.ts`) allows it for the local full-stack gate, and it stays clear of the dev server (8080) and the credential-free browser run (18080), which the list also allows. +It is ready when it prints `READY `. Use that URL for interactive driving. The chosen slot is saved in `.verify/state/stack.env` and restored by later commands. The generated `function.env` lists that app origin in `LOCAL_GATE_APP_ORIGINS`; CORS accepts this override only for dev origins. Production does not set it. Vite serves the working tree live, so uncommitted edits to `src/` apply on reload. Changes to `supabase/functions/` are picked up by `functions serve`. A new migration needs `down` then `up`. @@ -65,7 +65,7 @@ If `up` fails, it tears itself down and prints the log directory (`.verify/logs/ Run `$H doctor` first whenever anything looks off. It exits 0 only when all of these hold: - The stack lock is held by this run's holder process. -- Port 18081 is served by this run's Vite process group. +- The allocated app port is served by this run's Vite process group. - Vite's served `src/lib/supabase/env.ts` has `VITE_LOCAL_PREVIEW: "true"` and the local API URL. - The Edge Functions process is alive, and `demo-code-login` answers CORS for the app origin. - The fake provider answers. @@ -106,7 +106,7 @@ Fake mode also runs `e2e/full-stack/`. Live mode runs the recipes, `e2e/live/`, To prove a feature without a recipe, copy `recipes/case-generation.spec.ts` to `recipes/.spec.ts` and use the selectors from the matching file in `features/`. Recipes are maintained files. Keep the ones worth keeping. -**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at `http://127.0.0.1:18081/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts//`. The one-time DebateOwl welcome never opens on this stack (`VITE_LOCAL_PREVIEW`) or under Playwright (`VITE_E2E`); open `/app?intro=show` to see it, and close it with `Got it` or Esc. Closing it continues straight into the tour's Start debate step when that account has not finished the tour. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you. +**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at the printed app origin plus `/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts//`. The one-time DebateOwl welcome never opens on this stack (`VITE_LOCAL_PREVIEW`) or under Playwright (`VITE_E2E`); open `/app?intro=show` to see it, and close it with `Got it` or Esc. Closing it continues straight into the tour's Start debate step when that account has not finished the tour. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you. Selectors: use accessible names and roles as the feature files list them (`getByRole("button", { name: "Generate debate case" })`). The exceptions use `data-testid`: Crossfire's controls, the Current Debate control, strip and dashboard, and the voice controls (`voice-mode`, `voice-mic`). Use `exact: true` for short names such as `Start` or `Save`, because the header's `Start debate` also matches `Start`. Never use coordinates. @@ -158,12 +158,12 @@ For those, see `docs/testing.md` (the live-provider gate and the production cana 1. Deletes users minted by `user`. 2. Kills only the process groups it recorded (functions, Vite, collaboration). -3. Removes `debatelab-fake-openrouter`. +3. Removes only the recorded slot's provider container. 4. Runs `supabase stop --no-backup` (the local DB is wiped). 5. Kills the lock holder and releases the lock if it is still ours. 6. Deletes `.verify/state/`. -It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run twice. Run it after every session, including failed ones. Never kill processes by name, and never run `supabase stop` for a stack you didn't start. After `down`, `docker ps` should show no `supabase_*` and no `debatelab-fake-openrouter` containers, and `~/.superset/debatelab-db.lock` should be gone. +It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run twice. Run it after every session, including failed ones. Never kill processes by name, and never run `supabase stop` for a stack you didn't start. After `down`, this worktree's Supabase containers, its provider, and its slot lock should be gone. Another worktree may still own the other slot; leave it running. `.verify/` is gitignored. Delete old artifact directories by hand when they are no longer needed. @@ -183,4 +183,4 @@ It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run - The client files a result in its debate after it renders. A `page.goto` straight after the `ai` response can drop it; wait for the result on screen (and its row, if the next step reloads) first. - In a voice round, wait for the opponent's reply to finish (`spokenByBrowser`) before clicking `voice-mic` again; speaking over it abandons the voice request. `recipes/voice-rounds.spec.ts` shows the wait. - Account deletion is irreversible. `recipes/account-data.spec.ts` deletes only its own `@fullstack.test` demo user and checks that before clicking. -- `.superset/db-gate.sh` and this skill share the lock. While your stack is up, the gate in any worktree waits (for up to 600 s) and then fails. Don't leave the stack up. +- `.superset/db-gate.sh` and this skill share two slots. An idle verification stack still occupies one; always run `down` when finished. The full gate's browser phase and `npm run verify` builds also share a local heavy-job lock; CI skips it. diff --git a/.claude/skills/verify-debatelab/bin/demo-user.mjs b/.claude/skills/verify-debatelab/bin/demo-user.mjs index 7fe8ece17..51463f08b 100644 --- a/.claude/skills/verify-debatelab/bin/demo-user.mjs +++ b/.claude/skills/verify-debatelab/bin/demo-user.mjs @@ -28,7 +28,7 @@ if (command === "create") { .single(); if (error) throw error; await appendFile(usersFile, `${data.id}\t${email}\n`); - console.log(`DEMO_CODE=${code}\nEMAIL=${email}\nSign in at http://127.0.0.1:18081/login -> "Demo code" tab.`); + console.log(`DEMO_CODE=${code}\nEMAIL=${email}\nSign in at ${process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:18081"}/login -> "Demo code" tab.`); } else if (command === "delete-all") { const lines = (await readFile(args[0], "utf8")).split("\n").filter(Boolean); const { data } = await admin.auth.admin.listUsers({ perPage: 1000 }); diff --git a/.claude/skills/verify-debatelab/bin/verify.sh b/.claude/skills/verify-debatelab/bin/verify.sh index f755e2ebe..1f60160da 100755 --- a/.claude/skills/verify-debatelab/bin/verify.sh +++ b/.claude/skills/verify-debatelab/bin/verify.sh @@ -25,15 +25,10 @@ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO="$(git -C "$SKILL_DIR" rev-parse --show-toplevel)" cd "$REPO" source "$REPO/.superset/lib/lock.sh" +source "$REPO/.superset/lib/slot.sh" source "$REPO/.superset/lib/supabase-cli.sh" use_pinned_supabase_cli "$REPO" -DB_LOCK="$HOME/.superset/debatelab-db.lock" -FAKE_PROVIDER=debatelab-fake-openrouter -APP_PORT=18081 # the only local client origin the Edge Function CORS list and invite fixture allow -FAKE_PORT=18090 -COLLAB_PORT=12345 -APP_ORIGIN="http://127.0.0.1:$APP_PORT" VERIFY_ROOT="$REPO/.verify" STATE_DIR="$VERIFY_ROOT/state" # secrets and pids; removed by down STATE="$STATE_DIR/stack.env" @@ -50,7 +45,7 @@ LIVE_KEYS_FILE="${VERIFY_LIVE_KEYS_FILE:-$HOME/.config/debatelab/live-verify.env say() { printf '%s\n' "$*"; } fail() { printf 'FAIL %s\n' "$*" >&2; exit 1; } record() { printf '%s=%q\n' "$1" "$2" >>"$STATE"; } -load_state() { [ -f "$STATE" ] || fail "no running verification stack (state file missing); run: verify.sh up"; source "$STATE"; } +load_state() { [ -f "$STATE" ] || fail "no running verification stack (state file missing); run: verify.sh up"; source "$STATE"; stack_slot_configure "${DEBATELAB_STACK_SLOT:-1}"; } alive() { [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; } port_busy() { ss -ltnH "sport = :$1" 2>/dev/null | grep -q .; } wait_url() { @@ -96,13 +91,12 @@ cmd_up() { fi fi - # The stack uses the CLI's default ports and shared container names, so only - # one worktree may run it. Refuse instead of waiting or double-driving. - lock_acquire "$DB_LOCK" 0 || fail "the local Supabase stack lock is held by pid $(cat "$DB_LOCK/pid" 2>/dev/null || echo '?'); another worktree owns it" - if docker ps --format '{{.Names}}' | grep -q '^supabase_'; then - lock_release "$DB_LOCK"; fail "Supabase containers are already running (not started by this run)" + stack_slot_acquire 600 || fail "no local Supabase slot available" + # Other projects may run in the other slot; never adopt this project's stack. + if docker ps --format '{{.Names}}' | grep -qxF "supabase_db_$PROJECT_ID"; then + lock_release "$DB_LOCK"; fail "Supabase containers for this worktree are already running" fi - for port in 54321 "$APP_PORT" "$FAKE_PORT"; do + for port in "$SUPABASE_API_PORT" "$SUPABASE_DB_PORT" "$APP_PORT" "$FAKE_PORT" "$COLLAB_PORT"; do if port_busy "$port"; then lock_release "$DB_LOCK"; fail "port $port is already in use"; fi done @@ -112,6 +106,7 @@ cmd_up() { local log_dir="$VERIFY_ROOT/logs/$run_id" mkdir -p "$log_dir" : >"$STATE"; : >"$USERS" + record DEBATELAB_STACK_SLOT "$DEBATELAB_STACK_SLOT" record PROJECT_ID "$PROJECT_ID" record LOG_DIR "$log_dir" record HEAD "$(git rev-parse HEAD)" @@ -164,6 +159,8 @@ cmd_up() { printf 'FAKE_OPENROUTER_URL=%s\n' "http://127.0.0.1:$FAKE_PORT" printf 'WORKSPACE_TEST_MAILER_URL=%s\n' "$mailer_url" printf 'DEMO_CODE_PEPPER=%s\n' "$(sed -n 's/^DEMO_CODE_PEPPER=//p' .superset/workspace-test.env)" + printf 'COLLABORATION_PORT=%s\n' "$COLLAB_PORT" + printf 'SUPABASE_DB_URL=%s\n' "$db_url" printf 'PLAYWRIGHT_BASE_URL=%s\n' "$APP_ORIGIN" printf 'VERIFY_MODE=%s\n' "$([ "$live" = true ] && echo live || echo fake)" printf 'LIVE_MODEL=%s\n' "$([ "$live" = true ] && echo "$LIVE_MODEL")" @@ -330,7 +327,7 @@ cmd_e2e() { cmd_down() { if [ ! -f "$STATE" ]; then say "nothing recorded in $STATE; nothing to stop"; return 0; fi - source "$STATE" + load_state if [ -s "$USERS" ] && [ -f "$STATE_DIR/drive.env" ]; then ( set -a; source "$STATE_DIR/drive.env"; set +a; node "$SKILL_DIR/bin/demo-user.mjs" delete-all "$USERS" ) || say "warn: could not delete minted demo users (stack is about to be wiped anyway)" fi diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 5532421af..701ec8f73 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -4,8 +4,8 @@ This directory is the maintained source for verifying DebateLab's user-facing be ## Baseline preconditions -- `bin/verify.sh up` has printed `READY http://127.0.0.1:18081`, and `bin/verify.sh doctor` exits 0. -- The browser client is the `up` instance at `http://127.0.0.1:18081`. Never use the `debatelab` container on `:8080` or a plain `npm run dev`, because both reach production. +- `bin/verify.sh up` has printed `READY `, and `bin/verify.sh doctor` exits 0. +- The browser client is the `up` instance at the printed app origin. Never use the `debatelab` container on `:8080` or a plain `npm run dev`, because both reach production. - Each scripted test signs in with its own fresh demo-code user (a Pro account with a daily limit of 999) and deletes it afterwards. The fixture marks the onboarding tour as seen, and the user starts with no Current Debate. - Most tools work inside a Current Debate (see [current-debate](./current-debate.md)). Start one with `startDebate(page, motion)` first, or answer the motion dialog that opens after submit with `chooseForMotion(page, "create" | "once")`. The Library's `Add source` stays disabled until a debate exists. - In fake mode, model answers come from the fake provider and contain the marker `FAKE-PROVIDER`. In live mode (`up --live`), every chat call goes to real OpenRouter pinned to `anthropic/claude-haiku-5.5` on the Anthropic BYOK key, research uses real Exa, and answers are real model text. diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index 0cda7d9ea..aee8c88ce 100644 --- a/.claude/skills/verify-debatelab/features/auth-demo-login.md +++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md @@ -31,7 +31,7 @@ Preconditions: - **Persist.** Reload the page. The URL stays `/app`. On `/app/settings`, the page shows `user.email`. - **Reject.** On a signed-out page, submit `DEMO-ZZZZ-ZZZZ`. The element with role `alert` has the text `Demo code not found.`, and the URL still matches `/login`. - **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`. -- **Email link.** Local email templates are code-only, so the recipe builds the link prod's email carries: `${LOCAL_SUPABASE_URL}/auth/v1/verify?token=pkce_&type=magiclink&redirect_to=`. The `redirect_to` is `http://127.0.0.1:18081/auth/callback?sb_flow_id=<32 hex>`. Route `**/auth/v1/otp**` to a 429 before clicking `Resend code` to reproduce the failed resend. +- **Email link.** Local email templates are code-only, so the recipe builds the link prod's email carries: `${LOCAL_SUPABASE_URL}/auth/v1/verify?token=pkce_&type=magiclink&redirect_to=`. The `redirect_to` is `/auth/callback?sb_flow_id=<32 hex>`, using this slot's app origin. Route `**/auth/v1/otp**` to a 429 before clicking `Resend code` to reproduce the failed resend. - **Resend countdown.** After each send, `Resend code` is disabled and named `Resend in 0:NN` for a minute (or as long as a 429 asked). The deadline is in sessionStorage under `debatelab:otp-resend-until:`; set it to `0` to end the wait, and the button turns back within a second (`expireResendCooldown` in `recipes/auth.spec.ts`). Sending again to the same address before then opens the code step without a request. - **Switch account.** After `Sign out`, sign the second account in on the same `/login` page. `page.goto("/login")` drops the history state that the bug depended on. Create the second user with `createDemoUser` and delete it in `finally`. - **Revoked session.** Signed in on `/app`, replace `access_token` in the `debate-auth-local` localStorage entry with a JWT the server refuses, `refresh_token` with `x`, and `expires_at` with now + 3000 s, then reload. Within a few seconds the URL matches `/login` and a `status` element says `Your session expired. Sign in again.`. A second reload no longer shows it. diff --git a/.claude/skills/verify-debatelab/features/workspace-autosave.md b/.claude/skills/verify-debatelab/features/workspace-autosave.md index ab87eeda8..8e7d06527 100644 --- a/.claude/skills/verify-debatelab/features/workspace-autosave.md +++ b/.claude/skills/verify-debatelab/features/workspace-autosave.md @@ -25,6 +25,6 @@ Preconditions: ## Gotchas -- `e2e/workspace-collaboration.spec.ts` starts its own collaboration process on port 12345 and must not run against `up --collab`. This recipe is the opposite: it needs `up --collab` and skips without it. Don't drive both on one stack. +- `e2e/workspace-collaboration.spec.ts` starts its own collaboration process on the slot's collaboration port (`12345` or `12445`) and must not run against `up --collab`. This recipe is the opposite: it needs `up --collab` and skips without it. Don't drive both on one stack. - `up` always gives the client `VITE_COLLABORATION_URL`, so Workspace routes render without `--collab` but the editor never connects. The recipe keys its skip off `VERIFY_COLLAB` in `drive.env`, not off the page. - The Workspace editor reports uncaught page errors through the fixture. Hocuspocus hands empty outgoing messages to `onOutgoingMessage`; `durableMutationFromMessage` ignores them (`src/lib/workspace/durable-ack.ts`). diff --git a/.claude/skills/verify-debatelab/playwright.verify.config.ts b/.claude/skills/verify-debatelab/playwright.verify.config.ts index 7d2fd3ea6..0763655ac 100644 --- a/.claude/skills/verify-debatelab/playwright.verify.config.ts +++ b/.claude/skills/verify-debatelab/playwright.verify.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ timeout: live ? 600_000 : 120_000, reporter: [["list"], ["json", { outputFile: join(artifactDir, "report.json") }]], use: { - baseURL: "http://127.0.0.1:18081", + baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:18081", locale: "en-US", actionTimeout: 15_000, // Proof runs keep everything, not only failures. diff --git a/.claude/skills/verify-debatelab/recipes/auth.spec.ts b/.claude/skills/verify-debatelab/recipes/auth.spec.ts index 19d45aba1..e6d2aaa3d 100644 --- a/.claude/skills/verify-debatelab/recipes/auth.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/auth.spec.ts @@ -120,7 +120,11 @@ test("auth-email-link: the emailed sign-in link signs in after a failed resend, await page.getByRole("button", { name: "Send code" }).click(); // The link returns to the callback route with this sign-in's own flow id. const redirectTo = new URL((await otpRequest).url()).searchParams.get("redirect_to") ?? ""; - expect(redirectTo).toMatch(/^http:\/\/127\.0\.0\.1:18081\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/); + const callback = new URL(redirectTo); + expect(callback.origin).toBe(process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:18081"); + expect(callback.pathname).toBe("/auth/callback"); + expect([...callback.searchParams.keys()]).toEqual(["sb_flow_id"]); + expect(callback.searchParams.get("sb_flow_id")).toMatch(/^[0-9a-f]{32}$/); await expect(page.getByText("Code sent").first()).toBeVisible(); const otp = await readOtp(request, user.email, sentAfter); await evidence.step(`Sent a code; the link returns to ${redirectTo.replace(/sb_flow_id=.*/, "sb_flow_id=")}`); diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index 5f6915b2b..256599bdd 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -1,12 +1,6 @@ #!/usr/bin/env bash # Runs the Docker-backed Supabase, database, collaboration, and full-stack browser gate -# with exclusive access across -# Superset workspaces of this repo. `supabase/config.toml` sets no -# project_id or custom port block, so `supabase db start` always binds the -# CLI's default ports and container names - two workspaces running it at -# once collide in Docker, not just on a socket. Use this instead of calling -# `supabase db start` directly whenever more than one workspace might be -# active. +# using one of two isolated local-stack slots across worktrees. # # --db-only starts only Postgres (`supabase db start`) and stops after # `npm run test:db` (pgTAP and the database scripts), still holding the lock @@ -23,11 +17,10 @@ esac DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$DIR/lib/lock.sh" +source "$DIR/lib/slot.sh" source "$DIR/lib/supabase-cli.sh" use_pinned_supabase_cli "$(cd "$DIR/.." && pwd)" -DB_LOCK="$HOME/.superset/debatelab-db.lock" -FAKE_PROVIDER=debatelab-fake-openrouter # LIVE_PROVIDERS=1 runs e2e/live against real OpenRouter, Jev, and Exa instead of # the fake provider. It needs LIVE_OPENROUTER_API_KEY and LIVE_EXA_API_KEY. # LIVE_MODEL=as-requested runs e2e/model-matrix instead: one case per catalog model. @@ -65,7 +58,7 @@ cleanup() { trap - EXIT stop_owned_processes - # The container name is shared by every worktree, so only remove our own. + # Remove only the provider for the slot we hold. if [ "$provider_started" = true ]; then docker rm -f "$FAKE_PROVIDER" >/dev/null 2>&1 || true fi @@ -86,8 +79,8 @@ cleanup() { exit "$status" } -echo "Waiting for exclusive access to the local Supabase stack..." -lock_acquire "$DB_LOCK" 600 +echo "Waiting for a local Supabase slot..." +stack_slot_acquire 600 trap cleanup EXIT if [ -n "${WORKSPACE_GATE_ARTIFACT_ROOT:-}" ]; then @@ -119,7 +112,7 @@ api_url="$(status_value API_URL)" anon_key="$(status_value ANON_KEY)" service_role_key="$(status_value SERVICE_ROLE_KEY)" database_url="$(status_value DB_URL)" -app_origin="http://127.0.0.1:18081" +app_origin="$APP_ORIGIN" project_id="$(basename "$(pwd)")" mailer_host="$(docker ps --filter "name=supabase_inbucket_$project_id" --format '{{.Names}}')" if [ -z "$mailer_host" ]; then @@ -143,7 +136,7 @@ fi docker rm -f "$FAKE_PROVIDER" >/dev/null 2>&1 || true provider_started=true docker run -d --rm --name "$FAKE_PROVIDER" --network "supabase_network_$project_id" \ - "${provider_mode[@]}" -p 127.0.0.1:18090:8787 -v "$(pwd)/e2e/full-stack/fake-openrouter.mjs:/fake.mjs:ro" \ + "${provider_mode[@]}" -p "127.0.0.1:$FAKE_PORT:8787" -v "$(pwd)/e2e/full-stack/fake-openrouter.mjs:/fake.mjs:ro" \ node:22-alpine node /fake.mjs >/dev/null setsid supabase functions serve --no-verify-jwt \ @@ -153,12 +146,12 @@ owned_pids+=("$!") VITE_LOCAL_PREVIEW=true \ VITE_LOCAL_SUPABASE_URL="$api_url" \ VITE_LOCAL_SUPABASE_ANON_KEY="$anon_key" \ -VITE_COLLABORATION_URL=ws://127.0.0.1:12345 \ - setsid npm run dev -- --host 127.0.0.1 --port 18081 --strictPort >"$runtime_dir/vite.log" 2>&1 & +VITE_COLLABORATION_URL="ws://127.0.0.1:$COLLAB_PORT" \ + setsid npm run dev -- --host 127.0.0.1 --port "$APP_PORT" --strictPort >"$runtime_dir/vite.log" 2>&1 & owned_pids+=("$!") mailer_url="$(status_value MAILPIT_URL)" -for url in "$mailer_url/api/v1/messages" "$app_origin" "http://127.0.0.1:18090/__requests"; do +for url in "$mailer_url/api/v1/messages" "$app_origin" "$FAKE_OPENROUTER_URL/__requests"; do for _ in $(seq 1 200); do if curl --fail --silent --output /dev/null "$url"; then break; fi sleep 0.1 @@ -186,12 +179,12 @@ LOCAL_SUPABASE_ANON_KEY="$anon_key" \ LOCAL_SUPABASE_SERVICE_ROLE_KEY="$service_role_key" \ WORKSPACE_TEST_MAILER_URL="$mailer_url" \ COLLABORATION_DATABASE_URL="$database_url" \ -COLLABORATION_PORT=12345 \ +COLLABORATION_PORT="$COLLAB_PORT" \ COLLABORATION_ALLOWED_ORIGINS="$app_origin" \ SUPABASE_URL="$api_url" \ SUPABASE_ANON_KEY="$anon_key" \ -FAKE_OPENROUTER_URL=http://127.0.0.1:18090 \ +FAKE_OPENROUTER_URL="$FAKE_OPENROUTER_URL" \ LIVE_MODEL="$LIVE_MODEL" \ DEMO_CODE_PEPPER="$(sed -n 's/^DEMO_CODE_PEPPER=//p' .superset/workspace-test.env)" \ PLAYWRIGHT_BASE_URL="$app_origin" \ - npx playwright test --config=playwright.workspace.config.ts "${test_selection[@]}" + bash "$DIR/heavy.sh" npx playwright test --config=playwright.workspace.config.ts "${test_selection[@]}" diff --git a/.superset/heavy.sh b/.superset/heavy.sh new file mode 100644 index 000000000..53c6ce9a9 --- /dev/null +++ b/.superset/heavy.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Serialize memory-heavy local work. CI and hosts without flock run normally. +set -euo pipefail +if [ -z "${CI:-}" ] && command -v flock >/dev/null 2>&1; then + mkdir -p "$HOME/.superset" + exec flock "$HOME/.superset/debatelab-heavy.lock" "$@" +fi +exec "$@" diff --git a/.superset/lib/lock.sh b/.superset/lib/lock.sh index f639fa8ae..57d40a1db 100755 --- a/.superset/lib/lock.sh +++ b/.superset/lib/lock.sh @@ -17,6 +17,11 @@ lock_acquire() { mkdir -p "$(dirname "$lock_dir")" while ! mkdir "$lock_dir" 2>/dev/null; do + # Old flock users could leave a plain file at a mkdir-lock path. + if [ -f "$lock_dir" ] && [ ! -L "$lock_dir" ]; then + rm -f -- "$lock_dir" + continue + fi # Reclaim a lock left behind by a process that no longer exists. if [ -f "$lock_dir/pid" ]; then local owner_pid @@ -25,6 +30,15 @@ lock_acquire() { rm -rf "$lock_dir" continue fi + elif [ -d "$lock_dir" ]; then + # Leave time for a process between mkdir and writing its pid. Support + # both GNU and BSD stat; a fresh directory is never reclaimed. + local modified + modified="$(stat -c %Y "$lock_dir" 2>/dev/null || stat -f %m "$lock_dir" 2>/dev/null || date +%s)" + if [ "$(( $(date +%s) - modified ))" -gt 120 ]; then + # rmdir cannot delete a lock whose owner has since written its pid. + if rmdir -- "$lock_dir" 2>/dev/null; then continue; fi + fi fi if [ "$waited" -ge "$timeout" ]; then diff --git a/.superset/lib/slot.sh b/.superset/lib/slot.sh new file mode 100644 index 000000000..3ddd09e27 --- /dev/null +++ b/.superset/lib/slot.sh @@ -0,0 +1,48 @@ +# Shared local-stack allocation. Source lock.sh before calling stack_slot_acquire. +# Keep slot 1 identical to the pinned CLI defaults; slot 2 offsets ports by 100. +stack_slot_configure() { + case "$1" in 1|2) ;; *) echo "Invalid stack slot: $1" >&2; return 1 ;; esac + export DEBATELAB_STACK_SLOT="$1" + local offset=$((($1 - 1) * 100)) + DB_LOCK="$HOME/.superset/debatelab-db.lock" + FAKE_PROVIDER=debatelab-fake-openrouter + if [ "$1" = 2 ]; then + DB_LOCK="$HOME/.superset/debatelab-db.slot2.lock" + FAKE_PROVIDER=debatelab-fake-openrouter-s2 + fi + export FAKE_PROVIDER + export APP_PORT=$((18081 + offset)) FAKE_PORT=$((18090 + offset)) COLLAB_PORT=$((12345 + offset)) + export APP_ORIGIN="http://127.0.0.1:$APP_PORT" + export FAKE_OPENROUTER_URL="http://127.0.0.1:$FAKE_PORT" + export COLLABORATION_PORT="$COLLAB_PORT" + # CLI 2.121.0 maps config paths to env keys (dots become underscores). + # Mailpit is [local_smtp], formerly [inbucket]. SMTP/POP3 host ports stay + # disabled (0), as in today's defaults; the web/API port is allocated here. + export SUPABASE_API_PORT=$((54321 + offset)) SUPABASE_DB_PORT=$((54322 + offset)) + export SUPABASE_DB_SHADOW_PORT=$((54320 + offset)) SUPABASE_STUDIO_PORT=$((54323 + offset)) + export SUPABASE_LOCAL_SMTP_PORT=$((54324 + offset)) + export SUPABASE_LOCAL_SMTP_SMTP_PORT=0 SUPABASE_LOCAL_SMTP_POP3_PORT=0 + export SUPABASE_ANALYTICS_PORT=$((54327 + offset)) SUPABASE_DB_POOLER_PORT=$((54329 + offset)) + export SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT=$((8083 + offset)) + export SUPABASE_DB_URL="postgresql://postgres:postgres@127.0.0.1:$SUPABASE_DB_PORT/postgres" +} + +stack_slot_acquire() { + local slots="${DEBATELAB_STACK_SLOTS:-2}" timeout="${1:-600}" waited=0 slot + case "$slots" in 1|2) ;; *) echo "DEBATELAB_STACK_SLOTS must be 1 or 2" >&2; return 1 ;; esac + while :; do + for ((slot=1; slot<=slots; slot++)); do + stack_slot_configure "$slot" + if lock_acquire "$DB_LOCK" 0 2>/dev/null; then + echo "Acquired local Supabase slot $slot (DB $SUPABASE_DB_PORT, app $APP_PORT)" + return 0 + fi + done + if [ "$waited" -ge "$timeout" ]; then + echo "Timed out after ${timeout}s waiting for a local Supabase slot ($slots enabled)" >&2 + return 1 + fi + sleep 1 + waited=$((waited + 1)) + done +} diff --git a/AGENTS.md b/AGENTS.md index abb6a3325..f7757d20e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Each Superset workspace is a separate git worktree. `.superset/config.json` runs `npm ci` when it creates a workspace and starts the app through `./.superset/dev.sh`. That script allocates a port from `8080-8179` in `~/.superset/debatelab-ports/allocations.json`; `./.superset/teardown.sh` releases it. -The local Supabase stack still uses shared default ports and container names. In Superset, run the database gate through `./.superset/db-gate.sh`. It holds `~/.superset/debatelab-db.lock` while the stack runs and stops the stack afterward. +Local Supabase verification has two slots shared by `./.superset/db-gate.sh` and `verify.sh`. Slot 1 keeps the default ports and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock`. Each worktree gets the first free slot and stops its stack afterward. `DEBATELAB_STACK_SLOTS=1` limits allocation to slot 1. See `.superset/lib/slot.sh` for the port table. ## System map @@ -82,7 +82,7 @@ npm run test:db npx supabase stop --no-backup ``` -Use only the repo-pinned Supabase CLI (the exact `supabase` devDependency, 2.121.0). `npm run` scripts, `./.superset/db-gate.sh`, `scripts/agent-stack.sh`, and the verify harness already put it first on PATH; anywhere else, call `npx supabase`. Never use a global CLI or `npx supabase@latest`, and never bump the version in one worktree only: each CLI version pulls its own ~7 GB set of Docker images onto the shared VPS. Change it in `package.json`, `package-lock.json`, and the `setup-cli` steps in `.github/workflows/` together. Do not run this gate concurrently from multiple worktrees. If this task starts the stack, stop it before finishing, including after failures when safe. +Use only the repo-pinned Supabase CLI (the exact `supabase` devDependency, 2.121.0). `npm run` scripts, `./.superset/db-gate.sh`, `scripts/agent-stack.sh`, and the verify harness already put it first on PATH; anywhere else, call `npx supabase`. Never use a global CLI or `npx supabase@latest`, and never bump the version in one worktree only: each CLI version pulls its own ~7 GB set of Docker images onto the shared VPS. Change it in `package.json`, `package-lock.json`, and the `setup-cli` steps in `.github/workflows/` together. Run concurrent gates only through the slot-aware wrapper, from separate worktrees. If this task starts the stack, stop it before finishing, including after failures when safe. WebKit is a separate release smoke test: diff --git a/docs/development.md b/docs/development.md index 4c4db217b..85d4f61f2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -137,7 +137,9 @@ npm run test:db npx supabase stop --no-backup ``` -The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` through database tests and the real-Auth collaboration browser journey, and stops the stack and owned processes on exit. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe. +The wrapper and `verify.sh` share two local Supabase slots across worktrees. Slot 1 keeps today's ports (API `54321`, database `54322`, app `18081`, provider `18090`, collaboration `12345`) and `~/.superset/debatelab-db.lock`. Slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock` plus the provider container `debatelab-fake-openrouter-s2`. The authoritative port table is [`.superset/lib/slot.sh`](../.superset/lib/slot.sh). Allocation takes the first free slot or waits up to 600 seconds for either; `DEBATELAB_STACK_SLOTS=1` restricts it to slot 1. Supabase containers and networks are named per worktree. A plain lock file, a dead owner, or an empty lock directory older than 120 seconds is reclaimed automatically. Use separate worktrees, and use the wrapper rather than starting Supabase directly when another task may be active. The task that starts a stack stops it before finishing, including after failures. + +Memory-heavy jobs share `~/.superset/debatelab-heavy.lock`. `npm run verify` wraps its build in [`.superset/heavy.sh`](../.superset/heavy.sh), and the full database gate wraps its browser phase. Use `bash .superset/heavy.sh npx expo export` for a local Expo export too. The helper skips locking when `CI` is set or `flock` is unavailable. It serializes these jobs without preventing two database stacks from running. ### A private database per agent diff --git a/docs/testing.md b/docs/testing.md index e41638259..80d9260b2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -176,7 +176,7 @@ In Superset, run: ./.superset/db-gate.sh ``` -The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. The `Database` workflow runs this full gate daily and on demand, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same lock, use `./.superset/db-gate.sh --db-only`: it starts Postgres alone (`supabase db start`), which pgTAP and the database scripts need and nothing more, in about 35 seconds locally. Pull requests that change the schema run this. Outside Superset, run it with: +The wrapper acquires the first free local stack slot, starts full local Supabase, runs `npm run test:db`, then holds that slot while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. The `Database` workflow runs this full gate daily and on demand, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same slot lock, use `./.superset/db-gate.sh --db-only`: it starts Postgres alone (`supabase db start`), which pgTAP and the database scripts need and nothing more, in about 35 seconds locally. Pull requests that change the schema run this. Outside Superset, run it with: ```bash npx supabase db start @@ -210,7 +210,7 @@ These journeys do not prove live provider behavior, web research (the local stac ### Interactive verification stack -To check one feature by hand, or keep evidence for a change, use the same stack as a long-lived instance through [`.claude/skills/verify-debatelab/`](../.claude/skills/verify-debatelab/SKILL.md). `bin/verify.sh up` starts it, and `doctor` proves the client resolved to the local project. `drive |e2e/>` runs Playwright recipes or maintained specs and keeps screenshots, ARIA snapshots, provider calls and database rows in `.verify/artifacts/`. `down` stops everything. It holds the same `~/.superset/debatelab-db.lock` as the gate, so the gate waits while it is up. By default it uses the fake provider and proves the same things as the full-stack journeys. `up --live` turns the fake provider into a capped recording proxy to real OpenRouter that pins every chat call to `meta/muse-spark-1.3-contributor`, and gives Edge Functions a real Exa key; it reads keys from `~/.config/debatelab/live-verify.env`, costs money, and needs Adam's approval. Neither mode proves Stripe Checkout, Google export, staging, or production. +To check one feature by hand, or keep evidence for a change, use the same stack as a long-lived instance through [`.claude/skills/verify-debatelab/`](../.claude/skills/verify-debatelab/SKILL.md). `bin/verify.sh up` starts it, and `doctor` proves the client resolved to the local project. `drive |e2e/>` runs Playwright recipes or maintained specs and keeps screenshots, ARIA snapshots, provider calls and database rows in `.verify/artifacts/`. `down` stops everything. It shares two slots with the gate, records its slot in `.verify/state/stack.env`, and restores it for `doctor`, `drive`, and `down`. A gate can use the other slot while it is up. By default it uses the fake provider and proves the same things as the full-stack journeys. `up --live` turns the fake provider into a capped recording proxy to real OpenRouter that pins every chat call to `meta/muse-spark-1.3-contributor`, and gives Edge Functions a real Exa key; it reads keys from `~/.config/debatelab/live-verify.env`, costs money, and needs Adam's approval. Neither mode proves Stripe Checkout, Google export, staging, or production. ### Live provider journeys @@ -298,7 +298,7 @@ The last two checks are the only production writes, and they touch only the cana `.github/workflows/prod-canary.yml` runs after every push to `main`, daily at 06:41 UTC, and on demand. A push run first waits, up to 15 minutes, for Vercel's production deployment of that commit to the `debate-navigator` project (GitHub environment `Production – debate-navigator`), and skips when `main` moves on first, because the newer commit's run checks the newer deployment. Main can deploy more than 10 times a day, so push runs set `CANARY_AI_REQUESTS=false` and skip the two AI checks. The signed-in session check still runs after every deployment. Only the daily and manual runs make real AI requests: two per run, plus at most two retries. It reads the password and email from secrets and the public anon key from the `PROD_SUPABASE_ANON_KEY` repository variable. -The local stack begins at port `54321` and is not parallel-safe across worktrees. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. +Local gates support two simultaneous worktrees. Slot 1 keeps ports beginning at `54321` and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to every enabled port and uses `~/.superset/debatelab-db.slot2.lock`. [`.superset/lib/slot.sh`](../.superset/lib/slot.sh) owns the port table. `DEBATELAB_STACK_SLOTS=1` restores single-slot allocation. Both entry points wait up to 600 seconds when all enabled slots are held. Stale plain files, dead owner pids, and empty lock directories older than 120 seconds are reclaimed. Local verify builds and the full gate browser phase share `~/.superset/debatelab-heavy.lock` through `bash .superset/heavy.sh`; CI and hosts without `flock` skip this lock. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. Never substitute a staging or production project for this gate. diff --git a/package.json b/package.json index bbb0ab701..acfb20ed8 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "test:edge": "deno test --config supabase/functions/deno.json --node-modules-dir=auto --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --config supabase/functions/deno.json --node-modules-dir=auto --allow-read --allow-env=LOCAL_GATE_APP_ORIGINS,DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-opus-default-migration.sh && bash scripts/test-admin-table-drift-migration.sh &&bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 5ad45e875..e5f17726d 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -25,7 +25,7 @@ const LANES = [ { name: "docs", command: "npm run docs:check" }, { name: "jargon", command: "npm run seo:jargon" }, { name: "lint", command: "npm run lint" }, - { name: "build", command: "npm run build" }, + { name: "build", command: "bash .superset/heavy.sh npm run build" }, { name: "sw-offline", command: "npm run test:sw" }, { name: "edge", command: "npm run test:edge" }, ], diff --git a/scripts/workspace-test-env.mjs b/scripts/workspace-test-env.mjs index 48b67bb6f..4b87b92f0 100644 --- a/scripts/workspace-test-env.mjs +++ b/scripts/workspace-test-env.mjs @@ -4,5 +4,10 @@ const [source, target] = process.argv.slice(2); const serviceRoleKey = process.env.LOCAL_SERVICE_ROLE_KEY; const mailerHost = process.env.LOCAL_MAILER_HOST; if (!source || !target || !serviceRoleKey || !mailerHost) throw new Error("Local Edge environment is incomplete"); -const fixture = (await readFile(source, "utf8")).replace("workspace-mailer.invalid", mailerHost); -await writeFile(target, `${fixture.trim()}\nWORKSPACE_INVITE_SERVICE_ROLE_KEY=${serviceRoleKey}\n`, { mode: 0o600 }); +const appOrigin = process.env.APP_ORIGIN || "http://127.0.0.1:18081"; +const provider = process.env.FAKE_PROVIDER || "debatelab-fake-openrouter"; +const fixture = (await readFile(source, "utf8")) + .replaceAll("workspace-mailer.invalid", mailerHost) + .replaceAll("http://127.0.0.1:18081", appOrigin) + .replaceAll("debatelab-fake-openrouter", provider); +await writeFile(target, `${fixture.trim()}\nLOCAL_GATE_APP_ORIGINS=${appOrigin}\nWORKSPACE_INVITE_SERVICE_ROLE_KEY=${serviceRoleKey}\n`, { mode: 0o600 }); diff --git a/src/test/scripts/db-gate.test.ts b/src/test/scripts/db-gate.test.ts index dca2b79a0..3ade48b1b 100644 --- a/src/test/scripts/db-gate.test.ts +++ b/src/test/scripts/db-gate.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, expect, it } from "vitest"; @@ -15,6 +15,58 @@ afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +it("reclaims stale lock paths without taking fresh or live locks", async () => { + const root = await mkdtemp(join(tmpdir(), "debatelab-lock-")); + temporaryRoots.push(root); + const stale = join(root, "stale"); + const plain = join(root, "plain"); + const fresh = join(root, "fresh"); + const live = join(root, "live"); + const dead = join(root, "dead"); + for (const path of [stale, fresh, live, dead]) await mkdir(path); + const old = new Date(Date.now() - 180_000); + await utimes(stale, old, old); + await writeFile(plain, ""); + await writeFile(join(live, "pid"), String(process.pid)); + const result = spawnSync("bash", ["-c", ` + set -euo pipefail + source "$1" + # Reap a real child, so its pid is known to be dead. + true & dead_pid=$!; wait "$dead_pid" + echo "$dead_pid" > "$2/dead/pid" + for name in stale plain dead; do + lock_acquire "$2/$name" 0 + [ "$(cat "$2/$name/pid")" = "$$" ] + lock_release "$2/$name" + done + ! lock_acquire "$2/fresh" 0 + ! lock_acquire "$2/live" 0 + `, "lock-test", resolve(".superset/lib/lock.sh"), root], { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); +}); + +it("uses slot 2 when slot 1 is held, and respects the single-slot cap", async () => { + const root = await mkdtemp(join(tmpdir(), "debatelab-slots-")); + temporaryRoots.push(root); + const lock = join(root, ".superset/debatelab-db.lock"); + await mkdir(lock, { recursive: true }); + await writeFile(join(lock, "pid"), String(process.pid)); + const result = spawnSync("bash", ["-c", ` + set -euo pipefail + source "$1/lock.sh" + source "$1/slot.sh" + DEBATELAB_STACK_SLOTS=2 stack_slot_acquire 0 + [ "$DEBATELAB_STACK_SLOT" = 2 ] + [ "$SUPABASE_DB_URL" = postgresql://postgres:postgres@127.0.0.1:54422/postgres ] + [ "$FAKE_PROVIDER" = debatelab-fake-openrouter-s2 ] + [ "$APP_ORIGIN" = http://127.0.0.1:18181 ] + lock_release "$DB_LOCK" + ! DEBATELAB_STACK_SLOTS=1 stack_slot_acquire 0 + ! DEBATELAB_STACK_SLOTS=3 stack_slot_acquire 0 + `, "slot-test", resolve(".superset/lib")], { encoding: "utf8", env: { ...process.env, HOME: root } }); + expect(result.status, result.stderr).toBe(0); +}); + it("stops local Supabase when database tests fail", async () => { const root = await mkdtemp(join(tmpdir(), "debatelab-db-gate-")); temporaryRoots.push(root); diff --git a/supabase/functions/_shared/cors.test.ts b/supabase/functions/_shared/cors.test.ts index 1f65d1307..56f65eb69 100644 --- a/supabase/functions/_shared/cors.test.ts +++ b/supabase/functions/_shared/cors.test.ts @@ -1,6 +1,28 @@ import { assertEquals } from "jsr:@std/assert@1.0.19"; import { ALLOWED_ORIGINS, getCorsHeaders, isAllowedOrigin, isDevOrigin } from "./cors.ts"; +Deno.test("local gate origins require both explicit listing and a dev origin", () => { + const previous = Deno.env.get("LOCAL_GATE_APP_ORIGINS"); + const local = "http://127.0.0.1:18181"; + try { + Deno.env.delete("LOCAL_GATE_APP_ORIGINS"); + assertEquals(isAllowedOrigin(local), false); + assertEquals(getCorsHeaders(local)["Access-Control-Allow-Origin"], ALLOWED_ORIGINS[0]); + Deno.env.set("LOCAL_GATE_APP_ORIGINS", `${local}, http://localhost:18181, https://evil.example, http://localhost.evil.com`); + for (const origin of [local, "http://localhost:18181"]) { + assertEquals(isAllowedOrigin(origin), true); + assertEquals(getCorsHeaders(origin)["Access-Control-Allow-Origin"], origin); + } + for (const origin of ["https://evil.example", "http://localhost.evil.com", "http://127.0.0.1:18182"]) { + assertEquals(isAllowedOrigin(origin), false); + assertEquals(getCorsHeaders(origin)["Access-Control-Allow-Origin"], ALLOWED_ORIGINS[0]); + } + } finally { + if (previous === undefined) Deno.env.delete("LOCAL_GATE_APP_ORIGINS"); + else Deno.env.set("LOCAL_GATE_APP_ORIGINS", previous); + } +}); + Deno.test("allows the isolated staging browser origin", () => { const headers = getCorsHeaders("http://127.0.0.1:18080"); assertEquals(headers["Access-Control-Allow-Origin"], "http://127.0.0.1:18080"); diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts index 1a49db9bd..4fbe9c499 100644 --- a/supabase/functions/_shared/cors.ts +++ b/supabase/functions/_shared/cors.ts @@ -32,7 +32,9 @@ const STAGING_WEB_APP_ORIGIN = "https://debatelab-staging.vercel.app"; export function isAllowedOrigin(origin: string): boolean { return (ALLOWED_ORIGINS as readonly string[]).includes(origin) || origin === STAGING_WEB_APP_ORIGIN || - VERCEL_PREVIEW_ORIGIN.test(origin); + VERCEL_PREVIEW_ORIGIN.test(origin) || + (isDevOrigin(origin) && (Deno.env.get("LOCAL_GATE_APP_ORIGINS") ?? "") + .split(",").some((allowed) => allowed.trim() === origin)); } export type CorsMethods = "POST, OPTIONS" | "GET, POST, OPTIONS" | "POST, GET, OPTIONS"; From f0ea8c172563afaaa74f139f810ca74d7be04d76 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 30 Oct 2026 10:46:27 +0000 Subject: [PATCH 2/7] Skip the local heavy lock whenever CI is set --- .superset/heavy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.superset/heavy.sh b/.superset/heavy.sh index 53c6ce9a9..fbec3cbc1 100644 --- a/.superset/heavy.sh +++ b/.superset/heavy.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Serialize memory-heavy local work. CI and hosts without flock run normally. set -euo pipefail -if [ -z "${CI:-}" ] && command -v flock >/dev/null 2>&1; then +if [ -z "${CI+x}" ] && command -v flock >/dev/null 2>&1; then mkdir -p "$HOME/.superset" exec flock "$HOME/.superset/debatelab-heavy.lock" "$@" fi From f44f18dafc4dbf44336fe2e2aee8bae32df87f92 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 30 Oct 2026 11:05:45 +0000 Subject: [PATCH 3/7] Reserve stack subnets to avoid the Pi tailnet route --- .claude/skills/verify-debatelab/SKILL.md | 2 +- .claude/skills/verify-debatelab/bin/verify.sh | 1 + .superset/db-gate.sh | 3 ++- .superset/lib/slot.sh | 19 +++++++++++++++++++ docs/development.md | 2 +- docs/testing.md | 2 +- src/test/scripts/db-gate.test.ts | 4 ++-- 7 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index 2fac342de..0b4171ba9 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -27,7 +27,7 @@ $H down # stop everything `up` started; evidence stays - **Plain `npm run dev` talks to the PRODUCTION Supabase project** (`src/lib/supabase/env.ts` falls back to production). Never sign in or submit forms on a dev server you started yourself. Drive only the `up` instance. It sets `VITE_LOCAL_PREVIEW=true`, and `doctor` proves the client resolved to its allocated local API URL (`54321` or `54421`). - The Docker container `debatelab` on `127.0.0.1:8080` is Adam's deployed app. Never drive it, restart it, or point tests at it. -- Local verification shares two slots with `.superset/db-gate.sh`. `up` takes the first free slot or waits up to 600 seconds for either. Slot 1 keeps the default ports and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock`. Set `DEBATELAB_STACK_SLOTS=1` to restrict allocation to slot 1. The port table is `.superset/lib/slot.sh`. Containers and networks are named per worktree; the provider name is per slot. `up` refuses a stack already running for this worktree or busy slot ports. Never bypass an ownership check. Plain lock files, dead owners, and empty lock directories older than 120 seconds are reclaimed. +- Local verification shares two slots with `.superset/db-gate.sh`. `up` takes the first free slot or waits up to 600 seconds for either. Slot 1 keeps the default ports and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock`. Set `DEBATELAB_STACK_SLOTS=1` to restrict allocation to slot 1. The port and subnet table is `.superset/lib/slot.sh`. It reserves `10.254.231.0/24` and `10.254.232.0/24` to avoid Docker allocating a subnet that overlaps the Pi's Tailscale route. An existing project network with another subnet is refused; stop its owning stack first. Containers and networks are named per worktree; the provider name is per slot. `up` refuses a stack already running for this worktree or busy slot ports. Never bypass an ownership check. Plain lock files, dead owners, and empty lock directories older than 120 seconds are reclaimed. - Recipes abort any request to `*.supabase.co` and fail the test if one is attempted. - Never print values from `.verify/state/` (`drive.env`, `function.env`). They are local test keys, but treat them as secrets. diff --git a/.claude/skills/verify-debatelab/bin/verify.sh b/.claude/skills/verify-debatelab/bin/verify.sh index 1f60160da..9367ff407 100755 --- a/.claude/skills/verify-debatelab/bin/verify.sh +++ b/.claude/skills/verify-debatelab/bin/verify.sh @@ -124,6 +124,7 @@ cmd_up() { trap 'say "up failed; tearing down (logs: $log_dir)"; cmd_down || true' ERR say "Starting local Supabase (logs: $log_dir)..." + stack_network_prepare "$REPO" record STACK_STARTED true supabase start >>"$log_dir/supabase.log" 2>&1 diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index 256599bdd..fe5e76b8e 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -92,8 +92,9 @@ fi # Shared CI runners intermittently hit registry rate limits while pulling images. start_command=(start) if [ "$DB_ONLY" = true ]; then start_command=(db start); fi -stack_started=true for attempt in 1 2 3; do + stack_network_prepare "$(pwd)" + stack_started=true if supabase "${start_command[@]}" >>"$runtime_dir/supabase.log" 2>&1; then break; fi if [ "$attempt" -eq 3 ]; then exit 1; fi echo "supabase start failed (attempt $attempt); retrying after $((attempt * 30))s" diff --git a/.superset/lib/slot.sh b/.superset/lib/slot.sh index 3ddd09e27..53f4f798a 100644 --- a/.superset/lib/slot.sh +++ b/.superset/lib/slot.sh @@ -15,6 +15,9 @@ stack_slot_configure() { export APP_ORIGIN="http://127.0.0.1:$APP_PORT" export FAKE_OPENROUTER_URL="http://127.0.0.1:$FAKE_PORT" export COLLABORATION_PORT="$COLLAB_PORT" + # Docker's next default subnet on the VPS overlaps the Pi's tailnet route. + # Reserve one subnet per locked slot instead of depending on its free pool. + STACK_SUBNET="10.254.$((230 + $1)).0/24" # CLI 2.121.0 maps config paths to env keys (dots become underscores). # Mailpit is [local_smtp], formerly [inbucket]. SMTP/POP3 host ports stay # disabled (0), as in today's defaults; the web/API port is allocated here. @@ -27,6 +30,22 @@ stack_slot_configure() { export SUPABASE_DB_URL="postgresql://postgres:postgres@127.0.0.1:$SUPABASE_DB_PORT/postgres" } +stack_network_prepare() { + local project_id network subnet + project_id="$(basename "$1")" + network="supabase_network_$project_id" + if subnet="$(docker network inspect "$network" --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null)"; then + if [ "$subnet" != "$STACK_SUBNET" ]; then + echo "Existing $network uses $subnet instead of $STACK_SUBNET; stop its owning stack first" >&2 + return 1 + fi + else + docker network create --driver bridge --subnet "$STACK_SUBNET" \ + --label "com.supabase.cli.project=$project_id" \ + --label "com.docker.compose.project=$project_id" "$network" >/dev/null + fi +} + stack_slot_acquire() { local slots="${DEBATELAB_STACK_SLOTS:-2}" timeout="${1:-600}" waited=0 slot case "$slots" in 1|2) ;; *) echo "DEBATELAB_STACK_SLOTS must be 1 or 2" >&2; return 1 ;; esac diff --git a/docs/development.md b/docs/development.md index 85d4f61f2..551fb2f3a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -137,7 +137,7 @@ npm run test:db npx supabase stop --no-backup ``` -The wrapper and `verify.sh` share two local Supabase slots across worktrees. Slot 1 keeps today's ports (API `54321`, database `54322`, app `18081`, provider `18090`, collaboration `12345`) and `~/.superset/debatelab-db.lock`. Slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock` plus the provider container `debatelab-fake-openrouter-s2`. The authoritative port table is [`.superset/lib/slot.sh`](../.superset/lib/slot.sh). Allocation takes the first free slot or waits up to 600 seconds for either; `DEBATELAB_STACK_SLOTS=1` restricts it to slot 1. Supabase containers and networks are named per worktree. A plain lock file, a dead owner, or an empty lock directory older than 120 seconds is reclaimed automatically. Use separate worktrees, and use the wrapper rather than starting Supabase directly when another task may be active. The task that starts a stack stops it before finishing, including after failures. +The wrapper and `verify.sh` share two local Supabase slots across worktrees. Slot 1 keeps today's ports (API `54321`, database `54322`, app `18081`, provider `18090`, collaboration `12345`) and `~/.superset/debatelab-db.lock`. Slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock` plus the provider container `debatelab-fake-openrouter-s2`. The authoritative port table is [`.superset/lib/slot.sh`](../.superset/lib/slot.sh). Allocation takes the first free slot or waits up to 600 seconds for either; `DEBATELAB_STACK_SLOTS=1` restricts it to slot 1. Supabase containers and networks are named per worktree. The helper reserves subnets `10.254.231.0/24` and `10.254.232.0/24` for slots 1 and 2, avoiding the Pi's Tailscale route that overlaps Docker's next default `192.168.0.0/20` network. It labels each project network so Supabase cleanup removes it. An existing network on a different subnet is refused; stop its owning stack before retrying. A plain lock file, a dead owner, or an empty lock directory older than 120 seconds is reclaimed automatically. Use separate worktrees, and use the wrapper rather than starting Supabase directly when another task may be active. The task that starts a stack stops it before finishing, including after failures. Memory-heavy jobs share `~/.superset/debatelab-heavy.lock`. `npm run verify` wraps its build in [`.superset/heavy.sh`](../.superset/heavy.sh), and the full database gate wraps its browser phase. Use `bash .superset/heavy.sh npx expo export` for a local Expo export too. The helper skips locking when `CI` is set or `flock` is unavailable. It serializes these jobs without preventing two database stacks from running. diff --git a/docs/testing.md b/docs/testing.md index 80d9260b2..c04d57c7d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -298,7 +298,7 @@ The last two checks are the only production writes, and they touch only the cana `.github/workflows/prod-canary.yml` runs after every push to `main`, daily at 06:41 UTC, and on demand. A push run first waits, up to 15 minutes, for Vercel's production deployment of that commit to the `debate-navigator` project (GitHub environment `Production – debate-navigator`), and skips when `main` moves on first, because the newer commit's run checks the newer deployment. Main can deploy more than 10 times a day, so push runs set `CANARY_AI_REQUESTS=false` and skip the two AI checks. The signed-in session check still runs after every deployment. Only the daily and manual runs make real AI requests: two per run, plus at most two retries. It reads the password and email from secrets and the public anon key from the `PROD_SUPABASE_ANON_KEY` repository variable. -Local gates support two simultaneous worktrees. Slot 1 keeps ports beginning at `54321` and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to every enabled port and uses `~/.superset/debatelab-db.slot2.lock`. [`.superset/lib/slot.sh`](../.superset/lib/slot.sh) owns the port table. `DEBATELAB_STACK_SLOTS=1` restores single-slot allocation. Both entry points wait up to 600 seconds when all enabled slots are held. Stale plain files, dead owner pids, and empty lock directories older than 120 seconds are reclaimed. Local verify builds and the full gate browser phase share `~/.superset/debatelab-heavy.lock` through `bash .superset/heavy.sh`; CI and hosts without `flock` skip this lock. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. +Local gates support two simultaneous worktrees. Slot 1 keeps ports beginning at `54321` and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to every enabled port and uses `~/.superset/debatelab-db.slot2.lock`. [`.superset/lib/slot.sh`](../.superset/lib/slot.sh) owns the port and subnet table. Each slot reserves a Docker subnet (`10.254.231.0/24` or `10.254.232.0/24`) so the second stack avoids the Pi's Tailscale route. `DEBATELAB_STACK_SLOTS=1` restores single-slot allocation. Both entry points wait up to 600 seconds when all enabled slots are held. Stale plain files, dead owner pids, and empty lock directories older than 120 seconds are reclaimed. Local verify builds and the full gate browser phase share `~/.superset/debatelab-heavy.lock` through `bash .superset/heavy.sh`; CI and hosts without `flock` skip this lock. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. Never substitute a staging or production project for this gate. diff --git a/src/test/scripts/db-gate.test.ts b/src/test/scripts/db-gate.test.ts index 3ade48b1b..7e457a186 100644 --- a/src/test/scripts/db-gate.test.ts +++ b/src/test/scripts/db-gate.test.ts @@ -77,7 +77,7 @@ it("stops local Supabase when database tests fail", async () => { // docker here would kill a gate running in any other worktree, so log it instead. await executable( join(bin, "docker"), - '#!/usr/bin/env bash\nprintf "docker %s\\n" "$*" >> "$CALL_LOG"\n', + '#!/usr/bin/env bash\nprintf "docker %s\\n" "$*" >> "$CALL_LOG"\nif [ "$1 $2" = "network inspect" ]; then exit 1; fi\n', ); await executable( join(bin, "supabase"), @@ -102,7 +102,7 @@ it("stops local Supabase when database tests fail", async () => { }); expect(result.status).toBe(17); - await expect(readFile(log, "utf8")).resolves.toBe( + expect((await readFile(log, "utf8")).split("\n").filter((line) => !line.startsWith("docker network ")).join("\n")).toBe( "supabase start\nnpm run test:db\nsupabase stop --no-backup\n", ); }); From d807b9097e83a3b66685e75fc158b8c3cfc2956c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 30 Oct 2026 11:31:29 +0000 Subject: [PATCH 4/7] Serialize stale lock recovery and clean up failed network setup --- .claude/skills/verify-debatelab/SKILL.md | 2 +- .claude/skills/verify-debatelab/bin/verify.sh | 10 +- .superset/lib/lock.py | 68 ++++++++++++++ .superset/lib/lock.sh | 50 +++------- .superset/lib/slot.sh | 8 +- docs/development.md | 2 +- docs/testing.md | 2 +- src/test/scripts/db-gate.test.ts | 91 ++++++++++++++++++- 8 files changed, 188 insertions(+), 45 deletions(-) create mode 100644 .superset/lib/lock.py diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index 0b4171ba9..93f02ac12 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -27,7 +27,7 @@ $H down # stop everything `up` started; evidence stays - **Plain `npm run dev` talks to the PRODUCTION Supabase project** (`src/lib/supabase/env.ts` falls back to production). Never sign in or submit forms on a dev server you started yourself. Drive only the `up` instance. It sets `VITE_LOCAL_PREVIEW=true`, and `doctor` proves the client resolved to its allocated local API URL (`54321` or `54421`). - The Docker container `debatelab` on `127.0.0.1:8080` is Adam's deployed app. Never drive it, restart it, or point tests at it. -- Local verification shares two slots with `.superset/db-gate.sh`. `up` takes the first free slot or waits up to 600 seconds for either. Slot 1 keeps the default ports and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock`. Set `DEBATELAB_STACK_SLOTS=1` to restrict allocation to slot 1. The port and subnet table is `.superset/lib/slot.sh`. It reserves `10.254.231.0/24` and `10.254.232.0/24` to avoid Docker allocating a subnet that overlaps the Pi's Tailscale route. An existing project network with another subnet is refused; stop its owning stack first. Containers and networks are named per worktree; the provider name is per slot. `up` refuses a stack already running for this worktree or busy slot ports. Never bypass an ownership check. Plain lock files, dead owners, and empty lock directories older than 120 seconds are reclaimed. +- Local verification shares two slots with `.superset/db-gate.sh`. `up` takes the first free slot or waits up to 600 seconds for either. Slot 1 keeps the default ports and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock`. Set `DEBATELAB_STACK_SLOTS=1` to restrict allocation to slot 1. The port and subnet table is `.superset/lib/slot.sh`. It reserves `10.254.231.0/24` and `10.254.232.0/24` to avoid Docker allocating a subnet that overlaps the Pi's Tailscale route. An existing project network with another subnet is refused; stop its owning stack first. Containers and networks are named per worktree; the provider name is per slot. `up` refuses a stack already running for this worktree or busy slot ports. Never bypass an ownership check. Plain lock files, dead owners, and directories without a PID older than 120 seconds are reclaimed. Python 3 standard-library file locks serialize recovery and PID publication. Persistent `.guard` files are not slot claims; leave them in place so every caller locks the same inode. - Recipes abort any request to `*.supabase.co` and fail the test if one is attempted. - Never print values from `.verify/state/` (`drive.env`, `function.env`). They are local test keys, but treat them as secrets. diff --git a/.claude/skills/verify-debatelab/bin/verify.sh b/.claude/skills/verify-debatelab/bin/verify.sh index 9367ff407..3022a93f0 100755 --- a/.claude/skills/verify-debatelab/bin/verify.sh +++ b/.claude/skills/verify-debatelab/bin/verify.sh @@ -124,7 +124,13 @@ cmd_up() { trap 'say "up failed; tearing down (logs: $log_dir)"; cmd_down || true' ERR say "Starting local Supabase (logs: $log_dir)..." - stack_network_prepare "$REPO" + stack_network_prepare "$REPO" || { + local network_status=$? + trap - ERR + say "up failed preparing the local network; tearing down (logs: $log_dir)" + cmd_down + return "$network_status" + } record STACK_STARTED true supabase start >>"$log_dir/supabase.log" 2>&1 @@ -346,7 +352,7 @@ cmd_down() { supabase stop --no-backup >>"${LOG_DIR:-/dev/null}/supabase.log" 2>&1 || say "warn: supabase stop failed; see ${LOG_DIR:-}/supabase.log" fi if [ -n "${HOLDER_PID:-}" ]; then - if [ "$(cat "$DB_LOCK/pid" 2>/dev/null)" = "$HOLDER_PID" ]; then lock_release "$DB_LOCK"; fi + if [ "$(cat "$DB_LOCK/pid" 2>/dev/null)" = "$HOLDER_PID" ]; then lock_release "$DB_LOCK" "$HOLDER_PID"; fi kill "$HOLDER_PID" 2>/dev/null || true fi rm -rf -- "$STATE_DIR" diff --git a/.superset/lib/lock.py b/.superset/lib/lock.py new file mode 100644 index 000000000..654150077 --- /dev/null +++ b/.superset/lib/lock.py @@ -0,0 +1,68 @@ +"""Atomic mkdir-lock operations, serialized by a persistent kernel-lock file.""" +import fcntl +import os +from pathlib import Path +import shutil +import stat +import sys +import time + + +def read_owner(path): + try: + owner = int((path / "pid").read_text().strip()) + return owner if owner > 0 else None + except (FileNotFoundError, ValueError): + return None + + +def alive(owner): + try: + os.kill(owner, 0) + return True + except (ProcessLookupError, OverflowError): + return False + except PermissionError: + return True + + +def operate(action, path, owner): + path.parent.mkdir(parents=True, exist_ok=True) + # Never unlink this guard: every process must lock the same inode. Its + # presence claims no slot, and the kernel releases it if a process dies. + with open(str(path) + ".guard", "a") as guard: + try: + flags = fcntl.LOCK_EX | (fcntl.LOCK_NB if action == "acquire" else 0) + fcntl.flock(guard, flags) + except BlockingIOError: + return 1 + if action == "release": + if path.is_dir() and not path.is_symlink() and read_owner(path) == owner: + shutil.rmtree(path) + return 0 + try: + info = path.lstat() + except FileNotFoundError: + info = None + if info is not None: + if stat.S_ISREG(info.st_mode): + path.unlink() + elif stat.S_ISDIR(info.st_mode): + previous = read_owner(path) + stale = not alive(previous) if previous else time.time() - info.st_mtime > 120 + if not stale: + return 1 + shutil.rmtree(path) + else: + return 1 + path.mkdir() + (path / "pid").write_text(str(owner) + "\n") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(operate(sys.argv[1], Path(sys.argv[2]), int(sys.argv[3]))) + except OSError as error: + print(f"lock: {sys.argv[1]} failed for {sys.argv[2]} ({type(error).__name__})", file=sys.stderr) + sys.exit(2) diff --git a/.superset/lib/lock.sh b/.superset/lib/lock.sh index 57d40a1db..9ed280faf 100755 --- a/.superset/lib/lock.sh +++ b/.superset/lib/lock.sh @@ -1,46 +1,22 @@ #!/usr/bin/env bash -# Portable (macOS + Linux) advisory locking via atomic mkdir. Superset does -# not serialize setup/run/teardown across workspaces of the same repo, so -# scripts that touch shared state (a port registry, the local Supabase -# stack) take one of these locks first. +# Portable (macOS + Linux) advisory mkdir locks. Python 3's standard-library +# fcntl guard serializes recovery, acquisition and release, including PID writes. # # Usage: # source lib/lock.sh # lock_acquire "$HOME/.superset/some.lock" 30 # trap 'lock_release "$HOME/.superset/some.lock"' EXIT +DEBATELAB_LOCK_HELPER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lock.py" lock_acquire() { - local lock_dir="$1" - local timeout="${2:-30}" - local waited=0 - - mkdir -p "$(dirname "$lock_dir")" - - while ! mkdir "$lock_dir" 2>/dev/null; do - # Old flock users could leave a plain file at a mkdir-lock path. - if [ -f "$lock_dir" ] && [ ! -L "$lock_dir" ]; then - rm -f -- "$lock_dir" - continue - fi - # Reclaim a lock left behind by a process that no longer exists. - if [ -f "$lock_dir/pid" ]; then - local owner_pid - owner_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)" - if [ -n "$owner_pid" ] && ! kill -0 "$owner_pid" 2>/dev/null; then - rm -rf "$lock_dir" - continue - fi - elif [ -d "$lock_dir" ]; then - # Leave time for a process between mkdir and writing its pid. Support - # both GNU and BSD stat; a fresh directory is never reclaimed. - local modified - modified="$(stat -c %Y "$lock_dir" 2>/dev/null || stat -f %m "$lock_dir" 2>/dev/null || date +%s)" - if [ "$(( $(date +%s) - modified ))" -gt 120 ]; then - # rmdir cannot delete a lock whose owner has since written its pid. - if rmdir -- "$lock_dir" 2>/dev/null; then continue; fi - fi + local lock_dir="$1" timeout="${2:-30}" waited=0 status + while :; do + if python3 "$DEBATELAB_LOCK_HELPER" acquire "$lock_dir" "$$"; then + return 0 + else + status=$? + [ "$status" -eq 1 ] || return "$status" fi - if [ "$waited" -ge "$timeout" ]; then echo "lock: timed out after ${timeout}s waiting for $lock_dir" >&2 return 1 @@ -48,11 +24,9 @@ lock_acquire() { sleep 1 waited=$((waited + 1)) done - - echo $$ > "$lock_dir/pid" } lock_release() { - local lock_dir="$1" - rm -rf "$lock_dir" + # Verification hands ownership to a long-lived holder; other callers use $$. + python3 "$DEBATELAB_LOCK_HELPER" release "$1" "${2:-$$}" } diff --git a/.superset/lib/slot.sh b/.superset/lib/slot.sh index 53f4f798a..a7a50f782 100644 --- a/.superset/lib/slot.sh +++ b/.superset/lib/slot.sh @@ -47,7 +47,7 @@ stack_network_prepare() { } stack_slot_acquire() { - local slots="${DEBATELAB_STACK_SLOTS:-2}" timeout="${1:-600}" waited=0 slot + local slots="${DEBATELAB_STACK_SLOTS:-2}" timeout="${1:-600}" waited=0 slot status case "$slots" in 1|2) ;; *) echo "DEBATELAB_STACK_SLOTS must be 1 or 2" >&2; return 1 ;; esac while :; do for ((slot=1; slot<=slots; slot++)); do @@ -55,6 +55,12 @@ stack_slot_acquire() { if lock_acquire "$DB_LOCK" 0 2>/dev/null; then echo "Acquired local Supabase slot $slot (DB $SUPABASE_DB_PORT, app $APP_PORT)" return 0 + else + status=$? + if [ "$status" -ne 1 ]; then + echo "Stack lock helper failed (exit $status); check Python 3 and permissions on $DB_LOCK" >&2 + return "$status" + fi fi done if [ "$waited" -ge "$timeout" ]; then diff --git a/docs/development.md b/docs/development.md index 551fb2f3a..0f3812bf0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -137,7 +137,7 @@ npm run test:db npx supabase stop --no-backup ``` -The wrapper and `verify.sh` share two local Supabase slots across worktrees. Slot 1 keeps today's ports (API `54321`, database `54322`, app `18081`, provider `18090`, collaboration `12345`) and `~/.superset/debatelab-db.lock`. Slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock` plus the provider container `debatelab-fake-openrouter-s2`. The authoritative port table is [`.superset/lib/slot.sh`](../.superset/lib/slot.sh). Allocation takes the first free slot or waits up to 600 seconds for either; `DEBATELAB_STACK_SLOTS=1` restricts it to slot 1. Supabase containers and networks are named per worktree. The helper reserves subnets `10.254.231.0/24` and `10.254.232.0/24` for slots 1 and 2, avoiding the Pi's Tailscale route that overlaps Docker's next default `192.168.0.0/20` network. It labels each project network so Supabase cleanup removes it. An existing network on a different subnet is refused; stop its owning stack before retrying. A plain lock file, a dead owner, or an empty lock directory older than 120 seconds is reclaimed automatically. Use separate worktrees, and use the wrapper rather than starting Supabase directly when another task may be active. The task that starts a stack stops it before finishing, including after failures. +The wrapper and `verify.sh` share two local Supabase slots across worktrees. Slot 1 keeps today's ports (API `54321`, database `54322`, app `18081`, provider `18090`, collaboration `12345`) and `~/.superset/debatelab-db.lock`. Slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock` plus the provider container `debatelab-fake-openrouter-s2`. The authoritative port table is [`.superset/lib/slot.sh`](../.superset/lib/slot.sh). Allocation takes the first free slot or waits up to 600 seconds for either; `DEBATELAB_STACK_SLOTS=1` restricts it to slot 1. Supabase containers and networks are named per worktree. The helper reserves subnets `10.254.231.0/24` and `10.254.232.0/24` for slots 1 and 2, avoiding the Pi's Tailscale route that overlaps Docker's next default `192.168.0.0/20` network. It labels each project network so Supabase cleanup removes it. An existing network on a different subnet is refused; stop its owning stack before retrying. A plain lock file, a dead owner, or a directory without a PID older than 120 seconds is reclaimed automatically. Lock operations require Python 3 and use only its standard library. A persistent `.guard` file serializes recovery and PID publication with a kernel file lock; the file itself never holds a slot, and process death releases the kernel lock. Leave guard files in place so every caller locks the same inode. Use separate worktrees, and use the wrapper rather than starting Supabase directly when another task may be active. The task that starts a stack stops it before finishing, including after failures. Memory-heavy jobs share `~/.superset/debatelab-heavy.lock`. `npm run verify` wraps its build in [`.superset/heavy.sh`](../.superset/heavy.sh), and the full database gate wraps its browser phase. Use `bash .superset/heavy.sh npx expo export` for a local Expo export too. The helper skips locking when `CI` is set or `flock` is unavailable. It serializes these jobs without preventing two database stacks from running. diff --git a/docs/testing.md b/docs/testing.md index c04d57c7d..36371ae91 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -298,7 +298,7 @@ The last two checks are the only production writes, and they touch only the cana `.github/workflows/prod-canary.yml` runs after every push to `main`, daily at 06:41 UTC, and on demand. A push run first waits, up to 15 minutes, for Vercel's production deployment of that commit to the `debate-navigator` project (GitHub environment `Production – debate-navigator`), and skips when `main` moves on first, because the newer commit's run checks the newer deployment. Main can deploy more than 10 times a day, so push runs set `CANARY_AI_REQUESTS=false` and skip the two AI checks. The signed-in session check still runs after every deployment. Only the daily and manual runs make real AI requests: two per run, plus at most two retries. It reads the password and email from secrets and the public anon key from the `PROD_SUPABASE_ANON_KEY` repository variable. -Local gates support two simultaneous worktrees. Slot 1 keeps ports beginning at `54321` and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to every enabled port and uses `~/.superset/debatelab-db.slot2.lock`. [`.superset/lib/slot.sh`](../.superset/lib/slot.sh) owns the port and subnet table. Each slot reserves a Docker subnet (`10.254.231.0/24` or `10.254.232.0/24`) so the second stack avoids the Pi's Tailscale route. `DEBATELAB_STACK_SLOTS=1` restores single-slot allocation. Both entry points wait up to 600 seconds when all enabled slots are held. Stale plain files, dead owner pids, and empty lock directories older than 120 seconds are reclaimed. Local verify builds and the full gate browser phase share `~/.superset/debatelab-heavy.lock` through `bash .superset/heavy.sh`; CI and hosts without `flock` skip this lock. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. +Local gates support two simultaneous worktrees. Slot 1 keeps ports beginning at `54321` and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to every enabled port and uses `~/.superset/debatelab-db.slot2.lock`. [`.superset/lib/slot.sh`](../.superset/lib/slot.sh) owns the port and subnet table. Each slot reserves a Docker subnet (`10.254.231.0/24` or `10.254.232.0/24`) so the second stack avoids the Pi's Tailscale route. `DEBATELAB_STACK_SLOTS=1` restores single-slot allocation. Both entry points wait up to 600 seconds when all enabled slots are held. Stale plain files, dead owner pids, and directories without a PID older than 120 seconds are reclaimed. Python 3 standard-library kernel locks serialize recovery and PID publication; persistent `.guard` files do not claim a slot and must remain in place. Local verify builds and the full gate browser phase share `~/.superset/debatelab-heavy.lock` through `bash .superset/heavy.sh`; CI and hosts without `flock` skip this lock. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. Never substitute a staging or production project for this gate. diff --git a/src/test/scripts/db-gate.test.ts b/src/test/scripts/db-gate.test.ts index 7e457a186..69389fb66 100644 --- a/src/test/scripts/db-gate.test.ts +++ b/src/test/scripts/db-gate.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { chmod, mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { chmod, cp, mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, expect, it } from "vitest"; @@ -67,6 +67,95 @@ it("uses slot 2 when slot 1 is held, and respects the single-slot cap", async () expect(result.status, result.stderr).toBe(0); }); +it("does not remove a replacement lock while its owner publishes the pid", async () => { + const root = await mkdtemp(join(tmpdir(), "debatelab-lock-race-")); + temporaryRoots.push(root); + const result = spawnSync("bash", ["-c", ` + set -euo pipefail + source "$1" + root="$2" + race_lock="$2/slot.lock" + mkdir "$race_lock" + touch -t 200001010000 "$race_lock" + mkfifo "$2/events" "$2/resume-a" "$2/resume-b" "$2/stop-a" "$2/stop-b" + ( + # Pause a stale remover after classification, before it removes the path. + rmdir() { echo reaping > "$root/events"; read -r _ < "$root/resume-a"; command rmdir "$@"; } + lock_acquire "$race_lock" 0 + echo acquired-a > "$root/events" + read -r _ < "$root/stop-a" + lock_release "$race_lock" + ) & a=$! + read -r first < "$2/events" + ( + mkdir() { + command mkdir "$@" || return $? + if [ "$1" = "$race_lock" ]; then + echo fresh-b > "$root/events" + read -r _ < "$root/resume-b" + fi + } + if lock_acquire "$race_lock" 0; then + echo acquired-b > "$root/events" + read -r _ < "$root/stop-b" + else + echo busy-b > "$root/events" + fi + ) & b=$! + read -r next < "$2/events" + if [ "$first" = reaping ]; then + [ "$next" = fresh-b ] + echo resume > "$2/resume-a" + read -r first < "$2/events" + echo resume > "$2/resume-b" + read -r next < "$2/events" + fi + echo stop > "$2/stop-a" + if [ "$next" = acquired-b ]; then echo stop > "$2/stop-b"; fi + wait "$a"; wait "$b" + [ "$first" = acquired-a ] && [ "$next" = busy-b ] + `, "race-test", resolve(".superset/lib/lock.sh"), root], { + encoding: "utf8", timeout: 5000, + }); + expect(result.status, result.stderr).toBe(0); +}); + +it("releases the verification holder and state when network creation fails", async () => { + const root = await mkdtemp(join(tmpdir(), "debatelab-up-failure-")); + temporaryRoots.push(root); + const bin = join(root, "bin"); + const harness = join(root, ".claude/skills/verify-debatelab/bin/verify.sh"); + await mkdir(bin); + await mkdir(join(root, ".superset")); + await mkdir(join(root, ".claude/skills/verify-debatelab/bin"), { recursive: true }); + await cp(resolve(".superset/lib"), join(root, ".superset/lib"), { recursive: true }); + await cp(resolve(".claude/skills/verify-debatelab/bin/verify.sh"), harness); + await executable(join(bin, "git"), '#!/usr/bin/env bash\ncase "$*" in *--show-toplevel*) echo "$TEST_REPO";; *) echo fixture-head;; esac\n'); + await executable(join(bin, "docker"), `#!/usr/bin/env bash +case "$1 $2" in + "network inspect") exit 1;; + "network create") sed -n 's/^HOLDER_PID=//p' "$TEST_REPO/.verify/state/stack.env" > "$TEST_REPO/holder.pid"; exit 17;; +esac +`); + await executable(join(bin, "supabase"), '#!/usr/bin/env bash\nexit 0\n'); + await executable(join(bin, "ss"), '#!/usr/bin/env bash\nexit 0\n'); + const result = spawnSync("bash", [harness, "up"], { + encoding: "utf8", timeout: 5000, + env: { ...process.env, HOME: root, TEST_REPO: root, DEBATELAB_SUPABASE_BIN_DIR: bin, PATH: `${bin}:${process.env.PATH}` }, + }); + const state = await readFile(join(root, ".verify/state/stack.env"), "utf8").catch(() => ""); + const holder = (await readFile(join(root, "holder.pid"), "utf8")).trim(); + let holderAlive = false; + if (holder) { + try { process.kill(Number(holder), 0); holderAlive = true; } catch { /* Already stopped. */ } + if (holderAlive) process.kill(Number(holder), "SIGTERM"); + } + expect(result.status, result.stderr).toBe(17); + expect(state).toBe(""); + expect(holderAlive).toBe(false); + await expect(readFile(join(root, ".superset/debatelab-db.lock/pid"), "utf8")).rejects.toThrow(); +}); + it("stops local Supabase when database tests fail", async () => { const root = await mkdtemp(join(tmpdir(), "debatelab-db-gate-")); temporaryRoots.push(root); From 498f61a6c3ac4e9136dd46e8336f53062cc2c3ce Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 30 Oct 2026 12:10:37 +0000 Subject: [PATCH 5/7] Start only the Supabase services the gates use The scheduled Database run on main (41048360004) failed before any test: CI could not pull studio and edge-runtime from public.ecr.aws ("Data limit exceeded"). The gate and the verify stack now skip studio, postgres-meta, logflare, vector, supavisor and realtime, which no test or recipe uses. A stack drops from 12 containers and 1,576 MiB to 7 and 717 MiB, so two slots fit on the VPS and CI pulls five fewer images. Storage stays: account deletion empties the user's buckets. The full gate passes with the reduced set: pgTAP PASS, 73 browser tests passed, 1 skipped. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-debatelab/bin/verify.sh | 2 +- .superset/db-gate.sh | 2 +- .superset/lib/slot.sh | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.claude/skills/verify-debatelab/bin/verify.sh b/.claude/skills/verify-debatelab/bin/verify.sh index 3022a93f0..ddf8def16 100755 --- a/.claude/skills/verify-debatelab/bin/verify.sh +++ b/.claude/skills/verify-debatelab/bin/verify.sh @@ -132,7 +132,7 @@ cmd_up() { return "$network_status" } record STACK_STARTED true - supabase start >>"$log_dir/supabase.log" 2>&1 + supabase start -x "$SUPABASE_START_EXCLUDE" >>"$log_dir/supabase.log" 2>&1 local status_json; status_json="$(supabase status -o json 2>/dev/null)" sv() { node -e 'const v=JSON.parse(process.argv[1])[process.argv[2]]; if (!v) process.exit(1); process.stdout.write(String(v))' "$status_json" "$1"; } diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index fe5e76b8e..0492f1baf 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -90,7 +90,7 @@ else runtime_dir="$(mktemp -d)" fi # Shared CI runners intermittently hit registry rate limits while pulling images. -start_command=(start) +start_command=(start -x "$SUPABASE_START_EXCLUDE") if [ "$DB_ONLY" = true ]; then start_command=(db start); fi for attempt in 1 2 3; do stack_network_prepare "$(pwd)" diff --git a/.superset/lib/slot.sh b/.superset/lib/slot.sh index a7a50f782..b638263f1 100644 --- a/.superset/lib/slot.sh +++ b/.superset/lib/slot.sh @@ -1,4 +1,9 @@ # Shared local-stack allocation. Source lock.sh before calling stack_slot_acquire. +# Containers no gate or recipe uses. Skipping them saves image pulls (CI hit +# public.ecr.aws's anonymous data limit pulling studio on 2026-10-30) and RAM +# per stack, so two slots fit on the VPS. Storage and imgproxy stay: account +# deletion empties the user's storage buckets. +SUPABASE_START_EXCLUDE="studio,postgres-meta,logflare,vector,supavisor,realtime" # Keep slot 1 identical to the pinned CLI defaults; slot 2 offsets ports by 100. stack_slot_configure() { case "$1" in 1|2) ;; *) echo "Invalid stack slot: $1" >&2; return 1 ;; esac From 14ce7ac626947776dafc337bb52c693bdaaee727 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 30 Oct 2026 12:25:39 +0000 Subject: [PATCH 6/7] Fix round-2 review findings on the stack slots - db-gate.test.ts expected a bare `supabase start`; it now expects the exclusion list, so dropping the list fails the test. - heavy.sh passes -o to flock, so a detached child such as the collaboration server can't keep the heavy lock after Playwright dies. - The Mailpit lookup anchors the container name. With two stacks up, a substring match could return another worktree's container. - The stale-lock race test now pauses lock.py itself (a test-only hook) between judging a lock stale and removing it, and checks that a second caller is refused. With the fcntl guard removed it fails; the old test passed either way because lock.py never called the bash mkdir/rmdir it intercepted. - supabase/README.md lists LOCAL_GATE_APP_ORIGINS; docs/testing.md no longer says the gate starts every Supabase service. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-debatelab/bin/verify.sh | 2 +- .superset/db-gate.sh | 2 +- .superset/heavy.sh | 3 +- .superset/lib/lock.py | 5 ++ docs/testing.md | 2 +- src/test/scripts/db-gate.test.ts | 74 ++++++------------- supabase/README.md | 1 + 7 files changed, 35 insertions(+), 54 deletions(-) diff --git a/.claude/skills/verify-debatelab/bin/verify.sh b/.claude/skills/verify-debatelab/bin/verify.sh index ddf8def16..78d30daef 100755 --- a/.claude/skills/verify-debatelab/bin/verify.sh +++ b/.claude/skills/verify-debatelab/bin/verify.sh @@ -139,7 +139,7 @@ cmd_up() { local api_url anon_key service_key db_url mailer_url mailer_host api_url="$(sv API_URL)"; anon_key="$(sv ANON_KEY)"; service_key="$(sv SERVICE_ROLE_KEY)" db_url="$(sv DB_URL)"; mailer_url="$(sv MAILPIT_URL)" - mailer_host="$(docker ps --filter "name=supabase_inbucket_$PROJECT_ID" --format '{{.Names}}')" + mailer_host="$(docker ps --filter "name=^supabase_inbucket_${PROJECT_ID}$" --format '{{.Names}}')" [ -n "$mailer_host" ] || { say "Local Mailpit container is unavailable"; false; } LOCAL_SERVICE_ROLE_KEY="$service_key" LOCAL_MAILER_HOST="$mailer_host" \ diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index 0492f1baf..c61bdc595 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -115,7 +115,7 @@ service_role_key="$(status_value SERVICE_ROLE_KEY)" database_url="$(status_value DB_URL)" app_origin="$APP_ORIGIN" project_id="$(basename "$(pwd)")" -mailer_host="$(docker ps --filter "name=supabase_inbucket_$project_id" --format '{{.Names}}')" +mailer_host="$(docker ps --filter "name=^supabase_inbucket_${project_id}$" --format '{{.Names}}')" if [ -z "$mailer_host" ]; then echo "Local Mailpit container is unavailable" >&2 exit 1 diff --git a/.superset/heavy.sh b/.superset/heavy.sh index fbec3cbc1..974d25d04 100644 --- a/.superset/heavy.sh +++ b/.superset/heavy.sh @@ -3,6 +3,7 @@ set -euo pipefail if [ -z "${CI+x}" ] && command -v flock >/dev/null 2>&1; then mkdir -p "$HOME/.superset" - exec flock "$HOME/.superset/debatelab-heavy.lock" "$@" + # -o: detached children (the collaboration server) must not keep the lock. + exec flock -o "$HOME/.superset/debatelab-heavy.lock" "$@" fi exec "$@" diff --git a/.superset/lib/lock.py b/.superset/lib/lock.py index 654150077..d2994fe2e 100644 --- a/.superset/lib/lock.py +++ b/.superset/lib/lock.py @@ -52,6 +52,11 @@ def operate(action, path, owner): stale = not alive(previous) if previous else time.time() - info.st_mtime > 120 if not stale: return 1 + pause = os.environ.get("DEBATELAB_LOCK_TEST_PAUSE") + if pause: + # Tests only: hold the guard here to prove a second caller is refused. + Path(pause + ".ready").touch() + Path(pause).read_text() shutil.rmtree(path) else: return 1 diff --git a/docs/testing.md b/docs/testing.md index 36371ae91..96567b10f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -176,7 +176,7 @@ In Superset, run: ./.superset/db-gate.sh ``` -The wrapper acquires the first free local stack slot, starts full local Supabase, runs `npm run test:db`, then holds that slot while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. The `Database` workflow runs this full gate daily and on demand, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same slot lock, use `./.superset/db-gate.sh --db-only`: it starts Postgres alone (`supabase db start`), which pgTAP and the database scripts need and nothing more, in about 35 seconds locally. Pull requests that change the schema run this. Outside Superset, run it with: +The wrapper acquires the first free local stack slot, starts local Supabase without the services no test uses (Studio, postgres-meta, analytics, the pooler and Realtime; the list is `SUPABASE_START_EXCLUDE` in `.superset/lib/slot.sh`), runs `npm run test:db`, then holds that slot while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. The `Database` workflow runs this full gate daily and on demand, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same slot lock, use `./.superset/db-gate.sh --db-only`: it starts Postgres alone (`supabase db start`), which pgTAP and the database scripts need and nothing more, in about 35 seconds locally. Pull requests that change the schema run this. Outside Superset, run it with: ```bash npx supabase db start diff --git a/src/test/scripts/db-gate.test.ts b/src/test/scripts/db-gate.test.ts index 69389fb66..08fa8b8c8 100644 --- a/src/test/scripts/db-gate.test.ts +++ b/src/test/scripts/db-gate.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { chmod, cp, mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -67,57 +67,31 @@ it("uses slot 2 when slot 1 is held, and respects the single-slot cap", async () expect(result.status, result.stderr).toBe(0); }); -it("does not remove a replacement lock while its owner publishes the pid", async () => { +it("refuses a second caller while another reclaims a stale lock", async () => { + // Round-1 review race: two waiters both judged one stale lock reclaimable + // and both returned success. The fcntl guard in lock.py must make the + // second caller wait. A pauses inside the guard, between judging the lock + // stale and removing it; without the guard, B would take the lock here. const root = await mkdtemp(join(tmpdir(), "debatelab-lock-race-")); temporaryRoots.push(root); - const result = spawnSync("bash", ["-c", ` - set -euo pipefail - source "$1" - root="$2" - race_lock="$2/slot.lock" - mkdir "$race_lock" - touch -t 200001010000 "$race_lock" - mkfifo "$2/events" "$2/resume-a" "$2/resume-b" "$2/stop-a" "$2/stop-b" - ( - # Pause a stale remover after classification, before it removes the path. - rmdir() { echo reaping > "$root/events"; read -r _ < "$root/resume-a"; command rmdir "$@"; } - lock_acquire "$race_lock" 0 - echo acquired-a > "$root/events" - read -r _ < "$root/stop-a" - lock_release "$race_lock" - ) & a=$! - read -r first < "$2/events" - ( - mkdir() { - command mkdir "$@" || return $? - if [ "$1" = "$race_lock" ]; then - echo fresh-b > "$root/events" - read -r _ < "$root/resume-b" - fi - } - if lock_acquire "$race_lock" 0; then - echo acquired-b > "$root/events" - read -r _ < "$root/stop-b" - else - echo busy-b > "$root/events" - fi - ) & b=$! - read -r next < "$2/events" - if [ "$first" = reaping ]; then - [ "$next" = fresh-b ] - echo resume > "$2/resume-a" - read -r first < "$2/events" - echo resume > "$2/resume-b" - read -r next < "$2/events" - fi - echo stop > "$2/stop-a" - if [ "$next" = acquired-b ]; then echo stop > "$2/stop-b"; fi - wait "$a"; wait "$b" - [ "$first" = acquired-a ] && [ "$next" = busy-b ] - `, "race-test", resolve(".superset/lib/lock.sh"), root], { - encoding: "utf8", timeout: 5000, + const lock = join(root, "slot.lock"); + const pause = join(root, "pause"); + await mkdir(lock); + await utimes(lock, new Date(2000, 0, 1), new Date(2000, 0, 1)); + expect(spawnSync("mkfifo", [pause]).status).toBe(0); + const helper = resolve(".superset/lib/lock.py"); + const a = spawn("python3", [helper, "acquire", lock, "4242"], { + env: { ...process.env, DEBATELAB_LOCK_TEST_PAUSE: pause }, }); - expect(result.status, result.stderr).toBe(0); + const aExit = new Promise((done) => a.on("exit", done)); + await expect.poll(() => readFile(`${pause}.ready`, "utf8").then(() => true, () => false)).toBe(true); + + const b = spawnSync("python3", [helper, "acquire", lock, String(process.pid)], { encoding: "utf8", timeout: 5000 }); + expect(b.status, "the second caller while the first holds the guard").toBe(1); + + await writeFile(pause, "go\n"); + expect(await aExit).toBe(0); + expect((await readFile(join(lock, "pid"), "utf8")).trim()).toBe("4242"); }); it("releases the verification holder and state when network creation fails", async () => { @@ -192,6 +166,6 @@ it("stops local Supabase when database tests fail", async () => { expect(result.status).toBe(17); expect((await readFile(log, "utf8")).split("\n").filter((line) => !line.startsWith("docker network ")).join("\n")).toBe( - "supabase start\nnpm run test:db\nsupabase stop --no-backup\n", + "supabase start -x studio,postgres-meta,logflare,vector,supavisor,realtime\nnpm run test:db\nsupabase stop --no-backup\n", ); }); diff --git a/supabase/README.md b/supabase/README.md index c034fe24c..d4493fcdd 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -165,6 +165,7 @@ Document names, never values. Depending on deployed features, server-side config - Supabase URL, anon key, and service-role key; - `OPENROUTER_API_KEY` for all model requests, plus `OPENROUTER_API_URL`, which only the local full-stack gate sets to reach its fake provider (leave it unset everywhere else); +- `LOCAL_GATE_APP_ORIGINS`, which only the local gate and verify stack set, so the CORS check accepts the slot's own app origin (`http://127.0.0.1:18181` for slot 2). It is honoured only for local dev origins; leave it unset everywhere else; - `ELEVENLABS_API_KEY` for the opponent's voice, and optionally `ELEVENLABS_VOICE_ID` to replace the default voice; without the key, voice rounds use the browser's voice; - research provider keys; - Stripe secret, webhook secret, and price configuration, plus `STRIPE_API_URL`, which only the local full-stack gate sets so the `account` function reaches its fake Stripe (leave it unset everywhere else); From 61a53ea12a8976f2d8b073f1941f961ce7b691d7 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 30 Oct 2026 12:30:32 +0000 Subject: [PATCH 7/7] Pause the lock race test on a file, not a FIFO CI lint failed on 14ce7ac6: knip reports `mkfifo` as an unlisted binary. The test-only hook in lock.py now waits for the pause file to appear instead of reading a FIFO, so the test needs no extra binary. It still fails with the fcntl guard removed. Co-Authored-By: Claude Opus 5.5 --- .superset/lib/lock.py | 3 ++- src/test/scripts/db-gate.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.superset/lib/lock.py b/.superset/lib/lock.py index d2994fe2e..6c3f04e20 100644 --- a/.superset/lib/lock.py +++ b/.superset/lib/lock.py @@ -56,7 +56,8 @@ def operate(action, path, owner): if pause: # Tests only: hold the guard here to prove a second caller is refused. Path(pause + ".ready").touch() - Path(pause).read_text() + while not Path(pause).exists(): + time.sleep(0.05) shutil.rmtree(path) else: return 1 diff --git a/src/test/scripts/db-gate.test.ts b/src/test/scripts/db-gate.test.ts index 08fa8b8c8..d473bd4f3 100644 --- a/src/test/scripts/db-gate.test.ts +++ b/src/test/scripts/db-gate.test.ts @@ -71,14 +71,14 @@ it("refuses a second caller while another reclaims a stale lock", async () => { // Round-1 review race: two waiters both judged one stale lock reclaimable // and both returned success. The fcntl guard in lock.py must make the // second caller wait. A pauses inside the guard, between judging the lock - // stale and removing it; without the guard, B would take the lock here. + // stale and removing it, until `pause` exists; without the guard, B would + // take the lock here. const root = await mkdtemp(join(tmpdir(), "debatelab-lock-race-")); temporaryRoots.push(root); const lock = join(root, "slot.lock"); const pause = join(root, "pause"); await mkdir(lock); await utimes(lock, new Date(2000, 0, 1), new Date(2000, 0, 1)); - expect(spawnSync("mkfifo", [pause]).status).toBe(0); const helper = resolve(".superset/lib/lock.py"); const a = spawn("python3", [helper, "acquire", lock, "4242"], { env: { ...process.env, DEBATELAB_LOCK_TEST_PAUSE: pause },