f1e7d4245a
Add missing test coverage for healRunId's core behavior: that a STALE run_id (tmux session gone) gets CLEARED to null with status: idle when read via GET. The existing test only verified the LIVE case (session still running). This test proves the release-on-gone path, simulating a session death via tmux mock.
1201 lines
47 KiB
JavaScript
1201 lines
47 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(), "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;
|
|
}
|
|
|
|
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 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 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();
|
|
}
|
|
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 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();
|
|
}
|
|
|
|
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 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();
|
|
}
|
|
});
|
|
|
|
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 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();
|
|
}
|
|
});
|
|
});
|