17d56d0e01
Clean break, no compatibility alias. Renames the product name, the CLI binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` -> `AGENTHUB_`), the Prometheus metric namespace (`ccam_*` -> `agenthub_*`), the tmux session prefix (`ccam-lane-<id>` -> `agenthub-lane-<id>`), the per-repo runtime profile directory (`.ccam/profile` -> `.agenthub/profile`), the lanes state root (`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` -> `/agenthub:*`). Because there is no alias, an existing install has to be updated by hand: rename its `.ccam/profile` directory and any `CCAM_*` variables, and reinstall the plugin under its new name. Two other name families are deliberately left alone here, since they are not just strings: the `Claude-Code-Monitor` repository/marketplace name, and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard` runtime directory, which holds the live database and needs a migration rather than a rename. Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
/**
|
|
* @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(), "agenthub-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");
|
|
|
|
/**
|
|
* Lanes whose boot hook actually spawned a detached process. `harness_spawn`
|
|
* children outlive the hook by design, so a test that throws before its own
|
|
* `downLane` — or never calls one — leaves a real server holding a real port
|
|
* AFTER the suite exits. The port pool is only ten wide per name, so ten such
|
|
* runs exhaust it and every later boot fails with EPORTBUSY: a leak in these
|
|
* tests reads as a failure in whichever test runs next.
|
|
*/
|
|
const bootedLanes = new Set();
|
|
|
|
// One teardown, in this order on purpose: downLane reads the pid files under
|
|
// LANES_ROOT, which lives inside SUITE_ROOT — removing the tree first would
|
|
// leave nothing to kill the children with.
|
|
after(async () => {
|
|
for (const id of bootedLanes) {
|
|
try {
|
|
const lane = lanesLib.getLane(id);
|
|
if (lane) await runtime.downLane(lane);
|
|
} catch {
|
|
/* already stopped, or torn down by the test itself */
|
|
}
|
|
}
|
|
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, ".agenthub", "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);
|
|
|
|
bootedLanes.add(lane.id);
|
|
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, []);
|
|
|
|
bootedLanes.delete(lane.id);
|
|
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("upLane qc option", () => {
|
|
it("injects QC_BOOT_ENV into the boot hook's environment when qc:true", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19300\nQC_BOOT_ENV=SOME_QC_VAR=from-qc\n", {
|
|
boot: [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'echo "QC=$SOME_QC_VAR" > "$LANE_DIR/qc-marker.txt"',
|
|
'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"),
|
|
});
|
|
|
|
bootedLanes.add(lane.id);
|
|
await runtime.upLane(lanesLib.getLane(lane.id), { qc: true });
|
|
const booted = lanesLib.getLane(lane.id);
|
|
const markerPath = pathMod.join(booted.cwd, "qc-marker.txt");
|
|
const marker = fsMod.readFileSync(markerPath, "utf8");
|
|
assert.match(marker, /QC=from-qc/);
|
|
|
|
// Stop the stack BEFORE releasing the slot: downLane locates the pid file
|
|
// through the lane's slot directory, so a released slot orphans a running
|
|
// child with no way left to reach it. Asserting the port went quiet is what
|
|
// makes a broken teardown fail HERE instead of leaking a live server into
|
|
// the next run's port pool.
|
|
const bootedPort = booted.ports.web;
|
|
await runtime.downLane(lanesLib.getLane(lane.id));
|
|
assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane");
|
|
bootedLanes.delete(lane.id);
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
|
|
it("does not touch the environment when qc is omitted (default false)", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19400\nQC_BOOT_ENV=SOME_QC_VAR=from-qc\n", {
|
|
boot: [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'echo "QC=${SOME_QC_VAR:-not-set}" > "$LANE_DIR/qc-marker-no-qc.txt"',
|
|
'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"),
|
|
});
|
|
|
|
bootedLanes.add(lane.id);
|
|
await runtime.upLane(lanesLib.getLane(lane.id));
|
|
const booted = lanesLib.getLane(lane.id);
|
|
const markerPath = pathMod.join(booted.cwd, "qc-marker-no-qc.txt");
|
|
const marker = fsMod.readFileSync(markerPath, "utf8");
|
|
assert.match(marker, /QC=not-set/);
|
|
|
|
const bootedPort = booted.ports.web;
|
|
await runtime.downLane(lanesLib.getLane(lane.id));
|
|
assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane");
|
|
bootedLanes.delete(lane.id);
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|