From 41e4a97278a2b5cb2cfd1b869585e1bbdaffd2f9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:14:33 -0700 Subject: [PATCH 1/4] feat(dev): ./dev.sh up --ephemeral with per-worktree slots (TASK-619) One native backend and one vite-preview frontend per worktree, on that worktree's ports, with its own Mongo and Postgres database on the shared compose pair. A locked registry in ~/.commonly/dev/slots.json assigns the slot, and ./dev.sh gc reaps slots past their 24 h TTL. Written by Forge, a Commonly agent (pod 6a692a1be833c668acdb84cf) Co-Authored-By: Claude Haiku 5.5 Co-authored-by: forge Agent-Model: claude/claude-haiku-5-5/default --- dev.sh | 10 ++ scripts/dev-ephemeral.sh | 202 +++++++++++++++++++++++++++++++++++++++ scripts/dev-slots.mjs | 132 +++++++++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100755 scripts/dev-ephemeral.sh create mode 100755 scripts/dev-slots.mjs diff --git a/dev.sh b/dev.sh index 97bfb99b71..093a6ab0d4 100755 --- a/dev.sh +++ b/dev.sh @@ -2,6 +2,8 @@ # Development environment management script # Usage: ./dev.sh [up|down|restart|logs|build] +# ./dev.sh up --ephemeral - native backend + preview frontend on this worktree's slot (scripts/dev-ephemeral.sh) +# ./dev.sh gc - reap ephemeral slots past their 24 h TTL # ./dev.sh test - unit tests (in-memory, no services needed) # ./dev.sh test:integration - integration tests (requires ./dev.sh up) # ./dev.sh clawdbot [up|down|logs] @@ -135,6 +137,10 @@ print_local_dev_notes() { case "$1" in up) + if [ "$2" = "--ephemeral" ]; then + # Per-worktree native stack on its own ports and database (D177). See scripts/dev-ephemeral.sh. + exec bash "$(dirname "$0")/scripts/dev-ephemeral.sh" up + fi ensure_local_env_file echo "๐Ÿš€ Starting development environment..." # Set higher timeout for frontend npm install @@ -150,6 +156,10 @@ case "$1" in print_local_dev_notes ;; + gc) + exec bash "$(dirname "$0")/scripts/dev-ephemeral.sh" gc + ;; + down) echo "๐Ÿ›‘ Stopping development environment..." docker compose -f $COMPOSE_FILE down diff --git a/scripts/dev-ephemeral.sh b/scripts/dev-ephemeral.sh new file mode 100755 index 0000000000..5e3a90bffe --- /dev/null +++ b/scripts/dev-ephemeral.sh @@ -0,0 +1,202 @@ +#!/bin/bash + +# ./dev.sh up --ephemeral and ./dev.sh gc (D177, TASK-619). +# One native backend and one vite-preview frontend per worktree, on that +# worktree's slot ports, with its own Mongo and Postgres database. The shared +# compose pair (mongo, postgres) is reused by every slot. +# +# scripts/dev-ephemeral.sh up - claim (or reuse) this worktree's slot and start it +# scripts/dev-ephemeral.sh gc - stop and drop slots past their 24 h TTL + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE=(docker compose -f "$ROOT/docker-compose.dev.yml") +DEV_DIR="${COMMONLY_DEV_DIR:-$HOME/.commonly/dev}" +LOCK_DIR="$DEV_DIR/registry.lock" +SLOTS_MJS="$ROOT/scripts/dev-slots.mjs" +MONGO_CREDS="commonly:commonly_dev" +PG_CREDS="postgres:postgres" + +# Node 20 matches CI. Use the Homebrew keg when it exists; otherwise fall back to PATH. +node20_bin() { + local prefix + prefix="$(brew --prefix node@20 2>/dev/null || true)" + if [ -n "$prefix" ] && [ -x "$prefix/bin/node" ]; then + echo "$prefix/bin" + fi +} + +lock_acquire() { + mkdir -p "$DEV_DIR" + local tries=0 + until mkdir "$LOCK_DIR" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 150 ]; then + echo "registry lock is held: $LOCK_DIR (remove it only if no ./dev.sh is running)" >&2 + exit 1 + fi + sleep 0.2 + done + trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT +} + +lock_release() { + rmdir "$LOCK_DIR" 2>/dev/null || true + trap - EXIT +} + +require_docker() { + if ! docker info >/dev/null 2>&1; then + echo "Docker is not running. The ephemeral slot needs the shared mongo and postgres containers." >&2 + exit 1 + fi +} + +wait_port() { + local port="$1" label="$2" tries=0 + until nc -z 127.0.0.1 "$port" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 150 ]; then + echo "$label did not open port $port within 30s" >&2 + exit 1 + fi + sleep 0.2 + done +} + +wait_http() { + local url="$1" label="$2" log="$3" tries=0 + until curl -fsS "$url" >/dev/null 2>&1; do + tries=$((tries + 1)) + if [ "$tries" -gt 300 ]; then + echo "$label did not answer $url within 60s. Last log lines:" >&2 + tail -n 20 "$log" >&2 || true + exit 1 + fi + sleep 0.2 + done +} + +ensure_pg_database() { + local name="$1" + if ! "${COMPOSE[@]}" exec -T postgres psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$name'" | grep -q 1; then + "${COMPOSE[@]}" exec -T postgres psql -U postgres -qc "CREATE DATABASE \"$name\"" + fi +} + +# Stops the processes this slot recorded, but only if the PID still belongs to a slot server. +# A PID the OS has since reused is left alone. +stop_pid() { + local pid="$1" pattern="$2" + [ -n "$pid" ] && [ "$pid" != "null" ] || return 0 + if kill -0 "$pid" 2>/dev/null && ps -o command= -p "$pid" | grep -q "$pattern"; then + kill "$pid" 2>/dev/null || true + fi +} + +up_ephemeral() { + require_docker + local node_bin + node_bin="$(node20_bin)" + if [ -n "$node_bin" ]; then + export PATH="$node_bin:$PATH" + else + echo "โš ๏ธ node@20 not found under Homebrew; using $(node -v). CI runs Node 20." >&2 + fi + + lock_acquire + local slug assignments + slug="$(basename "$ROOT" | tr -cs 'a-zA-Z0-9' '_' | tr 'A-Z' 'a-z' | cut -c1-24)" + assignments="$(node "$SLOTS_MJS" claim "$ROOT" "$slug")" + eval "$assignments" + lock_release + + echo "๐Ÿงฉ Slot $SLOT_N: backend :$BACKEND_PORT, frontend :$FRONTEND_PORT, database $DB_NAME" + mkdir -p "$SLOT_DIR" + + "${COMPOSE[@]}" up -d mongo postgres >/dev/null + wait_port 27017 mongo + wait_port 5432 postgres + ensure_pg_database "$DB_NAME" + + echo "๐Ÿ”จ Building backend (Node $(node -v))..." + (cd "$ROOT/backend" && npm run build >"$SLOT_DIR/build-backend.log" 2>&1) || { + echo "backend build failed; see $SLOT_DIR/build-backend.log" >&2 + tail -n 20 "$SLOT_DIR/build-backend.log" >&2 + exit 1 + } + + # Start the server with the slot's own connection settings. RUN_WORKER=0 keeps the + # scheduler out of a local walk, and LOCAL_DEV_LOGIN_ENABLED exposes the fixed dev login. + (cd "$ROOT/backend" && env \ + PORT="$BACKEND_PORT" \ + MONGO_URI="mongodb://$MONGO_CREDS@127.0.0.1:27017/$DB_NAME?authSource=admin" \ + PG_HOST=127.0.0.1 PG_PORT=5432 PG_USER=postgres PG_PASSWORD=postgres PG_DATABASE="$DB_NAME" \ + RUN_WORKER=0 LOCAL_DEV_LOGIN_ENABLED=true \ + NODE_OPTIONS="--max-old-space-size=768" \ + nohup node dist/server.js >"$SLOT_DIR/backend.log" 2>&1 & echo $! >"$SLOT_DIR/backend.pid") + local backend_pid + backend_pid="$(cat "$SLOT_DIR/backend.pid")" + wait_http "http://127.0.0.1:$BACKEND_PORT/api/health/live" backend "$SLOT_DIR/backend.log" + + echo "๐Ÿ”จ Building frontend..." + (cd "$ROOT/frontend" && REACT_APP_API_URL="http://127.0.0.1:$BACKEND_PORT" \ + ./node_modules/.bin/vite build --outDir "$SLOT_DIR/web" --emptyOutDir >"$SLOT_DIR/build-frontend.log" 2>&1) || { + echo "frontend build failed; see $SLOT_DIR/build-frontend.log" >&2 + tail -n 20 "$SLOT_DIR/build-frontend.log" >&2 + exit 1 + } + (cd "$ROOT/frontend" && nohup ./node_modules/.bin/vite preview --outDir "$SLOT_DIR/web" \ + --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort >"$SLOT_DIR/preview.log" 2>&1 & echo $! >"$SLOT_DIR/frontend.pid") + local frontend_pid + frontend_pid="$(cat "$SLOT_DIR/frontend.pid")" + wait_http "http://127.0.0.1:$FRONTEND_PORT/" frontend "$SLOT_DIR/preview.log" + + lock_acquire + node "$SLOTS_MJS" set-pids "$SLOT_N" "$backend_pid" "$frontend_pid" + lock_release + + local rss + rss="$(ps -o rss= -p "$backend_pid","$frontend_pid" | awk '{s+=$1} END {printf "%.0f", s/1024}')" + echo "โœ… Slot $SLOT_N is up: frontend http://127.0.0.1:$FRONTEND_PORT backend http://127.0.0.1:$BACKEND_PORT" + echo " RSS of backend + preview: ${rss} MB (excludes Docker VM)" + echo " Stop it with: ./dev.sh gc (expires after 24 h; logs in $SLOT_DIR)" +} + +gc_ephemeral() { + lock_acquire + local line + node "$SLOTS_MJS" expired | while IFS= read -r line; do + [ -n "$line" ] || continue + local n db backend frontend dir + n="$(node -pe 'JSON.parse(process.argv[1]).n' "$line")" + db="$(node -pe 'JSON.parse(process.argv[1]).dbName' "$line")" + backend="$(node -pe 'JSON.parse(process.argv[1]).pids?.backend ?? ""' "$line")" + frontend="$(node -pe 'JSON.parse(process.argv[1]).pids?.frontend ?? ""' "$line")" + dir="$DEV_DIR/slot-$n" + echo "๐Ÿงน Reaping slot $n ($db)" + stop_pid "$backend" "dist/server.js" + stop_pid "$frontend" "vite" + if docker info >/dev/null 2>&1; then + "${COMPOSE[@]}" exec -T postgres psql -U postgres -qc "DROP DATABASE IF EXISTS \"$db\"" 2>/dev/null \ + || echo " could not drop Postgres database $db" >&2 + "${COMPOSE[@]}" exec -T mongo mongosh --quiet -u commonly -p commonly_dev \ + --authenticationDatabase admin "$db" --eval 'db.dropDatabase()' >/dev/null 2>&1 \ + || echo " could not drop Mongo database $db (mongosh missing or auth failed)" >&2 + fi + rm -rf "$dir" + node "$SLOTS_MJS" drop "$n" + done + lock_release + echo "โœ… gc done" +} + +case "${1:-}" in + up) up_ephemeral ;; + gc) gc_ephemeral ;; + *) + echo "usage: scripts/dev-ephemeral.sh up|gc" >&2 + exit 2 + ;; +esac diff --git a/scripts/dev-slots.mjs b/scripts/dev-slots.mjs new file mode 100755 index 0000000000..406ff215de --- /dev/null +++ b/scripts/dev-slots.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// Slot registry for ./dev.sh up --ephemeral (D177, TASK-619). +// Each worktree gets one slot: a number, two ports and one Mongo/Postgres +// database name. State lives in ~/.commonly/dev/slots.json. Callers hold the +// registry lock (scripts/dev-ephemeral.sh); this script does no locking itself. +// +// claim reuse this worktree's slot or take the lowest free one +// set-pids +// expired list slots past their TTL, one JSON object per line +// drop forget a slot + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; + +const DIR = process.env.COMMONLY_DEV_DIR || path.join(os.homedir(), '.commonly', 'dev'); +const FILE = path.join(DIR, 'slots.json'); +const MAX_SLOTS = Number(process.env.COMMONLY_DEV_MAX_SLOTS || 8); +const TTL_MS = 24 * 60 * 60 * 1000; + +// Slot n owns backend 5100+10n and frontend 5101+10n. Ten ports per slot leaves room +// for the Vite HMR port and anything else a later step adds without renumbering. +const backendPort = (n) => 5100 + 10 * n; +const frontendPort = (n) => 5101 + 10 * n; + +function load() { + try { + return JSON.parse(fs.readFileSync(FILE, 'utf8')); + } catch { + return { slots: {} }; + } +} + +function save(registry) { + fs.mkdirSync(DIR, { recursive: true }); + const tmp = `${FILE}.${process.pid}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`); + fs.renameSync(tmp, FILE); +} + +function slotDir(n) { + return path.join(DIR, `slot-${n}`); +} + +function claim(worktreePath, slug) { + const key = crypto.createHash('sha1').update(path.resolve(worktreePath)).digest('hex').slice(0, 16); + const registry = load(); + const now = Date.now(); + let n = Object.keys(registry.slots).find((id) => registry.slots[id].key === key); + if (n === undefined) { + for (let candidate = 1; candidate <= MAX_SLOTS; candidate += 1) { + if (!registry.slots[String(candidate)]) { + n = String(candidate); + break; + } + } + } + if (n === undefined) { + process.stderr.write(`all ${MAX_SLOTS} ephemeral slots are taken; run ./dev.sh gc or raise COMMONLY_DEV_MAX_SLOTS\n`); + process.exit(3); + } + const previous = registry.slots[n] || {}; + const entry = { + key, + worktree: path.resolve(worktreePath), + dbName: `commonly_${slug}_${n}`, + backendPort: backendPort(Number(n)), + frontendPort: frontendPort(Number(n)), + createdAt: previous.createdAt || new Date(now).toISOString(), + expiresAt: new Date(now + TTL_MS).toISOString(), + pids: previous.pids || null, + }; + registry.slots[n] = entry; + save(registry); + // Shell-safe assignments for `eval`. Every value is generated here, so none needs quoting. + process.stdout.write( + [ + `SLOT_N=${n}`, + `SLOT_DIR=${slotDir(n)}`, + `DB_NAME=${entry.dbName}`, + `BACKEND_PORT=${entry.backendPort}`, + `FRONTEND_PORT=${entry.frontendPort}`, + '', + ].join('\n'), + ); +} + +function setPids(n, backend, frontend) { + const registry = load(); + if (!registry.slots[n]) { + process.stderr.write(`no slot ${n}\n`); + process.exit(1); + } + registry.slots[n].pids = { backend: Number(backend), frontend: Number(frontend) }; + save(registry); +} + +function expired() { + const registry = load(); + const now = Date.now(); + for (const [n, entry] of Object.entries(registry.slots)) { + if (Date.parse(entry.expiresAt) <= now) { + process.stdout.write(`${JSON.stringify({ n, ...entry })}\n`); + } + } +} + +function drop(n) { + const registry = load(); + delete registry.slots[n]; + save(registry); +} + +const [command, ...args] = process.argv.slice(2); +switch (command) { + case 'claim': + claim(args[0], args[1]); + break; + case 'set-pids': + setPids(args[0], args[1], args[2]); + break; + case 'expired': + expired(); + break; + case 'drop': + drop(args[0]); + break; + default: + process.stderr.write('usage: dev-slots.mjs claim|set-pids|expired|drop\n'); + process.exit(2); +} From e86d4f0943fa5e9afde06f6fe2503d5218c2fb3b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:43:30 -0700 Subject: [PATCH 2/4] fix(dev): make ./dev.sh up --ephemeral boot two worktrees at once (TASK-619) Found by booting two worktrees for real: - the shared mongo/postgres pair has fixed container names, so every worktree must use the same compose project (-p commonly); a second project failed with a name conflict. - the pids recorded were the background subshell, not node. Launch with exec and detached stdio, and record the PID that listens on the port. - Postgres roles: the shared volume's superuser is `commonly`, not `postgres`. Admin and app credentials are configurable. - gc drops Mongo with the backend's mongoose; the mongo image has no mongosh. The commonly role cannot dropDatabase on slot DBs, so the Mongo drop is best-effort and reported as a warning. Written by Forge, a Commonly agent (pod 6a692a1be833c668acdb84cf) Co-Authored-By: Claude Haiku 5.5 Co-authored-by: forge Agent-Model: claude/claude-haiku-5-5/default --- scripts/dev-ephemeral.sh | 61 ++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/scripts/dev-ephemeral.sh b/scripts/dev-ephemeral.sh index 5e3a90bffe..d852057eca 100755 --- a/scripts/dev-ephemeral.sh +++ b/scripts/dev-ephemeral.sh @@ -11,12 +11,18 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -COMPOSE=(docker compose -f "$ROOT/docker-compose.dev.yml") +# The mongo and postgres services have fixed container names, so every worktree must +# use the same compose project. Otherwise a second worktree fails with a name conflict. +COMPOSE=(docker compose -p "${COMMONLY_SHARED_PROJECT:-commonly}" -f "$ROOT/docker-compose.dev.yml") DEV_DIR="${COMMONLY_DEV_DIR:-$HOME/.commonly/dev}" LOCK_DIR="$DEV_DIR/registry.lock" SLOTS_MJS="$ROOT/scripts/dev-slots.mjs" +# Postgres: the shared volume was initialised with the role `commonly`. Docker exec reaches it over +# the local socket without a password; the backend needs the role and password explicitly. +PG_ADMIN_USER="${COMMONLY_PG_ADMIN_USER:-commonly}" +PG_APP_USER="${PG_USER:-commonly}" +PG_APP_PASSWORD="${PG_PASSWORD:-}" MONGO_CREDS="commonly:commonly_dev" -PG_CREDS="postgres:postgres" # Node 20 matches CI. Use the Homebrew keg when it exists; otherwise fall back to PATH. node20_bin() { @@ -78,10 +84,21 @@ wait_http() { done } +# The PID that actually listens on a port. A background subshell's $! is not reliable as the server PID. +listening_pid() { + local pid + pid="$(/usr/sbin/lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1 || true)" + if [ -z "$pid" ]; then + echo "nothing is listening on port $1 after the health check passed" >&2 + exit 1 + fi + echo "$pid" +} + ensure_pg_database() { local name="$1" - if ! "${COMPOSE[@]}" exec -T postgres psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$name'" | grep -q 1; then - "${COMPOSE[@]}" exec -T postgres psql -U postgres -qc "CREATE DATABASE \"$name\"" + if ! "${COMPOSE[@]}" exec -T postgres psql -U "$PG_ADMIN_USER" -tAc "SELECT 1 FROM pg_database WHERE datname='$name'" | grep -q 1; then + "${COMPOSE[@]}" exec -T postgres psql -U "$PG_ADMIN_USER" -qc "CREATE DATABASE \"$name\"" fi } @@ -107,7 +124,7 @@ up_ephemeral() { lock_acquire local slug assignments - slug="$(basename "$ROOT" | tr -cs 'a-zA-Z0-9' '_' | tr 'A-Z' 'a-z' | cut -c1-24)" + slug="$(basename "$ROOT" | tr -cs 'a-zA-Z0-9' '_' | tr 'A-Z' 'a-z' | sed -E 's/^_+//; s/_+$//' | cut -c1-24)" assignments="$(node "$SLOTS_MJS" claim "$ROOT" "$slug")" eval "$assignments" lock_release @@ -129,16 +146,19 @@ up_ephemeral() { # Start the server with the slot's own connection settings. RUN_WORKER=0 keeps the # scheduler out of a local walk, and LOCAL_DEV_LOGIN_ENABLED exposes the fixed dev login. - (cd "$ROOT/backend" && env \ + # exec makes the subshell's PID the node PID, so backend.pid names the server itself. + # Its stdio is detached, or the pipe into this script never closes. + (cd "$ROOT/backend" && exec env \ PORT="$BACKEND_PORT" \ MONGO_URI="mongodb://$MONGO_CREDS@127.0.0.1:27017/$DB_NAME?authSource=admin" \ - PG_HOST=127.0.0.1 PG_PORT=5432 PG_USER=postgres PG_PASSWORD=postgres PG_DATABASE="$DB_NAME" \ + PG_HOST=127.0.0.1 PG_PORT=5432 PG_USER="$PG_APP_USER" PG_PASSWORD="$PG_APP_PASSWORD" PG_DATABASE="$DB_NAME" \ RUN_WORKER=0 LOCAL_DEV_LOGIN_ENABLED=true \ NODE_OPTIONS="--max-old-space-size=768" \ - nohup node dist/server.js >"$SLOT_DIR/backend.log" 2>&1 & echo $! >"$SLOT_DIR/backend.pid") - local backend_pid - backend_pid="$(cat "$SLOT_DIR/backend.pid")" + node dist/server.js "$SLOT_DIR/backend.log" 2>&1) /dev/null 2>&1 & wait_http "http://127.0.0.1:$BACKEND_PORT/api/health/live" backend "$SLOT_DIR/backend.log" + local backend_pid + backend_pid="$(listening_pid "$BACKEND_PORT")" + echo "$backend_pid" >"$SLOT_DIR/backend.pid" echo "๐Ÿ”จ Building frontend..." (cd "$ROOT/frontend" && REACT_APP_API_URL="http://127.0.0.1:$BACKEND_PORT" \ @@ -147,11 +167,12 @@ up_ephemeral() { tail -n 20 "$SLOT_DIR/build-frontend.log" >&2 exit 1 } - (cd "$ROOT/frontend" && nohup ./node_modules/.bin/vite preview --outDir "$SLOT_DIR/web" \ - --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort >"$SLOT_DIR/preview.log" 2>&1 & echo $! >"$SLOT_DIR/frontend.pid") - local frontend_pid - frontend_pid="$(cat "$SLOT_DIR/frontend.pid")" + (cd "$ROOT/frontend" && exec ./node_modules/.bin/vite preview --outDir "$SLOT_DIR/web" \ + --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort "$SLOT_DIR/preview.log" 2>&1) /dev/null 2>&1 & wait_http "http://127.0.0.1:$FRONTEND_PORT/" frontend "$SLOT_DIR/preview.log" + local frontend_pid + frontend_pid="$(listening_pid "$FRONTEND_PORT")" + echo "$frontend_pid" >"$SLOT_DIR/frontend.pid" lock_acquire node "$SLOTS_MJS" set-pids "$SLOT_N" "$backend_pid" "$frontend_pid" @@ -179,11 +200,15 @@ gc_ephemeral() { stop_pid "$backend" "dist/server.js" stop_pid "$frontend" "vite" if docker info >/dev/null 2>&1; then - "${COMPOSE[@]}" exec -T postgres psql -U postgres -qc "DROP DATABASE IF EXISTS \"$db\"" 2>/dev/null \ + "${COMPOSE[@]}" exec -T postgres psql -U "$PG_ADMIN_USER" -qc "DROP DATABASE IF EXISTS \"$db\"" 2>/dev/null \ || echo " could not drop Postgres database $db" >&2 - "${COMPOSE[@]}" exec -T mongo mongosh --quiet -u commonly -p commonly_dev \ - --authenticationDatabase admin "$db" --eval 'db.dropDatabase()' >/dev/null 2>&1 \ - || echo " could not drop Mongo database $db (mongosh missing or auth failed)" >&2 + # The mongo image ships no mongosh, so drop through the backend's own mongoose. + (cd "$ROOT/backend" && node -e ' + const mongoose = require("mongoose"); + mongoose.connect(process.argv[1]).then((c) => c.connection.db.dropDatabase()) + .then(() => mongoose.disconnect()).catch((e) => { console.error(e.message); process.exit(1); }); + ' "mongodb://$MONGO_CREDS@127.0.0.1:27017/$db?authSource=admin" >/dev/null) \ + || echo " could not drop Mongo database $db" >&2 fi rm -rf "$dir" node "$SLOTS_MJS" drop "$n" From e1397d78bb2defe0fc6d5694545d81567c247d22 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:26:32 -0700 Subject: [PATCH 3/4] fix(dev): strip the caller's env from slot servers and require the listener to be ours (TASK-619) The backend inherited the whole shell environment, so a prod secret exported in the caller's shell reached a local slot. It now starts under env -i with only PATH, HOME, TMPDIR and LANG plus the slot's own settings. The health check only proved that something answered the slot port. A stale server from another slot would have been recorded as this slot's PID. The listener must now be the PID we launched. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- scripts/dev-ephemeral.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/dev-ephemeral.sh b/scripts/dev-ephemeral.sh index d852057eca..b17b16ce60 100755 --- a/scripts/dev-ephemeral.sh +++ b/scripts/dev-ephemeral.sh @@ -148,16 +148,27 @@ up_ephemeral() { # scheduler out of a local walk, and LOCAL_DEV_LOGIN_ENABLED exposes the fixed dev login. # exec makes the subshell's PID the node PID, so backend.pid names the server itself. # Its stdio is detached, or the pipe into this script never closes. - (cd "$ROOT/backend" && exec env \ + # env -i: the server gets only what it needs, so a prod MONGO_URI, JWT secret or API key in the + # caller's shell never reaches a local slot. + local launched_backend_pid + (cd "$ROOT/backend" && exec env -i \ + PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" LANG="${LANG:-en_US.UTF-8}" \ PORT="$BACKEND_PORT" \ MONGO_URI="mongodb://$MONGO_CREDS@127.0.0.1:27017/$DB_NAME?authSource=admin" \ PG_HOST=127.0.0.1 PG_PORT=5432 PG_USER="$PG_APP_USER" PG_PASSWORD="$PG_APP_PASSWORD" PG_DATABASE="$DB_NAME" \ RUN_WORKER=0 LOCAL_DEV_LOGIN_ENABLED=true \ NODE_OPTIONS="--max-old-space-size=768" \ node dist/server.js "$SLOT_DIR/backend.log" 2>&1) /dev/null 2>&1 & + launched_backend_pid=$! wait_http "http://127.0.0.1:$BACKEND_PORT/api/health/live" backend "$SLOT_DIR/backend.log" + # The health check only proves that something answers the port. Require the listener to be the + # process we launched, or a stale server from another slot would be recorded as this slot's. local backend_pid backend_pid="$(listening_pid "$BACKEND_PORT")" + if [ "$backend_pid" != "$launched_backend_pid" ]; then + echo "port $BACKEND_PORT answered from pid $backend_pid, not the slot server ($launched_backend_pid)" >&2 + exit 1 + fi echo "$backend_pid" >"$SLOT_DIR/backend.pid" echo "๐Ÿ”จ Building frontend..." From 15ad10cc2177d71135f62184d58b1ff7d9d64d4d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:59:49 -0700 Subject: [PATCH 4/4] fix(dev): per-slot Postgres and Mongo, fail-closed gc, slot login and CORS (TASK-619) Each slot now runs its own Postgres 17 and Mongo 8.2 containers on their own volumes, bound to 127.0.0.1. The shared compose services are no longer touched, so the slot's database names, credentials and removal are its own. gc removes a slot's containers and volumes; a slot stays claimed if any of them cannot be removed, so its number is not reused while its data exists. The backend gets a per-slot session secret and local login user (generated once into the slot directory) and FRONTEND_URL for CORS. A slot is only reported up after the local login succeeds, which exercises both. Registry: a missing file is an empty registry; any other read or parse error, or a wrong shape, stops the caller. Launched PIDs are recorded as soon as they start, so a failed run leaves something the next run or gc can stop. lsof is found through PATH, with /usr/sbin added for macOS. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- scripts/dev-ephemeral.sh | 229 ++++++++++++++++++++++++++++++--------- scripts/dev-slots.mjs | 26 ++++- 2 files changed, 200 insertions(+), 55 deletions(-) diff --git a/scripts/dev-ephemeral.sh b/scripts/dev-ephemeral.sh index b17b16ce60..15330264d0 100755 --- a/scripts/dev-ephemeral.sh +++ b/scripts/dev-ephemeral.sh @@ -2,27 +2,27 @@ # ./dev.sh up --ephemeral and ./dev.sh gc (D177, TASK-619). # One native backend and one vite-preview frontend per worktree, on that -# worktree's slot ports, with its own Mongo and Postgres database. The shared -# compose pair (mongo, postgres) is reused by every slot. +# worktree's slot ports. Each slot also gets its own Mongo and Postgres +# containers on their own volumes, so no slot touches the shared compose +# services and gc can remove a slot's data by removing its containers. # # scripts/dev-ephemeral.sh up - claim (or reuse) this worktree's slot and start it -# scripts/dev-ephemeral.sh gc - stop and drop slots past their 24 h TTL +# scripts/dev-ephemeral.sh gc - stop and remove slots past their 24 h TTL set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# The mongo and postgres services have fixed container names, so every worktree must -# use the same compose project. Otherwise a second worktree fails with a name conflict. -COMPOSE=(docker compose -p "${COMMONLY_SHARED_PROJECT:-commonly}" -f "$ROOT/docker-compose.dev.yml") DEV_DIR="${COMMONLY_DEV_DIR:-$HOME/.commonly/dev}" LOCK_DIR="$DEV_DIR/registry.lock" SLOTS_MJS="$ROOT/scripts/dev-slots.mjs" -# Postgres: the shared volume was initialised with the role `commonly`. Docker exec reaches it over -# the local socket without a password; the backend needs the role and password explicitly. -PG_ADMIN_USER="${COMMONLY_PG_ADMIN_USER:-commonly}" -PG_APP_USER="${PG_USER:-commonly}" -PG_APP_PASSWORD="${PG_PASSWORD:-}" -MONGO_CREDS="commonly:commonly_dev" +# Same images as docker-compose.dev.yml, so a slot runs the versions CI runs. +PG_IMAGE="postgres:17" +MONGO_IMAGE="mongo:8.2" +# Local only: each slot's Postgres and Mongo listen on 127.0.0.1. +PG_APP_USER="commonly" +PG_APP_PASSWORD="${COMMONLY_SLOT_PG_PASSWORD:-commonly_dev}" + +LSOF="" # Node 20 matches CI. Use the Homebrew keg when it exists; otherwise fall back to PATH. node20_bin() { @@ -54,7 +54,17 @@ lock_release() { require_docker() { if ! docker info >/dev/null 2>&1; then - echo "Docker is not running. The ephemeral slot needs the shared mongo and postgres containers." >&2 + echo "Docker is not running. The ephemeral slot needs its own Mongo and Postgres containers." >&2 + exit 1 + fi +} + +# lsof is found through PATH. macOS installs it in /usr/sbin, which some shells leave off PATH. +require_lsof() { + PATH="$PATH:/usr/sbin:/sbin" + LSOF="$(command -v lsof || true)" + if [ -z "$LSOF" ]; then + echo "lsof is not on PATH. The ephemeral slot needs it to record the PID that listens on each port." >&2 exit 1 fi } @@ -71,6 +81,19 @@ wait_port() { done } +# Fails when a port is still taken. Used after the recorded processes are stopped. +wait_port_free() { + local port="$1" tries=0 + while nc -z 127.0.0.1 "$port" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 50 ]; then + echo "port $port is still in use; stop that process or run ./dev.sh gc" >&2 + exit 1 + fi + sleep 0.2 + done +} + wait_http() { local url="$1" label="$2" log="$3" tries=0 until curl -fsS "$url" >/dev/null 2>&1; do @@ -87,7 +110,7 @@ wait_http() { # The PID that actually listens on a port. A background subshell's $! is not reliable as the server PID. listening_pid() { local pid - pid="$(/usr/sbin/lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1 || true)" + pid="$("$LSOF" -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1 || true)" if [ -z "$pid" ]; then echo "nothing is listening on port $1 after the health check passed" >&2 exit 1 @@ -95,25 +118,81 @@ listening_pid() { echo "$pid" } -ensure_pg_database() { - local name="$1" - if ! "${COMPOSE[@]}" exec -T postgres psql -U "$PG_ADMIN_USER" -tAc "SELECT 1 FROM pg_database WHERE datname='$name'" | grep -q 1; then - "${COMPOSE[@]}" exec -T postgres psql -U "$PG_ADMIN_USER" -qc "CREATE DATABASE \"$name\"" - fi -} - -# Stops the processes this slot recorded, but only if the PID still belongs to a slot server. -# A PID the OS has since reused is left alone. +# Stops a process this slot recorded, but only if the PID still belongs to that server. +# A PID the OS has since reused is left alone. Waits until the process is gone. stop_pid() { - local pid="$1" pattern="$2" + local pid="$1" pattern="$2" tries=0 [ -n "$pid" ] && [ "$pid" != "null" ] || return 0 if kill -0 "$pid" 2>/dev/null && ps -o command= -p "$pid" | grep -q "$pattern"; then kill "$pid" 2>/dev/null || true + while kill -0 "$pid" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 50 ]; then + kill -9 "$pid" 2>/dev/null || true + break + fi + sleep 0.2 + done + fi +} + +# Per-slot secrets: created once, kept in the slot directory, never shared between slots. +ensure_slot_secrets() { + local file="$SLOT_DIR/secrets.env" + if [ ! -f "$file" ]; then + (umask 077 && { + echo "JWT_SECRET=$(openssl rand -hex 32)" + echo "LOCAL_DEV_LOGIN_PASSWORD=$(openssl rand -hex 16)" + } >"$file") + fi + # shellcheck disable=SC1090 + . "$file" +} + +# Starts a named container, or restarts it when it exists but is stopped. +ensure_container() { + local name="$1" + shift + if docker container inspect "$name" >/dev/null 2>&1; then + if [ "$(docker inspect -f '{{.State.Running}}' "$name")" != "true" ]; then + docker start "$name" >/dev/null + fi + else + docker run -d --name "$name" "$@" >/dev/null fi } +# Removes a slot's containers and volumes. Returns non-zero if any of them still exists. +remove_slot_containers() { + local n="$1" name + for name in "commonly-slot-$n-pg" "commonly-slot-$n-mongo"; do + if docker container inspect "$name" >/dev/null 2>&1; then + docker rm -f "$name" >/dev/null || return 1 + fi + done + for name in "commonly-slot-$n-pgdata" "commonly-slot-$n-mongodata"; do + if docker volume inspect "$name" >/dev/null 2>&1; then + docker volume rm "$name" >/dev/null || return 1 + fi + done +} + +# Records a launched PID in the registry right away, so a later failure in this run leaves +# something the next run or gc can stop. +record_pid() { + local which="$1" pid="$2" + lock_acquire + if [ "$which" = "backend" ]; then + node "$SLOTS_MJS" set-pids "$SLOT_N" "$pid" "" + else + node "$SLOTS_MJS" set-pids "$SLOT_N" "" "$pid" + fi + lock_release +} + up_ephemeral() { require_docker + require_lsof local node_bin node_bin="$(node20_bin)" if [ -n "$node_bin" ]; then @@ -129,13 +208,37 @@ up_ephemeral() { eval "$assignments" lock_release - echo "๐Ÿงฉ Slot $SLOT_N: backend :$BACKEND_PORT, frontend :$FRONTEND_PORT, database $DB_NAME" + local pg_port mongo_port + pg_port=$((5102 + 10 * SLOT_N)) + mongo_port=$((5103 + 10 * SLOT_N)) + echo "๐Ÿงฉ Slot $SLOT_N: backend :$BACKEND_PORT, frontend :$FRONTEND_PORT, postgres :$pg_port, mongo :$mongo_port, database $DB_NAME" mkdir -p "$SLOT_DIR" + ensure_slot_secrets + + # A slot that is already running is replaced, not reused. Its recorded servers are stopped + # first, and a port still taken after that stops the run, so nothing runs that this run did not start. + stop_pid "${BACKEND_PID:-}" "dist/server.js" + stop_pid "${FRONTEND_PID:-}" "vite" + wait_port_free "$BACKEND_PORT" + wait_port_free "$FRONTEND_PORT" - "${COMPOSE[@]}" up -d mongo postgres >/dev/null - wait_port 27017 mongo - wait_port 5432 postgres - ensure_pg_database "$DB_NAME" + docker volume create "commonly-slot-$SLOT_N-pgdata" >/dev/null + docker volume create "commonly-slot-$SLOT_N-mongodata" >/dev/null + ensure_container "commonly-slot-$SLOT_N-pg" \ + -e POSTGRES_USER="$PG_APP_USER" -e POSTGRES_PASSWORD="$PG_APP_PASSWORD" -e POSTGRES_DB="$DB_NAME" \ + -p "127.0.0.1:$pg_port:5432" -v "commonly-slot-$SLOT_N-pgdata:/var/lib/postgresql" "$PG_IMAGE" + ensure_container "commonly-slot-$SLOT_N-mongo" \ + -p "127.0.0.1:$mongo_port:27017" -v "commonly-slot-$SLOT_N-mongodata:/data/db" "$MONGO_IMAGE" + wait_port "$mongo_port" "slot mongo" + local tries=0 + until docker exec "commonly-slot-$SLOT_N-pg" pg_isready -U "$PG_APP_USER" -d "$DB_NAME" >/dev/null 2>&1; do + tries=$((tries + 1)) + if [ "$tries" -gt 150 ]; then + echo "slot postgres did not become ready within 30s" >&2 + exit 1 + fi + sleep 0.2 + done echo "๐Ÿ”จ Building backend (Node $(node -v))..." (cd "$ROOT/backend" && npm run build >"$SLOT_DIR/build-backend.log" 2>&1) || { @@ -144,25 +247,26 @@ up_ephemeral() { exit 1 } - # Start the server with the slot's own connection settings. RUN_WORKER=0 keeps the - # scheduler out of a local walk, and LOCAL_DEV_LOGIN_ENABLED exposes the fixed dev login. + # The server gets only what it needs. A fresh worktree has no backend/.env, so the slot's own + # session secret and local login user are passed here. # exec makes the subshell's PID the node PID, so backend.pid names the server itself. # Its stdio is detached, or the pipe into this script never closes. - # env -i: the server gets only what it needs, so a prod MONGO_URI, JWT secret or API key in the - # caller's shell never reaches a local slot. - local launched_backend_pid (cd "$ROOT/backend" && exec env -i \ PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" LANG="${LANG:-en_US.UTF-8}" \ PORT="$BACKEND_PORT" \ - MONGO_URI="mongodb://$MONGO_CREDS@127.0.0.1:27017/$DB_NAME?authSource=admin" \ - PG_HOST=127.0.0.1 PG_PORT=5432 PG_USER="$PG_APP_USER" PG_PASSWORD="$PG_APP_PASSWORD" PG_DATABASE="$DB_NAME" \ - RUN_WORKER=0 LOCAL_DEV_LOGIN_ENABLED=true \ + MONGO_URI="mongodb://127.0.0.1:$mongo_port/$DB_NAME" \ + PG_HOST=127.0.0.1 PG_PORT="$pg_port" PG_USER="$PG_APP_USER" PG_PASSWORD="$PG_APP_PASSWORD" PG_DATABASE="$DB_NAME" \ + FRONTEND_URL="http://127.0.0.1:$FRONTEND_PORT" \ + JWT_SECRET="$JWT_SECRET" \ + LOCAL_DEV_LOGIN_ENABLED=true LOCAL_DEV_LOGIN_EMAIL="dev-slot-$SLOT_N@commonly.local" \ + LOCAL_DEV_LOGIN_PASSWORD="$LOCAL_DEV_LOGIN_PASSWORD" \ + RUN_WORKER=0 \ NODE_OPTIONS="--max-old-space-size=768" \ node dist/server.js "$SLOT_DIR/backend.log" 2>&1) /dev/null 2>&1 & - launched_backend_pid=$! + local launched_backend_pid=$! + record_pid "backend" "$launched_backend_pid" wait_http "http://127.0.0.1:$BACKEND_PORT/api/health/live" backend "$SLOT_DIR/backend.log" - # The health check only proves that something answers the port. Require the listener to be the - # process we launched, or a stale server from another slot would be recorded as this slot's. + # Health alone only proves that something answers the port. The listener must be the process we launched. local backend_pid backend_pid="$(listening_pid "$BACKEND_PORT")" if [ "$backend_pid" != "$launched_backend_pid" ]; then @@ -171,6 +275,24 @@ up_ephemeral() { fi echo "$backend_pid" >"$SLOT_DIR/backend.pid" + # Login checks the slot's login user and session secret together. Health passes without either. + # The user is created after the server starts listening, so wait for its ready line first. + tries=0 + until grep -q '^\[local-dev-login\] Ready' "$SLOT_DIR/backend.log" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 300 ]; then + echo "local login user was not ready on slot $SLOT_N; see $SLOT_DIR/backend.log" >&2 + exit 1 + fi + sleep 0.2 + done + local login_body + login_body="$(printf '{"email":"dev-slot-%s@commonly.local","password":"%s"}' "$SLOT_N" "$LOCAL_DEV_LOGIN_PASSWORD")" + if ! curl -fsS -H 'content-type: application/json' -d "$login_body" "http://127.0.0.1:$BACKEND_PORT/api/auth/login" 2>/dev/null | grep -q '"token"'; then + echo "local login failed on slot $SLOT_N; see $SLOT_DIR/backend.log" >&2 + exit 1 + fi + echo "๐Ÿ”จ Building frontend..." (cd "$ROOT/frontend" && REACT_APP_API_URL="http://127.0.0.1:$BACKEND_PORT" \ ./node_modules/.bin/vite build --outDir "$SLOT_DIR/web" --emptyOutDir >"$SLOT_DIR/build-frontend.log" 2>&1) || { @@ -180,9 +302,15 @@ up_ephemeral() { } (cd "$ROOT/frontend" && exec ./node_modules/.bin/vite preview --outDir "$SLOT_DIR/web" \ --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort "$SLOT_DIR/preview.log" 2>&1) /dev/null 2>&1 & + local launched_frontend_pid=$! + record_pid "frontend" "$launched_frontend_pid" wait_http "http://127.0.0.1:$FRONTEND_PORT/" frontend "$SLOT_DIR/preview.log" local frontend_pid frontend_pid="$(listening_pid "$FRONTEND_PORT")" + if [ "$frontend_pid" != "$launched_frontend_pid" ]; then + echo "port $FRONTEND_PORT answered from pid $frontend_pid, not the slot preview ($launched_frontend_pid)" >&2 + exit 1 + fi echo "$frontend_pid" >"$SLOT_DIR/frontend.pid" lock_acquire @@ -192,7 +320,7 @@ up_ephemeral() { local rss rss="$(ps -o rss= -p "$backend_pid","$frontend_pid" | awk '{s+=$1} END {printf "%.0f", s/1024}')" echo "โœ… Slot $SLOT_N is up: frontend http://127.0.0.1:$FRONTEND_PORT backend http://127.0.0.1:$BACKEND_PORT" - echo " RSS of backend + preview: ${rss} MB (excludes Docker VM)" + echo " RSS of backend + preview: ${rss} MB (excludes Docker VM and the slot's containers)" echo " Stop it with: ./dev.sh gc (expires after 24 h; logs in $SLOT_DIR)" } @@ -210,16 +338,15 @@ gc_ephemeral() { echo "๐Ÿงน Reaping slot $n ($db)" stop_pid "$backend" "dist/server.js" stop_pid "$frontend" "vite" - if docker info >/dev/null 2>&1; then - "${COMPOSE[@]}" exec -T postgres psql -U "$PG_ADMIN_USER" -qc "DROP DATABASE IF EXISTS \"$db\"" 2>/dev/null \ - || echo " could not drop Postgres database $db" >&2 - # The mongo image ships no mongosh, so drop through the backend's own mongoose. - (cd "$ROOT/backend" && node -e ' - const mongoose = require("mongoose"); - mongoose.connect(process.argv[1]).then((c) => c.connection.db.dropDatabase()) - .then(() => mongoose.disconnect()).catch((e) => { console.error(e.message); process.exit(1); }); - ' "mongodb://$MONGO_CREDS@127.0.0.1:27017/$db?authSource=admin" >/dev/null) \ - || echo " could not drop Mongo database $db" >&2 + # The slot stays claimed until its containers and volumes are gone. Otherwise a later + # claim could reuse the number while the old data still exists. + if ! docker info >/dev/null 2>&1; then + echo " Docker is not running; slot $n stays claimed for a later gc" >&2 + continue + fi + if ! remove_slot_containers "$n"; then + echo " could not remove slot $n's containers or volumes; slot stays claimed" >&2 + continue fi rm -rf "$dir" node "$SLOTS_MJS" drop "$n" diff --git a/scripts/dev-slots.mjs b/scripts/dev-slots.mjs index 406ff215de..a2c7badcc9 100755 --- a/scripts/dev-slots.mjs +++ b/scripts/dev-slots.mjs @@ -24,12 +24,22 @@ const TTL_MS = 24 * 60 * 60 * 1000; const backendPort = (n) => 5100 + 10 * n; const frontendPort = (n) => 5101 + 10 * n; +// A missing file is an empty registry. Anything else that goes wrong (unreadable file, +// bad JSON, a wrong shape) must stop the caller: an empty registry here would hand a live +// slot's number to another worktree and lose the record of its processes and databases. function load() { + let raw; try { - return JSON.parse(fs.readFileSync(FILE, 'utf8')); - } catch { - return { slots: {} }; + raw = fs.readFileSync(FILE, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return { slots: {} }; + throw error; } + const registry = JSON.parse(raw); + if (!registry || typeof registry !== 'object' || !registry.slots || typeof registry.slots !== 'object' || Array.isArray(registry.slots)) { + throw new Error(`${FILE} is not a slot registry; move it aside and run again`); + } + return registry; } function save(registry) { @@ -81,18 +91,26 @@ function claim(worktreePath, slug) { `DB_NAME=${entry.dbName}`, `BACKEND_PORT=${entry.backendPort}`, `FRONTEND_PORT=${entry.frontendPort}`, + `BACKEND_PID=${entry.pids?.backend ?? ''}`, + `FRONTEND_PID=${entry.pids?.frontend ?? ''}`, '', ].join('\n'), ); } +// An empty argument keeps that process's recorded PID. The launcher records each PID as soon as +// it starts, so a run that fails later still leaves a PID the next run or gc can stop. function setPids(n, backend, frontend) { const registry = load(); if (!registry.slots[n]) { process.stderr.write(`no slot ${n}\n`); process.exit(1); } - registry.slots[n].pids = { backend: Number(backend), frontend: Number(frontend) }; + const previous = registry.slots[n].pids || {}; + registry.slots[n].pids = { + backend: backend === '' ? previous.backend ?? null : Number(backend), + frontend: frontend === '' ? previous.frontend ?? null : Number(frontend), + }; save(registry); }