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
@@ -0,0 +1,169 @@
# A1 — Lane runtime isolation: slots, ports, profile hooks, detached lifecycle
**Status:** approved 2026-08-03. Roadmap it belongs to:
`docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`.
## Problem
A lane isolates **code** and nothing else. `server/lib/worktree.js` gives each lane
its own git worktree and branch, so two agents never overwrite each other's files —
but they share every other resource: the same ports, the same database, the same
`.env`, the same upload directory. Booting two lanes' stacks at once does not work.
Shipyard (`AgentWorkflow/`) solved this with per-lane ports, database, Redis logical
index and `.env`. That is **not** containerization — every lane runs on the host as
the same user. Its isolation is *resource namespacing at the application layer*.
## Scope
Shipyard parity is six independent subsystems (see the roadmap). A — runtime
isolation — goes first because it blocks the rest, and splits into three:
- **A1 (this spec)** — a lane can boot its stack on isolated ports and directories,
and the stack survives a dashboard restart. Profiles are hand-written.
- **A2** — data isolation: `.env` seeding, database create/drop/migrate/seed, Redis index.
- **A3** — stack detection + `ccam lane profile init` scaffolding, designed **from**
the real profiles written during A1/A2.
The split point matters. Shipyard was genericized but its `profiles/` is still
empty (`PROFILE=_template`, `SOURCE_REPO=""`, `state/` empty) — a clean seam that
never ran against a real app. Designing presets before a real profile exists
repeats exactly that.
**A1 done means:** `ccam lane up` boots a lane's stack on ports no other lane uses,
and `ccam update` (or any dashboard restart) leaves running stacks alone.
## Decisions
| | Choice | Rationale |
|---|---|---|
| Profile location | `<repo>/.ccam/profile/` | CCAM is multi-repo (lanes keyed by `cwd`); one global `PROFILE` is wrong the moment two repos have lanes. A profile that lives in the repo is already present in a fresh worktree. |
| Engine | Node, `server/lib/` | `execFile` with argument arrays (repo rule: never build a git/shell command as a string). Shares the SQLite DB and the websocket. Hooks stay shell scripts; the engine spawns them. |
| Resource model | Declared per repo | `PORTS="api fe worker"`, `LANE_DIRS="…"`. An app with no database declares none, so A2 generates no database hooks — no dead code. |
| Process ownership | Fully detached | pid files outside the worktree; a dashboard restart does not touch a running stack. |
| Port allocation | Base + slot, step aside on collision, record the real number | Predictable in the common case, does not hard-fail when a port is busy. |
| Adopted lanes | May `up`/`down`; every write path refused | `up` only runs what the user can already run themselves. Writes into their `cwd` (A2) are refused outright. |
| Runtime state | DB owns allocation, disk owns handles, up/down **computed on read** | A process dies to OOM, `kill`, or a reboot — caching a truth you do not control buys ghost state. Slot allocation is the opposite: fully controlled, needs to be race-free, so it belongs in the DB. |
## Invariants
1. **The runtime never writes `stage`, `status`, or `notes`** — only `slot` and
`ports`. In CCAM `status=running` means *an agent is working*, not *a stack is
up*; conflating the two breaks `classifyLiveness`. Boot failures surface through
`GET /runtime` and the hook log. Same reasoning as "the console never writes a
lane's stage".
2. **CCAM still does not orchestrate.** A1 adds *primitives* (`up`/`down`/`hook`)
that a session calls. The dashboard chains nothing, evaluates no gate, retries nothing.
3. **No shell command built as a string.** `execFile` + argv arrays everywhere.
Hooks are the deliberate exception — they *are* scripts, spawned as
`bash <hook> <args…>` with a fixed argv, never interpolated.
4. **`profile.env` is parsed, never sourced.** Sourcing arbitrary shell from a repo
into the dashboard process is a code-execution path. Hooks are executed on
purpose; config is only read.
5. **Adopted lanes stay non-destroyable** (`assertManaged` in `worktree.js`). A1
adds no bypass.
## Design
### Schema (`server/db.js`)
```sql
ALTER TABLE lanes ADD COLUMN slot INTEGER;
ALTER TABLE lanes ADD COLUMN ports TEXT NOT NULL DEFAULT '{}';
CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_slot ON lanes(slot) WHERE slot IS NOT NULL;
```
Added with the existing probe-each-column-independently migration pattern.
`ports` is parsed by the same `hydrate()` branch that already handles
`stages`/`links`, corrupt-blob fallback included.
`slot` and `ports` join `PROVISIONING_FIELDS` and stay **out of** `PATCHABLE`, for
the reason `kind` is excluded: a client that can set `slot` can change the
slot-derived `DB_NAME` A2 will use — which aims a drop at another database.
No column records "is up". Deliberate.
### Modules
| File | Responsibility |
|---|---|
| `server/lib/ports.js` | `isListening(port)` via a short-timeout `net.connect`; `listenerPids(port)` via `lsof`, falling back to `ss`, returning `[]` with a one-time warning when neither exists. |
| `server/lib/lane-slots.js` | `allocateSlot()` — lowest free in `[1, LANE_MAX_SLOTS]` (default 9), called inside the existing `withLaneLock`, with the unique index as the backstop. `releaseSlot()` on `remove` only — **not** on `reset`, or a lane's ports and database move under a running session. `resolvePorts(slot, profile)` — prefers `PORT_BASE_<name> + slot`, then `+100`, `+200`… (the last digit stays the slot, so the number still reads as "lane 3"), up to 10 tries before `EPORTBUSY` naming the occupying pid. |
| `server/lib/lane-profile.js` | `resolveProfile(lane)` — looks in `lane.cwd/.ccam/profile/` **first**, `lane.source_repo/.ccam/profile/` second, because the profile lives in the repo and a branch that edits its hooks must run its own. `runHook(lane, name, args, {onLine})` — fixed hook-name allowlist, `bash <hook>` with an argv array, Shipyard's env contract verbatim so its profiles port unchanged (`LANE` = the **slot**, `LANE_DIR`, `RUN_DIR`, `LOG_DIR`, `PROFILE_DIR`, `SOURCE_REPO`, `<NAME>_PORT` per declared port), `harness_spawn` exported as a bash function writing the same `$RUN_DIR/<name>.pid` + `$LOG_DIR/<name>.log`, the `GIT_*` scrub from `worktree.js:git()`, and output streamed both to the log file and to a new `lane_hook_output` websocket message. |
| `server/lib/lane-runtime.js` | `upLane` (defensive down → mkdir run/log/`LANE_DIRS``boot``health`; no `bootstrap`, which belongs to provision/reset as in `lane-up.sh`), `downLane` (recursive pid-tree kill from the pid files — ported from `lane-down.sh:kill_tree`, which exists because detached uvicorn/celery prefork children were being missed — then a port-listener backstop; idempotent, sleep-free), `runtimeFacts` (**computed**: `kill -0` per pid file, `isListening` per port, plus `last-error.json`). |
State lives at `LANES_ROOT/.state/lane<slot>/{run,logs}/`, **outside** the
worktree: `lane reset` runs `git clean -fd`, which would sweep pid files mid-flight.
**Adopt-after-restart costs no code.** Pid files are on disk, detached processes
keep running, and `runtimeFacts` recomputes on read. That is the payoff of making
up/down computed rather than stored.
### Routes (`server/routes/lanes.js`)
```
POST /api/lanes/:id/up { build? } 202 → background → broadcastLane
POST /api/lanes/:id/down { mcp? } 200
GET /api/lanes/:id/runtime 200
POST /api/lanes/:id/hook/:name { args? } 202
GET /api/lanes/:id/logs/:svc ?tail=N 200
```
All behind `sameOriginGuard` and the existing `withLaneLock`. `GET /runtime`
follows the contract of `GET /:id/git`: a lane with no profile returns
`{available:false}` with HTTP 200 — a normal state, not a fault. It probes ports
and stats pid files, which is why it is its own endpoint rather than part of the
polled `GET /api/lanes`.
`:name` and `:svc` come from allowlists, never from the request; log paths are
`realpath`-confined to `LOG_DIR`; `args` is an array of strings passed straight to
argv, never joined.
### Errors
| Condition | Code | Behaviour |
|---|---|---|
| No `.ccam/profile/` | `ENOPROFILE` | 400, naming both paths searched |
| Unknown hook | `ENOHOOK` | 400 |
| Hook exits non-zero | — | `lane_hook_output` + `last-error.json`; reflected by `/runtime` |
| Every candidate port busy | `EPORTBUSY` | names the occupying pid |
| `health` fails | `EUNHEALTHY` | **leaves the processes running** — killing them destroys the evidence — and points at the log |
| Slot pool exhausted | `ESLOTS` | 409 |
| Name outside an allowlist | — | 400, nothing spawned |
## Verification
`npm run test:server` (required) and `npm run test:client` (review the snapshot
diff before regenerating). A fixture repo with a three-line profile: `boot.sh`
starts `python3 -m http.server $FE_PORT` through `harness_spawn`, `health.sh` curls
it with retries.
Covered:
- **Real lifecycle** — `up``/runtime` healthy with the port listening → simulate
a dashboard restart (rebuild the module) → still healthy, not rebooted → `down`
port free, pid files gone.
- **Slots** — nine allocations yield 1..9; releasing 4 makes the next allocation 4;
concurrent allocations through `withLaneLock` never collide.
- **Port step-aside** — occupy the preferred port, assert `+100` is chosen and
recorded in `ports`.
- **Profile parsing** — missing keys fall back to defaults; no profile returns
`null` without throwing; a value containing `$(id)` stays literal and is **not**
executed.
- **Guards** — `PATCH /api/lanes/:id` ignores `slot`/`ports`; a hook name outside
the allowlist returns 400 and spawns nothing.
- **Env** — with `GIT_DIR` set on the parent, the hook does not see it.
Manual: create a lane on ccam-lanes itself, write a three-line profile,
`ccam lane up`, open the port, restart the server, confirm the stack survived.
## Out of scope
`.env` seeding and databases (A2). Detection and scaffolding (A3). Per-feature
state, proof gallery, cross-lane locks, the `ship-feature` skill, integrations
(subsystems BF).
Container isolation is a separate design, not a later phase: hooks and MCP servers
would have to run *inside* the container, so the `cwd` a hook reports becomes a
container path and no longer matches the lane's host `cwd` — breaking
`resolveLaneByCwd`, which is what binds a session to a lane at all.
+155
View File
@@ -8820,6 +8820,161 @@ paths:
description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone.
'404':
description: Lane not found.
/api/lanes/{id}/runtime:
get:
tags:
- Lanes
summary: A lane's own application stack
description: Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .ccam/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.
operationId: getLaneRuntime
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: available:false (no profile); available:true with provisioned:false (no slot yet); or the full facts with slot, ports, services, up, healthy, steppedAside, logs and lastError.
'404':
description: Lane not found.
/api/lanes/{id}/up:
post:
tags:
- Lanes
summary: Boot a lane's stack
description: 'Runs the profile''s boot then health hooks. Returns 202 and finishes in the background because a build can take minutes; progress streams as lane_hook_output and the attempt ends with a lane_runtime message. Does NOT run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running, because their logs are what identify the service that never came up. Writes only slot and ports on the lane row: never stage, status or notes.'
operationId: upLane
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
build:
type: boolean
default: true
description: false passes --no-build to the boot hook, reusing an existing build.
responses:
'202':
description: Accepted; the boot runs in the background.
'400':
description: ENOPROFILE — no .ccam/profile, with the paths searched.
'403':
description: The browser request was not same-origin/loopback.
'404':
description: Lane not found.
'409':
description: ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids).
/api/lanes/{id}/down:
post:
tags:
- Lanes
summary: Stop a lane's stack
description: Kills each recorded pid tree bottom-up (killing a parent first reparents its children to init, where nothing knows to look for them), then sweeps listeners on the lane's ports ONLY when a pid file existed — a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server the user started there. Idempotent, and a no-op for a lane that was never up.
operationId: downLane
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: '{ok, killed: number[], runtime}.'
'403':
description: The browser request was not same-origin/loopback.
'404':
description: Lane not found.
/api/lanes/{id}/hook/{name}:
post:
tags:
- Lanes
summary: Run one of the lane profile's hooks
description: The surface a driving session uses for ci-gate, e2e, migrate and friends. Returns 202; output streams as lane_hook_output and completion arrives as lane_hook_result with the exit code. The name is checked against a fixed allowlist BEFORE anything is spawned, and args travels as an array of strings straight into argv — neither is ever joined into a command string.
operationId: runLaneHook
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: name
in: path
required: true
schema:
type: string
enum:
- bootstrap
- boot
- health
- migrate
- seed
- ci-gate
- e2e
- regen
- db-create
- db-drop
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
args:
type: array
items:
type: string
responses:
'202':
description: Accepted; the hook runs in the background.
'400':
description: ENOHOOK (name outside the allowlist) or ENOPROFILE.
'403':
description: The browser request was not same-origin/loopback.
'404':
description: Lane not found.
'409':
description: ENOSLOT — the lane has no runtime yet; bring it up first.
/api/lanes/{id}/logs/{svc}:
get:
tags:
- Lanes
summary: Tail a lane's hook or service log
description: The resolved path is confined to the lane's log directory after realpath, so a name from the request can never escape it. Read-only, so no same-origin guard.
operationId: getLaneLog
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: svc
in: path
required: true
schema:
type: string
- name: tail
in: query
required: false
schema:
type: integer
default: 65536
maximum: 1048576
description: Trailing bytes to return; capped at 1 MiB.
responses:
'200':
description: '{available, svc, size, truncated, text}, or {available:false} for a lane with no slot.'
'404':
description: Lane not found, or ENOLOG — no such log for this lane.
/api/lanes/branches:
get:
tags:
+370
View File
@@ -0,0 +1,370 @@
/**
* @file Tests for A2 data isolation: machine-level secrets, per-lane database
* lifecycle (create once / reset / drop), and `.env` seeding.
*
* The database lifecycle tests use a SQLite-shaped fixture hook (write a file,
* remove a file) rather than a real Postgres — the property worth proving is
* CCAM's own orchestration (when a hook runs, what name it is trusted with,
* that a repeat call is a no-op), not any particular database engine.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const pathMod = require("node:path");
const fsMod = require("node:fs");
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "ccam-data-"));
process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes");
process.env.CCAM_SECRETS_PATH = pathMod.join(SUITE_ROOT, "secrets.env");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const lanesLib = require("../lib/lanes");
const slots = require("../lib/lane-slots");
const profileLib = require("../lib/lane-profile");
const runtime = require("../lib/lane-runtime");
const envLib = require("../lib/lane-env");
const servicesLib = require("../lib/lane-services");
const secretsLib = require("../lib/secrets");
after(() => fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true }));
let laneSeq = 0;
/** A managed lane row backed by a real directory. */
function makeLane(over = {}) {
laneSeq += 1;
const cwd = pathMod.join(SUITE_ROOT, `data-lane-cwd-${laneSeq}`);
fsMod.mkdirSync(cwd, { recursive: true });
return lanesLib.createLane({
title: `data-lane ${laneSeq}`,
cwd,
kind: "managed",
source_repo: cwd,
...over,
});
}
/** Write a profile into a repo directory. `hooks` maps name -> script body. */
function writeProfile(root, envText, hooks = {}) {
const dir = pathMod.join(root, ".ccam", "profile");
fsMod.mkdirSync(pathMod.join(dir, "hooks"), { recursive: true });
fsMod.writeFileSync(pathMod.join(dir, "profile.env"), envText);
for (const [name, body] of Object.entries(hooks)) {
const file = pathMod.join(dir, "hooks", `${name}.sh`);
fsMod.writeFileSync(file, body);
fsMod.chmodSync(file, 0o755);
}
return dir;
}
// A file-based "database" so the create/drop lifecycle is provable without a
// real engine: db-create WRITES (so a wrongly-repeated call would clobber
// existing content, proving the once-only marker actually matters), db-drop
// removes.
const SQLITE_DB_HOOKS = {
"db-create":
'#!/usr/bin/env bash\nset -euo pipefail\necho created > "$LANE_DIR/${1:-$DB_NAME}.db"\n',
"db-drop": '#!/usr/bin/env bash\nset -euo pipefail\nrm -f "$LANE_DIR/${1:-$DB_NAME}.db"\n',
};
describe("secrets", () => {
const secretsPath = process.env.CCAM_SECRETS_PATH;
it("falls back to defaults when the file is absent", () => {
assert.equal(fsMod.existsSync(secretsPath), false);
const s = secretsLib.readSecrets();
assert.equal(s.PG_HOST, "127.0.0.1");
assert.equal(s.PG_USER, "postgres");
});
it("refuses a group/world-readable secrets file, falling back to defaults", () => {
fsMod.writeFileSync(secretsPath, "PG_USER=leaked-user\n");
fsMod.chmodSync(secretsPath, 0o644);
const s = secretsLib.readSecrets();
assert.equal(s.PG_USER, "postgres");
});
it("loads a mode-0600 file", () => {
fsMod.writeFileSync(secretsPath, "PG_USER=custom-user\nPG_PASS=custom-pass\n");
fsMod.chmodSync(secretsPath, 0o600);
const s = secretsLib.readSecrets();
assert.equal(s.PG_USER, "custom-user");
assert.equal(s.PG_PASS, "custom-pass");
});
});
describe("lane-env seedEnv", () => {
it("rewrites ENV_REWRITE keys, keeping every other line byte-identical", () => {
const source = pathMod.join(SUITE_ROOT, "env-source-repo");
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
fsMod.writeFileSync(
pathMod.join(source, "backend", ".env"),
"FOO=bar\nDATABASE_URL=stale\nBAZ=qux\n"
);
const lane = makeLane({ source_repo: source });
writeProfile(
lane.cwd,
["DB_PREFIX=envtest_l", "ENV_FILES=backend/.env", "ENV_REWRITE=DATABASE_URL"].join("\n")
);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
envLib.seedEnv(current, profile, secretsLib.readSecrets());
const lines = fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8").split("\n");
assert.equal(lines[0], "FOO=bar");
assert.equal(lines[2], "BAZ=qux");
assert.match(lines[1], /^DATABASE_URL=postgresql:\/\//);
slots.releaseSlot(lane.id);
});
it("--force preserves ENV_PRESERVE keys from the lane's own existing file", () => {
const source = pathMod.join(SUITE_ROOT, "env-source-repo-2");
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
fsMod.writeFileSync(
pathMod.join(source, "backend", ".env"),
"JWT_SECRET=source-secret\nOTHER=from-source\n"
);
const lane = makeLane({ source_repo: source });
writeProfile(lane.cwd, ["ENV_FILES=backend/.env", "ENV_PRESERVE=JWT_SECRET"].join("\n"));
slots.allocateSlot(lane.id);
let current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
envLib.seedEnv(current, profile, secretsLib.readSecrets());
fsMod.writeFileSync(
pathMod.join(current.cwd, "backend/.env"),
"JWT_SECRET=lane-own-secret\nOTHER=from-source\n"
);
envLib.seedEnv(current, profile, secretsLib.readSecrets(), { force: true });
const text = fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8");
assert.match(text, /JWT_SECRET=lane-own-secret/);
slots.releaseSlot(lane.id);
});
it("falls back to .env.example when the source .env is missing", () => {
const source = pathMod.join(SUITE_ROOT, "env-source-repo-3");
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
fsMod.writeFileSync(pathMod.join(source, "backend", ".env.example"), "TEMPLATE=1\n");
const lane = makeLane({ source_repo: source });
writeProfile(lane.cwd, "ENV_FILES=backend/.env\n");
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
const result = envLib.seedEnv(current, profile, secretsLib.readSecrets());
assert.equal(result.seeded[0].fromExample, true);
assert.match(
fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8"),
/TEMPLATE=1/
);
slots.releaseSlot(lane.id);
});
it("throws ENOTMANAGED for an adopted lane", () => {
const lane = makeLane({ kind: "adopted" });
writeProfile(lane.cwd, "ENV_FILES=backend/.env\n");
assert.throws(
() =>
envLib.seedEnv(
lanesLib.getLane(lane.id),
profileLib.resolveProfile(lanesLib.getLane(lane.id)),
secretsLib.readSecrets()
),
(err) => err.code === "ENOTMANAGED"
);
});
it("is a no-op when ENV_FILES is not declared", () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=noenv_l\n");
const result = envLib.seedEnv(lane, profileLib.resolveProfile(lane), secretsLib.readSecrets());
assert.equal(result.skipped, true);
});
});
describe("lane-services database guard", () => {
it("refuses to drop a name that isn't this lane's own derived one, before spawning anything", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=guard_l\n", SQLITE_DB_HOOKS);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
await assert.rejects(
() => servicesLib.dropDatabase(current, profile, "someone-elses-db"),
(err) => err.code === "EBADDBNAME"
);
assert.equal(fsMod.existsSync(pathMod.join(current.cwd, "someone-elses-db.db")), false);
slots.releaseSlot(lane.id);
});
it("throws ENOTMANAGED for an adopted lane's own derived name", async () => {
const lane = makeLane({ kind: "adopted" });
writeProfile(lane.cwd, "DB_PREFIX=guard2_l\n", SQLITE_DB_HOOKS);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
await assert.rejects(
() => servicesLib.dropDatabase(current, profile, slots.dbName(profile, current.slot)),
(err) => err.code === "ENOTMANAGED"
);
slots.releaseSlot(lane.id);
});
it("creates a database once, tracked by a marker so a repeat call is a no-op", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=once_l\n", SQLITE_DB_HOOKS);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
const first = await servicesLib.ensureDatabase(current, profile, {});
assert.equal(first.created, true);
const dbFile = pathMod.join(current.cwd, `once_l${current.slot}.db`);
fsMod.writeFileSync(dbFile, "sentinel");
const second = await servicesLib.ensureDatabase(current, profile, {});
assert.equal(second.created, false);
assert.equal(fsMod.readFileSync(dbFile, "utf8"), "sentinel", "hook must not re-run");
slots.releaseSlot(lane.id);
});
it("is a no-op when DB_PREFIX is not declared", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "", SQLITE_DB_HOOKS);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const result = await servicesLib.ensureDatabase(
current,
profileLib.resolveProfile(current),
{}
);
assert.deepEqual(result, { name: null, created: false });
slots.releaseSlot(lane.id);
});
});
describe("lane data lifecycle: provision / reset / remove", () => {
it("provision creates the database; reset recreates it; remove drops it", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=lifecycle_l\n", SQLITE_DB_HOOKS);
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
const dbFile = pathMod.join(provisioned.cwd, `lifecycle_l${provisioned.slot}.db`);
assert.ok(fsMod.existsSync(dbFile), "provision creates the database");
fsMod.writeFileSync(dbFile, "old-feature-data");
const profile = profileLib.resolveProfile(provisioned);
await runtime.resetLaneData(lanesLib.getLane(lane.id), profile, {});
assert.equal(
fsMod.readFileSync(dbFile, "utf8").trim(),
"created",
"reset drops and recreates, losing the old content"
);
await runtime.removeLaneData(lanesLib.getLane(lane.id), profile, {});
assert.equal(fsMod.existsSync(dbFile), false, "remove drops the database");
slots.releaseSlot(lane.id);
});
it("--keep-db leaves the database untouched across reset", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=keepdb_l\n", SQLITE_DB_HOOKS);
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
const dbFile = pathMod.join(provisioned.cwd, `keepdb_l${provisioned.slot}.db`);
fsMod.writeFileSync(dbFile, "must-survive");
const profile = profileLib.resolveProfile(provisioned);
await runtime.resetLaneData(lanesLib.getLane(lane.id), profile, { keepDb: true });
assert.equal(fsMod.readFileSync(dbFile, "utf8"), "must-survive");
slots.releaseSlot(lane.id);
});
it("removeLaneData never touches an adopted lane's database", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=adopted_l\n", SQLITE_DB_HOOKS);
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
const dbFile = pathMod.join(provisioned.cwd, `adopted_l${provisioned.slot}.db`);
assert.ok(fsMod.existsSync(dbFile));
lanesLib.setProvisioningFacts(lane.id, { kind: "adopted" });
const adoptedLane = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(adoptedLane);
await runtime.removeLaneData(adoptedLane, profile, {});
assert.ok(fsMod.existsSync(dbFile), "an adopted lane's data is never CCAM's to destroy");
slots.releaseSlot(lane.id);
});
it("isolates two lanes' databases from each other", async () => {
const a = makeLane();
const b = makeLane();
writeProfile(a.cwd, "DB_PREFIX=iso_l\n", SQLITE_DB_HOOKS);
writeProfile(b.cwd, "DB_PREFIX=iso_l\n", SQLITE_DB_HOOKS);
const pa = await runtime.provisionLane(lanesLib.getLane(a.id));
const pb = await runtime.provisionLane(lanesLib.getLane(b.id));
assert.notEqual(pa.slot, pb.slot);
const dbA = pathMod.join(pa.cwd, `iso_l${pa.slot}.db`);
const dbB = pathMod.join(pb.cwd, `iso_l${pb.slot}.db`);
fsMod.writeFileSync(dbA, "rows-for-a");
fsMod.writeFileSync(dbB, "rows-for-b");
assert.notEqual(dbA, dbB);
assert.notEqual(fsMod.readFileSync(dbA, "utf8"), fsMod.readFileSync(dbB, "utf8"));
slots.releaseSlot(a.id);
slots.releaseSlot(b.id);
});
});
describe("runtimeFacts never leaks a secret value", () => {
it("reports only names and an index, never a connection string", async () => {
fsMod.writeFileSync(process.env.CCAM_SECRETS_PATH, "PG_PASS=super-secret-password\n");
fsMod.chmodSync(process.env.CCAM_SECRETS_PATH, 0o600);
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=leak_l\nREDIS=1\n", SQLITE_DB_HOOKS);
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
const facts = await runtime.runtimeFacts(lanesLib.getLane(provisioned.id));
assert.equal(facts.database.name, `leak_l${provisioned.slot}`);
assert.equal(facts.redisIndex, provisioned.slot);
assert.doesNotMatch(JSON.stringify(facts), /super-secret-password/);
slots.releaseSlot(lane.id);
});
it("redacts a hook echoing $DATABASE_URL, including its URL-encoded form", async () => {
// A password with a character that changes shape once URL-encoded, so
// both the raw and encoded substrings actually get exercised.
fsMod.writeFileSync(process.env.CCAM_SECRETS_PATH, "PG_PASS=p@ss/word\n");
fsMod.chmodSync(process.env.CCAM_SECRETS_PATH, 0o600);
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=echo_l\n", {
"db-create": '#!/usr/bin/env bash\nset -euo pipefail\necho "connecting to $DATABASE_URL"\n',
});
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
const result = await profileLib.runHook(current, profile, "db-create", [
slots.dbName(profile, current.slot),
]);
assert.doesNotMatch(result.output, /p@ss\/word/);
assert.match(result.output, /\[REDACTED\]/);
assert.doesNotMatch(fsMod.readFileSync(result.logPath, "utf8"), /p@ss\/word/);
slots.releaseSlot(lane.id);
});
});
+369
View File
@@ -0,0 +1,369 @@
/**
* @file Tests for lane runtime isolation: slot and port allocation, profile
* parsing and hook execution, and the boot/down lifecycle.
*
* The lifecycle tests run a REAL profile whose boot hook starts a REAL server and
* whose health hook polls it, because the properties worth proving are exactly
* the ones a mock cannot show: that a service detached by `harness_spawn` keeps
* listening after the hook exits, that runtime facts recomputed from scratch still
* find it (the "survives a dashboard restart" claim), and that `downLane` reaches
* a process it never spawned directly.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const pathMod = require("node:path");
const fsMod = require("node:fs");
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "ccam-runtime-"));
process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const lanesLib = require("../lib/lanes");
const slots = require("../lib/lane-slots");
const profileLib = require("../lib/lane-profile");
const runtime = require("../lib/lane-runtime");
const { isListening } = require("../lib/ports");
after(() => fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true }));
let laneSeq = 0;
/** A lane row backed by a real directory, so profile lookup and mkdir work. */
function makeLane(over = {}) {
laneSeq += 1;
const cwd = pathMod.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
fsMod.mkdirSync(cwd, { recursive: true });
return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed", ...over });
}
/** Write a profile into a repo directory. `hooks` maps name -> script body. */
function writeProfile(root, envText, hooks = {}) {
const dir = pathMod.join(root, ".ccam", "profile");
fsMod.mkdirSync(pathMod.join(dir, "hooks"), { recursive: true });
fsMod.writeFileSync(pathMod.join(dir, "profile.env"), envText);
for (const [name, body] of Object.entries(hooks)) {
const file = pathMod.join(dir, "hooks", `${name}.sh`);
fsMod.writeFileSync(file, body);
fsMod.chmodSync(file, 0o755);
}
return dir;
}
/** Hold a port open so the allocator has to step aside. */
function occupy(port) {
const server = net.createServer(() => {});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve(server));
});
}
describe("profile parsing", () => {
it("keeps command substitution literal instead of executing it", () => {
const parsed = profileLib.parseEnvFile(
['PORTS="api fe"', "export EVIL=$(id)", "TICK=`whoami`", "# comment", "", "BARE=plain"].join(
"\n"
)
);
assert.equal(parsed.PORTS, "api fe");
assert.equal(parsed.EVIL, "$(id)");
assert.equal(parsed.TICK, "`whoami`");
assert.equal(parsed.BARE, "plain");
assert.equal(parsed["# comment"], undefined);
});
it("fills defaults for omitted declarations", () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=web\n");
const profile = profileLib.resolveProfile(lane);
assert.deepEqual(profile.ports, ["web"]);
assert.equal(profile.env.PORT_BASE_fe, "3000");
assert.equal(profile.env.BACKEND_DIR, "backend");
});
it("returns null when no profile exists, without throwing", () => {
const lane = makeLane();
assert.equal(profileLib.resolveProfile(lane), null);
});
it("prefers the lane's own working copy over the source repo", () => {
const source = pathMod.join(SUITE_ROOT, "source-repo");
fsMod.mkdirSync(source, { recursive: true });
writeProfile(source, "PORTS=fromsource\n");
const lane = makeLane({ source_repo: source });
writeProfile(lane.cwd, "PORTS=fromlane\n");
assert.deepEqual(profileLib.resolveProfile(lane).ports, ["fromlane"]);
});
it("falls back to the source repo when the worktree has none", () => {
const source = pathMod.join(SUITE_ROOT, "source-repo-2");
fsMod.mkdirSync(source, { recursive: true });
writeProfile(source, "PORTS=fromsource\n");
const lane = makeLane({ source_repo: source });
assert.deepEqual(profileLib.resolveProfile(lane).ports, ["fromsource"]);
});
});
describe("slot allocation", () => {
it("hands out the lowest free slot and reuses a released one", () => {
const made = [];
for (let i = 0; i < 4; i += 1) {
const lane = makeLane();
slots.allocateSlot(lane.id);
made.push(lanesLib.getLane(lane.id));
}
assert.deepEqual(
made.map((lane) => lane.slot),
[1, 2, 3, 4]
);
slots.releaseSlot(made[1].id);
assert.equal(lanesLib.getLane(made[1].id).slot, null);
const next = makeLane();
assert.equal(slots.allocateSlot(next.id), 2);
for (const lane of [...made, next]) slots.releaseSlot(lane.id);
});
it("refuses to allocate past the configured ceiling", () => {
const previous = process.env.LANE_MAX_SLOTS;
process.env.LANE_MAX_SLOTS = "2";
const made = [makeLane(), makeLane(), makeLane()];
slots.allocateSlot(made[0].id);
slots.allocateSlot(made[1].id);
assert.throws(
() => slots.allocateSlot(made[2].id),
(err) => err.code === "ESLOTS"
);
for (const lane of made) slots.releaseSlot(lane.id);
if (previous === undefined) delete process.env.LANE_MAX_SLOTS;
else process.env.LANE_MAX_SLOTS = previous;
});
});
describe("port allocation", () => {
it("derives base + slot when the port is free", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18500\n");
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const ports = await slots.resolvePorts(current, profileLib.resolveProfile(current));
assert.equal(ports.api, 18500 + current.slot);
slots.releaseSlot(lane.id);
});
it("steps aside by 100 when the preferred port is taken, keeping the slot digit", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18600\n");
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const held = await occupy(18600 + current.slot);
try {
const ports = await slots.resolvePorts(current, profileLib.resolveProfile(current));
assert.equal(ports.api, 18700 + current.slot);
assert.equal(ports.api % 10, current.slot % 10);
} finally {
held.close();
slots.releaseSlot(lane.id);
}
});
it("never reuses a port another lane has recorded, even while that lane is down", async () => {
const a = makeLane();
const b = makeLane();
writeProfile(a.cwd, "PORTS=api\nPORT_BASE_api=18800\n");
// b's base is offset so its preferred number collides with a's recorded one.
slots.allocateSlot(a.id);
const laneA = lanesLib.getLane(a.id);
lanesLib.setProvisioningFacts(laneA.id, { ports: { api: 18800 + laneA.slot } });
slots.allocateSlot(b.id);
const laneB = lanesLib.getLane(b.id);
writeProfile(b.cwd, `PORTS=api\nPORT_BASE_api=${18800 + laneA.slot - laneB.slot}\n`);
const ports = await slots.resolvePorts(laneB, profileLib.resolveProfile(laneB));
assert.notEqual(ports.api, 18800 + laneA.slot);
slots.releaseSlot(a.id);
slots.releaseSlot(b.id);
});
});
describe("hook execution", () => {
it("exports the lane contract and scrubs inherited git variables", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18900\nCUSTOM=from-profile\n", {
boot: '#!/usr/bin/env bash\necho "LANE=$LANE API_PORT=$API_PORT CUSTOM=$CUSTOM GITDIR=[${GIT_DIR:-unset}] ARG=$1"\n',
});
slots.allocateSlot(lane.id);
lanesLib.setProvisioningFacts(lane.id, { ports: { api: 18999 } });
const current = lanesLib.getLane(lane.id);
process.env.GIT_DIR = "/somewhere/else/.git";
try {
const result = await profileLib.runHook(current, profileLib.resolveProfile(current), "boot", [
"--no-build",
]);
assert.equal(result.code, 0);
assert.match(result.output, new RegExp(`LANE=${current.slot}\\b`));
assert.match(result.output, /API_PORT=18999/);
assert.match(result.output, /CUSTOM=from-profile/);
assert.match(result.output, /GITDIR=\[unset\]/);
assert.match(result.output, /ARG=--no-build/);
} finally {
delete process.env.GIT_DIR;
slots.releaseSlot(lane.id);
}
});
it("rejects a hook name outside the allowlist without spawning anything", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=api\n", { boot: "#!/usr/bin/env bash\ntrue\n" });
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
await assert.rejects(
() => profileLib.runHook(current, profileLib.resolveProfile(current), "../../etc/passwd"),
(err) => err.code === "ENOHOOK"
);
slots.releaseSlot(lane.id);
});
it("resolves with the exit code rather than throwing when a hook fails", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=api\n", {
boot: '#!/usr/bin/env bash\necho "nope" >&2\nexit 3\n',
});
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const result = await profileLib.runHook(current, profileLib.resolveProfile(current), "boot");
assert.equal(result.code, 3);
assert.match(result.output, /nope/);
assert.ok(fsMod.existsSync(result.logPath));
slots.releaseSlot(lane.id);
});
});
describe("lifecycle", () => {
/** A profile whose boot hook starts a real HTTP listener and detaches it. */
function serverProfile(lane, base) {
writeProfile(lane.cwd, `PORTS=web\nPORT_BASE_web=${base}\nLANE_DIRS="uploads .cache"\n`, {
boot: [
"#!/usr/bin/env bash",
"set -euo pipefail",
'harness_spawn web "$LANE_DIR" python3 -m http.server "$WEB_PORT" --bind 127.0.0.1',
"",
].join("\n"),
health: [
"#!/usr/bin/env bash",
"set -euo pipefail",
"for _ in $(seq 1 50); do",
' if curl -sf "http://127.0.0.1:$WEB_PORT/" >/dev/null; then exit 0; fi',
" sleep 0.2",
"done",
"exit 1",
"",
].join("\n"),
});
}
it("boots a detached stack, still sees it after a cold recompute, then stops it", async () => {
const lane = makeLane();
serverProfile(lane, 19100);
const facts = await runtime.upLane(lanesLib.getLane(lane.id));
assert.equal(facts.available, true);
assert.equal(facts.up, true);
assert.equal(facts.healthy, true);
const booted = lanesLib.getLane(lane.id);
const port = booted.ports.web;
assert.ok(port, "a port was recorded");
assert.equal(await isListening(port), true);
// The declared per-lane directories exist inside the working copy.
assert.ok(fsMod.existsSync(pathMod.join(booted.cwd, "uploads")));
assert.ok(fsMod.existsSync(pathMod.join(booted.cwd, ".cache")));
// "Survives a dashboard restart": nothing in memory is consulted — the facts
// are rebuilt from the pid file on disk and a fresh port probe.
const recomputed = await runtime.runtimeFacts(lanesLib.getLane(lane.id));
assert.equal(recomputed.up, true);
assert.equal(recomputed.healthy, true);
assert.equal(recomputed.services.find((s) => s.name === "web").alive, true);
await runtime.downLane(lanesLib.getLane(lane.id));
assert.equal(await isListening(port), false);
const afterDown = await runtime.runtimeFacts(lanesLib.getLane(lane.id));
assert.equal(afterDown.up, false);
assert.deepEqual(afterDown.services, []);
slots.releaseSlot(lane.id);
});
it("records a failed health check without touching the lane's agent fields", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19200\n", {
boot: "#!/usr/bin/env bash\ntrue\n",
health: "#!/usr/bin/env bash\nexit 1\n",
});
await assert.rejects(
() => runtime.upLane(lanesLib.getLane(lane.id)),
(err) => err.code === "EUNHEALTHY"
);
const after = lanesLib.getLane(lane.id);
assert.equal(after.stage, "idle", "runtime must not write stage");
assert.equal(after.status, "idle", "runtime must not write status");
assert.equal(after.notes, null, "runtime must not write notes");
const facts = await runtime.runtimeFacts(after);
assert.equal(facts.lastError.code, "EUNHEALTHY");
slots.releaseSlot(lane.id);
});
it("reports available:false for a lane with no profile", async () => {
const lane = makeLane();
const facts = await runtime.runtimeFacts(lane);
assert.equal(facts.available, false);
assert.ok(facts.searched.length > 0);
await assert.rejects(
() => runtime.upLane(lane),
(err) => err.code === "ENOPROFILE"
);
});
it("is a no-op when taking down a lane that was never up", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=web\n", { boot: "#!/usr/bin/env bash\ntrue\n" });
assert.deepEqual(await runtime.downLane(lane), { killed: [] });
});
it("refuses a LANE_DIRS entry that escapes the working copy", () => {
const lane = makeLane();
writeProfile(lane.cwd, 'PORTS=web\nLANE_DIRS="../escaped"\n');
assert.throws(
() => runtime.makeLaneDirs(lane, profileLib.resolveProfile(lane)),
(err) => err.code === "EBADLANEDIR"
);
});
});
describe("allocation is not client-patchable", () => {
it("ignores slot and ports coming through updateLane", () => {
const lane = makeLane();
slots.allocateSlot(lane.id);
const before = lanesLib.getLane(lane.id);
lanesLib.updateLane(lane.id, { slot: 99, ports: { api: 1 }, title: "renamed" });
const after = lanesLib.getLane(lane.id);
assert.equal(after.slot, before.slot);
assert.deepEqual(after.ports, before.ports);
assert.equal(after.title, "renamed", "patchable fields still apply");
slots.releaseSlot(lane.id);
});
});
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# db-create.sh Create this lane's Postgres database inside a docker-compose
# service, via `createdb`. Ported verbatim from Shipyard's own lane-bootstrap
# / lane-up / lane-reset scripts, so a Postgres-via-compose repo gets parity
# by copying this template into its own .ccam/profile/hooks/.
#
# Called as: db-create.sh <db-name> (also available as $DB_NAME)
# Requires, declared in the repo's own profile.env:
# COMPOSE_FILE path to the docker-compose file (e.g. "$LANE_DIR/docker-compose.yml")
# DB_SERVICE the compose service name running Postgres
# Provided by CCAM's hook environment (server/lib/secrets.js):
# PG_USER (PG_PASS is deliberately not exported — trust auth inside the
# compose network needs no password for this call)
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
NAME="${1:-$DB_NAME}"
[ -n "$NAME" ] || { echo "db-create: no database name given" >&2; exit 1; }
if docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" \
psql -U "$PG_USER" -tAc "SELECT 1 FROM pg_database WHERE datname='$NAME'" | grep -q 1; then
echo "db-create: $NAME already exists"
exit 0
fi
docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" createdb -U "$PG_USER" "$NAME"
echo "db-create: created $NAME"
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# db-drop.sh Drop this lane's Postgres database inside a docker-compose
# service, via `dropdb --if-exists`. Ported verbatim from Shipyard's own
# lane-reset / lane-remove scripts. CCAM only ever calls this with a name it
# derived itself (server/lib/lane-services.js:dropDatabase), so this script
# never needs to re-validate its argument.
#
# Called as: db-drop.sh <db-name> (also available as $DB_NAME)
# Requires/provides: same as db-create.sh in this same directory.
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
NAME="${1:-$DB_NAME}"
[ -n "$NAME" ] || { echo "db-drop: no database name given" >&2; exit 1; }
docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" dropdb --if-exists -U "$PG_USER" "$NAME"
echo "db-drop: dropped $NAME (if it existed)"
@@ -0,0 +1,23 @@
# postgres-compose profile template — data-isolation additions (A2).
# Copy the declarations you need into your repo's own .ccam/profile/profile.env
# alongside db-create.sh / db-drop.sh from this template's hooks/ directory.
DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB
DB_KIND="postgres" # informational
DB_URL_SCHEME="postgresql" # DATABASE_URL scheme
REDIS=1 # 1 = allocate a logical Redis index = slot
# (16 logical DBs by default, 0-15 — keep
# LANE_MAX_SLOTS <= 15 if you turn this on)
ENV_FILES="backend/.env" # file(s) to seed, relative to the lane
ENV_SOURCE="backend/.env" # source path in the source repo
ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys CCAM overwrites per lane
ENV_PRESERVE="JWT_SECRET" # keys kept from the lane's OWN file on --force
UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR
# Read by db-create.sh / db-drop.sh, NOT by CCAM itself:
COMPOSE_FILE="docker-compose.yml" # relative to the lane's working copy
DB_SERVICE="postgres" # the compose service name running Postgres
# Machine-level credentials (PG_HOST/PORT/USER/PASS, REDIS_HOST/PORT) come
# from ~/.ccam/secrets.env, never from here — see server/lib/secrets.js.
+26
View File
@@ -493,6 +493,32 @@ try {
db.prepare("ALTER TABLE lanes ADD COLUMN detected_at TEXT").run();
}
// Migrate: per-lane runtime allocation. `slot` is the small integer every runtime
// fact derives from (ports now; database name and Redis index later) — the
// numbering Shipyard gets for free from its fixed lane1..lane9 directories and
// CCAM, keyed by cwd, has to allocate. `ports` is a JSON map name -> port,
// recording the number a lane ACTUALLY got: the preferred `base + slot` may be
// taken by something outside CCAM, so the allocator steps aside and the real
// number has to survive a restart or the next boot would move a live lane.
//
// Allocation is lazy — a lane that is only ever watched never takes a slot — so
// the column is nullable and the uniqueness constraint is partial. That index is
// the backstop under the lock in lane-slots.js, not a substitute for it.
// One probe per column, same reasoning as the detection columns above.
try {
db.prepare("SELECT slot FROM lanes LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE lanes ADD COLUMN slot INTEGER").run();
}
try {
db.prepare("SELECT ports FROM lanes LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE lanes ADD COLUMN ports TEXT NOT NULL DEFAULT '{}'").run();
}
db.prepare(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_slot ON lanes(slot) WHERE slot IS NOT NULL"
).run();
// Migrate: link agent rows to a workflow run. Workflow inner-agents are already
// ingested as subagents (same subagents/ dir); these columns add the grouping +
// phase that the run journal provides. Additive, safe on existing DBs.
+150
View File
@@ -0,0 +1,150 @@
/**
* @file Seed and repair a lane's `.env` file(s): copy the source repository's
* real `.env` into the lane on first boot (or on a `--force` refresh) and then
* rewrite the per-lane keys (`DATABASE_URL`, `REDIS_URL`, `UPLOAD_DIR`, …) so
* the file is correct ON ITS OWN — not merely masked by a hook's runtime
* exports. Ports Shipyard's `lane-env-seed.sh`.
*
* Two hard-won behaviours are preserved verbatim. A `--force` refresh keeps
* `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's OWN existing file:
* swapping in the source's secret would 401 a running lane's tokens until
* reboot. And a missing source `.env` falls back to `.env.example` with a
* loud warning rather than a silent, broken seed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { assertManaged } = require("./worktree");
const { splitList, parseEnvFile } = require("./lane-profile");
const { dataFacts } = require("./lane-slots");
/** Resolve `relative` against `root`, refusing anything that climbs out of
* it — same confinement rule `lane-runtime.js:makeLaneDirs` applies to
* `LANE_DIRS`, so a `.` file declared by a repository can only ever touch
* its own tree. */
function confine(root, relative, code) {
const target = path.resolve(root, relative);
const rel = path.relative(root, target);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
throw Object.assign(new Error(`path escapes ${root}: ${relative}`), { code });
}
return target;
}
/**
* Rewrite declared keys inside an `.env` file's text, preserving every other
* line byte-for-byte. A key not already present is appended, mirroring
* Shipyard's python rewriter.
*
* @param {string} text - The file's current contents.
* @param {Record<string,string>} want - Keys to set, already resolved to their final values.
* @returns {string}
*/
function rewriteEnvText(text, want) {
const seen = new Set();
const lines = text.split("\n").map((line) => {
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/.exec(line);
if (match && Object.hasOwn(want, match[1])) {
seen.add(match[1]);
return `${match[1]}=${want[match[1]]}`;
}
return line;
});
for (const [key, value] of Object.entries(want)) {
if (!seen.has(key)) lines.push(`${key}=${value}`);
}
return lines.join("\n");
}
/**
* Seed or repair a lane's declared `.env` file(s).
*
* A no-op when the profile declares no `ENV_FILES` — the feature is off by
* default, and an adopted repo that never opts in gets no `.env` writes at
* all. Refuses on an adopted lane (`assertManaged`): that file is the user's
* real working config, not a template CCAM may overwrite.
*
* @param {object} lane - Lane row; `slot` must already be allocated.
* @param {object} profile - Resolved profile.
* @param {Record<string,string>} secrets - From `secrets.js:readSecrets()`.
* @param {{force?: boolean}} [options] - `force` re-copies from source even when the file exists.
* @returns {{skipped: boolean, seeded?: Array<{file: string, fromExample: boolean}>}}
*/
function seedEnv(lane, profile, secrets, { force = false } = {}) {
assertManaged(lane);
const files = splitList(profile.env.ENV_FILES);
if (!files.length) return { skipped: true };
const sources = splitList(profile.env.ENV_SOURCE || profile.env.ENV_FILES);
const rewriteKeys = new Set(splitList(profile.env.ENV_REWRITE));
const preserveKeys = splitList(profile.env.ENV_PRESERVE);
const sourceRoot = lane.source_repo || lane.cwd;
const facts = dataFacts(lane, profile, secrets);
const computed = {
DATABASE_URL: facts.databaseUrl,
REDIS_URL: facts.redisUrl,
UPLOAD_DIR: facts.uploadDir,
};
const seeded = [];
for (let i = 0; i < files.length; i += 1) {
const relFile = files[i];
const relSource = sources[i] || relFile;
const targetPath = confine(lane.cwd, relFile, "EBADENVFILE");
const sourcePath = confine(sourceRoot, relSource, "EBADENVFILE");
const existed = fs.existsSync(targetPath);
const preserved = {};
if (existed && force && preserveKeys.length) {
try {
const current = parseEnvFile(fs.readFileSync(targetPath, "utf8"));
for (const key of preserveKeys) {
if (current[key] !== undefined) preserved[key] = current[key];
}
} catch {
/* an unreadable existing file has nothing worth preserving */
}
}
if (!existed || force) {
let content;
let fromExample = false;
if (fs.existsSync(sourcePath)) {
content = fs.readFileSync(sourcePath, "utf8");
} else if (fs.existsSync(`${sourcePath}.example`)) {
content = fs.readFileSync(`${sourcePath}.example`, "utf8");
fromExample = true;
console.warn(
`[lane-env] lane ${lane.id}: ${relSource} is missing — seeded ${relFile} from ` +
`${relSource}.example instead (no real secrets/keys)`
);
} else {
throw Object.assign(
new Error(`no ${relSource} (or ${relSource}.example) to seed ${relFile} from`),
{ code: "ENOENVSOURCE", relSource }
);
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, content);
seeded.push({ file: relFile, fromExample });
}
const want = {};
for (const [key, value] of Object.entries(computed)) {
if (value != null && rewriteKeys.has(key)) want[key] = value;
}
Object.assign(want, preserved);
if (Object.keys(want).length) {
fs.writeFileSync(targetPath, rewriteEnvText(fs.readFileSync(targetPath, "utf8"), want));
}
}
if (facts.uploadDir) fs.mkdirSync(facts.uploadDir, { recursive: true });
return { skipped: false, seeded };
}
module.exports = { seedEnv, rewriteEnvText };
+11
View File
@@ -10,6 +10,8 @@ const fs = require("node:fs");
const { db } = require("../db");
const wt = require("./worktree");
const lanesLib = require("./lanes");
const { resolveProfile } = require("./lane-profile");
const { dbName } = require("./lane-slots");
/**
* Preflight for reset, remove, or purge. Returns an object describing what will happen:
@@ -74,6 +76,14 @@ async function preflight(lane, action) {
}
}
// The database name this action would drop (reset unless --keep-db,
// remove always) — echoed the same way `head`/`dirty` are, so the
// confirmation dialog names the destructive fact rather than leaving it a
// surprise. Null when the lane has no slot yet or the profile declares no
// DB_PREFIX — nothing has been derived to drop.
const profile = resolveProfile(lane);
const database = profile && lane.slot ? dbName(profile, lane.slot) : null;
return {
action,
lane: lane.id,
@@ -83,6 +93,7 @@ async function preflight(lane, action) {
untracked,
unpushed,
head: head || null,
database,
blocked,
warnings,
};
+397
View File
@@ -0,0 +1,397 @@
/**
* @file The stack seam. A profile is a repository's own description of how to
* build, boot and check its stack: `<repo>/.ccam/profile/` holding a `profile.env`
* of declarations plus a `hooks/` directory of shell scripts. CCAM stays
* stack-agnostic and calls those hooks with a stable environment contract, which
* is the same contract Shipyard's `run_hook` exports so its profiles port over
* unchanged.
*
* Two rules this module exists to enforce. Config is PARSED, never sourced —
* sourcing arbitrary shell from a repository into the dashboard process would be
* a code-execution path; hooks are executed deliberately, config is only read.
* And a hook is always spawned as `bash <hook> <args…>` with a fixed argument
* array, never a command string.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { slotDirs, dataFacts } = require("./lane-slots");
/** Directory, relative to a repository root, holding its profile. */
const PROFILE_SUBDIR = path.join(".ccam", "profile");
/**
* Hook names CCAM will run, ever.
*
* A fixed allowlist rather than "whatever is in hooks/": `:name` arrives from an
* HTTP route, and a name taken from a request is a path taken from a request.
* `bootstrap`/`migrate`/`seed`/`ci-gate`/`e2e`/`regen` are not called by A1's
* lifecycle but are listed here because `POST /:id/hook/:name` can run them on a
* session's behalf, and the driving skill needs that surface stable.
*/
const HOOKS = Object.freeze([
"bootstrap",
"boot",
"health",
"migrate",
"seed",
"ci-gate",
"e2e",
"regen",
"db-create",
"db-drop",
]);
/**
* Defaults for every declaration a profile may omit, so a missing key degrades
* to something usable rather than breaking a lane. Mirrors the defaults block in
* Shipyard's `_common.sh` so a ported profile behaves identically.
*/
const DEFAULTS = Object.freeze({
PORTS: "api fe",
PORT_BASE_api: "8000",
PORT_BASE_fe: "3000",
LANE_DIRS: "",
BACKEND_DIR: "backend",
FRONTEND_DIR: "frontend",
API_PATH: "/api",
// A2 data isolation — every one of these empty/0 is "feature off", so an
// adopted repo that never declares them gets no per-lane database, no Redis
// index, and no .env rewriting: dead code never runs rather than running on
// guessed values.
DB_PREFIX: "",
DB_KIND: "",
DB_URL_SCHEME: "postgresql",
REDIS: "0",
ENV_FILES: "",
ENV_SOURCE: "",
ENV_REWRITE: "",
ENV_PRESERVE: "",
UPLOAD_SUBDIR: "",
});
/**
* Parse a `KEY=VALUE` declaration file.
*
* Not a shell parser and not trying to be: comments and blank lines are skipped,
* an `export ` prefix is tolerated (profiles ported from Shipyard have it), and
* one layer of matching quotes is stripped. Everything else is taken literally —
* `$(id)`, backticks and `${VAR}` stay as written. That literalness IS the
* security property; do not add expansion here.
*
* @param {string} text - File contents.
* @returns {Record<string, string>}
*/
function parseEnvFile(text) {
const out = {};
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
if (!match) continue;
let value = match[2].trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
out[match[1]] = value;
}
return out;
}
/** Split a space-separated declaration into a deduplicated list. */
function splitList(value) {
return [...new Set((value || "").split(/\s+/).filter(Boolean))];
}
/**
* Find and read a lane's profile.
*
* The lane's OWN working copy is searched first, its source repository second.
* The profile lives in the repository, so a worktree already carries the version
* belonging to its branch — and a branch that changes a boot command must boot
* with the command it changed, not the one on the base branch. The source-repo
* fallback covers a profile the user keeps gitignored, which never reaches a
* worktree through git.
*
* @param {object} lane - Lane row (`cwd`, `source_repo`).
* @returns {{dir: string, env: Record<string,string>, hooks: Set<string>, ports: string[], laneDirs: string[]}|null}
* null when neither location has a profile — a normal state, not a fault.
*/
function resolveProfile(lane) {
const candidates = [lane.cwd, lane.source_repo].filter(Boolean);
for (const root of candidates) {
const dir = path.join(root, PROFILE_SUBDIR);
if (!fs.existsSync(path.join(dir, "profile.env"))) continue;
let declared = {};
try {
declared = parseEnvFile(fs.readFileSync(path.join(dir, "profile.env"), "utf8"));
} catch {
continue; // unreadable profile is the same as no profile
}
const env = { ...DEFAULTS, ...declared };
const hooks = new Set();
for (const name of HOOKS) {
if (fs.existsSync(path.join(dir, "hooks", `${name}.sh`))) hooks.add(name);
}
return {
dir,
env,
hooks,
ports: splitList(env.PORTS),
laneDirs: splitList(env.LANE_DIRS),
};
}
return null;
}
/** The profile paths searched for a lane, for an ENOPROFILE message. */
function profileSearchPaths(lane) {
return [lane.cwd, lane.source_repo]
.filter(Boolean)
.map((root) => path.join(root, PROFILE_SUBDIR));
}
/**
* A `harness_spawn` shell function, injected into every hook.
*
* Same name and signature as Shipyard's so ported hooks work untouched:
* `harness_spawn <name> <workdir> <cmd…>` backgrounds a long-lived service and
* records its pid where downLane looks.
*
* The stdio detachment is not optional. A child that inherits the caller's stdout
* holds that pipe open, so a caller reading to EOF never returns — the failure
* that stalled Shipyard's boot stage until it was fixed the same way. `nohup` plus
* a closed stdin plus redirected output is what lets a lane's stack outlive both
* the hook and the dashboard.
*/
const HARNESS_SPAWN = `
harness_spawn() {
local name="$1" wd="$2"; shift 2
( cd "$wd" || exit 1
nohup "$@" >"$LOG_DIR/$name.log" 2>&1 </dev/null &
echo $! >"$RUN_DIR/$name.pid"
) </dev/null >/dev/null 2>&1
}
die() { echo "profile: $*" >&2; exit 1; }
`;
/**
* The environment contract every hook can rely on.
*
* Deliberately identical to Shipyard's `run_hook` exports where the concept
* survives the port, so a profile written for the harness runs here unchanged.
* `LANE` is the SLOT, not the lane id — Shipyard hooks use it to derive per-lane
* names, and the slot is what carries that meaning.
*
* Git variables are scrubbed for the same reason `worktree.js:git()` scrubs them:
* a hook that shells out to git must not inherit a git context pointing at the
* dashboard's own repository, and `GIT_CONFIG_*` can inject `core.hooksPath` into
* every git call the hook makes.
*
* `require("./secrets")` is deferred to the function body rather than hoisted
* to the top of the file: `secrets.js` itself requires this module for
* `parseEnvFile`, and a top-level require here would complete the cycle while
* this file's own `module.exports` is still empty, handing `secrets.js` an
* `undefined` parser. Deferring past module-load time breaks the cycle.
*/
function hookEnv(lane, profile) {
const { readSecrets } = require("./secrets");
const dirs = slotDirs(lane.slot);
const secrets = readSecrets();
const facts = dataFacts(lane, profile, secrets);
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
for (const name of Object.keys(env)) {
if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete env[name];
}
env.GIT_TERMINAL_PROMPT = "0";
Object.assign(env, profile.env, {
LANE: String(lane.slot),
LANE_ID: String(lane.id),
LANE_DIR: lane.cwd,
SOURCE_REPO: lane.source_repo || lane.cwd,
PROFILE_DIR: profile.dir,
RUN_DIR: dirs.runDir,
LOG_DIR: dirs.logDir,
});
// A2 data-isolation facts, present only when their owning declaration is —
// a profile with no DB_PREFIX sees no DB_NAME/DATABASE_URL at all, so a
// db-create.sh hook that forgot to check DB_PREFIX fails loudly (unset var
// under `set -u`) instead of quietly touching a database named "undefined".
if (facts.dbName) {
env.DB_NAME = facts.dbName;
env.DATABASE_URL = facts.databaseUrl;
env.TEST_DATABASE_URL = facts.testDatabaseUrl;
// Raw connection settings, not just the assembled URL: Shipyard's own
// db-create/db-drop hooks call `createdb -U "$PG_USER"` directly (trust
// auth inside the compose network, no password needed), and a ported
// profile expects these names verbatim. PG_PASS is deliberately withheld —
// nothing in the ported hooks needs it, and every value that DOES reach a
// hook's environment is a value that could end up in an echoed debug line.
env.PG_HOST = secrets.PG_HOST;
env.PG_PORT = secrets.PG_PORT;
env.PG_USER = secrets.PG_USER;
}
if (facts.redisUrl) {
env.REDIS_URL = facts.redisUrl;
env.REDIS_HOST = secrets.REDIS_HOST;
env.REDIS_PORT = secrets.REDIS_PORT;
}
if (facts.uploadDir) env.UPLOAD_DIR = facts.uploadDir;
// <NAME>_PORT for every declared port, upper-cased: PORTS="api fe" -> API_PORT, FE_PORT.
for (const [name, port] of Object.entries(lane.ports || {})) {
env[`${name.toUpperCase()}_PORT`] = String(port);
}
return env;
}
/**
* Run one of the profile's hooks.
*
* Spawned through a one-line bash wrapper that defines the shell helpers,
* `export -f`s them (exactly as Shipyard's `run_hook` does) and then `exec`s the
* hook, so the hook runs as its own script with its own `set -e` while still
* seeing `harness_spawn`. The hook path and its arguments travel as an argument
* ARRAY appended after the wrapper — never interpolated into the script text, so
* a lane directory containing a quote or a space is a path, not a command.
*
* Output is streamed line by line to `onLine` (the caller broadcasts it) and
* appended to `$LOG_DIR/<name>.log`, so a boot is watchable live and readable
* afterwards. Any value that came from `secrets.js` (currently `PG_PASS`, and
* therefore the password segment of `DATABASE_URL`/`TEST_DATABASE_URL`) is
* redacted from that stream first: `runHook`'s output reaches a browser tab
* over the `lane_hook_output` websocket, and a hook that echoes its own
* environment (common while debugging a failing migration) must not publish a
* database password to everyone watching.
*
* @param {object} lane - Lane row with an allocated slot and resolved ports.
* @param {object} profile - From resolveProfile().
* @param {string} name - Hook name; must be in HOOKS.
* @param {string[]} [args] - Extra arguments passed to the hook.
* @param {{onLine?: (line: string, stream: "stdout"|"stderr") => void, timeoutMs?: number}} [options]
* @returns {Promise<{code: number, output: string}>} Resolves even on a non-zero exit.
*/
function runHook(lane, profile, name, args = [], options = {}) {
if (!HOOKS.includes(name)) {
return Promise.reject(
Object.assign(new Error(`unknown hook: ${name}`), { code: "ENOHOOK", hook: name })
);
}
const hookPath = path.join(profile.dir, "hooks", `${name}.sh`);
if (!fs.existsSync(hookPath)) {
return Promise.reject(
Object.assign(new Error(`profile has no hook "${name}.sh"`), {
code: "ENOHOOK",
hook: name,
path: hookPath,
})
);
}
const dirs = slotDirs(lane.slot);
fs.mkdirSync(dirs.runDir, { recursive: true });
fs.mkdirSync(dirs.logDir, { recursive: true });
const logPath = path.join(dirs.logDir, `${name}.log`);
const logStream = fs.createWriteStream(logPath, { flags: "a" });
// Deferred for the same reason as inside hookEnv(): secrets.js requires this
// module, so a top-level require here would complete the load cycle early.
// Only the password is redacted — host/port/user are not secret on their
// own, and treating them as such would mangle unrelated numbers in output.
// Both forms: DATABASE_URL embeds the URL-encoded password, so a raw echo
// of the password and an echo of DATABASE_URL need separate substrings.
const pgPass = require("./secrets").readSecrets().PG_PASS;
const secretValues = [pgPass, pgPass && encodeURIComponent(pgPass)].filter(Boolean);
const redact = (text) => secretValues.reduce((s, v) => s.split(v).join("[REDACTED]"), text);
return new Promise((resolve, reject) => {
const child = spawn(
"bash",
[
"-c",
`${HARNESS_SPAWN}\nexport -f harness_spawn die\nexec bash "$@"`,
"bash",
hookPath,
...args.map(String),
],
{
cwd: lane.cwd,
env: hookEnv(lane, profile),
stdio: ["ignore", "pipe", "pipe"],
}
);
let output = "";
let settled = false;
const timer = options.timeoutMs
? setTimeout(() => {
child.kill("SIGKILL");
}, options.timeoutMs)
: null;
const consume = (stream, which) => {
let buffer = "";
stream.setEncoding("utf8");
stream.on("data", (raw) => {
const chunk = secretValues.length ? redact(raw) : raw;
output += chunk;
logStream.write(chunk);
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) options.onLine?.(line, which);
});
stream.on("end", () => {
if (buffer) options.onLine?.(buffer, which);
});
};
consume(child.stdout, "stdout");
consume(child.stderr, "stderr");
const finish = (fn, value) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
logStream.end();
fn(value);
};
child.on("error", (err) => finish(reject, err));
child.on("close", (code) => finish(resolve, { code: code ?? 1, output, logPath }));
});
}
module.exports = {
HOOKS,
DEFAULTS,
PROFILE_SUBDIR,
parseEnvFile,
splitList,
resolveProfile,
profileSearchPaths,
hookEnv,
runHook,
};
+532
View File
@@ -0,0 +1,532 @@
/**
* @file Lane stack lifecycle: bring a lane's services up through its profile's
* hooks, take them down, and report what is actually running. Also owns the
* data-isolation lifecycle (A2): `provisionLane`/`resetLaneData`/`removeLaneData`
* seed `.env`, create/drop the lane's own database, and run `bootstrap`/
* `migrate`/`seed` at the points Shipyard's `lane-bootstrap.sh`/`lane-up.sh`/
* `lane-reset.sh`/`lane-remove.sh` do — CCAM's version of "ports came from A1,
* everything else a lane needs to run its own stack comes from here."
*
* Two properties shape everything here.
*
* Services are FULLY DETACHED (see `harness_spawn` in lane-profile.js), so a
* lane's stack outlives both the hook that started it and the dashboard itself —
* restarting or updating CCAM must never kill work in progress.
*
* Liveness is COMPUTED, never stored. Whether a stack is up is not a fact CCAM
* controls: a process dies to OOM, to a stray `kill`, to a reboot. A cached
* "running" flag would be wrong from that moment until something noticed, so
* `runtimeFacts` re-derives it from pid files and port probes on every read. The
* payoff is that adopting a stack after a dashboard restart needs no code at all.
*
* This module writes `slot` and `ports` and nothing else on the lane row. It never
* writes `stage`, `status` or `notes`: in CCAM those describe the AGENT's work, not
* the stack's state, and conflating "a server is listening" with "an agent is
* running" would corrupt lane liveness. Boot failures live in `last-error.json`
* and surface through `runtimeFacts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const lanesLib = require("./lanes");
const {
allocateSlot,
resolvePorts,
slotDirs,
portsSteppedAside,
portBase,
dbName,
} = require("./lane-slots");
const { resolveProfile, profileSearchPaths, runHook } = require("./lane-profile");
const { isListening, listenerPids } = require("./ports");
const { seedEnv } = require("./lane-env");
const { ensureDatabase, dropDatabase } = require("./lane-services");
const { readSecrets } = require("./secrets");
const execFileAsync = promisify(execFile);
/** A health hook that never returns is a failed boot, not an eternal wait. */
const HEALTH_TIMEOUT_MS = Number(process.env.LANE_HEALTH_TIMEOUT_MS) || 180_000;
const BOOT_TIMEOUT_MS = Number(process.env.LANE_BOOT_TIMEOUT_MS) || 900_000;
/** Resolve a lane's profile or throw the error a route turns into a 400. */
function requireProfile(lane) {
const profile = resolveProfile(lane);
if (!profile) {
throw Object.assign(
new Error(`lane has no .ccam/profile — looked in: ${profileSearchPaths(lane).join(", ")}`),
{ code: "ENOPROFILE", searched: profileSearchPaths(lane) }
);
}
return profile;
}
/** Direct children of a pid. Empty when pgrep is unavailable — the parent still dies. */
async function childPids(pid) {
try {
const { stdout } = await execFileAsync("pgrep", ["-P", String(pid)]);
return stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => /^\d+$/.test(line))
.map(Number);
} catch {
return [];
}
}
/**
* Kill a process and every descendant, deepest first.
*
* Bottom-up matters: killing the parent first reparents its children to init,
* where nothing knows to look for them. This is the failure `lane-down.sh`'s
* `kill_tree` was written for — a uvicorn reloader or a celery prefork pool whose
* workers survived a kill aimed at the recorded pid, kept the port bound, and made
* the next boot fail for a reason nothing reported.
*
* @param {number} pid - Root of the tree.
* @returns {Promise<number[]>} Pids signalled.
*/
async function killTree(pid) {
const killed = [];
for (const child of await childPids(pid)) {
killed.push(...(await killTree(child)));
}
try {
process.kill(pid, "SIGKILL");
killed.push(pid);
} catch {
/* already gone */
}
return killed;
}
/** True when a pid exists and we may signal it. */
function isAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means it exists but belongs to someone else — still alive.
return err.code === "EPERM";
}
}
/** Recorded services: every `<name>.pid` the boot hook left in the run directory. */
function readPidFiles(runDir) {
let entries = [];
try {
entries = fs.readdirSync(runDir);
} catch {
return [];
}
const out = [];
for (const entry of entries) {
if (!entry.endsWith(".pid")) continue;
const raw = fs.readFileSync(path.join(runDir, entry), "utf8").trim();
if (!/^\d+$/.test(raw)) continue;
out.push({ name: entry.slice(0, -4), pid: Number(raw), file: path.join(runDir, entry) });
}
return out;
}
/** Persist why a boot failed, where runtimeFacts can find it. */
function recordError(lane, error) {
const { stateDir, errorFile } = slotDirs(lane.slot);
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
errorFile,
JSON.stringify(
{ at: new Date().toISOString(), code: error.code || null, message: error.message },
null,
2
)
);
}
/** Drop a stale failure once a boot succeeds. */
function clearError(lane) {
try {
fs.rmSync(slotDirs(lane.slot).errorFile, { force: true });
} catch {
/* nothing recorded */
}
}
function readError(slot) {
try {
return JSON.parse(fs.readFileSync(slotDirs(slot).errorFile, "utf8"));
} catch {
return null;
}
}
/**
* Resolve one `LANE_DIRS` entry against the lane's working copy, refusing
* anything that escapes it (an absolute path, or one climbing out with `..`)
* rather than silently touching a directory somewhere else on the machine.
*/
function resolveLaneDirEntry(lane, entry) {
const target = path.resolve(lane.cwd, entry);
const relative = path.relative(lane.cwd, target);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw Object.assign(new Error(`LANE_DIRS entry escapes the lane: ${entry}`), {
code: "EBADLANEDIR",
entry,
});
}
return target;
}
/** Create the per-lane directories the profile declares. */
function makeLaneDirs(lane, profile) {
for (const entry of profile.laneDirs) {
fs.mkdirSync(resolveLaneDirEntry(lane, entry), { recursive: true });
}
}
/**
* Empty and recreate the per-lane directories a `reset` should start fresh —
* Shipyard's `rm -rf "$(lane_upload_dir "$N")"/*` generalized to every
* declared `LANE_DIRS` entry, since a fresh feature should not inherit a
* previous one's uploaded files.
*/
function clearLaneDirs(lane, profile) {
for (const entry of profile.laneDirs) {
const target = resolveLaneDirEntry(lane, entry);
fs.rmSync(target, { recursive: true, force: true });
fs.mkdirSync(target, { recursive: true });
}
}
/**
* Run a hook only when the profile declares it, throwing `errCode` on a
* non-zero exit. Shared by every lifecycle step below (`bootstrap`, `migrate`,
* `seed`) so "the profile never declared this" and "the hook failed" stay two
* distinct, consistently-coded outcomes everywhere they're checked.
*/
async function runRequiredHook(lane, profile, name, options, errCode) {
if (!profile.hooks.has(name)) return null;
const result = await runHook(lane, profile, name, [], options);
if (result.code !== 0) {
throw Object.assign(new Error(`${name} hook exited ${result.code}`), {
code: errCode,
logPath: result.logPath,
});
}
return result;
}
/**
* Provision a freshly-created managed lane's data isolation: seed its `.env`,
* run `bootstrap`/`migrate`/`seed`, and create its database. Runs once, right
* after the worktree itself exists — the other half of isolation A1 did not
* cover (ports and directories came from A1; database, `.env` and uploads
* come from here). Every step degrades to nothing when the profile never
* declares the underlying feature: no `bootstrap` hook, no `DB_PREFIX`, no
* `ENV_FILES` are all normal, not partial failures.
*
* @param {object} lane - Lane row, freshly worktree-provisioned (kind "managed").
* @param {{onLine?: Function}} [options]
* @returns {Promise<object>} The lane row after provisioning.
*/
async function provisionLane(lane, options = {}) {
const profile = requireProfile(lane);
const { onLine } = options;
let current = lane;
if (!current.slot) {
allocateSlot(current.id);
current = lanesLib.getLane(current.id);
}
seedEnv(current, profile, readSecrets());
await runRequiredHook(
current,
profile,
"bootstrap",
{ onLine, timeoutMs: BOOT_TIMEOUT_MS },
"EBOOTSTRAPFAILED"
);
await ensureDatabase(current, profile, { onLine });
await runRequiredHook(current, profile, "migrate", { onLine }, "EMIGRATEFAILED");
await runRequiredHook(current, profile, "seed", { onLine }, "ESEEDFAILED");
return lanesLib.getLane(current.id);
}
/**
* Reset a lane's data isolation after `resetWorktree` has already put its
* working copy back on the base branch: refresh `.env` (a `--force` refresh,
* so it tracks whatever the base branch's source `.env` now says), re-run
* `bootstrap` (a reset can land on a branch with different dependencies —
* Shipyard's own comment: "deps move under node_modules/venv"), clear the
* declared `LANE_DIRS`, and — unless `keepDb` — drop, recreate, migrate and
* reseed the database. `keepDb` skips that whole block, not just the drop:
* a caller who wants to keep their data wants it left alone, migrations
* included.
*
* @param {object} lane - Lane row, after `resetWorktree`.
* @param {object} profile - Resolved profile.
* @param {{keepDb?: boolean, onLine?: Function}} [options]
*/
async function resetLaneData(lane, profile, options = {}) {
const { keepDb = false, onLine } = options;
seedEnv(lane, profile, readSecrets(), { force: true });
await runRequiredHook(
lane,
profile,
"bootstrap",
{ onLine, timeoutMs: BOOT_TIMEOUT_MS },
"EBOOTSTRAPFAILED"
);
clearLaneDirs(lane, profile);
if (keepDb) return;
if (!dbName(profile, lane.slot)) return;
await dropDatabase(lane, profile, dbName(profile, lane.slot), { onLine });
await ensureDatabase(lane, profile, { onLine });
await runRequiredHook(lane, profile, "migrate", { onLine }, "EMIGRATEFAILED");
await runRequiredHook(lane, profile, "seed", { onLine }, "ESEEDFAILED");
}
/**
* Drop a managed lane's database and its `_test` sibling before the rest of
* removal tears down the worktree and state directory.
*
* A no-op for an adopted lane — its data was never CCAM's to create, so it is
* never CCAM's to destroy either, the same invariant `assertDestroyable`
* enforces for the worktree itself. Best-effort per database: mirrors
* Shipyard's `dropdb --if-exists ... || true` — a failed drop is logged, not
* thrown, because it must never block removing the lane's dashboard record.
*
* @param {object} lane - Lane row.
* @param {object} profile - Resolved profile.
* @param {{onLine?: Function}} [options]
*/
async function removeLaneData(lane, profile, options = {}) {
if (lane.kind !== "managed") return;
const name = dbName(profile, lane.slot);
if (!name) return;
for (const target of [name, `${name}_test`]) {
try {
await dropDatabase(lane, profile, target, options);
} catch (err) {
console.warn(`[lane-runtime] lane ${lane.id}: db-drop ${target} failed: ${err.message}`);
}
}
}
/**
* Boot a lane's stack.
*
* Caller MUST hold the lane lock: slot allocation reads-then-writes across the
* `await` in port resolution, and two concurrent ups would otherwise both believe
* they own the slot.
*
* Runs `boot` then `health`; deliberately NOT `bootstrap`, which belongs to
* provisioning and reset (Shipyard's `lane-up.sh` draws the same line — installing
* dependencies on every boot would make a routine restart minutes long).
*
* A failing health check leaves the processes running. They are the evidence: the
* logs of a half-booted stack are what tells the user which service never came up,
* and killing them to report a tidy failure destroys exactly that.
*
* @param {object} lane - Lane row.
* @param {{build?: boolean, onLine?: Function}} [options]
* @returns {Promise<object>} Runtime facts after the attempt.
*/
async function upLane(lane, options = {}) {
const profile = requireProfile(lane);
const { build = true, onLine } = options;
let current = lane;
if (!current.slot) {
allocateSlot(current.id);
current = lanesLib.getLane(current.id);
}
const ports = await resolvePorts(current, profile);
lanesLib.setProvisioningFacts(current.id, { ports });
current = lanesLib.getLane(current.id);
const dirs = slotDirs(current.slot);
fs.mkdirSync(dirs.runDir, { recursive: true });
fs.mkdirSync(dirs.logDir, { recursive: true });
makeLaneDirs(current, profile);
// Defensive: a re-run on a live lane must not double-start services that then
// fight over the same ports.
await downLane(current, { profile });
try {
// Repair .env on every boot (a lane whose file was hand-edited or never
// seeded gets fixed here), then ensure the database exists — cheap when it
// already does — migrate on every boot (schemas drift while a lane sits
// idle), and seed only when this boot is the one that created the database.
if (current.kind === "managed") seedEnv(current, profile, readSecrets());
const db = await ensureDatabase(current, profile, { onLine });
await runRequiredHook(current, profile, "migrate", { onLine }, "EMIGRATEFAILED");
if (db.created) await runRequiredHook(current, profile, "seed", { onLine }, "ESEEDFAILED");
const boot = await runHook(current, profile, "boot", build ? [] : ["--no-build"], {
onLine,
timeoutMs: BOOT_TIMEOUT_MS,
});
if (boot.code !== 0) {
throw Object.assign(new Error(`boot hook exited ${boot.code}`), {
code: "EBOOTFAILED",
logPath: boot.logPath,
});
}
if (profile.hooks.has("health")) {
const health = await runHook(current, profile, "health", [], {
onLine,
timeoutMs: HEALTH_TIMEOUT_MS,
});
if (health.code !== 0) {
throw Object.assign(new Error(`health check failed (exit ${health.code})`), {
code: "EUNHEALTHY",
logPath: health.logPath,
});
}
}
clearError(current);
} catch (err) {
recordError(current, err);
throw err;
}
return runtimeFacts(lanesLib.getLane(current.id));
}
/**
* Stop a lane's stack. Idempotent, and safe on a lane that was never up.
*
* Kills each recorded pid tree, then — only when there WAS something recorded —
* sweeps any listener still holding the lane's ports. That condition is a
* deliberate departure from Shipyard, which always sweeps: a lane whose stack is
* already down still owns its port numbers, and if the user has since started
* their own server on one, an unconditional sweep would kill it. Requiring a pid
* file keeps the backstop for the case it exists to cover — a detached child that
* outlived the parent we recorded — without ever reaching a stranger's process.
*
* @param {object} lane - Lane row.
* @param {{profile?: object}} [options]
* @returns {Promise<{killed: number[]}>}
*/
async function downLane(lane, options = {}) {
if (!lane.slot) return { killed: [] };
const { runDir } = slotDirs(lane.slot);
const recorded = readPidFiles(runDir);
const killed = [];
for (const service of recorded) {
killed.push(...(await killTree(service.pid)));
fs.rmSync(service.file, { force: true });
}
if (recorded.length) {
for (const port of Object.values(lane.ports || {})) {
for (const pid of await listenerPids(port)) {
if (!killed.includes(pid)) killed.push(...(await killTree(pid)));
}
}
}
return { killed };
}
/**
* What is actually running for this lane, computed fresh on every call.
*
* Follows the contract of `GET /api/lanes/:id/git`: a lane with no profile is a
* normal state, reported as `{available: false}` rather than an error. Callers
* probe ports and stat pid files here, which is why this is its own endpoint and
* not part of the polled lane list.
*
* @param {object} lane - Lane row.
* @returns {Promise<object>}
*/
async function runtimeFacts(lane) {
const profile = resolveProfile(lane);
if (!profile) return { available: false, searched: profileSearchPaths(lane) };
if (!lane.slot) {
return { available: true, provisioned: false, hooks: [...profile.hooks], ports: {} };
}
const dirs = slotDirs(lane.slot);
const services = readPidFiles(dirs.runDir).map((service) => ({
name: service.name,
pid: service.pid,
alive: isAlive(service.pid),
}));
const ports = {};
for (const name of profile.ports) {
const port = lane.ports?.[name] ?? null;
ports[name] = {
port,
expected: portBase(profile, name) + lane.slot,
listening: port ? await isListening(port) : false,
};
}
let logs = [];
try {
logs = fs.readdirSync(dirs.logDir).filter((entry) => entry.endsWith(".log"));
} catch {
/* never booted */
}
// Names and an index, never a connection string: DATABASE_URL/REDIS_URL embed
// the secrets.env password, and this object is exactly what GET /runtime
// returns to a browser tab.
const name = dbName(profile, lane.slot);
const database = name ? { name, testName: `${name}_test` } : null;
const redisIndex = profile.env.REDIS === "1" ? lane.slot : null;
return {
available: true,
provisioned: true,
slot: lane.slot,
kind: lane.kind,
hooks: [...profile.hooks],
profileDir: profile.dir,
services,
ports,
database,
redisIndex,
steppedAside: portsSteppedAside(lane, profile),
up: services.some((service) => service.alive),
healthy:
Object.values(ports).length > 0 && Object.values(ports).every((entry) => entry.listening),
logs,
logDir: dirs.logDir,
lastError: readError(lane.slot),
};
}
module.exports = {
upLane,
downLane,
runtimeFacts,
requireProfile,
provisionLane,
resetLaneData,
removeLaneData,
killTree,
isAlive,
readPidFiles,
makeLaneDirs,
clearLaneDirs,
HEALTH_TIMEOUT_MS,
BOOT_TIMEOUT_MS,
};
+98
View File
@@ -0,0 +1,98 @@
/**
* @file A lane's own database: create it once at provisioning and boot, drop
* it on remove. CCAM stays stack-agnostic here — `createdb` vs `mysqladmin
* create` vs `touch foo.db` genuinely differ, so the actual command lives in
* the profile's `db-create.sh`/`db-drop.sh` hooks (already in the A1
* allowlist); this module only decides WHEN to call them and guards WHICH
* name a drop may ever target.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { assertManaged } = require("./worktree");
const { dbName, slotDirs } = require("./lane-slots");
const { runHook } = require("./lane-profile");
/**
* Where CCAM records that a slot's database has already been created.
*
* A hook can't be trusted to know this on its own without being stack-aware
* (a plain `createdb` errors on a database that already exists; `touch`
* wouldn't), so CCAM tracks it itself with one flag file per database name,
* beside the rest of a slot's runtime bookkeeping. This is also how `upLane`
* tells "freshly created" from "already existed" to decide whether to seed.
*/
function markerPath(slot, name) {
return path.join(slotDirs(slot).stateDir, `db-created-${name}`);
}
/**
* Ensure a lane's database exists, creating it via the profile's `db-create`
* hook the first time only.
*
* A no-op when the profile declares no `DB_PREFIX` — no name is derived, so
* no hook is ever called and nothing is created.
*
* @param {object} lane - Lane row; `slot` must already be allocated.
* @param {object} profile - Resolved profile.
* @param {{onLine?: Function, timeoutMs?: number}} [options]
* @returns {Promise<{name: string|null, created: boolean}>}
*/
async function ensureDatabase(lane, profile, options = {}) {
const name = dbName(profile, lane.slot);
if (!name) return { name: null, created: false };
const marker = markerPath(lane.slot, name);
if (fs.existsSync(marker)) return { name, created: false };
const result = await runHook(lane, profile, "db-create", [name], options);
if (result.code !== 0) {
throw Object.assign(new Error(`db-create hook exited ${result.code}`), {
code: "EDBCREATE",
logPath: result.logPath,
});
}
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(marker, new Date().toISOString());
return { name, created: true };
}
/**
* Drop a database via the profile's `db-drop` hook.
*
* Two checks run before anything is spawned, and neither trusts the caller:
* `assertManaged` (an adopted lane's data is the user's own, never CCAM's to
* destroy) and a name check against what THIS lane's slot actually derives —
* the lane's own database or its `_test` sibling, and nothing else. Only a
* name CCAM itself computed can ever reach the hook.
*
* @param {object} lane - Lane row; `slot` must already be allocated.
* @param {object} profile - Resolved profile.
* @param {string} name - The database to drop; must be `dbName` or `${dbName}_test`.
* @param {{onLine?: Function, timeoutMs?: number}} [options]
* @returns {Promise<{name: string}>}
*/
async function dropDatabase(lane, profile, name, options = {}) {
assertManaged(lane);
const derived = dbName(profile, lane.slot);
const allowed = derived && (name === derived || name === `${derived}_test`);
if (!allowed) {
throw Object.assign(new Error(`refusing to drop undeclared database: ${name}`), {
code: "EBADDBNAME",
});
}
const result = await runHook(lane, profile, "db-drop", [name], options);
if (result.code !== 0) {
throw Object.assign(new Error(`db-drop hook exited ${result.code}`), {
code: "EDBDROP",
logPath: result.logPath,
});
}
fs.rmSync(markerPath(lane.slot, name), { force: true });
return { name };
}
module.exports = { ensureDatabase, dropDatabase, markerPath };
+245
View File
@@ -0,0 +1,245 @@
/**
* @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_<name> + 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ĩ <vinnt@smartgift.vn>
*/
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_<name> + 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<Record<string, number>>} 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<string,string>} 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,
};
+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,
};
+116
View File
@@ -0,0 +1,116 @@
/**
* @file TCP port probing for lane runtime allocation. Two questions the runtime
* layer needs answered about a local port: is anything listening on it, and if so
* which processes. `isListening` decides whether a lane's preferred port is free
* and whether its stack actually came up; `listenerPids` names the occupier in an
* EPORTBUSY error and backs up `downLane`'s pid-tree kill when a detached child
* outlives its recorded parent.
*
* Deliberately has no opinion about lanes — it takes a port number and returns a
* fact, so it can be tested against a throwaway server with no database.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const net = require("node:net");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
/** How long to wait for a connect before calling the port free. */
const PROBE_TIMEOUT_MS = Number(process.env.LANE_PORT_PROBE_MS) || 300;
/**
* Is something accepting TCP connections on this port?
*
* Connect-based rather than bind-based on purpose: binding to test a port races
* with the thing we are about to start (we would have to release the socket
* before the hook binds it, and a sibling lane could take it in between), and on
* some platforms a successful bind says nothing about a listener already held by
* another user. A refused connection is unambiguous — nobody is serving there.
*
* A timeout counts as "listening": a port that accepts the TCP handshake but
* never responds is occupied, and treating it as free would hand a lane a port it
* cannot bind.
*
* @param {number} port - TCP port on the loopback interface.
* @returns {Promise<boolean>}
*/
function isListening(port) {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const done = (result) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(result);
};
socket.setTimeout(PROBE_TIMEOUT_MS);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(true));
socket.once("error", () => done(false));
socket.connect(port, "127.0.0.1");
});
}
/**
* Warned once per process when neither `lsof` nor `ss` exists, so a container or
* a minimal image does not print the same line on every probe.
*/
let warnedNoTool = false;
/** Parse a newline-separated list of pids, dropping anything non-numeric. */
function parsePidList(stdout) {
return [
...new Set(
stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => /^\d+$/.test(line))
.map(Number)
),
];
}
/**
* Which processes are listening on this port.
*
* Best-effort by design: this only ever enriches an error message or adds a
* backstop to a kill that has already been attempted through the recorded pid
* files. A missing tool must never fail a lane operation, so every failure path
* returns an empty array rather than throwing.
*
* `lsof` first (present on macOS and most Linux installs), then `ss` from
* iproute2 (present on minimal Linux images where lsof is not).
*
* @param {number} port - TCP port on the loopback interface.
* @returns {Promise<number[]>} Listening pids, or [] when unknown.
*/
async function listenerPids(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
return parsePidList(stdout);
} catch (err) {
// lsof exits 1 when nothing matches — that is an answer, not a missing tool.
if (err.code === 1) return [];
}
try {
const { stdout } = await execFileAsync("ss", ["-lptnH", `sport = :${port}`]);
return [...new Set([...stdout.matchAll(/pid=(\d+)/g)].map((m) => Number(m[1])))];
} catch {
/* fall through to the warning */
}
if (!warnedNoTool) {
warnedNoTool = true;
console.warn(
"[ports] neither lsof nor ss is available — port occupants cannot be named, " +
"and lane down falls back to the recorded pid files alone"
);
}
return [];
}
module.exports = { isListening, listenerPids, PROBE_TIMEOUT_MS };
+79
View File
@@ -0,0 +1,79 @@
/**
* @file Machine-level lane secrets: the database and Redis connection settings
* shared by every lane on this host. Deliberately NOT part of a repository's
* `.ccam/profile/` — a profile is committed and read by anyone who clones the
* repo, and a database password does not belong there. Lives instead at
* `~/.ccam/secrets.env`, parsed with the same literal `KEY=VALUE` reader
* `lane-profile.js` uses for `profile.env` (config is parsed, never sourced).
*
* Never returned by any route: `GET /runtime` may report which keys are
* present, never their values.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { parseEnvFile } = require("./lane-profile");
const SECRETS_PATH =
process.env.CCAM_SECRETS_PATH || path.join(os.homedir(), ".ccam", "secrets.env");
/** Every declaration a lane's database/Redis facts can rely on when the file
* is absent or unreadable — a local default stack, not a guess. */
const DEFAULTS = Object.freeze({
PG_HOST: "127.0.0.1",
PG_PORT: "5432",
PG_USER: "postgres",
PG_PASS: "postgres",
REDIS_HOST: "127.0.0.1",
REDIS_PORT: "6379",
});
let warnedMissing = false;
let warnedPerms = false;
/**
* Read `~/.ccam/secrets.env`, merged over DEFAULTS.
*
* Never throws: a missing file warns once and falls back to DEFAULTS (a lane
* with no secrets file still gets a usable local Postgres/Redis target), and a
* file readable by group or world is refused outright rather than trusted —
* loading it would make CCAM the thing that taught a shared machine's other
* users the database password.
*
* @returns {Record<string,string>}
*/
function readSecrets() {
if (!fs.existsSync(SECRETS_PATH)) {
if (!warnedMissing) {
warnedMissing = true;
console.warn(
`[secrets] no ${SECRETS_PATH} — per-lane databases use built-in defaults ` +
`(${DEFAULTS.PG_HOST}:${DEFAULTS.PG_PORT})`
);
}
return { ...DEFAULTS };
}
const mode = fs.statSync(SECRETS_PATH).mode & 0o777;
if (mode & 0o077) {
if (!warnedPerms) {
warnedPerms = true;
console.warn(
`[secrets] ${SECRETS_PATH} is readable by group or world (mode ${mode.toString(8)}) ` +
`— refusing to load it. Fix with: chmod 600 ${SECRETS_PATH}`
);
}
return { ...DEFAULTS };
}
try {
return { ...DEFAULTS, ...parseEnvFile(fs.readFileSync(SECRETS_PATH, "utf8")) };
} catch {
return { ...DEFAULTS }; // unreadable file is the same as no file
}
}
module.exports = { SECRETS_PATH, DEFAULTS, readSecrets };
+144
View File
@@ -280,6 +280,150 @@ const paths = {
},
},
},
"/api/lanes/{id}/runtime": {
get: {
tags: ["Lanes"],
summary: "A lane's own application stack",
description:
"Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .ccam/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.",
operationId: "getLaneRuntime",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: {
description:
"available:false (no profile); available:true with provisioned:false (no slot yet); or the full facts with slot, ports, services, up, healthy, steppedAside, logs and lastError.",
},
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/up": {
post: {
tags: ["Lanes"],
summary: "Boot a lane's stack",
description:
"Runs the profile's boot then health hooks. Returns 202 and finishes in the background because a build can take minutes; progress streams as lane_hook_output and the attempt ends with a lane_runtime message. Does NOT run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running, because their logs are what identify the service that never came up. Writes only slot and ports on the lane row: never stage, status or notes.",
operationId: "upLane",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: false,
content: {
"application/json": {
schema: {
type: "object",
properties: {
build: {
type: "boolean",
default: true,
description:
"false passes --no-build to the boot hook, reusing an existing build.",
},
},
},
},
},
},
responses: {
202: { description: "Accepted; the boot runs in the background." },
400: { description: "ENOPROFILE — no .ccam/profile, with the paths searched." },
403: { description: "The browser request was not same-origin/loopback." },
404: { description: "Lane not found." },
409: { description: "ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids)." },
},
},
},
"/api/lanes/{id}/down": {
post: {
tags: ["Lanes"],
summary: "Stop a lane's stack",
description:
"Kills each recorded pid tree bottom-up (killing a parent first reparents its children to init, where nothing knows to look for them), then sweeps listeners on the lane's ports ONLY when a pid file existed — a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server the user started there. Idempotent, and a no-op for a lane that was never up.",
operationId: "downLane",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "{ok, killed: number[], runtime}." },
403: { description: "The browser request was not same-origin/loopback." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/hook/{name}": {
post: {
tags: ["Lanes"],
summary: "Run one of the lane profile's hooks",
description:
"The surface a driving session uses for ci-gate, e2e, migrate and friends. Returns 202; output streams as lane_hook_output and completion arrives as lane_hook_result with the exit code. The name is checked against a fixed allowlist BEFORE anything is spawned, and args travels as an array of strings straight into argv — neither is ever joined into a command string.",
operationId: "runLaneHook",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{
name: "name",
in: "path",
required: true,
schema: {
type: "string",
enum: [
"bootstrap",
"boot",
"health",
"migrate",
"seed",
"ci-gate",
"e2e",
"regen",
"db-create",
"db-drop",
],
},
},
],
requestBody: {
required: false,
content: {
"application/json": {
schema: {
type: "object",
properties: { args: { type: "array", items: { type: "string" } } },
},
},
},
},
responses: {
202: { description: "Accepted; the hook runs in the background." },
400: { description: "ENOHOOK (name outside the allowlist) or ENOPROFILE." },
403: { description: "The browser request was not same-origin/loopback." },
404: { description: "Lane not found." },
409: { description: "ENOSLOT — the lane has no runtime yet; bring it up first." },
},
},
},
"/api/lanes/{id}/logs/{svc}": {
get: {
tags: ["Lanes"],
summary: "Tail a lane's hook or service log",
description:
"The resolved path is confined to the lane's log directory after realpath, so a name from the request can never escape it. Read-only, so no same-origin guard.",
operationId: "getLaneLog",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "svc", in: "path", required: true, schema: { type: "string" } },
{
name: "tail",
in: "query",
required: false,
schema: { type: "integer", default: 65536, maximum: 1048576 },
description: "Trailing bytes to return; capped at 1 MiB.",
},
],
responses: {
200: {
description:
"{available, svc, size, truncated, text}, or {available:false} for a lane with no slot.",
},
404: { description: "Lane not found, or ENOLOG — no such log for this lane." },
},
},
},
"/api/lanes/branches": {
get: {
tags: ["Lanes"],
+266 -1
View File
@@ -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 };
}