/** * @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("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("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("broadcasts lane_update on router apply", async () => { // Without this the client's cached row keeps the OLD router_profile: the // picker looks right until the user switches lanes and back, at which // point the effect re-reads the stale row and flips the selection. const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-")); const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" }); const id = created.body.lane.id; // Start somewhere other than native, so the broadcast has to carry a // CHANGED value rather than the one the row already held. require("../lib/lanes").setRouterProfile(id, "some-router"); const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`); const messages = []; await new Promise((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); }); ws.on("message", (msg) => { try { messages.push(JSON.parse(msg)); } catch { /* parse error */ } }); try { const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" }); assert.equal(applied.status, 200); await new Promise((resolve) => setTimeout(resolve, 100)); const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id); assert.ok(msg, "router apply should broadcast lane_update"); // `native` is stored as null — what matters is that the pushed row is the // post-apply one, not the "some-router" the client still has cached. assert.equal(msg.data.lane.router_profile, null); } finally { // A socket left open by a failed assertion keeps the whole run alive. ws.close(); } await request("DELETE", `/api/lanes/${id}`); 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("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" }, }); 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"); // Session B's second PostToolUse clears the flag (B is now bound) await request("POST", "/api/hooks/event", { hook_type: "PostToolUse", data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" }, }); 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("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: "cursor", }); 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 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: "cursor", // 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 }); 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 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 }); 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}`); }); });