/** * @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ĩ */ 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("refuses to start in a workspace folder that no longer exists (409 ENOCWD)", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-gone-")); const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id; fs.rmSync(cwd, { recursive: true, force: true }); // Without the check the pane started in the account's home instead. const r = await request("POST", `/api/lanes/${id}/start`, {}); assert.equal(r.status, 409); assert.equal(r.body.error.code, "ENOCWD"); }); 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) in the lane's folder (#69) — nothing from // the request. const argv = created.slice(created.indexOf("--") + 1); assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]); assert.deepEqual(argv.slice(3), ["sh", cwd]); 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 && ...`. 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 }); } }); });