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:
+266
-1
@@ -30,10 +30,25 @@ const {
|
||||
slugify,
|
||||
} = require("../lib/worktree");
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
const { HOOKS, runHook, resolveProfile } = require("../lib/lane-profile");
|
||||
const { slotDirs } = require("../lib/lane-slots");
|
||||
const {
|
||||
upLane,
|
||||
downLane,
|
||||
runtimeFacts,
|
||||
requireProfile,
|
||||
provisionLane,
|
||||
resetLaneData,
|
||||
removeLaneData,
|
||||
} = require("../lib/lane-runtime");
|
||||
|
||||
const router = Router();
|
||||
const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;
|
||||
|
||||
/** Bytes of a hook log returned by default — enough to see a failure's tail. */
|
||||
const LOG_TAIL_DEFAULT = 64 * 1024;
|
||||
const LOG_TAIL_MAX = 1024 * 1024;
|
||||
|
||||
/** Seconds since this lane's session last emitted an event; null if never. */
|
||||
function lastEventAge(lane) {
|
||||
if (!lane.session_id) return null;
|
||||
@@ -317,6 +332,18 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
await addWorktree({ sourceRepo: resolvedSourceRepo, dir, branch, base: baseBranch });
|
||||
// base_branch is a provisioning fact, not a patchable field — see PATCHABLE.
|
||||
lanesLib.setProvisioningFacts(lane.id, { base_branch: baseBranch });
|
||||
|
||||
// A2 data isolation: only when the repo actually declares a profile — a
|
||||
// worktree lane with none is a normal state (nothing about A1 required
|
||||
// one either), so this is a no-op rather than a provisioning failure.
|
||||
const worktreeLane = lanesLib.getLane(lane.id);
|
||||
const profile = resolveProfile(worktreeLane);
|
||||
if (profile) {
|
||||
const onLine = (line, stream) =>
|
||||
broadcast("lane_hook_output", { laneId: lane.id, hook: "provision", stream, line });
|
||||
await provisionLane(worktreeLane, { onLine });
|
||||
}
|
||||
|
||||
lanesLib.updateLane(lane.id, { status: "idle", notes: null });
|
||||
} catch (err) {
|
||||
lanesLib.updateLane(lane.id, {
|
||||
@@ -419,6 +446,216 @@ function sendLifecycleError(res, err) {
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Runtime: a lane's own stack, isolated by slot-derived ports and directories.
|
||||
*
|
||||
* These routes are registered BEFORE the "/:id/:action" catch-all below, which
|
||||
* would otherwise swallow "up" and "down" as unknown actions. They are also
|
||||
* deliberately NOT folded into that catch-all: it drives a lane's Claude RUN,
|
||||
* while these drive the application the lane is working on — two different
|
||||
* lifecycles that happen to share a lane id.
|
||||
*
|
||||
* None of them writes `stage`, `status` or `notes`. A booted stack is not an
|
||||
* agent at work, and only `slot`/`ports` describe the runtime.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
/** Map a runtime error onto its status code. */
|
||||
function sendRuntimeError(res, err) {
|
||||
const badRequest = ["ENOPROFILE", "ENOHOOK", "EBADLANEDIR", "EBADSVC"];
|
||||
if (badRequest.includes(err.code)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
...(err.searched ? { searched: err.searched } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (err.code === "ESLOTS") {
|
||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code === "EPORTBUSY") {
|
||||
return res.status(409).json({
|
||||
error: { code: err.code, message: err.message, port: err.preferred, pids: err.pids },
|
||||
});
|
||||
}
|
||||
return res.status(500).json({
|
||||
error: { code: err.code || "ERUNTIME", message: err.message, logPath: err.logPath },
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
|
||||
function laneOr404(req, res) {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) {
|
||||
res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
return null;
|
||||
}
|
||||
return lane;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is running for this lane, computed fresh.
|
||||
*
|
||||
* Follows `GET /:id/git`'s contract: a lane with no profile answers
|
||||
* `{available:false}` with HTTP 200, because that is a normal state and not a
|
||||
* fault. It probes ports and stats pid files, which is why it is its own endpoint
|
||||
* rather than a field on the polled lane list.
|
||||
*/
|
||||
router.get("/:id/runtime", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
res.json(await runtimeFacts(lane));
|
||||
} catch (err) {
|
||||
sendRuntimeError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Tail one of the lane's hook or service logs.
|
||||
*
|
||||
* `:svc` is resolved against the log directory's real contents and the result is
|
||||
* confined to that directory after `realpath`, so a name from the request can
|
||||
* never escape it.
|
||||
*/
|
||||
router.get("/:id/logs/:svc", (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
if (!lane.slot) return res.json({ available: false });
|
||||
|
||||
const { logDir } = slotDirs(lane.slot);
|
||||
const file = path.resolve(logDir, `${req.params.svc}.log`);
|
||||
let real;
|
||||
try {
|
||||
real = fs.realpathSync(file);
|
||||
const relative = path.relative(fs.realpathSync(logDir), real);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("escapes log dir");
|
||||
} catch {
|
||||
return res.status(404).json({ error: { code: "ENOLOG", message: "no such log" } });
|
||||
}
|
||||
|
||||
const requested = Number(req.query.tail);
|
||||
const tail = Math.min(
|
||||
Number.isInteger(requested) && requested > 0 ? requested : LOG_TAIL_DEFAULT,
|
||||
LOG_TAIL_MAX
|
||||
);
|
||||
const { size } = fs.statSync(real);
|
||||
const start = Math.max(0, size - tail);
|
||||
const fd = fs.openSync(real, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(size - start);
|
||||
fs.readSync(fd, buffer, 0, buffer.length, start);
|
||||
res.json({
|
||||
available: true,
|
||||
svc: req.params.svc,
|
||||
size,
|
||||
truncated: start > 0,
|
||||
text: buffer.toString("utf8"),
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Boot the lane's stack. 202 + background, like `POST /worktree`: a build can run
|
||||
* for minutes and the caller should not hold a socket open for it. Progress
|
||||
* streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports`
|
||||
* the boot may have changed.
|
||||
*/
|
||||
router.post("/:id/up", sameOriginGuard, (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
requireProfile(lane);
|
||||
} catch (err) {
|
||||
return sendRuntimeError(res, err);
|
||||
}
|
||||
|
||||
const build = req.body?.build !== false;
|
||||
res.status(202).json({ ok: true, laneId: lane.id });
|
||||
|
||||
void withLaneLock(lane.id, async () => {
|
||||
const onLine = (line, stream) =>
|
||||
broadcast("lane_hook_output", { laneId: lane.id, hook: "up", stream, line });
|
||||
try {
|
||||
const facts = await upLane(lanesLib.getLane(lane.id), { build, onLine });
|
||||
broadcast("lane_runtime", { laneId: lane.id, runtime: facts });
|
||||
} catch (err) {
|
||||
broadcast("lane_runtime", {
|
||||
laneId: lane.id,
|
||||
error: { code: err.code || "ERUNTIME", message: err.message },
|
||||
});
|
||||
}
|
||||
broadcastLane(lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
/** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */
|
||||
router.post("/:id/down", sameOriginGuard, async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const result = await withLaneLock(lane.id, () => downLane(lanesLib.getLane(lane.id)));
|
||||
const facts = await runtimeFacts(lanesLib.getLane(lane.id));
|
||||
broadcast("lane_runtime", { laneId: lane.id, runtime: facts });
|
||||
res.json({ ok: true, killed: result.killed, runtime: facts });
|
||||
} catch (err) {
|
||||
sendRuntimeError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run one of the profile's hooks on the lane — the surface a driving session uses
|
||||
* for `ci-gate`, `e2e`, `migrate` and friends.
|
||||
*
|
||||
* `:name` is checked against the hook allowlist BEFORE anything is spawned, and
|
||||
* `args` travels as an array of strings straight into argv. Neither is ever
|
||||
* joined into a command string.
|
||||
*/
|
||||
router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
const { name } = req.params;
|
||||
if (!HOOKS.includes(name)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOHOOK", message: `unknown hook ${name}`, allowed: HOOKS } });
|
||||
}
|
||||
const args = Array.isArray(req.body?.args) ? req.body.args.map(String) : [];
|
||||
|
||||
let profile;
|
||||
try {
|
||||
profile = requireProfile(lane);
|
||||
} catch (err) {
|
||||
return sendRuntimeError(res, err);
|
||||
}
|
||||
if (!lane.slot) {
|
||||
return res.status(409).json({
|
||||
error: { code: "ENOSLOT", message: "lane has no runtime yet — bring it up first" },
|
||||
});
|
||||
}
|
||||
|
||||
res.status(202).json({ ok: true, laneId: lane.id, hook: name });
|
||||
|
||||
void withLaneLock(lane.id, async () => {
|
||||
const onLine = (line, stream) =>
|
||||
broadcast("lane_hook_output", { laneId: lane.id, hook: name, stream, line });
|
||||
try {
|
||||
const result = await runHook(lanesLib.getLane(lane.id), profile, name, args, { onLine });
|
||||
broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: result.code });
|
||||
} catch (err) {
|
||||
broadcast("lane_hook_result", {
|
||||
laneId: lane.id,
|
||||
hook: name,
|
||||
code: null,
|
||||
error: { code: err.code || "ERUNTIME", message: err.message },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Lane control. Deliberately thin: every action maps onto one existing
|
||||
* run-spawner call. There is no queue, no chaining, no gate evaluation — the
|
||||
@@ -447,6 +684,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
|
||||
|
||||
await stopLaneRun(lockedLane);
|
||||
// A running stack holds files open in the very directory reset and remove
|
||||
// are about to rewrite or delete, and its processes would outlive the lane
|
||||
// still bound to its ports. Stop it before touching git. Idempotent and a
|
||||
// no-op for a lane that was never brought up.
|
||||
if (action === "reset" || action === "remove") {
|
||||
await downLane(lanesLib.getLane(lane.id));
|
||||
}
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const facts = await preflight(current, action);
|
||||
assertExpectedPreflight(action, facts, body.expect);
|
||||
@@ -463,13 +707,34 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
|
||||
if (action === "reset") {
|
||||
await resetWorktree(current);
|
||||
return { lane: lanesLib.clearLane(current.id) };
|
||||
const resetLane = lanesLib.clearLane(current.id);
|
||||
// A2 data isolation, only when the lane actually has both a profile
|
||||
// and an allocated slot — a lane that was never brought up has
|
||||
// nothing of this kind to reset.
|
||||
const profile = resolveProfile(resetLane);
|
||||
if (profile && resetLane.slot) {
|
||||
const onLine = (line, stream) =>
|
||||
broadcast("lane_hook_output", { laneId: resetLane.id, hook: "reset", stream, line });
|
||||
await resetLaneData(resetLane, profile, { keepDb: body.keepDb === true, onLine });
|
||||
}
|
||||
return { lane: lanesLib.getLane(current.id) };
|
||||
}
|
||||
if (action === "remove") {
|
||||
// Drop the lane's own database(s) before anything else — its state
|
||||
// directory (the drop-created marker) is about to be deleted too.
|
||||
const profile = resolveProfile(current);
|
||||
if (profile && current.slot) await removeLaneData(current, profile);
|
||||
// Forgetting an adopted lane only removes dashboard metadata. The
|
||||
// filesystem destroy guard is deliberately reached only for managed
|
||||
// worktrees, where removal can actually touch a directory.
|
||||
if (current.kind === "managed") await removeWorktree(current);
|
||||
// Runtime bookkeeping outlives the row otherwise: pid files and hook
|
||||
// logs under .state/lane<slot>/ would be inherited by whichever lane
|
||||
// claims that slot next. Deleting the row is what frees the slot —
|
||||
// usedSlots() reads the table, so there is nothing else to release.
|
||||
if (current.slot) {
|
||||
fs.rmSync(slotDirs(current.slot).stateDir, { recursive: true, force: true });
|
||||
}
|
||||
lanesLib.deleteLane(current.id);
|
||||
return { removed: current.id };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user