/** * @file Slot and port allocation for lane runtimes. A slot is the small integer * every per-lane runtime fact derives from — the numbering Shipyard gets for free * from its fixed `lane1..lane9` directories, and CCAM, whose lanes are keyed by * `cwd`, has to allocate. Ports come from `PORT_BASE_ + slot`, stepping * aside when something outside CCAM already holds the number. Database name, * Redis logical index, and upload directory are the same idea one layer up * (`dataFacts`) — every fact a lane's slot number determines, in one place. * * Allocation is the one runtime fact CCAM fully controls, which is why it lives * in the database (transactional, uniquely indexed) while liveness does not. * @author Nguyễn Ngọc Trí Vĩ */ const path = require("node:path"); const lanesLib = require("./lanes"); const { isListening, listenerPids } = require("./ports"); const { LANES_ROOT } = require("./worktree"); /** * How many lanes may hold a runtime at once. * * Nine by default, which is Shipyard's ceiling and worth keeping as a default: * a single decimal digit keeps `base + slot` readable (`:8003` is lane 3), and * Redis ships 16 logical databases, so A2's per-lane index stays in range. It is * configurable because CCAM, unlike Shipyard, has no structural reason to stop at * nine — a machine that can run twenty stacks may raise it, at the cost of ports * that no longer read as a slot number. * * Read per call so a test can change it without reloading the module. */ function maxSlots() { const raw = Number(process.env.LANE_MAX_SLOTS); return Number.isInteger(raw) && raw > 0 ? raw : 9; } /** How many `+100` steps to try before giving up on a port name. */ const PORT_STEP = 100; const PORT_MAX_STEPS = 10; /** * Claim the lowest free slot for a lane. * * Lowest-free rather than next-highest so a released slot is reused and the * numbers stay small and readable. MUST be called inside `withLaneLock` — the * read and the write are separated by nothing here, but the caller's subsequent * port resolution is async, and two provisions interleaving there would both * believe they own the number. The partial unique index on `lanes.slot` is the * backstop that turns a missed lock into a loud constraint error rather than two * lanes silently sharing a runtime. * * @param {number} laneId - Lane to assign the slot to. * @returns {number} The claimed slot. * @throws {Error} ESLOTS when every slot is taken. */ function allocateSlot(laneId) { const used = new Set(lanesLib.usedSlots()); const limit = maxSlots(); for (let slot = 1; slot <= limit; slot += 1) { if (used.has(slot)) continue; lanesLib.setProvisioningFacts(laneId, { slot }); return slot; } throw Object.assign(new Error(`all ${limit} lane slots are in use`), { code: "ESLOTS" }); } /** * Give a lane's slot and ports back to the pool. * * Called on `remove` only. A `reset` deliberately keeps them: Shipyard preserves * a lane's identity across resets, and moving a lane's ports (and, at A2, its * database) out from under a session that is mid-feature would be a silent, * confusing failure rather than a fresh start. * * @param {number} laneId - Lane to release. */ function releaseSlot(laneId) { lanesLib.setProvisioningFacts(laneId, { slot: null, ports: {} }); } /** * The ports a lane should bind, by declared name. * * For each name the profile declares, prefer `PORT_BASE_ + slot`, then * `+100`, `+200`… The step keeps the last digit equal to the slot, so a * stepped-aside port still reads as "lane 3" — the property that makes the whole * `base + slot` scheme worth having. * * A number is rejected when anything is listening on it, when another lane has * recorded it (a lane whose stack is down still owns its number), or when an * earlier name in this same call already took it. * * Previously-recorded ports for THIS lane are reused as-is when still free, so a * lane that stepped aside once keeps the number its `.env`, bookmarks and any * seeded browser session already point at. * * @param {object} lane - Lane row; `slot` must already be allocated. * @param {object} profile - Resolved profile (supplies `ports` and `env`). * @returns {Promise>} Map of port name to port number. * @throws {Error} EPORTBUSY when a name exhausts its candidates. */ async function resolvePorts(lane, profile) { const reserved = lanesLib.reservedPorts(lane.id); const previous = lane.ports || {}; const assigned = {}; const takenHere = new Set(); for (const name of profile.ports) { const base = portBase(profile, name); const candidates = []; // The number this lane used last time comes first: stability beats tidiness. if (Number.isInteger(previous[name])) candidates.push(previous[name]); for (let step = 0; step < PORT_MAX_STEPS; step += 1) { const candidate = base + step * PORT_STEP + lane.slot; if (!candidates.includes(candidate)) candidates.push(candidate); } let chosen = null; for (const candidate of candidates) { if (takenHere.has(candidate) || reserved.has(candidate)) continue; if (await isListening(candidate)) continue; chosen = candidate; break; } if (chosen === null) { const preferred = base + lane.slot; const pids = await listenerPids(preferred); throw Object.assign( new Error( `no free port for "${name}": tried ${candidates.join(", ")}` + (pids.length ? ` (${preferred} held by pid ${pids.join(", ")})` : "") ), { code: "EPORTBUSY", portName: name, preferred, pids } ); } assigned[name] = chosen; takenHere.add(chosen); } return assigned; } /** * The configured base for a port name, defaulting to 8000 so a profile that adds * a service without declaring its base still gets a usable (if unsurprising) * number rather than NaN. */ function portBase(profile, name) { const raw = Number(profile.env[`PORT_BASE_${name}`]); return Number.isInteger(raw) ? raw : 8000; } /** * Where a lane's runtime bookkeeping lives: pid files, hook logs, the last boot * error. * * Under `LANES_ROOT/.state/`, deliberately OUTSIDE the worktree. `lane reset` * runs `git clean -fd`, which would sweep pid files out from under a running * stack and leave processes nobody can find to kill. `.state/` also sits outside * every path the three-check destroy guard reasons about, so runtime bookkeeping * can never be mistaken for a lane's working copy. * * @param {number} slot - Allocated slot. * @returns {{stateDir: string, runDir: string, logDir: string, errorFile: string}} */ function slotDirs(slot) { const stateDir = path.join(LANES_ROOT, ".state", `lane${slot}`); return { stateDir, runDir: path.join(stateDir, "run"), logDir: path.join(stateDir, "logs"), errorFile: path.join(stateDir, "last-error.json"), }; } /** True when a lane's ports differ from `base + slot` — the UI flags this. */ function portsSteppedAside(lane, profile) { if (!lane.slot) return false; return profile.ports.some( (name) => lane.ports[name] && lane.ports[name] !== portBase(profile, name) + lane.slot ); } /** * The database name a slot derives, or null when the profile has no * `DB_PREFIX` — the one gate every A2 data-isolation feature reads, so "off" * means no name is ever allocated rather than an empty-prefix name like `"3"`. * * @param {object} profile - Resolved profile. * @param {number} slot - Allocated slot. * @returns {string|null} */ function dbName(profile, slot) { return profile.env.DB_PREFIX ? `${profile.env.DB_PREFIX}${slot}` : null; } /** * Every slot-derived data-isolation fact a lane can have, in the one place * every other slot-derived fact (ports, directories) already lives. Each * field is null when its owning declaration is absent, so a caller can test * "is this feature on" with a single truthiness check instead of re-reading * profile.env itself. * * @param {object} lane - Lane row; `slot` must already be allocated. * @param {object} profile - Resolved profile. * @param {Record} secrets - From `secrets.js:readSecrets()`. * @returns {{dbName: string|null, databaseUrl: string|null, testDatabaseUrl: string|null, redisUrl: string|null, uploadDir: string|null}} */ function dataFacts(lane, profile, secrets) { const name = dbName(profile, lane.slot); const scheme = profile.env.DB_URL_SCHEME || "postgresql"; // encodeURIComponent on user/pass: a real password containing @, #, or % // would otherwise produce a URL the DB client parses wrong or rejects. const urlFor = (n) => n ? `${scheme}://${encodeURIComponent(secrets.PG_USER)}:${encodeURIComponent(secrets.PG_PASS)}@${secrets.PG_HOST}:${secrets.PG_PORT}/${n}` : null; return { dbName: name, databaseUrl: urlFor(name), testDatabaseUrl: urlFor(name ? `${name}_test` : null), redisUrl: profile.env.REDIS === "1" ? `redis://${secrets.REDIS_HOST}:${secrets.REDIS_PORT}/${lane.slot}` : null, uploadDir: profile.env.UPLOAD_SUBDIR ? path.join(lane.cwd, profile.env.UPLOAD_SUBDIR) : null, }; } module.exports = { allocateSlot, releaseSlot, resolvePorts, portBase, portsSteppedAside, slotDirs, dbName, dataFacts, maxSlots, PORT_STEP, PORT_MAX_STEPS, };