67210bcd61
Several suites start real `agenthub-lane-<id>` tmux sessions and then kill or reset them, with lane ids from their own throwaway database. On the user's default tmux server those names collide with the dashboard's real lane panes, so a test run — including the one the pre-commit hook runs on every commit — killed whichever live session shared an id, the pane the commit was made from included. `npm run test:server` now goes through scripts/test-server.js, which drops TMUX, points TMUX_TMPDIR at a fresh directory and kills that server after. Also fixes the ERUNTIMEOUT test's tmux mock, which had the wrong (async, cmd+args) signature and only passed by accident.
1290 lines
51 KiB
JavaScript
1290 lines
51 KiB
JavaScript
/**
|
|
* @file Tests for lane lifecycle operations: preflight (what will be lost?),
|
|
* reset, remove, and purge. Each operation is preceded by preflight to confirm
|
|
* the counts and blocks before destructive action.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const http = require("node:http");
|
|
const { execFileSync } = require("node:child_process");
|
|
const { PassThrough } = require("node:stream");
|
|
const { EventEmitter } = require("node:events");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `dashboard-lifecycle-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lifecycle-"));
|
|
process.env.LANES_ROOT = path.join(ROOT, "lanes");
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const runs = require("../lib/pty-run");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function request(method, urlPath, body, extraHeaders = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlPath, BASE);
|
|
const payload = body ? JSON.stringify(body) : null;
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
method,
|
|
headers: {
|
|
...(payload
|
|
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
|
|
: {}),
|
|
...extraHeaders,
|
|
},
|
|
},
|
|
(res) => {
|
|
let data = "";
|
|
res.on("data", (c) => (data += c));
|
|
res.on("end", () => {
|
|
let parsed = null;
|
|
try {
|
|
parsed = JSON.parse(data);
|
|
} catch {
|
|
/* non-JSON body */
|
|
}
|
|
resolve({ status: res.statusCode, body: parsed });
|
|
});
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (payload) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function makeRunChild({ exitsOnKill }) {
|
|
const child = new EventEmitter();
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
child.stdin = new PassThrough();
|
|
child.killed = false;
|
|
child.kill = function (signal) {
|
|
if (signal === "SIGTERM") this.killed = true;
|
|
if (exitsOnKill) setImmediate(() => this.emit("exit", 143, signal));
|
|
return true;
|
|
};
|
|
return child;
|
|
}
|
|
|
|
// Puts a fake `claude` binary on PATH so a real `/start` spawns a real tmux
|
|
// session running THIS script instead of the system Claude Code CLI. Tests
|
|
// that mock tmux's own exec calls (to simulate a stuck/live session) still
|
|
// spawn this real process underneath — without the stub, that spawn launches
|
|
// the actual `claude` binary and, because the mock replaces the app's own
|
|
// kill-session call, the real process is never actually terminated, leaking
|
|
// a live tmux session + CLI process for good. Returns the restore function.
|
|
function stubClaudeBinary(name) {
|
|
const bin = path.join(ROOT, `${name}-bin`);
|
|
const claude = path.join(bin, "claude");
|
|
fs.mkdirSync(bin, { recursive: true });
|
|
fs.writeFileSync(
|
|
claude,
|
|
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
|
|
);
|
|
fs.chmodSync(claude, 0o755);
|
|
const originalPath = process.env.PATH;
|
|
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
|
return () => {
|
|
process.env.PATH = originalPath;
|
|
};
|
|
}
|
|
|
|
async function waitForProvisioning(id) {
|
|
const deadline = Date.now() + 5000;
|
|
let response;
|
|
while (Date.now() < deadline) {
|
|
response = await request("GET", `/api/lanes/${id}`);
|
|
if (response.body.lane.status !== "provisioning") return response.body.lane;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
assert.fail(
|
|
`lane ${id} was still provisioning after 5 seconds; last response: ${JSON.stringify(response.body)}`
|
|
);
|
|
}
|
|
|
|
// Git helper with clean environment (no hook vars)
|
|
const SRC = path.join(ROOT, "src-repo");
|
|
const g = (cwd, ...args) => {
|
|
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;
|
|
env.GIT_TERMINAL_PROMPT = "0";
|
|
return execFileSync("git", args, { cwd, encoding: "utf8", env });
|
|
};
|
|
|
|
before(async () => {
|
|
// Create source repo
|
|
fs.mkdirSync(SRC, { recursive: true });
|
|
g(SRC, "init", "-b", "main");
|
|
g(SRC, "config", "user.email", "test@example.com");
|
|
g(SRC, "config", "user.name", "Test");
|
|
fs.writeFileSync(path.join(SRC, "README.md"), "hello\n");
|
|
fs.writeFileSync(path.join(SRC, ".gitignore"), "node_modules/\n.env\n");
|
|
g(SRC, "add", "-A");
|
|
g(SRC, "commit", "-m", "init");
|
|
|
|
// Start server
|
|
const app = createApp();
|
|
server = await startServer(app, 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
after(() => {
|
|
if (server) server.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("preflight", () => {
|
|
it("returns 404 for an unknown lane", async () => {
|
|
const r = await request("GET", "/api/lanes/99999/preflight?action=reset");
|
|
assert.equal(r.status, 404);
|
|
assert.ok(r.body.error);
|
|
});
|
|
|
|
it("returns 400 for an unknown action", async () => {
|
|
const c = await request("POST", "/api/lanes", { cwd: "/tmp/preflight-unknown-action" });
|
|
const id = c.body.lane.id;
|
|
const r = await request("GET", `/api/lanes/${id}/preflight?action=frobnicate`);
|
|
assert.equal(r.status, 400);
|
|
assert.ok(r.body.error);
|
|
await request("DELETE", `/api/lanes/${id}`);
|
|
});
|
|
|
|
it("preflight on an adopted lane blocks with 'adopted'", async () => {
|
|
const c = await request("POST", "/api/lanes", {
|
|
cwd: "/tmp/preflight-adopted",
|
|
kind: "adopted",
|
|
});
|
|
const id = c.body.lane.id;
|
|
const r = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
|
|
assert.equal(r.status, 200);
|
|
assert.ok(r.body.blocked);
|
|
assert.ok(r.body.blocked.includes("adopted"));
|
|
await request("DELETE", `/api/lanes/${id}`);
|
|
});
|
|
|
|
it("preflight counts dirty, untracked and unpushed for a managed lane", async () => {
|
|
// Create a managed lane with a worktree
|
|
const wt = require("../lib/worktree");
|
|
const dir = path.join(process.env.LANES_ROOT, "src-repo__test-dirty");
|
|
|
|
// Create the worktree
|
|
await wt.addWorktree({
|
|
sourceRepo: SRC,
|
|
dir,
|
|
branch: "feat/test-dirty",
|
|
base: "main",
|
|
});
|
|
|
|
// Create the lane
|
|
const c = await request("POST", "/api/lanes", {
|
|
cwd: dir,
|
|
kind: "managed",
|
|
source_repo: SRC,
|
|
base_branch: "main",
|
|
branch: "feat/test-dirty",
|
|
slug: "test-dirty",
|
|
});
|
|
const id = c.body.lane.id;
|
|
|
|
// One commit of the lane's own work, so `unpushed` has something honest to
|
|
// count: with no remote it is measured as base_branch..HEAD, not the whole
|
|
// repository history.
|
|
fs.writeFileSync(path.join(dir, "lane-work.txt"), "lane work\n");
|
|
g(dir, "add", "lane-work.txt");
|
|
g(dir, "commit", "-m", "lane work");
|
|
|
|
// Dirty the worktree
|
|
fs.appendFileSync(path.join(dir, "README.md"), "edit\n");
|
|
fs.writeFileSync(path.join(dir, "scratch.txt"), "untracked\n");
|
|
|
|
// Get preflight
|
|
const r = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.action, "reset");
|
|
assert.equal(r.body.dirty, 1);
|
|
assert.equal(r.body.untracked, 1);
|
|
// Exactly the one commit ahead of main, not main's own commit as well.
|
|
assert.equal(r.body.unpushed, 1);
|
|
assert.ok(r.body.head);
|
|
assert.ok(Array.isArray(r.body.blocked));
|
|
// "unpushed-commits" is a hard blocker (force overrides it); "no-remote" is
|
|
// informational only and must not appear in blocked.
|
|
assert.ok(r.body.blocked.includes("unpushed-commits"));
|
|
assert.ok(!r.body.blocked.includes("no-remote"));
|
|
assert.ok(Array.isArray(r.body.warnings));
|
|
assert.ok(r.body.warnings.includes("no-remote"));
|
|
|
|
await request("DELETE", `/api/lanes/${id}`);
|
|
});
|
|
|
|
it("preflight for purge counts only eligible sessions and dependent rows", async () => {
|
|
const cwd = "/tmp/preflight-purge";
|
|
const c = await request("POST", "/api/lanes", { cwd });
|
|
const id = c.body.lane.id;
|
|
const { db } = require("../db");
|
|
|
|
// The completed child is eligible; the active child, lane-bound session, and
|
|
// string-prefix sibling each exercise one of purgeCandidateSessions' exclusions.
|
|
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
|
|
"preflight-purge-eligible",
|
|
`${cwd}/child`,
|
|
"completed"
|
|
);
|
|
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
|
|
"preflight-purge-active",
|
|
`${cwd}/active-child`,
|
|
"active"
|
|
);
|
|
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
|
|
"preflight-purge-bound",
|
|
cwd,
|
|
"completed"
|
|
);
|
|
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
|
|
"preflight-purge-prefix-sibling",
|
|
`${cwd}-other`,
|
|
"completed"
|
|
);
|
|
db.prepare("UPDATE lanes SET session_id = ? WHERE id = ?").run("preflight-purge-bound", id);
|
|
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, ?)").run(
|
|
"preflight-purge-eligible",
|
|
"PostToolUse"
|
|
);
|
|
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, ?)").run(
|
|
"preflight-purge-eligible",
|
|
"Stop"
|
|
);
|
|
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, ?, ?)").run(
|
|
"preflight-purge-eligible",
|
|
"test-model",
|
|
1
|
|
);
|
|
|
|
const r = await request("GET", `/api/lanes/${id}/preflight?action=purge`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.action, "purge");
|
|
assert.equal(r.body.lane, id);
|
|
assert.equal(r.body.sessions, 1);
|
|
assert.equal(r.body.events, 2);
|
|
assert.equal(r.body.tokenRows, 1);
|
|
assert.equal(r.body.activeSessionSkipped, true);
|
|
|
|
await request("DELETE", `/api/lanes/${id}`);
|
|
});
|
|
|
|
it("preflight returns 'missing' block when the lane directory vanishes", async () => {
|
|
const wt = require("../lib/worktree");
|
|
const dir = path.join(process.env.LANES_ROOT, "src-repo__test-missing");
|
|
|
|
// Create the worktree
|
|
await wt.addWorktree({
|
|
sourceRepo: SRC,
|
|
dir,
|
|
branch: "feat/test-missing",
|
|
base: "main",
|
|
});
|
|
|
|
// Create the lane
|
|
const c = await request("POST", "/api/lanes", {
|
|
cwd: dir,
|
|
kind: "managed",
|
|
source_repo: SRC,
|
|
base_branch: "main",
|
|
branch: "feat/test-missing",
|
|
slug: "test-missing",
|
|
});
|
|
const id = c.body.lane.id;
|
|
|
|
// Remove the directory
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
// Get preflight - should report missing but not crash
|
|
const r = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
|
|
assert.equal(r.status, 200);
|
|
assert.ok(r.body.blocked);
|
|
assert.ok(r.body.blocked.includes("missing"));
|
|
|
|
await request("DELETE", `/api/lanes/${id}`);
|
|
});
|
|
|
|
it("counts only the lane's own commits as unpushed in a local-only repo, not the whole history", async () => {
|
|
// Regression: with no remote, `unpushed` used to be `rev-list --count HEAD`,
|
|
// so a freshly provisioned worktree in a repo with history reported every
|
|
// commit in that repo as unpushed and demanded Force to discard commits a
|
|
// `reset --hard <base>` would never touch.
|
|
const deepRepo = path.join(ROOT, "deep-repo");
|
|
fs.mkdirSync(deepRepo, { recursive: true });
|
|
g(deepRepo, "init", "-b", "main");
|
|
g(deepRepo, "config", "user.email", "test@example.com");
|
|
g(deepRepo, "config", "user.name", "Test");
|
|
for (let i = 1; i <= 5; i += 1) {
|
|
fs.writeFileSync(path.join(deepRepo, `f${i}.txt`), `${i}\n`);
|
|
g(deepRepo, "add", "-A");
|
|
g(deepRepo, "commit", "-m", `commit ${i}`);
|
|
}
|
|
assert.equal(g(deepRepo, "rev-list", "--count", "HEAD").trim(), "5");
|
|
assert.equal(g(deepRepo, "remote").trim(), "");
|
|
|
|
const wt = require("../lib/worktree");
|
|
const dir = path.join(process.env.LANES_ROOT, "deep-repo__fresh");
|
|
await wt.addWorktree({ sourceRepo: deepRepo, dir, branch: "feat/fresh", base: "main" });
|
|
const c = await request("POST", "/api/lanes", {
|
|
cwd: dir,
|
|
kind: "managed",
|
|
source_repo: deepRepo,
|
|
base_branch: "main",
|
|
branch: "feat/fresh",
|
|
slug: "fresh",
|
|
});
|
|
const id = c.body.lane.id;
|
|
|
|
const fresh = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
|
|
assert.equal(fresh.status, 200);
|
|
// Nothing of this lane's own has been committed yet.
|
|
assert.equal(fresh.body.unpushed, 0);
|
|
assert.ok(!fresh.body.blocked.includes("unpushed-commits"));
|
|
// The user is still told nothing is backed up — that is the warning's job.
|
|
assert.ok(fresh.body.warnings.includes("no-remote"));
|
|
|
|
// One commit of its own now counts, and only that one.
|
|
fs.writeFileSync(path.join(dir, "mine.txt"), "mine\n");
|
|
g(dir, "add", "mine.txt");
|
|
g(dir, "commit", "-m", "my work");
|
|
const after = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
|
|
assert.equal(after.body.unpushed, 1);
|
|
assert.ok(after.body.blocked.includes("unpushed-commits"));
|
|
assert.ok(!after.body.blocked.includes("no-remote"));
|
|
|
|
await request("DELETE", `/api/lanes/${id}`);
|
|
});
|
|
});
|
|
|
|
describe("managed worktree provisioning", () => {
|
|
it("creates a managed lane, returns 202 provisioning, then flips to idle when the worktree lands", async () => {
|
|
const created = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo: SRC,
|
|
title: "Managed Lane",
|
|
base: "main",
|
|
});
|
|
|
|
assert.equal(created.status, 202);
|
|
assert.equal(created.body.lane.kind, "managed");
|
|
assert.equal(created.body.lane.status, "provisioning");
|
|
assert.equal(created.body.lane.branch, "feat/managed-lane");
|
|
|
|
const lane = await waitForProvisioning(created.body.lane.id);
|
|
assert.equal(lane.status, "idle");
|
|
assert.equal(lane.base_branch, "main");
|
|
assert.equal(fs.existsSync(lane.cwd), true);
|
|
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "feat/managed-lane");
|
|
});
|
|
|
|
it("honours LANE_BASE_BRANCH as the default base when the request omits base", async () => {
|
|
g(SRC, "branch", "alt-base");
|
|
process.env.LANE_BASE_BRANCH = "alt-base";
|
|
try {
|
|
const created = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo: SRC,
|
|
title: "Base Env Lane",
|
|
});
|
|
assert.equal(created.status, 202);
|
|
const lane = await waitForProvisioning(created.body.lane.id);
|
|
assert.equal(lane.status, "idle");
|
|
assert.equal(lane.base_branch, "alt-base");
|
|
} finally {
|
|
delete process.env.LANE_BASE_BRANCH;
|
|
}
|
|
});
|
|
|
|
it("honours LANE_BRANCH_PREFIX for the managed lane's feature branch name", async () => {
|
|
process.env.LANE_BRANCH_PREFIX = "wt/";
|
|
try {
|
|
const created = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo: SRC,
|
|
title: "Prefix Env Lane",
|
|
base: "main",
|
|
});
|
|
assert.equal(created.status, 202);
|
|
assert.equal(created.body.lane.branch, "wt/prefix-env-lane");
|
|
const lane = await waitForProvisioning(created.body.lane.id);
|
|
assert.equal(lane.status, "idle");
|
|
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "wt/prefix-env-lane");
|
|
} finally {
|
|
delete process.env.LANE_BRANCH_PREFIX;
|
|
}
|
|
});
|
|
|
|
it("rejects a sourceRepo that is not an absolute path or not a git repo", async () => {
|
|
const cases = ["relative-repo", path.join(ROOT, "missing-repo"), ROOT];
|
|
for (const sourceRepo of cases) {
|
|
const response = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo,
|
|
title: "Invalid Source",
|
|
});
|
|
assert.equal(response.status, 400);
|
|
assert.equal(response.body.error.code, "EBADSOURCEREPO");
|
|
}
|
|
});
|
|
|
|
it("suffixes the slug when the directory already exists", async () => {
|
|
fs.mkdirSync(path.join(process.env.LANES_ROOT, "src-repo__collision"), { recursive: true });
|
|
const created = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo: SRC,
|
|
title: "Collision",
|
|
base: "main",
|
|
});
|
|
|
|
assert.equal(created.status, 202);
|
|
assert.equal(created.body.lane.slug, "collision-2");
|
|
assert.equal(created.body.lane.cwd, path.join(process.env.LANES_ROOT, "src-repo__collision-2"));
|
|
|
|
const lane = await waitForProvisioning(created.body.lane.id);
|
|
assert.equal(lane.status, "idle");
|
|
});
|
|
|
|
it("returns 409 when no unique worktree directory is found in 50 attempts", async () => {
|
|
const root = path.join(process.env.LANES_ROOT, "src-repo__collision-cap");
|
|
for (let suffix = 1; suffix <= 51; suffix += 1) {
|
|
const dir = suffix === 1 ? root : `${root}-${suffix}`;
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
const response = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo: SRC,
|
|
title: "Collision Cap",
|
|
base: "main",
|
|
});
|
|
|
|
assert.equal(response.status, 409);
|
|
assert.equal(response.body.error.code, "EWORKTREEDIRCOLLISION");
|
|
assert.equal(
|
|
response.body.error.message,
|
|
"could not allocate a unique worktree directory after 50 attempts"
|
|
);
|
|
});
|
|
|
|
it("marks the lane failed with git's message when provisioning fails", async () => {
|
|
const emptyRepo = path.join(ROOT, "empty-repo");
|
|
fs.mkdirSync(emptyRepo, { recursive: true });
|
|
g(emptyRepo, "init", "-b", "main");
|
|
|
|
const created = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo: emptyRepo,
|
|
title: "Unborn Repo",
|
|
base: "main",
|
|
});
|
|
|
|
assert.equal(created.status, 202);
|
|
assert.equal(created.body.lane.status, "provisioning");
|
|
const lane = await waitForProvisioning(created.body.lane.id);
|
|
assert.equal(lane.kind, "managed");
|
|
assert.equal(lane.status, "failed");
|
|
assert.match(lane.notes, /fatal:|ambiguous argument|unknown revision/i);
|
|
assert.equal(fs.existsSync(lane.cwd), false);
|
|
|
|
// A failed provisioning never created a worktree, so POST remove correctly
|
|
// refuses it; DELETE remains the non-destructive "forget this lane row" path.
|
|
const removed = await request("DELETE", `/api/lanes/${lane.id}`);
|
|
assert.equal(removed.status, 200);
|
|
const missing = await request("GET", `/api/lanes/${lane.id}`);
|
|
assert.equal(missing.status, 404);
|
|
});
|
|
|
|
it("uses a caller-supplied branch name instead of deriving one from the slug", async () => {
|
|
const created = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo: SRC,
|
|
title: "Custom Branch",
|
|
base: "main",
|
|
branch: "custom/my-branch",
|
|
});
|
|
|
|
assert.equal(created.status, 202);
|
|
assert.equal(created.body.lane.branch, "custom/my-branch");
|
|
const lane = await waitForProvisioning(created.body.lane.id);
|
|
assert.equal(lane.status, "idle");
|
|
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "custom/my-branch");
|
|
});
|
|
|
|
it("rejects an invalid caller-supplied branch name before creating anything", async () => {
|
|
const response = await request("POST", "/api/lanes/worktree", {
|
|
sourceRepo: SRC,
|
|
title: "Bad Branch",
|
|
base: "main",
|
|
branch: "not a valid branch..name",
|
|
});
|
|
|
|
assert.equal(response.status, 400);
|
|
assert.equal(response.body.error.code, "EBADBRANCH");
|
|
});
|
|
});
|
|
|
|
describe("GET /api/lanes/browse", () => {
|
|
it("lists a directory's immediate subdirectories, marking git repos", async () => {
|
|
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.body.path, ROOT);
|
|
const names = response.body.entries.map((e) => e.name);
|
|
assert.ok(names.includes("src-repo"));
|
|
const srcRepoEntry = response.body.entries.find((e) => e.name === "src-repo");
|
|
assert.equal(srcRepoEntry.isGitRepo, true);
|
|
});
|
|
|
|
it("reports the parent directory, or null at the filesystem root", async () => {
|
|
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
|
assert.equal(response.body.parent, path.dirname(ROOT));
|
|
|
|
const rootResponse = await request("GET", "/api/lanes/browse?path=/");
|
|
assert.equal(rootResponse.body.parent, null);
|
|
});
|
|
|
|
it("rejects a path that does not exist or is not a directory", async () => {
|
|
const missing = await request(
|
|
"GET",
|
|
`/api/lanes/browse?path=${encodeURIComponent(path.join(ROOT, "does-not-exist"))}`
|
|
);
|
|
assert.equal(missing.status, 400);
|
|
assert.equal(missing.body.error.code, "ENOTFOUND");
|
|
|
|
const filePath = path.join(ROOT, "a-file.txt");
|
|
fs.writeFileSync(filePath, "hi\n");
|
|
const notADir = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(filePath)}`);
|
|
assert.equal(notADir.status, 400);
|
|
assert.equal(notADir.body.error.code, "ENOTADIR");
|
|
});
|
|
});
|
|
|
|
describe("destructive lane lifecycle actions", () => {
|
|
async function createManagedLane(slug) {
|
|
const wt = require("../lib/worktree");
|
|
const dir = path.join(process.env.LANES_ROOT, `src-repo__${slug}`);
|
|
const branch = `feat/${slug}`;
|
|
await wt.addWorktree({ sourceRepo: SRC, dir, branch, base: "main" });
|
|
const created = await request("POST", "/api/lanes", {
|
|
cwd: dir,
|
|
kind: "managed",
|
|
source_repo: SRC,
|
|
base_branch: "main",
|
|
branch,
|
|
slug,
|
|
});
|
|
assert.equal(created.status, 201);
|
|
return created.body.lane;
|
|
}
|
|
|
|
function destructiveExpect(report) {
|
|
return {
|
|
head: report.head,
|
|
dirty: report.dirty,
|
|
untracked: report.untracked,
|
|
unpushed: report.unpushed,
|
|
};
|
|
}
|
|
|
|
it("reset requires confirm, restores the branch from base and clears lane state", async () => {
|
|
const lane = await createManagedLane("reset-clean");
|
|
fs.appendFileSync(path.join(lane.cwd, "README.md"), "changed\n");
|
|
fs.writeFileSync(path.join(lane.cwd, "untracked.txt"), "remove me\n");
|
|
fs.mkdirSync(path.join(lane.cwd, "node_modules"));
|
|
fs.writeFileSync(path.join(lane.cwd, "node_modules", "preserved.txt"), "keep me\n");
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
|
|
|
const unconfirmed = await request("POST", `/api/lanes/${lane.id}/reset`, {});
|
|
assert.equal(unconfirmed.status, 400);
|
|
assert.equal(unconfirmed.body.error.code, "ECONFIRM");
|
|
|
|
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(reset.status, 200);
|
|
assert.equal(reset.body.lane.stage, "idle");
|
|
assert.equal(reset.body.lane.status, "idle");
|
|
assert.equal(reset.body.lane.run_id, null);
|
|
assert.equal(fs.readFileSync(path.join(lane.cwd, "README.md"), "utf8"), "hello\n");
|
|
assert.equal(fs.existsSync(path.join(lane.cwd, "untracked.txt")), false);
|
|
assert.equal(
|
|
fs.readFileSync(path.join(lane.cwd, "node_modules", "preserved.txt"), "utf8"),
|
|
"keep me\n"
|
|
);
|
|
});
|
|
|
|
it("reset refuses with 409 when unpushed commits exist, and proceeds with force", async () => {
|
|
const lane = await createManagedLane("reset-force");
|
|
fs.writeFileSync(path.join(lane.cwd, "committed.txt"), "commit me\n");
|
|
g(lane.cwd, "add", "committed.txt");
|
|
g(lane.cwd, "commit", "-m", "unpushed work");
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
|
// One commit ahead of main — main's own commit is the base, not lane work.
|
|
assert.equal(preflight.body.unpushed, 1);
|
|
|
|
const refused = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(refused.status, 409);
|
|
assert.equal(refused.body.error.code, "EUNPUSHED");
|
|
|
|
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(reset.status, 200);
|
|
assert.equal(g(lane.cwd, "rev-list", "--count", "HEAD").trim(), "1");
|
|
});
|
|
|
|
it("reset returns 409 ESTALE when the head moved since preflight", async () => {
|
|
const lane = await createManagedLane("reset-stale");
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
|
fs.writeFileSync(path.join(lane.cwd, "head-moved.txt"), "moved\n");
|
|
g(lane.cwd, "add", "head-moved.txt");
|
|
g(lane.cwd, "commit", "-m", "move head");
|
|
|
|
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(reset.status, 409);
|
|
assert.equal(reset.body.error.code, "ESTALE");
|
|
assert.equal(g(lane.cwd, "log", "-1", "--format=%s").trim(), "move head");
|
|
});
|
|
|
|
it("remove deletes the lane row only, leaving a managed worktree and its branch on disk", async () => {
|
|
const lane = await createManagedLane("remove-managed");
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
|
|
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
|
|
confirm: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(removed.status, 200);
|
|
assert.equal(removed.body.ok, true);
|
|
// The user's code is the user's: forgetting a lane may never cost a branch.
|
|
assert.equal(fs.existsSync(lane.cwd), true);
|
|
assert.equal(g(SRC, "branch", "--list", lane.branch).trim().endsWith(lane.branch), true);
|
|
const missing = await request("GET", `/api/lanes/${lane.id}`);
|
|
assert.equal(missing.status, 404);
|
|
});
|
|
|
|
it("reset refuses an adopted lane, while remove forgets its row without touching its directory", async () => {
|
|
const adoptedDir = path.join(ROOT, "adopted-preserved");
|
|
fs.mkdirSync(adoptedDir, { recursive: true });
|
|
const adoptedFile = path.join(adoptedDir, "do-not-delete.txt");
|
|
fs.writeFileSync(adoptedFile, "real project contents\n");
|
|
const created = await request("POST", "/api/lanes", { cwd: adoptedDir });
|
|
const lane = created.body.lane;
|
|
const resetPreflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
|
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: destructiveExpect(resetPreflight.body),
|
|
});
|
|
assert.equal(reset.status, 400);
|
|
assert.equal(reset.body.error.code, "ENOTMANAGED");
|
|
assert.equal(fs.existsSync(adoptedDir), true);
|
|
assert.equal(fs.readFileSync(adoptedFile, "utf8"), "real project contents\n");
|
|
|
|
const removePreflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
|
|
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: destructiveExpect(removePreflight.body),
|
|
});
|
|
assert.equal(removed.status, 200);
|
|
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
|
|
assert.equal(fs.existsSync(adoptedDir), true);
|
|
assert.equal(fs.readFileSync(adoptedFile, "utf8"), "real project contents\n");
|
|
});
|
|
|
|
it("forgets a lane with unpushed commits without force, adopted or managed — removal destroys nothing", async () => {
|
|
const adoptedDir = path.join(ROOT, "adopted-unpushed");
|
|
fs.mkdirSync(adoptedDir, { recursive: true });
|
|
g(adoptedDir, "init");
|
|
g(adoptedDir, "config", "user.email", "test@example.com");
|
|
g(adoptedDir, "config", "user.name", "Test User");
|
|
fs.writeFileSync(path.join(adoptedDir, "README.md"), "adopted\n");
|
|
g(adoptedDir, "add", "README.md");
|
|
g(adoptedDir, "commit", "-m", "adopted commit");
|
|
const adopted = (await request("POST", "/api/lanes", { cwd: adoptedDir })).body.lane;
|
|
const adoptedPreflight = await request(
|
|
"GET",
|
|
`/api/lanes/${adopted.id}/preflight?action=remove`
|
|
);
|
|
assert.ok(adoptedPreflight.body.unpushed > 0);
|
|
const forgotten = await request("POST", `/api/lanes/${adopted.id}/remove`, {
|
|
confirm: true,
|
|
expect: destructiveExpect(adoptedPreflight.body),
|
|
});
|
|
assert.equal(forgotten.status, 200);
|
|
assert.equal(fs.existsSync(adoptedDir), true);
|
|
|
|
const managed = await createManagedLane("remove-unpushed-force");
|
|
fs.writeFileSync(path.join(managed.cwd, "committed.txt"), "commit me\n");
|
|
g(managed.cwd, "add", "committed.txt");
|
|
g(managed.cwd, "commit", "-m", "unpushed managed work");
|
|
const managedPreflight = await request(
|
|
"GET",
|
|
`/api/lanes/${managed.id}/preflight?action=remove`
|
|
);
|
|
assert.ok(managedPreflight.body.unpushed > 0);
|
|
const forgottenManaged = await request("POST", `/api/lanes/${managed.id}/remove`, {
|
|
confirm: true,
|
|
expect: destructiveExpect(managedPreflight.body),
|
|
});
|
|
assert.equal(forgottenManaged.status, 200);
|
|
// The unpushed commit is still there, on a branch nothing deleted.
|
|
assert.equal(fs.existsSync(managed.cwd), true);
|
|
assert.equal(g(managed.cwd, "log", "-1", "--format=%s").trim(), "unpushed managed work");
|
|
});
|
|
|
|
it("requires a complete expect object and returns stale diagnostics", async () => {
|
|
const lane = await createManagedLane("expect-required");
|
|
const resetPreflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
|
const omitted = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
});
|
|
assert.equal(omitted.status, 400);
|
|
assert.equal(omitted.body.error.code, "EEXPECT");
|
|
const partial = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: { head: resetPreflight.body.head },
|
|
});
|
|
assert.equal(partial.status, 400);
|
|
assert.equal(partial.body.error.code, "EEXPECT");
|
|
|
|
fs.writeFileSync(path.join(lane.cwd, "changed-after-preflight.txt"), "changed\n");
|
|
const stale = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: destructiveExpect(resetPreflight.body),
|
|
});
|
|
assert.equal(stale.status, 409);
|
|
assert.equal(stale.body.error.code, "ESTALE");
|
|
assert.deepEqual(stale.body.error.expected, destructiveExpect(resetPreflight.body));
|
|
assert.equal(stale.body.error.current.untracked, 1);
|
|
|
|
const purgePreflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=purge`);
|
|
const purgePartial = await request("POST", `/api/lanes/${lane.id}/purge`, {
|
|
confirm: true,
|
|
expect: { sessions: purgePreflight.body.sessions, events: purgePreflight.body.events },
|
|
});
|
|
assert.equal(purgePartial.status, 400);
|
|
assert.equal(purgePartial.body.error.code, "EEXPECT");
|
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
|
});
|
|
|
|
it("waits for the tmux session to exit before resetting its worktree", async () => {
|
|
const lane = await createManagedLane("await-real-exit");
|
|
fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n");
|
|
const bin = path.join(ROOT, "run-exit-bin");
|
|
const claude = path.join(bin, "claude");
|
|
fs.mkdirSync(bin, { recursive: true });
|
|
fs.writeFileSync(
|
|
claude,
|
|
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
|
|
);
|
|
fs.chmodSync(claude, 0o755);
|
|
const originalPath = process.env.PATH;
|
|
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
|
try {
|
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, {
|
|
prompt: "keep running",
|
|
});
|
|
assert.equal(started.status, 200);
|
|
const running = await request("GET", `/api/lanes/${lane.id}`);
|
|
const runId = running.body.lane.run_id;
|
|
assert.equal(typeof runId, "string");
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
|
|
|
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(reset.status, 200);
|
|
assert.equal(runs.getRun(runId).status, "gone");
|
|
assert.equal(fs.existsSync(path.join(lane.cwd, "written-by-run.txt")), false);
|
|
} finally {
|
|
process.env.PATH = originalPath;
|
|
}
|
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
|
});
|
|
|
|
it("returns ERUNTIMEOUT and leaves the worktree untouched when a tmux session never exits", async () => {
|
|
const tmux = require("../lib/tmux");
|
|
const lane = await createManagedLane("await-timeout");
|
|
const sentinel = path.join(lane.cwd, "must-survive-timeout.txt");
|
|
fs.writeFileSync(sentinel, "still here\n");
|
|
|
|
// Start a run for the lane
|
|
const restorePath = stubClaudeBinary("await-timeout");
|
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
|
|
assert.equal(started.status, 200);
|
|
const runId = started.body.lane.run_id;
|
|
|
|
// Mock tmux so has-session always returns 0 (session exists), simulating a stuck session
|
|
// Same shape as tmux.js's own exec: sync, argv only, throws on failure.
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "has-session" && args[2] === runId) return "";
|
|
if (args[0] === "list-sessions") return `${runId}\n`;
|
|
if (args[0] === "kill-session" && args[2] === runId) return "";
|
|
throw Object.assign(new Error("mocked tmux failure"), { status: 1 });
|
|
});
|
|
|
|
try {
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
|
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
|
confirm: true,
|
|
force: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(reset.status, 500);
|
|
assert.equal(reset.body.error.code, "ERUNTIMEOUT");
|
|
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
|
|
} finally {
|
|
tmux.__reset();
|
|
// The mocked kill-session above only fools the app's own check — the
|
|
// real tmux session + claude stub spawned above is still alive and
|
|
// must be killed for real, or it leaks past this test run.
|
|
try {
|
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
|
} catch {
|
|
// already gone
|
|
}
|
|
restorePath();
|
|
}
|
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
|
});
|
|
|
|
it("removes a managed lane whose directory git no longer lists as a worktree (issue #28)", async () => {
|
|
// Reported as "Lane action failed: lane is not listed as a worktree in …":
|
|
// a managed lane whose directory is a plain repo git never registered (or no
|
|
// longer registers) as a worktree used to hit assertDestroyable's check 3 and
|
|
// become unremovable. Removal no longer asks git anything.
|
|
const lane = await createManagedLane("remove-not-a-worktree");
|
|
g(SRC, "worktree", "remove", "--force", lane.cwd);
|
|
fs.mkdirSync(lane.cwd, { recursive: true });
|
|
g(lane.cwd, "init");
|
|
g(lane.cwd, "config", "user.email", "test@example.com");
|
|
g(lane.cwd, "config", "user.name", "Test User");
|
|
fs.writeFileSync(path.join(lane.cwd, "keep.txt"), "user code\n");
|
|
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
|
|
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
|
|
confirm: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(removed.status, 200, JSON.stringify(removed.body));
|
|
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
|
|
assert.equal(fs.readFileSync(path.join(lane.cwd, "keep.txt"), "utf8"), "user code\n");
|
|
});
|
|
|
|
it("removes a lane whose worktree was deleted by hand, leaving git's own records alone", async () => {
|
|
// A missing directory changes nothing about `remove` any more: it drops the
|
|
// dashboard's row and never touches git. Cleaning up git's stale worktree
|
|
// entry is the user's own `git worktree prune`.
|
|
const lane = await createManagedLane("remove-hand-deleted");
|
|
fs.rmSync(lane.cwd, { recursive: true, force: true });
|
|
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
|
|
assert.equal(preflight.status, 200);
|
|
assert.ok(preflight.body.blocked.includes("missing"));
|
|
assert.equal(preflight.body.head, null);
|
|
|
|
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
|
|
confirm: true,
|
|
expect: destructiveExpect(preflight.body),
|
|
});
|
|
assert.equal(removed.status, 200, JSON.stringify(removed.body));
|
|
assert.equal(removed.body.ok, true);
|
|
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
|
|
// The branch survives — it is where the deleted directory's commits live.
|
|
assert.equal(g(SRC, "branch", "--list", lane.branch).trim().endsWith(lane.branch), true);
|
|
});
|
|
|
|
it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
|
|
// Overwriting run_id while its tmux session is alive orphans that session: a later
|
|
// reset kills and awaits only the RECORDED run's session, then `git clean -fd` the
|
|
// directory the orphan session is still using.
|
|
const tmux = require("../lib/tmux");
|
|
const lane = await createManagedLane("start-twice");
|
|
|
|
// Start a run for the lane
|
|
const restorePath = stubClaudeBinary("start-twice");
|
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
|
assert.equal(started.status, 200);
|
|
const runId = started.body.lane.run_id;
|
|
assert.equal(runs.getRun(runId).status, "running");
|
|
|
|
// Mock tmux so the session appears live
|
|
tmux.__setExecImpl(async (cmd, args) => {
|
|
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
}
|
|
if (cmd === "tmux" && args[0] === "list-sessions") {
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
}
|
|
return { code: 1, stdout: "", stderr: "" };
|
|
});
|
|
|
|
try {
|
|
// Try to start a second run — should be refused
|
|
const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" });
|
|
assert.equal(second.status, 409);
|
|
assert.equal(second.body.error.code, "ERUNLIVE");
|
|
// The first run is still the recorded one — nothing was overwritten.
|
|
const after = await request("GET", `/api/lanes/${lane.id}`);
|
|
assert.equal(after.body.lane.run_id, runId);
|
|
} finally {
|
|
tmux.__reset();
|
|
// The real tmux session behind the "first" run is never reset/killed
|
|
// in this test, mocked or otherwise — kill it for real so it doesn't
|
|
// leak past this test run.
|
|
try {
|
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
|
} catch {
|
|
// already gone
|
|
}
|
|
restorePath();
|
|
}
|
|
|
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
|
});
|
|
|
|
it("start is serialized behind the per-lane lock, not left atomic only by accident", async () => {
|
|
// The check-then-spawn in the "start" case has no `await` between reading
|
|
// run_id and writing it, so it happens to be atomic today regardless of
|
|
// locking. Wrapping it in withLaneLock is what keeps that true even after
|
|
// a future edit adds one — provable by holding the SAME lock from outside
|
|
// the route and confirming `start` genuinely queues behind it rather than
|
|
// running immediately.
|
|
const { withLaneLock } = require("../lib/lane-lock");
|
|
const lane = await createManagedLane("start-lock-order");
|
|
|
|
const bin = path.join(ROOT, "start-lock-bin");
|
|
const claude = path.join(bin, "claude");
|
|
fs.mkdirSync(bin, { recursive: true });
|
|
fs.writeFileSync(
|
|
claude,
|
|
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
|
|
);
|
|
fs.chmodSync(claude, 0o755);
|
|
const originalPath = process.env.PATH;
|
|
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
|
|
|
let releaseHeldLock;
|
|
const held = withLaneLock(
|
|
lane.id,
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseHeldLock = resolve;
|
|
})
|
|
);
|
|
// Give the held lock a moment to actually become the chain's current entry.
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
let startSettled = false;
|
|
const startPromise = request("POST", `/api/lanes/${lane.id}/start`, {
|
|
prompt: "queued behind the held lock",
|
|
}).then((res) => {
|
|
startSettled = true;
|
|
return res;
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
assert.equal(startSettled, false, "start must still be queued behind the held lock");
|
|
// Nothing was spawned while queued.
|
|
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).body.lane.run_id, null);
|
|
|
|
releaseHeldLock();
|
|
await held;
|
|
const started = await startPromise;
|
|
|
|
try {
|
|
assert.equal(started.status, 200, JSON.stringify(started.body));
|
|
const runId = started.body.lane.run_id;
|
|
assert.equal(typeof runId, "string");
|
|
runs.killRun(runId);
|
|
const deadline = Date.now() + 2000;
|
|
while (runs.getRun(runId).status !== "gone" && Date.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
assert.equal(runs.getRun(runId).status, "gone");
|
|
} finally {
|
|
process.env.PATH = originalPath;
|
|
}
|
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
|
});
|
|
|
|
it("PATCH with an invalid kind returns 400, not a 500 from Express's default handler", async () => {
|
|
const created = await request("POST", "/api/lanes", { cwd: "/tmp/patch-bad-kind" });
|
|
const id = created.body.lane.id;
|
|
const patched = await request("PATCH", `/api/lanes/${id}`, { kind: "gremlin" });
|
|
assert.equal(patched.status, 400);
|
|
assert.equal(patched.body.error.code, "EBADKIND");
|
|
// And a valid kind is silently ignored rather than rewriting check 1.
|
|
const rejected = await request("PATCH", `/api/lanes/${id}`, { kind: "managed" });
|
|
assert.equal(rejected.status, 200);
|
|
assert.equal(rejected.body.lane.kind, "adopted");
|
|
await request("DELETE", `/api/lanes/${id}`);
|
|
});
|
|
|
|
it("purge deletes the lane's sessions and reports the counts", async () => {
|
|
const cwd = path.join(ROOT, "purge-counts");
|
|
const created = await request("POST", "/api/lanes", { cwd });
|
|
const lane = created.body.lane;
|
|
const { db } = require("../db");
|
|
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
|
|
"purge-action-session",
|
|
cwd,
|
|
"completed"
|
|
);
|
|
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, ?)").run(
|
|
"purge-action-session",
|
|
"Stop"
|
|
);
|
|
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, ?, ?)").run(
|
|
"purge-action-session",
|
|
"test-model",
|
|
3
|
|
);
|
|
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=purge`);
|
|
assert.equal(preflight.body.sessions, 1);
|
|
assert.equal(preflight.body.events, 1);
|
|
assert.equal(preflight.body.tokenRows, 1);
|
|
|
|
const purged = await request("POST", `/api/lanes/${lane.id}/purge`, {
|
|
confirm: true,
|
|
expect: {
|
|
sessions: preflight.body.sessions,
|
|
events: preflight.body.events,
|
|
tokenRows: preflight.body.tokenRows,
|
|
bytesEstimate: preflight.body.bytesEstimate,
|
|
activeSessionSkipped: preflight.body.activeSessionSkipped,
|
|
},
|
|
});
|
|
assert.equal(purged.status, 200);
|
|
assert.deepEqual(purged.body.purged, { sessions: 1, events: 1, tokenRows: 1 });
|
|
assert.equal(
|
|
db.prepare("SELECT COUNT(*) AS count FROM sessions WHERE id = ?").get("purge-action-session")
|
|
.count,
|
|
0
|
|
);
|
|
assert.equal(
|
|
db
|
|
.prepare("SELECT COUNT(*) AS count FROM events WHERE session_id = ?")
|
|
.get("purge-action-session").count,
|
|
0
|
|
);
|
|
assert.equal(
|
|
db
|
|
.prepare("SELECT COUNT(*) AS count FROM token_usage WHERE session_id = ?")
|
|
.get("purge-action-session").count,
|
|
0
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("lane ensure, start mode, lane_id and releasing a finished run", () => {
|
|
const { db } = require("../db");
|
|
|
|
async function adoptedLane(name) {
|
|
const cwd = path.join(ROOT, `ensure-${name}`);
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const created = await request("POST", "/api/lanes/ensure", { cwd, title: name });
|
|
assert.equal(created.status, 201, JSON.stringify(created.body));
|
|
return created.body.lane;
|
|
}
|
|
|
|
it("creates an adopted lane when no lane owns the cwd", async () => {
|
|
const cwd = path.join(ROOT, "ensure-fresh");
|
|
const r = await request("POST", "/api/lanes/ensure", { cwd, title: "Fresh" });
|
|
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
assert.equal(r.body.created, true);
|
|
assert.equal(r.body.lane.cwd, cwd);
|
|
assert.equal(r.body.lane.kind, "adopted");
|
|
assert.equal(r.body.lane.title, "Fresh");
|
|
});
|
|
|
|
it("returns the existing lane for an exact cwd without creating a second one", async () => {
|
|
const cwd = path.join(ROOT, "ensure-exact");
|
|
const first = await request("POST", "/api/lanes/ensure", { cwd });
|
|
assert.equal(first.status, 201);
|
|
const second = await request("POST", "/api/lanes/ensure", { cwd, title: "ignored" });
|
|
assert.equal(second.status, 200);
|
|
assert.equal(second.body.created, false);
|
|
assert.equal(second.body.lane.id, first.body.lane.id);
|
|
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 1);
|
|
});
|
|
|
|
it("returns the owning lane for a cwd nested inside it", async () => {
|
|
const parent = path.join(ROOT, "ensure-parent");
|
|
const nested = path.join(parent, "packages", "app");
|
|
const owner = await request("POST", "/api/lanes/ensure", { cwd: parent });
|
|
assert.equal(owner.status, 201);
|
|
const r = await request("POST", "/api/lanes/ensure", { cwd: nested });
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.created, false);
|
|
assert.equal(r.body.lane.id, owner.body.lane.id);
|
|
assert.equal(r.body.lane.cwd, parent);
|
|
// A path-boundary sibling is NOT owned by it.
|
|
const sibling = await request("POST", "/api/lanes/ensure", { cwd: `${parent}-sibling` });
|
|
assert.equal(sibling.status, 201);
|
|
assert.equal(sibling.body.created, true);
|
|
assert.notEqual(sibling.body.lane.id, owner.body.lane.id);
|
|
});
|
|
|
|
it("with exact:true, creates a lane for a nested cwd instead of returning its parent (#42)", async () => {
|
|
const parent = path.join(ROOT, "ensure-exact-parent");
|
|
const nested = path.join(parent, "services");
|
|
const owner = await request("POST", "/api/lanes/ensure", { cwd: parent });
|
|
assert.equal(owner.status, 201);
|
|
const r = await request("POST", "/api/lanes/ensure", { cwd: nested, exact: true });
|
|
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
assert.equal(r.body.created, true);
|
|
assert.equal(r.body.lane.cwd, nested);
|
|
// The same directory still answers with its own lane, never a second one.
|
|
const again = await request("POST", "/api/lanes/ensure", { cwd: nested, exact: true });
|
|
assert.equal(again.body.created, false);
|
|
assert.equal(again.body.lane.id, r.body.lane.id);
|
|
});
|
|
|
|
it("yields exactly one lane for two concurrent ensure calls on the same path", async () => {
|
|
const cwd = path.join(ROOT, "ensure-concurrent");
|
|
const [a, b] = await Promise.all([
|
|
request("POST", "/api/lanes/ensure", { cwd }),
|
|
request("POST", "/api/lanes/ensure", { cwd }),
|
|
]);
|
|
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 1);
|
|
assert.equal(a.body.lane.id, b.body.lane.id);
|
|
assert.deepEqual([a.status, b.status].sort(), [200, 201]);
|
|
assert.deepEqual([a.body.created, b.body.created].sort(), [false, true]);
|
|
});
|
|
|
|
it("rejects a relative cwd with 400 EBADCWD", async () => {
|
|
const r = await request("POST", "/api/lanes/ensure", { cwd: "relative/path" });
|
|
assert.equal(r.status, 400);
|
|
assert.equal(r.body.error.code, "EBADCWD");
|
|
});
|
|
|
|
it("refuses a cross-origin ensure", async () => {
|
|
const cwd = path.join(ROOT, "ensure-cross-origin");
|
|
const r = await request("POST", "/api/lanes/ensure", { cwd }, { Origin: "http://evil.test" });
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error.code, "EBADORIGIN");
|
|
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 0);
|
|
});
|
|
|
|
it("leaves a lane that already has a live run_id untouched during healing", async () => {
|
|
const tmux = require("../lib/tmux");
|
|
const lane = await adoptedLane("release-moved-on");
|
|
|
|
// Create a run for this lane.
|
|
const restorePath = stubClaudeBinary("release-moved-on");
|
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
|
assert.equal(started.status, 200, JSON.stringify(started.body));
|
|
const runId = started.body.lane.run_id;
|
|
|
|
// Mock tmux so the run appears to be live.
|
|
tmux.__setExecImpl(async (cmd, args) => {
|
|
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
}
|
|
if (cmd === "tmux" && args[0] === "list-sessions") {
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
}
|
|
return { code: 1, stdout: "", stderr: "" };
|
|
});
|
|
|
|
try {
|
|
// Read the lane — it should NOT clear the run_id since it's still live.
|
|
const before = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
|
|
assert.equal(before.run_id, runId);
|
|
assert.equal(before.status, "running");
|
|
|
|
// Read again — same result, healing preserves live runs.
|
|
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
|
|
assert.equal(after.run_id, runId);
|
|
assert.equal(after.status, "running");
|
|
} finally {
|
|
tmux.__reset();
|
|
// The app never calls kill-session here (healing preserves the "live"
|
|
// run) — kill the real tmux session directly so it doesn't leak.
|
|
try {
|
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
|
} catch {
|
|
// already gone
|
|
}
|
|
restorePath();
|
|
}
|
|
});
|
|
|
|
it("clears a stale run_id and sets status to idle when the tmux session is gone", async () => {
|
|
const tmux = require("../lib/tmux");
|
|
const lane = await adoptedLane("release-stale-run");
|
|
|
|
// Start a run for this lane.
|
|
const restorePath = stubClaudeBinary("release-stale-run");
|
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
|
assert.equal(started.status, 200, JSON.stringify(started.body));
|
|
const runId = started.body.lane.run_id;
|
|
assert.equal(typeof runId, "string");
|
|
assert.equal(started.body.lane.status, "running");
|
|
|
|
// Mock tmux so the session appears to be gone (has-session fails with status 1).
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
|
const e = new Error("no such session");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
if (args[0] === "list-sessions") {
|
|
return "";
|
|
}
|
|
return "";
|
|
});
|
|
|
|
try {
|
|
// Read the lane — it should clear the run_id and set status to idle.
|
|
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
|
|
assert.equal(after.run_id, null, "run_id should be cleared for stale session");
|
|
assert.equal(after.status, "idle", "status should be idle after run is gone");
|
|
} finally {
|
|
tmux.__reset();
|
|
// The app believes the session is already gone and never calls
|
|
// kill-session — kill the real tmux session directly so it doesn't leak.
|
|
try {
|
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
|
} catch {
|
|
// already gone
|
|
}
|
|
restorePath();
|
|
}
|
|
});
|
|
});
|