/** * @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ĩ */ 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(), "ccam-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 ` 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 tears down the worktree and the branch, and deletes the lane row", 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, force: true, expect: destructiveExpect(preflight.body), }); assert.equal(removed.status, 200); assert.equal(removed.body.ok, true); assert.equal(fs.existsSync(lane.cwd), false); assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), ""); 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 an adopted lane with unpushed commits without force, while managed removal still requires it", 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 refused = await request("POST", `/api/lanes/${managed.id}/remove`, { confirm: true, expect: destructiveExpect(managedPreflight.body), }); assert.equal(refused.status, 409); assert.equal(refused.body.error.code, "EUNPUSHED"); await request("DELETE", `/api/lanes/${managed.id}`); }); 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 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: "" }; } if (cmd === "tmux" && args[0] === "kill-session" && args[1] === "-t" && args[2] === runId) { return { code: 0, stdout: "", stderr: "" }; } return { code: 1, stdout: "", stderr: "" }; }); 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 lane whose worktree was deleted by hand, taking the prune path", async () => { // The design promises "the lane reports `missing` and only `remove` is // offered, taking the prune path". Before this, `remove` hit check 2, which // maps a non-existent path to EOUTSIDEROOT → 400, so the lane row could // never be removed through the destructive action at all. 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); // git's stale record and the lane's branch are both gone. const wt = require("../lib/worktree"); assert.equal( (await wt.listWorktrees(SRC)).some((w) => w.path === lane.cwd), false ); assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), ""); }); 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("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(); } }); });