feat(lanes): per-lane database, Redis, and .env isolation (A1+A2)

Gives each lane its own slot-derived runtime (ports, detached process
lifecycle, profile-driven hooks) and its own database/Redis logical
index/.env file, so two lanes running the same repo's stack at once no
longer share state. Machine-level DB/Redis credentials live at
~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's
output is redacted of that password (raw and URL-encoded forms) before
it reaches a log file or the lane_hook_output websocket broadcast.
Wired into provision/up/reset/remove; reset accepts --keep-db to skip
the drop/recreate/migrate/reseed block entirely.
This commit is contained in:
2026-08-04 10:03:40 +07:00
parent d71086f677
commit 9d145865dd
19 changed files with 3265 additions and 12 deletions
+67 -11
View File
@@ -46,11 +46,13 @@ const WATCH_STAGE_RE = /watch|poll/i;
/**
* Fields a client may change through `PATCH /api/lanes/:id`.
*
* `kind`, `source_repo`, `slug` and `base_branch` are deliberately ABSENT: they
* are provisioning-time facts, and `kind` is check 1 of the destroy guard. A
* client that could flip `kind` to "managed" at runtime could point the guard at
* a directory the user owns. Provisioning writes them through
* setProvisioningFacts instead.
* `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are
* deliberately ABSENT: they are provisioning-time facts, and `kind` is check 1 of
* the destroy guard. A client that could flip `kind` to "managed" at runtime could
* point the guard at a directory the user owns; a client that could set `slot`
* could move every slot-derived runtime fact (the ports a lane binds, and later
* the database name a drop targets) onto another lane's resources. Provisioning
* writes them through setProvisioningFacts instead.
*/
const PATCHABLE = new Set([
"title",
@@ -67,7 +69,14 @@ const PATCHABLE = new Set([
]);
/** Provisioning-time facts, writable only by this module's internal setter. */
const PROVISIONING_FIELDS = new Set(["kind", "source_repo", "base_branch", "slug"]);
const PROVISIONING_FIELDS = new Set([
"kind",
"source_repo",
"base_branch",
"slug",
"slot",
"ports",
]);
const nowIso = () => new Date().toISOString();
@@ -83,6 +92,7 @@ function hydrate(row) {
if (!row) return null;
let stages = {};
let links = {};
let ports = {};
try {
stages = JSON.parse(row.stages || "{}");
} catch {
@@ -93,7 +103,12 @@ function hydrate(row) {
} catch {
/* corrupt blob -> empty */
}
return { ...row, stages, links };
try {
ports = JSON.parse(row.ports || "{}");
} catch {
/* corrupt blob -> empty */
}
return { ...row, stages, links, ports };
}
function createLane({
@@ -157,9 +172,10 @@ function updateLane(id, patch = {}) {
}
/**
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach
* today only `base_branch`, resolved after `git worktree add` succeeds. Server
* -internal: no route passes user input here.
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach:
* `base_branch`, resolved after `git worktree add` succeeds, and the runtime
* allocation (`slot`, `ports`) the lane earns on its first boot. Server-internal:
* no route passes user input here.
*
* @param {number} id - The lane id.
* @param {object} facts - Subset of PROVISIONING_FIELDS to write.
@@ -171,7 +187,7 @@ function setProvisioningFacts(id, facts = {}) {
if (!PROVISIONING_FIELDS.has(k)) continue;
if (k === "kind") validateKind(v);
cols.push(`${k} = ?`);
vals.push(v);
vals.push(k === "ports" && typeof v === "object" ? JSON.stringify(v) : v);
}
if (cols.length) {
cols.push("updated_at = ?");
@@ -185,6 +201,44 @@ function deleteLane(id) {
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
}
/** Slots currently held by a lane, ascending. Input to the slot allocator. */
function usedSlots() {
return db
.prepare("SELECT slot FROM lanes WHERE slot IS NOT NULL ORDER BY slot ASC")
.all()
.map((row) => row.slot);
}
/**
* Every port already recorded by another lane.
*
* A live listener is not the only claim on a port: a lane whose stack is
* currently down still owns the number it booted on, and handing that number to
* a second lane would make the two fight the moment the first comes back up.
* Deriving ports from `base + slot` keeps lanes of ONE repo apart on its own, but
* two repos with different `PORT_BASE_*` values can still land on the same
* number — so the allocator subtracts this set as well as what is listening.
*
* @param {number} [excludeLaneId] - Lane being allocated for; its own reservation is not a conflict.
* @returns {Set<number>}
*/
function reservedPorts(excludeLaneId = null) {
const taken = new Set();
for (const row of db.prepare("SELECT id, ports FROM lanes").all()) {
if (excludeLaneId !== null && Number(row.id) === Number(excludeLaneId)) continue;
let ports;
try {
ports = JSON.parse(row.ports || "{}");
} catch {
continue; // corrupt blob claims nothing
}
for (const port of Object.values(ports)) {
if (Number.isInteger(port)) taken.add(port);
}
}
return taken;
}
/**
* Record a stage transition. `stage_since` moves ONLY when the stage value
* actually changes, so the UI's time-on-phase is real; a re-report of the same
@@ -523,4 +577,6 @@ module.exports = {
hasActiveLaneSession,
purgeLaneSessions,
setProvisioningFacts,
usedSlots,
reservedPorts,
};