Files
AgentHub/server/__tests__/lanes-api.test.js
T
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00

2170 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
* counters the header badges read, and the last-event age that drives liveness.
* @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 path = require("path");
const os = require("os");
const http = require("http");
const fs = require("fs");
const { WebSocket } = require("ws");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${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 { createApp, startServer } = require("../index");
let server;
let BASE;
function request(method, urlPath, body, headers = {}) {
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: {
...headers,
...(payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {}),
},
},
(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();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => server && server.close());
describe("/api/lanes", () => {
let laneId;
it("creates a lane", async () => {
const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.title, "Lane A");
assert.equal(r.body.lane.stage, "idle");
laneId = r.body.lane.id;
});
it("rejects a relative cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
assert.equal(r.status, 400);
});
it("rejects a duplicate cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 409);
});
it("rejects a stage that names no node of the lane's pipeline", async () => {
// Silently storing it is what made "CI gates" read never-reached on a lane
// where the agent had been declaring `test` (the default pipeline's name)
// all along.
const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "implemnt" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSTAGE");
assert.ok(r.body.error.stages.includes("implement"));
});
it("accepts a stage declared by one of the node's aliases", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "coding" });
assert.equal(r.status, 200);
});
it("reports a stage and returns node states", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
stage: "review",
status: "running",
evidence: null,
});
assert.equal(r.status, 200);
const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "current");
assert.ok(r.body.lane.progress > 0);
});
it("404s on an unknown lane", async () => {
const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
assert.equal(r.status, 404);
});
it("lists lanes with counters", async () => {
const r = await request("GET", "/api/lanes");
assert.equal(r.status, 200);
assert.ok(r.body.lanes.length >= 1);
assert.equal(r.body.counts.total, r.body.lanes.length);
assert.equal(typeof r.body.counts.running, "number");
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("serves the same routes under /api/workspaces, the UI's word", async () => {
const lanes = await request("GET", "/api/lanes");
const list = await request("GET", "/api/workspaces");
assert.equal(list.status, 200);
assert.deepEqual(
list.body.lanes.map((l) => l.id),
lanes.body.lanes.map((l) => l.id)
);
const one = await request("GET", `/api/workspaces/${laneId}`);
assert.equal(one.status, 200);
assert.equal(one.body.lane.id, laneId);
// Mounted through the sources router, which sits before the lanes router.
const sources = await request("GET", `/api/workspaces/${laneId}/sources`);
assert.equal(sources.status, 200);
assert.deepEqual(sources.body, (await request("GET", `/api/lanes/${laneId}/sources`)).body);
});
it("exposes pipeline templates", async () => {
const r = await request("GET", "/api/lanes/pipelines");
assert.equal(r.status, 200);
assert.ok(r.body.pipelines.some((p) => p.id === "default"));
});
it("patches and deletes", async () => {
const p = await request("PATCH", `/api/lanes/${laneId}`, { ci_status: "green" });
assert.equal(p.body.lane.ci_status, "green");
const d = await request("DELETE", `/api/lanes/${laneId}`);
assert.equal(d.status, 200);
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
});
// The Workspace's inline rename is a text field PATCHing this route, so a
// title is the one lane column a user types directly. Bad input on any
// patchable column is a 400: reaching better-sqlite3 with a non-scalar
// raises a datatype error Express can only report as a 500.
it("renames a lane, and answers 400 rather than 500 for unbindable input", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-rename-route" });
const id = created.body.lane.id;
const named = await request("PATCH", `/api/lanes/${id}`, { title: " checkout flow " });
assert.equal(named.status, 200);
assert.equal(named.body.lane.title, "checkout flow");
const cleared = await request("PATCH", `/api/lanes/${id}`, { title: "" });
assert.equal(cleared.body.lane.title, "");
for (const patch of [{ title: { evil: true } }, { notes: { a: 1 } }, { ci_status: true }]) {
const bad = await request("PATCH", `/api/lanes/${id}`, patch);
assert.equal(bad.status, 400, `expected 400 for ${JSON.stringify(patch)}`);
assert.equal(bad.body.error.code, "EBADFIELD");
}
// A NOT NULL column patched to null is the caller's mistake too.
const notNull = await request("PATCH", `/api/lanes/${id}`, { status: null });
assert.equal(notNull.status, 400);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane deletion", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
const id = created.body.lane.id;
const rejected = await request("DELETE", `/api/lanes/${id}`, undefined, {
Origin: "https://attacker.example",
});
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 200);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane patching", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-patch" });
const id = created.body.lane.id;
const rejected = await request(
"PATCH",
`/api/lanes/${id}`,
{ run_id: "attacker-run" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.run_id, null);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane creation", async () => {
const rejected = await request(
"POST",
"/api/lanes",
{ cwd: "/tmp/lane-cross-origin-create" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
});
it("rejects cross-origin stage reporting", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-stage" });
const id = created.body.lane.id;
const rejected = await request(
"POST",
`/api/lanes/${id}/stage`,
{ stage: "implement" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.stage, "idle");
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("opens the native picker only for a local, unproxied, same-origin browser", async () => {
const nd = require("../lib/native-dialog");
const desktop = require("../lib/desktop-env");
const calls = [];
nd.__setExecImpl(async (cmd) => {
calls.push(cmd);
return { code: 0, stdout: "/tmp/picked\n", stderr: "" };
});
desktop.__setExecImpl(() => "DISPLAY=:0\n");
try {
for (const headers of [
{ Origin: "https://attacker.example" },
{ "X-Forwarded-For": "203.0.113.9" },
{ Forwarded: "for=203.0.113.9" },
]) {
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" }, headers);
assert.equal(r.status, 403, JSON.stringify(headers));
}
assert.deepEqual(calls, []);
if (process.platform === "linux") {
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" });
assert.equal(r.status, 200);
assert.equal(r.body.path, "/tmp/picked");
nd.__setExecImpl(async () => ({ missing: true }));
const none = await request("POST", "/api/lanes/pick-path", { kind: "file" });
assert.equal(none.status, 501);
assert.equal(none.body.error.code, "ENODIALOG");
}
} finally {
nd.__setExecImpl(null);
desktop.__setExecImpl(() => {
throw new Error("no systemd user manager in this test");
});
}
});
it("broadcasts lane_update on create", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
const messages = [];
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("ws test timeout")), 5000);
});
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
Promise.race([timeout]).catch(reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
const createResp = await request("POST", "/api/lanes", {
title: "WS Test Lane",
cwd: "/tmp/ws-test-lane",
});
assert.equal(createResp.status, 201);
const newLaneId = createResp.body.lane.id;
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const laneUpdateMsg = messages.find(
(m) => m.type === "lane_update" && m.data.lane?.id === newLaneId
);
assert.ok(laneUpdateMsg, "create should broadcast lane_update");
assert.equal(laneUpdateMsg.data.lane.id, newLaneId);
assert.equal(laneUpdateMsg.data.lane.title, "WS Test Lane");
// Test delete broadcast
messages.length = 0;
const deleteResp = await request("DELETE", `/api/lanes/${newLaneId}`);
assert.equal(deleteResp.status, 200);
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const deleteMsg = messages.find(
(m) => m.type === "lane_update" && m.data.removed === newLaneId
);
assert.ok(deleteMsg, "delete should broadcast lane_update with removed");
assert.equal(deleteMsg.data.removed, newLaneId);
ws.close();
});
it("starts a session on the proxy it names, and refuses one the store lacks", async () => {
const tmux = require("../lib/tmux");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-proxy-start-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
try {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw new Error("no such session");
return "";
});
const bad = await request("POST", `/api/lanes/${id}/start`, { proxy: "nope" });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADPROXY");
// The pre-rename field still names a proxy, for a client older than it.
const legacy = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
assert.equal(legacy.body.error.code, "EBADPROXY");
// The per-workspace route is gone: a proxy belongs to a session now.
const gone = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
assert.equal(gone.status, 404);
} finally {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("liveness of a just-started run", () => {
// A new run keeps the lane's PREVIOUS session_id until the new session's
// first hook rebinds it, and that session may have been silent for days —
// judged by it alone, a run started a second ago read "dead" until a
// refresh (and stayed so for a resume, whose hooks change no lane field).
it("ages the lane from the newer of its last event and its run's start", async () => {
const tmux = require("../lib/tmux");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-live-start-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const runId = `agenthub-lane-${id}`;
const old = new Date(Date.now() - 3 * 86400e3).toISOString();
try {
tmux.__setExecImpl(() => "");
db.prepare(
"INSERT INTO sessions (id, status, started_at, cwd) VALUES ('sess-old-run', 'completed', ?, ?)"
).run(old, cwd);
db.prepare(
"INSERT INTO events (session_id, event_type, summary, created_at) VALUES ('sess-old-run', 'Stop', '', ?)"
).run(old);
lanesLib.updateLane(id, { session_id: "sess-old-run", run_id: runId, status: "running" });
const runAt = db.prepare(
"INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, started_at, lane_id) VALUES (?, 'interactive', ?, 'running', ?, ?)"
);
runAt.run(runId, cwd, new Date().toISOString(), id);
const fresh = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(fresh.liveness, "active");
runAt.run(runId, cwd, old, id);
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.liveness, "dead");
// A lane no hook ever binds a session to (a catalog tool, Codex without
// its hooks) has no signal at all, and stays "active" however long it runs.
lanesLib.updateLane(id, { session_id: null });
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.liveness, "active");
} finally {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("lanes changed by another server on the same DB", () => {
// Hooks go to ONE server per database (the lowest port), so a browser on the
// other one never heard about a lane the hooks moved until it refreshed.
it("re-broadcasts a lane another connection changed, and a removed one", async () => {
const Database = require("better-sqlite3");
const { broadcastForeignLaneChanges } = require("../routes/lanes");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-foreign-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const other = new Database(TEST_DB);
try {
broadcastForeignLaneChanges(); // prime what this server has seen
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [] });
other
.prepare("UPDATE lanes SET status = 'running', updated_at = ? WHERE id = ?")
.run(new Date(Date.now() + 5).toISOString(), id);
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [id], removed: [] });
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [] });
other.prepare("DELETE FROM lanes WHERE id = ?").run(id);
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [id] });
} finally {
other.close();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("lane start input", () => {
it("answers 400 for a model that is not a plain model id", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
try {
const r = await request("POST", `/api/lanes/${id}/start`, { model: "--dangerous" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADMODEL");
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
it("answers 400 for a fork with nothing to fork, or on a tool that cannot fork", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-fork-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const codexId = (await request("POST", "/api/lanes", { cwd, tool: "codex" })).body.lane.id;
try {
let r = await request("POST", `/api/lanes/${id}/start`, { forkSession: true });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSESSION");
r = await request("POST", `/api/lanes/${codexId}/start`, {
forkSession: true,
resumeSessionId: "abc12345",
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EUNSUPPORTED");
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
// A Terminal tab: the user's own shell in the workspace folder, no AI tool —
// so it is no agent at work and leaves the lane's run_id/status alone.
it("opens a plain shell for `shell: true`, without marking the lane running", async () => {
const tmux = require("../lib/tmux");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-shell-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
let created = null;
try {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session" && !created) throw new Error("no such session");
if (args[0] === "new-session") created = args;
return "";
});
const r = await request("POST", `/api/lanes/${id}/start`, {
slot: 1,
shell: true,
prompt: "ignored",
tool: "codex",
});
assert.equal(r.status, 200);
assert.equal(r.body.run_id, `agenthub-lane-${id}-1`);
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
assert.equal(created[created.indexOf("-c") + 1], cwd);
// After `--` only the fixed size-wait wrapper that execs the user's own
// default shell (issue #55) — nothing from the request.
const argv = created.slice(created.indexOf("--") + 1);
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
assert.equal(argv.length, 3);
assert.match(argv[2], /exec "\$SHELL" -l$/);
assert.ok(!created.includes("ignored"));
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.run_id, null);
assert.notEqual(lane.status, "running");
const run = (await request("GET", `/api/run/agenthub-lane-${id}-1`)).body;
assert.equal(run.tool, "shell");
} finally {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-hook-a",
title: "Hooked",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a/sub/dir" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-lane-1");
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", message: "needs permission" },
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("ignores a hook whose cwd is under no lane", async () => {
const before = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-orphan", cwd: "/tmp/not-a-lane" },
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
});
it("keeps the lane's session when a split pane (slot 1–9) of the workspace sends hooks", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-split-pane" });
const id = created.body.lane.id;
const hook = (session_id, run_id) =>
request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
run_id,
data: {
session_id,
cwd: "/tmp/lane-split-pane",
tool_name: "Read",
tool_use_id: session_id,
},
});
await hook("sess-main", `agenthub-lane-${id}`);
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-main");
// A split pane's session never takes the lane over…
await hook("sess-split", `agenthub-lane-${id}-2`);
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-main");
// …while a session outside the dashboard still does, as before.
await hook("sess-outside", undefined);
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-outside");
});
it("clears needs_action only from the session that raised it, not from cross-session rebinds", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-cross-session",
title: "Cross-Session",
});
const id = created.body.lane.id;
// Session A sets needs_action
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session", message: "blocked" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-a");
// Session B PostToolUse: rebinds lane to B, but does NOT clear A's flag
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-b",
cwd: "/tmp/lane-cross-session",
tool_name: "Read",
tool_use_id: "t1",
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
// B is bound now, but it still did not raise the flag: A is still waiting.
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-b",
cwd: "/tmp/lane-cross-session",
tool_name: "Read",
tool_use_id: "t2",
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
// A answering its own prompt is what clears it.
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-a",
cwd: "/tmp/lane-cross-session",
tool_name: "Read",
tool_use_id: "t3",
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("raises needs_action with default 'needs you' when Notification has no message", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-default-msg",
title: "Default Msg",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "needs you");
await request("DELETE", `/api/lanes/${id}`);
});
it("the bare idle nudge never raises needs_action and clears a stale one", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-idle-nudge",
title: "Idle Nudge",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-nudge", cwd: "/tmp/lane-idle-nudge" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-nudge",
cwd: "/tmp/lane-idle-nudge",
message: "Claude needs your permission to use Bash",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"Claude needs your permission to use Bash"
);
// The 60s idle nudge fires after Stop: it proves the CLI is parked at an
// idle prompt, so it must clear rather than pin the banner.
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-nudge",
cwd: "/tmp/lane-idle-nudge",
message: "Claude is waiting for your input",
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null);
assert.equal(lane.status, "idle");
await request("DELETE", `/api/lanes/${id}`);
});
it("mirrors CLI turn state onto lane.status for a lane the dashboard did not launch", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-turn-status",
title: "Turn Status",
});
const id = created.body.lane.id;
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
await request("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
// A subagent finishing is not the end of the turn.
await request("POST", "/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
await request("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("hook → stage detection", () => {
it("a Bash test-run hook sets detected_stage to tests, a following Read leaves it unchanged", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-a",
title: "Detect",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
let lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Read",
tool_input: { file_path: "/tmp/lane-detect-a/foo.js" },
},
});
lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("a hook whose cwd is under no lane changes nothing", async () => {
const beforeLanes = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-orphan",
cwd: "/tmp/not-a-lane-detect",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, beforeLanes);
});
it("a lane already declared at ship ignores an implement detection", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-declared",
title: "Declared",
});
const id = created.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "ship", status: "running" });
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-2",
cwd: "/tmp/lane-detect-declared",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-declared/foo.js" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("a malformed hook payload still returns 200 and leaves the lane untouched", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-malformed",
title: "Malformed",
});
const id = created.body.lane.id;
const r1 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-3",
cwd: "/tmp/lane-detect-malformed",
tool_input: "just a string, no tool_name",
},
});
assert.equal(r1.status, 200);
const r2 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-detect-4", cwd: "/tmp/lane-detect-malformed" },
});
assert.equal(r2.status, 200);
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("detection is visible in GET /api/lanes/:id with a detected node, and no node is 'done'", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-visible",
title: "Visible",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-5",
cwd: "/tmp/lane-detect-visible",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
const testsNode = lane.pipeline_nodes.find((n) => n.id === "tests");
assert.equal(testsNode.detected, true);
assert.ok(!lane.pipeline_nodes.some((n) => n.state === "done"));
await request("DELETE", `/api/lanes/${id}`);
});
it("a throwing recordDetection does not cost the lane bookkeeping in the same hook", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-throws",
title: "Throws",
});
const id = created.body.lane.id;
// Bind the session and raise needs_action, exactly as a permission prompt does.
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-detect-throw", cwd: "/tmp/lane-detect-throws" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
message: "needs permission",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
// Stand in for the real failures recordDetection can raise mid-hook: ENOLANE
// when the lane is deleted between resolveLaneByCwd and its lookup, or
// SQLITE_BUSY from another process on the same database.
const lanesLib = require("../lib/lanes");
const realRecordDetection = lanesLib.recordDetection;
lanesLib.recordDetection = () => {
throw Object.assign(new Error("no lane 999"), { code: "ENOLANE" });
};
try {
const r = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-throws/foo.js" },
},
});
assert.equal(r.status, 200);
} finally {
lanesLib.recordDetection = realRecordDetection;
}
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null, "bookkeeping must survive a failed detection");
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("lane actions", () => {
it("requires confirmation, then removes an adopted lane row without its directory", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-a" });
const id = c.body.lane.id;
assert.equal((await request("POST", `/api/lanes/${id}/remove`, {})).status, 400);
const preflight = await request("GET", `/api/lanes/${id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${id}/remove`, {
confirm: true,
expect: {
head: preflight.body.head,
dirty: preflight.body.dirty,
untracked: preflight.body.untracked,
unpushed: preflight.body.unpushed,
},
});
assert.equal(removed.status, 200);
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 404);
});
it("rejects an unknown action", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-b" });
const id = c.body.lane.id;
const before = await request("GET", `/api/lanes/${id}`);
const r = await request("POST", `/api/lanes/${id}/frobnicate`, {});
assert.equal(r.status, 400);
const after = await request("GET", `/api/lanes/${id}`);
assert.equal(after.body.lane.stage, before.body.lane.stage);
assert.equal(after.body.lane.status, before.body.lane.status);
await request("DELETE", `/api/lanes/${id}`);
});
it("clear resets stage state but keeps the lane", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-c", title: "Keep me" });
const id = c.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "review", status: "running" });
const r = await request("POST", `/api/lanes/${id}/clear`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.stage, "idle");
assert.equal(r.body.lane.title, "Keep me");
assert.deepEqual(r.body.lane.stages, {});
await request("DELETE", `/api/lanes/${id}`);
});
it("stop on a lane with no run is a no-op, not a 500", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-d" });
const r = await request("POST", `/api/lanes/${c.body.lane.id}/stop`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.status, "idle");
await request("DELETE", `/api/lanes/${c.body.lane.id}`);
});
it("message on a lane is no longer supported via REST", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
const id = c.body.lane.id;
const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EUNSUPPORTED");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("GET /api/lanes/:id/git", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-api-git-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("reports the working-copy facts for a lane pointing at a real repo", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/api-facts");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "one\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "api facts fixture");
fs.writeFileSync(path.join(dir, "a.txt"), "two\n");
fs.writeFileSync(path.join(dir, "untracked.txt"), "u\n");
const created = await request("POST", "/api/lanes", { cwd: dir, title: "git facts" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.branch, "feat/api-facts");
assert.equal(r.body.subject, "api facts fixture");
assert.equal(r.body.head, g(dir, "rev-parse", "--short", "HEAD").trim());
assert.equal(r.body.dirty, 1);
assert.equal(r.body.untracked, 1);
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false for a plain directory, not an error", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "plain dir" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false when the lane's directory is gone", async () => {
const dir = path.join(ROOT, "vanishes");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "vanishing" });
const id = created.body.lane.id;
fs.rmSync(dir, { recursive: true, force: true });
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("404s for a lane that does not exist", async () => {
const r = await request("GET", "/api/lanes/999999/git");
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOLANE");
});
it("resolves to the facts route, not an action error", async () => {
// The `/:id/:action` catch-all is a POST and cannot shadow this GET, so
// this pins the response SHAPE rather than any registration order.
const dir = path.join(ROOT, "ordering");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "ordering" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.ok("available" in r.body, JSON.stringify(r.body));
assert.equal(r.body.error, undefined);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("the lane git surface: changes, branches, actions (issue #34)", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-api-gitops-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
let laneId;
let dir;
before(async () => {
dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/api-changes");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "one\ntwo\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "seed");
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTWO\n");
fs.writeFileSync(path.join(dir, "fresh.txt"), "new file\n");
const created = await request("POST", "/api/lanes", { cwd: dir, title: "git ops" });
laneId = created.body.lane.id;
});
after(async () => {
await request("DELETE", `/api/lanes/${laneId}`);
fs.rmSync(ROOT, { recursive: true, force: true });
});
it("summarizes the working tree without patch text", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.range, "work");
assert.deepEqual(r.body.files.map((f) => f.path).sort(), ["a.txt", "fresh.txt"]);
assert.ok(r.body.files.every((f) => f.hunks === undefined));
});
it("returns one file's hunks when a path is given", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/changes?path=a.txt`);
assert.equal(r.status, 200);
assert.equal(r.body.files.length, 1);
assert.ok(r.body.files[0].hunks.length > 0);
});
it("400s for a path with no changes", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/changes?path=nope.txt`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENOFILECHANGE");
});
it("400s for the branch range on a lane with no base branch", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/changes?range=branch`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENOBASE");
});
it("lists the lane's own branches", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/branches`);
assert.equal(r.status, 200);
assert.deepEqual(r.body.branches, ["feat/api-changes"]);
assert.equal(r.body.current, "feat/api-changes");
});
it("commits through the action route and leaves the tree clean", async () => {
const r = await request("POST", `/api/lanes/${laneId}/git/commit`, {
message: "feat: through the api",
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(g(dir, "log", "-1", "--format=%s").trim(), "feat: through the api");
const after = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.deepEqual(after.body.files, []);
});
it("400s a commit with nothing staged and an unknown action", async () => {
const empty = await request("POST", `/api/lanes/${laneId}/git/commit`, { message: "again" });
assert.equal(empty.status, 400);
assert.equal(empty.body.error.code, "ENOCHANGES");
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
assert.equal(bogus.status, 400);
assert.equal(bogus.body.error.code, "EBADACTION");
// Its own route, not an action: never reaches claude with nothing staged.
const suggest = await request("POST", `/api/lanes/${laneId}/git/commit-message`, {});
assert.equal(suggest.status, 400);
assert.equal(suggest.body.error.code, "ENOSTAGED");
});
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
const r = await request("POST", `/api/lanes/${laneId}/git/publish`, { url: "file:///tmp/x" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADURL");
assert.equal(g(dir, "remote").trim(), "");
});
it("reads the log, one commit's diff, a whole file, the path list and the tree", async () => {
const log = await request("GET", `/api/lanes/${laneId}/git/log?limit=5`);
assert.equal(log.status, 200);
assert.deepEqual(
log.body.commits.map((c) => c.subject),
["feat: through the api", "seed"]
);
const sha = log.body.commits[0].sha;
const commit = await request(
"GET",
`/api/lanes/${laneId}/git/changes?range=commit&sha=${sha}&full=1`
);
assert.equal(commit.status, 200);
assert.equal(commit.body.sha, sha);
assert.ok(commit.body.files.some((f) => f.path === "a.txt" && f.hunks.length > 0));
const bad = await request("GET", `/api/lanes/${laneId}/git/changes?range=commit&sha=nope`);
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADREV");
const file = await request("GET", `/api/lanes/${laneId}/git/file?path=a.txt&rev=HEAD`);
assert.equal(file.status, 200);
assert.deepEqual(file.body.lines, ["one", "TWO"]);
const escape = await request("GET", `/api/lanes/${laneId}/git/file?path=../../etc/passwd`);
assert.equal(escape.status, 400);
assert.equal(escape.body.error.code, "EBADPATH");
const ls = await request("GET", `/api/lanes/${laneId}/git/ls?q=a.t`);
assert.equal(ls.status, 200);
assert.deepEqual(ls.body.paths, ["a.txt"]);
const tree = await request("GET", `/api/lanes/${laneId}/git/tree`);
assert.equal(tree.status, 200);
assert.ok(tree.body.entries.some((e) => e.name === "a.txt" && e.dir === false));
});
it("saves a file from the editor over the exact bytes it loaded, and nothing else", async () => {
const opened = await request("GET", `/api/lanes/${laneId}/git/file?path=a.txt&rev=work`);
assert.equal(typeof opened.body.sha, "string");
const saved = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "a.txt",
content: "one\nTWO\nthree",
baseSha: opened.body.sha,
});
assert.equal(saved.status, 200, JSON.stringify(saved.body));
// The final newline the file had survives an editor that drops it.
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
assert.notEqual(saved.body.sha, opened.body.sha);
// Saving over the OLD stamp is a conflict, never a silent overwrite.
const stale = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "a.txt",
content: "clobbered",
baseSha: opened.body.sha,
});
assert.equal(stale.status, 409);
assert.equal(stale.body.error.code, "ESTALE");
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
const escape = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "../../etc/passwd",
content: "x",
baseSha: "0".repeat(40),
});
assert.equal(escape.status, 400);
assert.equal(escape.body.error.code, "EBADPATH");
const secret = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: ".claude/settings.local.json",
content: "{}",
baseSha: "0".repeat(40),
});
assert.equal(secret.status, 403);
assert.equal(secret.body.error.code, "ESECRET");
});
it("stages one file, commits only the index, and refuses an unchanged path", async () => {
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTHREE\n");
fs.writeFileSync(path.join(dir, "b.txt"), "left behind\n");
const staged = await request("POST", `/api/lanes/${laneId}/git/stage`, { paths: ["a.txt"] });
assert.equal(staged.status, 200, JSON.stringify(staged.body));
const unknown = await request("POST", `/api/lanes/${laneId}/git/stage`, {
paths: ["../../etc/passwd"],
});
assert.equal(unknown.status, 400);
assert.equal(unknown.body.error.code, "EBADPATH");
const committed = await request("POST", `/api/lanes/${laneId}/git/commit`, {
message: "feat: only the index",
only: "staged",
});
assert.equal(committed.status, 200, JSON.stringify(committed.body));
const after = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.deepEqual(
after.body.files.map((f) => f.path),
["b.txt"]
);
});
it("refuses a discard without confirm, then throws the file away with it", async () => {
const refused = await request("POST", `/api/lanes/${laneId}/git/discard`, {
paths: ["b.txt"],
});
assert.equal(refused.status, 400);
assert.equal(refused.body.error.code, "ECONFIRM");
assert.ok(fs.existsSync(path.join(dir, "b.txt")));
const done = await request("POST", `/api/lanes/${laneId}/git/discard`, {
paths: ["b.txt"],
confirm: true,
});
assert.equal(done.status, 200, JSON.stringify(done.body));
assert.ok(!fs.existsSync(path.join(dir, "b.txt")));
});
it("creates a branch without switching and refuses to delete a protected one", async () => {
const created = await request("POST", `/api/lanes/${laneId}/git/branch-create`, {
branch: "feat/sibling",
});
assert.equal(created.status, 200, JSON.stringify(created.body));
assert.equal(g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/api-changes");
const protectedBranch = await request("POST", `/api/lanes/${laneId}/git/branch-delete`, {
branch: "main",
});
assert.equal(protectedBranch.status, 400);
// No `main` in this fixture, so the missing-branch check answers first —
// what matters is that neither path deletes anything.
assert.ok(["EPROTECTED", "ENOBRANCH"].includes(protectedBranch.body.error.code));
const deleted = await request("POST", `/api/lanes/${laneId}/git/branch-delete`, {
branch: "feat/sibling",
});
assert.equal(deleted.status, 200, JSON.stringify(deleted.body));
assert.equal(g(dir, "branch", "--list", "feat/sibling").trim(), "");
});
it("maps a git exit status to 409 EGIT with git's stderr in the message", async () => {
// A remote that is not a repository: `git push` exits 128, and execFile
// reports that as a NUMERIC err.code. It must never reach the client as
// `code: 128`, nor as a 500 — git refusing is not a server fault.
g(dir, "remote", "add", "origin", path.join(ROOT, "no-such-remote"));
try {
const r = await request("POST", `/api/lanes/${laneId}/git/push`, {});
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EGIT");
assert.match(r.body.error.message, /^git push failed\n/);
assert.match(r.body.error.message, /no-such-remote/);
} finally {
g(dir, "remote", "remove", "origin");
}
});
it("404s every git route for a lane that does not exist", async () => {
for (const url of [
"/api/lanes/999999/git/changes",
"/api/lanes/999999/git/branches",
"/api/lanes/999999/git/log",
"/api/lanes/999999/git/file?path=a.txt",
"/api/lanes/999999/git/ls",
"/api/lanes/999999/git/tree",
]) {
const r = await request("GET", url);
assert.equal(r.status, 404);
}
});
});
describe("GET /api/lanes/branches", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-api-branches-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("lists the repo's local branches and the current one", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "init");
g(dir, "branch", "feat/x");
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
assert.equal(r.status, 200);
assert.deepEqual([...r.body.branches].sort(), ["feat/x", "main"]);
assert.equal(r.body.current, "main");
});
it("400s for a relative path", async () => {
const r = await request("GET", "/api/lanes/branches?repo=relative/path");
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("400s for a path that does not exist", async () => {
const r = await request(
"GET",
`/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`
);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("400s for a directory that is not a git repository", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("is not swallowed by the /:id catch-all", async () => {
// "/branches" would otherwise be read as a lane id and 404 with ENOLANE.
const r = await request("GET", "/api/lanes/branches?repo=%2Fdoes%2Fnot%2Fmatter");
assert.notEqual(r.body?.error?.code, "ENOLANE");
});
});
describe("detection attributes work to the lane the work touched", () => {
// Measured on a real session: 325 of 400 hook events carried the SESSION's
// cwd, while the actual edits and test runs happened inside a different
// repo reached with `cd <other> && ...`. The lane doing the work detected
// nothing; the lane the terminal happened to start in absorbed all of it.
it("credits a Bash command that cd's into another lane to THAT lane", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-session",
title: "where the shell started",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-worked",
title: "where the work happened",
});
const sessionId = session.body.lane.id;
const workedId = worked.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-1",
cwd: "/tmp/lane-attr-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-attr-worked && npm run test:server" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${workedId}`)).body.lane.detected_stage,
"tests"
);
assert.equal(
(await request("GET", `/api/lanes/${sessionId}`)).body.lane.detected_stage,
null,
"the session's own lane did no work and must not be credited"
);
await request("DELETE", `/api/lanes/${workedId}`);
await request("DELETE", `/api/lanes/${sessionId}`);
});
it("credits an Edit to the lane owning the edited file", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-2",
cwd: "/tmp/lane-edit-session",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-edit-worked/server/lib/x.js" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.detected_stage,
"implement"
);
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.detected_stage,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
it("falls back to the session's lane when the tool names no other lane's path", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-fallback",
title: "fallback",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-3",
cwd: "/tmp/lane-attr-fallback",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("keeps session bookkeeping on the session's lane, not the worked-in lane", async () => {
// session_id / needs_action ARE session-scoped facts: the session really is
// bound to the directory it started in. Only the stage inference follows
// the work.
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-4",
cwd: "/tmp/lane-book-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-book-worked && npm test" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.session_id,
"sess-attr-4"
);
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.session_id,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
it("a hook event at a shared cwd only touches the claude lane, never the codex lane", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/lane-dual-tool",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/lane-dual-tool",
tool: "codex",
});
assert.notEqual(claudeLane.body.lane.id, codexLane.body.lane.id);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-dual-tool",
cwd: "/tmp/lane-dual-tool",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${claudeLane.body.lane.id}`)).body.lane.detected_stage,
"tests"
);
assert.equal(
(await request("GET", `/api/lanes/${claudeLane.body.lane.id}`)).body.lane.session_id,
"sess-dual-tool"
);
assert.equal(
(await request("GET", `/api/lanes/${codexLane.body.lane.id}`)).body.lane.detected_stage,
null,
"the codex lane sharing this cwd must not absorb a claude hook event"
);
assert.equal(
(await request("GET", `/api/lanes/${codexLane.body.lane.id}`)).body.lane.session_id,
null
);
await request("DELETE", `/api/lanes/${claudeLane.body.lane.id}`);
await request("DELETE", `/api/lanes/${codexLane.body.lane.id}`);
});
});
describe("GET /:id/ci/status", () => {
let laneId;
let laneCwd;
let restorePath;
let bin;
before(async () => {
laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-route-"));
const r = await request("POST", "/api/lanes", { title: "CI status lane", cwd: laneCwd });
laneId = r.body.lane.id;
bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-bin-"));
fs.writeFileSync(
path.join(bin, "gh"),
`#!/usr/bin/env bash
if [ "$1" = "run" ] && [ "$2" = "list" ]; then
echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]'
exit 0
fi
if [ "$1" = "run" ] && [ "$2" = "view" ]; then
echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}'
exit 0
fi
exit 1
`
);
fs.chmodSync(path.join(bin, "gh"), 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
restorePath = () => {
process.env.PATH = originalPath;
};
});
after(() => {
if (restorePath) restorePath();
fs.rmSync(laneCwd, { recursive: true, force: true });
fs.rmSync(bin, { recursive: true, force: true });
});
it("404s for an unknown lane", async () => {
const r = await request("GET", "/api/lanes/999999/ci/status?ref=development");
assert.equal(r.status, 404);
});
it("400s when ref is missing", async () => {
const r = await request("GET", `/api/lanes/${laneId}/ci/status`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADREF");
});
it("returns the derived state for a real ref", async () => {
const r = await request("GET", `/api/lanes/${laneId}/ci/status?ref=development`);
assert.equal(r.status, 200);
assert.equal(r.body.state, "success");
assert.equal(r.body.checks[0].name, "tests");
});
});
describe("/api/lanes tool tests", () => {
it("POST /api/lanes/ensure stores the requested tool", async () => {
const res = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/codex-lane",
tool: "codex",
});
assert.equal(res.status, 201);
assert.equal(res.body.lane.tool, "codex");
});
it("POST /api/lanes/ensure rejects an unknown tool", async () => {
const res = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/bad-tool",
tool: "not-a-tool",
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "EBADTOOL");
});
it("POST /api/lanes/ensure creates a second lane for the same cwd on a different tool", async () => {
const first = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/claude-lane",
tool: "claude",
});
assert.equal(first.status, 201);
const second = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/claude-lane",
tool: "codex",
});
assert.equal(second.status, 201);
assert.equal(second.body.created, true);
assert.notEqual(second.body.lane.id, first.body.lane.id);
assert.equal(second.body.lane.cwd, first.body.lane.cwd);
assert.equal(second.body.lane.tool, "codex");
assert.equal(first.body.lane.tool, "claude");
});
it("POST /api/lanes/ensure re-ensuring the same (cwd, tool) pair is a no-op", async () => {
const first = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/claude-lane-2",
tool: "claude",
});
assert.equal(first.status, 201);
const second = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/claude-lane-2",
tool: "claude",
});
assert.equal(second.status, 200);
assert.equal(second.body.created, false);
assert.equal(second.body.lane.id, first.body.lane.id);
});
it("POST /api/lanes/ensure resolves a symlinked path, existing or not, to one lane", async () => {
const real = fs.mkdtempSync(path.join(os.tmpdir(), "lane-real-"));
const link = `${real}-link`;
fs.symlinkSync(real, link);
try {
for (const sub of ["", "/not-yet"]) {
const first = await request("POST", "/api/lanes/ensure", { cwd: real + sub });
const second = await request("POST", "/api/lanes/ensure", { cwd: link + sub });
assert.equal(second.body.created, false, sub);
assert.equal(second.body.lane.id, first.body.lane.id, sub);
}
} finally {
fs.rmSync(link);
fs.rmSync(real, { recursive: true });
}
});
it("POST /api/lanes/ensure re-ensuring with the same tool is a no-op", async () => {
const first = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/same-tool-lane",
tool: "codex",
});
assert.equal(first.status, 201);
const second = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/same-tool-lane",
tool: "codex",
});
assert.equal(second.status, 200);
assert.equal(second.body.created, false);
assert.equal(second.body.lane.tool, "codex");
});
it("POST /api/lanes/ensure defaults to 'claude' tool when tool is not specified", async () => {
const res = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/default-tool-lane",
});
assert.equal(res.status, 201);
assert.equal(res.body.lane.tool, "claude");
});
it("GET /api/lanes returns both lanes for a shared cwd with distinct IDs and tools", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-list-cwd",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-list-cwd",
tool: "codex",
});
const claudeId = claudeLane.body.lane.id;
const codexId = codexLane.body.lane.id;
const list = await request("GET", "/api/lanes");
assert.equal(list.status, 200);
const dualCwdLanes = list.body.lanes.filter((l) => l.cwd === "/tmp/dual-list-cwd");
assert.equal(dualCwdLanes.length, 2, "expected both lanes to appear in the list");
const found = {
claude: dualCwdLanes.find((l) => l.tool === "claude"),
codex: dualCwdLanes.find((l) => l.tool === "codex"),
};
assert.ok(found.claude, "claude lane must be in the list");
assert.ok(found.codex, "codex lane must be in the list");
assert.equal(found.claude.id, claudeId);
assert.equal(found.codex.id, codexId);
assert.notEqual(found.claude.id, found.codex.id);
await request("DELETE", `/api/lanes/${claudeId}`);
await request("DELETE", `/api/lanes/${codexId}`);
});
it("DELETE one lane of a (cwd, tool) pair leaves the sibling lane untouched", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-delete-cwd",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-delete-cwd",
tool: "codex",
});
const claudeId = claudeLane.body.lane.id;
const codexId = codexLane.body.lane.id;
// Delete the codex lane
const deleteRes = await request("DELETE", `/api/lanes/${codexId}`);
assert.equal(deleteRes.status, 200);
// Verify the codex lane is gone
assert.equal((await request("GET", `/api/lanes/${codexId}`)).status, 404);
// Verify the claude lane still exists and is untouched
const claudeAfterDelete = await request("GET", `/api/lanes/${claudeId}`);
assert.equal(claudeAfterDelete.status, 200);
assert.equal(claudeAfterDelete.body.lane.tool, "claude");
assert.equal(claudeAfterDelete.body.lane.cwd, "/tmp/dual-delete-cwd");
await request("DELETE", `/api/lanes/${claudeId}`);
});
it("POST /api/lanes/ensure rejects an invalid tool even when two valid lanes already exist at that cwd", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-invalid-tool-cwd",
tool: "claude",
});
await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-invalid-tool-cwd",
tool: "codex",
});
const badRes = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-invalid-tool-cwd",
tool: "not-a-tool", // invalid
});
assert.equal(badRes.status, 400);
assert.equal(badRes.body.error.code, "EBADTOOL");
await request("DELETE", `/api/lanes/${claudeLane.body.lane.id}`);
});
it("POST /api/lanes/worktree with tool parameter creates a lane with the specified tool", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
`agenthub-worktree-src-${Date.now()}`
);
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
require("child_process").execSync("git init", { cwd: sourceRepo });
// A worktree needs a commit to branch from (an unborn repo is EEMPTYREPO).
require("child_process").execSync(
"git -c user.email=t@h -c user.name=t commit -q --allow-empty -m init",
{ cwd: sourceRepo }
);
const res = await request("POST", "/api/lanes/worktree", {
sourceRepo,
slug: "test-codex-worktree",
tool: "codex",
});
assert.equal(res.status, 202, "worktree create should return 202 Accepted");
assert.equal(res.body.lane.tool, "codex");
// Cleanup
fs.rmSync(sourceRepo, { recursive: true, force: true });
// Note: the actual provisioning happens asynchronously, so we don't verify it here.
// Just verify the lane was created with the right tool.
if (res.body.lane.id) {
await request("DELETE", `/api/lanes/${res.body.lane.id}`);
}
});
it("POST /api/lanes/worktree refuses a repo with no commits, creating no row", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
`agenthub-worktree-empty-${Date.now()}`
);
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
require("child_process").execSync("git init -q", { cwd: sourceRepo });
const before = (await request("GET", "/api/lanes")).body.lanes.length;
const res = await request("POST", "/api/lanes/worktree", { sourceRepo, slug: "empty" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "EEMPTYREPO");
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
fs.rmSync(sourceRepo, { recursive: true, force: true });
});
it("POST /api/lanes/worktree defaults to 'claude' tool when tool is not specified", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
`agenthub-worktree-default-${Date.now()}`
);
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
require("child_process").execSync("git init", { cwd: sourceRepo });
// A worktree needs a commit to branch from (an unborn repo is EEMPTYREPO).
require("child_process").execSync(
"git -c user.email=t@h -c user.name=t commit -q --allow-empty -m init",
{ cwd: sourceRepo }
);
const res = await request("POST", "/api/lanes/worktree", {
sourceRepo,
slug: "test-default-worktree",
});
assert.equal(res.status, 202);
assert.equal(res.body.lane.tool, "claude");
// Cleanup
fs.rmSync(sourceRepo, { recursive: true, force: true });
if (res.body.lane.id) {
await request("DELETE", `/api/lanes/${res.body.lane.id}`);
}
});
it("POST /api/lanes/ensure with tool parameter preserves title on re-ensure", async () => {
const first = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/ensure-title-cwd",
tool: "claude",
title: "My Claude Lane",
});
assert.equal(first.body.lane.title, "My Claude Lane");
// Re-ensure without title should preserve the original
const second = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/ensure-title-cwd",
tool: "claude",
});
assert.equal(second.status, 200);
assert.equal(second.body.created, false);
assert.equal(second.body.lane.title, "My Claude Lane");
await request("DELETE", `/api/lanes/${first.body.lane.id}`);
});
it("two lanes at the same cwd with different tools have independent session_id tracking", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-session-cwd",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-session-cwd",
tool: "codex",
});
const claudeId = claudeLane.body.lane.id;
const codexId = codexLane.body.lane.id;
// Simulate a hook event that should only update the claude lane
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-dual-session-test",
cwd: "/tmp/dual-session-cwd",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const claudeAfter = await request("GET", `/api/lanes/${claudeId}`);
const codexAfter = await request("GET", `/api/lanes/${codexId}`);
assert.equal(claudeAfter.body.lane.session_id, "sess-dual-session-test");
assert.equal(codexAfter.body.lane.session_id, null);
await request("DELETE", `/api/lanes/${claudeId}`);
await request("DELETE", `/api/lanes/${codexId}`);
});
});
describe("a session's own worktree (claude --worktree)", () => {
const { execFileSync } = require("child_process");
const tmux = require("../lib/tmux");
const { db } = require("../db");
// Inside a git hook GIT_DIR / GIT_INDEX_FILE name the repo being committed.
const gitEnv = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
const gitIn = (dir, ...args) =>
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
encoding: "utf8",
env: gitEnv,
});
let repo, wtDir, laneId;
before(async () => {
repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-session-wt-")));
gitIn(repo, "init", "-q", "-b", "main");
fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
gitIn(repo, "add", ".");
gitIn(repo, "commit", "-qm", "init");
gitIn(repo, "worktree", "add", "-q", "-b", "worktree-x", ".claude/worktrees/x");
wtDir = path.join(repo, ".claude", "worktrees", "x");
laneId = (await request("POST", "/api/lanes", { cwd: repo })).body.lane.id;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES ('wt-sess-1', 'w', 'completed', ?, ?, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')"
).run(wtDir, laneId);
});
after(() => fs.rmSync(repo, { recursive: true, force: true }));
it("reads and writes git in the session's worktree, never falling back", async () => {
fs.writeFileSync(path.join(wtDir, "only-in-wt.txt"), "w\n");
const inWt = await request("GET", `/api/lanes/${laneId}/git/changes?session=wt-sess-1`);
assert.ok(inWt.body.files.some((f) => f.path === "only-in-wt.txt"));
const facts = await request("GET", `/api/lanes/${laneId}/git?session=wt-sess-1`);
assert.equal(facts.body.branch, "worktree-x");
assert.equal(facts.body.worktree.branch, "worktree-x");
const main = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.ok(!main.body.files.some((f) => f.path === "only-in-wt.txt"));
const bad = await request("POST", `/api/lanes/${laneId}/git/stage?session=nope`, {
paths: ["only-in-wt.txt"],
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "ENOWORKTREE");
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?session=wt-sess-1`, {
paths: ["only-in-wt.txt"],
});
assert.equal(ok.status, 200);
assert.match(gitIn(wtDir, "diff", "--cached", "--name-only"), /only-in-wt\.txt/);
});
it("resumes a worktree session in its worktree", async () => {
let cwd = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") cwd = args[args.indexOf("-c") + 1];
return "";
});
try {
const r = await request("POST", `/api/lanes/${laneId}/start`, {
slot: 2,
resumeSessionId: "wt-sess-1",
});
assert.equal(r.status, 200);
assert.equal(cwd, wtDir);
} finally {
tmux.__reset();
}
});
it("removes it only when asked, and only when git agrees it is clean", async () => {
const url = `/api/lanes/${laneId}/session-worktree/remove`;
let r = await request("POST", url, { session: "wt-sess-1" });
assert.equal(r.body.error.code, "ECONFIRM");
r = await request("POST", url, { session: "wt-sess-1", confirm: true });
assert.equal(r.status, 409); // a staged file: git refuses
assert.ok(fs.existsSync(wtDir));
gitIn(wtDir, "reset", "-q");
fs.rmSync(path.join(wtDir, "only-in-wt.txt"));
r = await request("POST", url, { session: "wt-sess-1", confirm: true });
assert.equal(r.status, 200);
assert.deepEqual(r.body, { removed: true, branch: "worktree-x", branchKept: false });
assert.equal(fs.existsSync(wtDir), false);
// Its worktree is gone: nothing to resume it in.
r = await request("POST", `/api/lanes/${laneId}/start`, {
slot: 3,
resumeSessionId: "wt-sess-1",
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENOWORKTREE");
});
});
describe("an extra folder's own repo (?source=)", () => {
const { execFileSync } = require("child_process");
const gitEnv = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
const gitIn = (dir, ...args) =>
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
encoding: "utf8",
env: gitEnv,
});
let ws, extra, laneId, otherLaneId, sourceId, fileSourceId;
before(async () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-src-git-")));
ws = path.join(root, "ws");
extra = path.join(root, "extra");
for (const dir of [ws, extra]) {
fs.mkdirSync(dir);
gitIn(dir, "init", "-q", "-b", "main");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
gitIn(dir, "add", ".");
gitIn(dir, "commit", "-qm", "init");
}
laneId = (await request("POST", "/api/lanes", { cwd: ws })).body.lane.id;
otherLaneId = (await request("POST", "/api/lanes", { cwd: extra })).body.lane.id;
const src = require("../lib/lane-sources");
sourceId = src.addFolder(laneId, extra).id;
fileSourceId = require("../db")
.db.prepare(
"INSERT INTO lane_sources (lane_id, kind, path, name, added_at) VALUES (?, 'file', ?, 'f', '2026-10-09T00:00:00Z')"
)
.run(laneId, path.join(extra, "a.txt")).lastInsertRowid;
});
it("reads and writes git in the extra folder, never falling back", async () => {
fs.writeFileSync(path.join(extra, "only-in-extra.txt"), "x\n");
const inExtra = await request("GET", `/api/lanes/${laneId}/git/changes?source=${sourceId}`);
assert.ok(inExtra.body.files.some((f) => f.path === "only-in-extra.txt"));
const facts = await request("GET", `/api/lanes/${laneId}/git?source=${sourceId}`);
assert.equal(facts.body.base, "main");
const main = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.ok(!main.body.files.some((f) => f.path === "only-in-extra.txt"));
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?source=${sourceId}`, {
paths: ["only-in-extra.txt"],
});
assert.equal(ok.status, 200);
assert.match(gitIn(extra, "diff", "--cached", "--name-only"), /only-in-extra\.txt/);
});
it("answers ENOSOURCE for a source that is not this workspace's folder", async () => {
for (const id of ["nope", "999999", String(fileSourceId)]) {
const r = await request("POST", `/api/lanes/${laneId}/git/stage?source=${id}`, {
paths: ["only-in-extra.txt"],
});
assert.equal(r.status, 404, id);
assert.equal(r.body.error.code, "ENOSOURCE", id);
}
// Another workspace's request may not borrow this one's source id.
const r = await request("GET", `/api/lanes/${otherLaneId}/git/changes?source=${sourceId}`);
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOSOURCE");
});
});
// The server, not a stale client list, picks the tab and refuses a second pane
// on a session one already holds.
describe("start: slot auto and a session already live", () => {
const tmux = require("../lib/tmux");
let id;
let cwd;
const live = new Set();
before(async () => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-auto-"));
id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
tmux.__setExecImpl((args) => {
const name = String(args[args.indexOf("-t") + 1] || "").replace(/^=|:$/g, "");
if (args[0] === "has-session" && !live.has(name)) throw new Error("no such session");
if (args[0] === "new-session") live.add(args[args.indexOf("-s") + 1]);
if (args[0] === "list-sessions") return [...live].join("\n");
return "";
});
});
after(() => {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
});
it("takes the first free slot for slot: auto", async () => {
live.add(`agenthub-lane-${id}`);
live.add(`agenthub-lane-${id}-1`);
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
assert.equal(r.status, 200);
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
});
it("answers ERUNLIVE when all ten slots are busy", async () => {
for (let s = 3; s <= 8; s++) live.add(`agenthub-lane-${id}-${s}`);
// Slot 9 is the tenth and last.
const ninth = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
assert.equal(ninth.body.run_id, `agenthub-lane-${id}-9`);
live.add(`agenthub-lane-${id}-9`);
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ERUNLIVE");
for (let s = 2; s <= 9; s++) live.delete(`agenthub-lane-${id}-${s}`);
});
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
const first = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
});
assert.equal(first.status, 200);
const again = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
});
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "ESESSIONLIVE");
assert.equal(again.body.error.run_id, first.body.run_id);
// A fork makes a new session, so it is not a duplicate.
const fork = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
forkSession: true,
});
assert.equal(fork.status, 200);
});
it("an explicit slot 0 never takes over a terminal tab that holds pane 0", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lane-slot0-"));
try {
const own = (await request("POST", "/api/lanes", { cwd: dir })).body.lane.id;
// A terminal tab started on a stopped workspace lands in pane 0 and, being
// no run, leaves `run_id` empty.
const shell = await request("POST", `/api/lanes/${own}/start`, {
slot: "auto",
shell: true,
});
assert.equal(shell.body.run_id, `agenthub-lane-${own}`);
for (const body of [{}, { slot: 0 }]) {
const r = await request("POST", `/api/lanes/${own}/start`, body);
assert.equal(r.status, 409, JSON.stringify(body));
assert.equal(r.body.error.code, "ERUNLIVE");
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});