Files
AgentHub/server/__tests__/lanes-api.test.js
T
nntrivi2001 876c6de6d2 fix(lanes): count every pipeline run, not one per lane
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.

Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.

Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.

Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
2026-09-09 15:32:09 +07:00

1315 lines
47 KiB
JavaScript

/**
* @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
* counters the header badges read, and the last-event age that drives liveness.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
const fs = require("fs");
const { WebSocket } = require("ws");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function request(method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: {
...headers,
...(payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {}),
},
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch {
/* non-JSON body */
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => server && server.close());
describe("/api/lanes", () => {
let laneId;
it("creates a lane", async () => {
const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.title, "Lane A");
assert.equal(r.body.lane.stage, "idle");
laneId = r.body.lane.id;
});
it("rejects a relative cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
assert.equal(r.status, 400);
});
it("rejects a duplicate cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 409);
});
it("rejects a stage that names no node of the lane's pipeline", async () => {
// Silently storing it is what made "CI gates" read never-reached on a lane
// where the agent had been declaring `test` (the default pipeline's name)
// all along.
const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "implemnt" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSTAGE");
assert.ok(r.body.error.stages.includes("implement"));
});
it("accepts a stage declared by one of the node's aliases", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "coding" });
assert.equal(r.status, 200);
});
it("reports a stage and returns node states", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
stage: "review",
status: "running",
evidence: null,
});
assert.equal(r.status, 200);
const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "current");
assert.ok(r.body.lane.progress > 0);
});
it("404s on an unknown lane", async () => {
const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
assert.equal(r.status, 404);
});
it("lists lanes with counters", async () => {
const r = await request("GET", "/api/lanes");
assert.equal(r.status, 200);
assert.ok(r.body.lanes.length >= 1);
assert.equal(r.body.counts.total, r.body.lanes.length);
assert.equal(typeof r.body.counts.running, "number");
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("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);
});
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();
});
});
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 <other> && ...`. The lane doing the work detected
// nothing; the lane the terminal happened to start in absorbed all of it.
it("credits a Bash command that cd's into another lane to THAT lane", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-session",
title: "where the shell started",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-worked",
title: "where the work happened",
});
const sessionId = session.body.lane.id;
const workedId = worked.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-1",
cwd: "/tmp/lane-attr-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-attr-worked && npm run test:server" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${workedId}`)).body.lane.detected_stage,
"tests"
);
assert.equal(
(await request("GET", `/api/lanes/${sessionId}`)).body.lane.detected_stage,
null,
"the session's own lane did no work and must not be credited"
);
await request("DELETE", `/api/lanes/${workedId}`);
await request("DELETE", `/api/lanes/${sessionId}`);
});
it("credits an Edit to the lane owning the edited file", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-2",
cwd: "/tmp/lane-edit-session",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-edit-worked/server/lib/x.js" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.detected_stage,
"implement"
);
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.detected_stage,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
it("falls back to the session's lane when the tool names no other lane's path", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-fallback",
title: "fallback",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-3",
cwd: "/tmp/lane-attr-fallback",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("keeps session bookkeeping on the session's lane, not the worked-in lane", async () => {
// session_id / needs_action ARE session-scoped facts: the session really is
// bound to the directory it started in. Only the stage inference follows
// the work.
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-4",
cwd: "/tmp/lane-book-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-book-worked && npm test" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.session_id,
"sess-attr-4"
);
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.session_id,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
it("a hook event at a shared cwd only touches the claude lane, never the codex lane", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/lane-dual-tool",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/lane-dual-tool",
tool: "codex",
});
assert.notEqual(claudeLane.body.lane.id, codexLane.body.lane.id);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-dual-tool",
cwd: "/tmp/lane-dual-tool",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${claudeLane.body.lane.id}`)).body.lane.detected_stage,
"tests"
);
assert.equal(
(await request("GET", `/api/lanes/${claudeLane.body.lane.id}`)).body.lane.session_id,
"sess-dual-tool"
);
assert.equal(
(await request("GET", `/api/lanes/${codexLane.body.lane.id}`)).body.lane.detected_stage,
null,
"the codex lane sharing this cwd must not absorb a claude hook event"
);
assert.equal(
(await request("GET", `/api/lanes/${codexLane.body.lane.id}`)).body.lane.session_id,
null
);
await request("DELETE", `/api/lanes/${claudeLane.body.lane.id}`);
await request("DELETE", `/api/lanes/${codexLane.body.lane.id}`);
});
});
describe("GET /:id/ci/status", () => {
let laneId;
let laneCwd;
let restorePath;
let bin;
before(async () => {
laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-route-"));
const r = await request("POST", "/api/lanes", { title: "CI status lane", cwd: laneCwd });
laneId = r.body.lane.id;
bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-bin-"));
fs.writeFileSync(
path.join(bin, "gh"),
`#!/usr/bin/env bash
if [ "$1" = "run" ] && [ "$2" = "list" ]; then
echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]'
exit 0
fi
if [ "$1" = "run" ] && [ "$2" = "view" ]; then
echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}'
exit 0
fi
exit 1
`
);
fs.chmodSync(path.join(bin, "gh"), 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
restorePath = () => {
process.env.PATH = originalPath;
};
});
after(() => {
if (restorePath) restorePath();
fs.rmSync(laneCwd, { recursive: true, force: true });
fs.rmSync(bin, { recursive: true, force: true });
});
it("404s for an unknown lane", async () => {
const r = await request("GET", "/api/lanes/999999/ci/status?ref=development");
assert.equal(r.status, 404);
});
it("400s when ref is missing", async () => {
const r = await request("GET", `/api/lanes/${laneId}/ci/status`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADREF");
});
it("returns the derived state for a real ref", async () => {
const r = await request("GET", `/api/lanes/${laneId}/ci/status?ref=development`);
assert.equal(r.status, 200);
assert.equal(r.body.state, "success");
assert.equal(r.body.checks[0].name, "tests");
});
});
describe("/api/lanes tool tests", () => {
it("POST /api/lanes/ensure stores the requested tool", async () => {
const res = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/codex-lane",
tool: "codex",
});
assert.equal(res.status, 201);
assert.equal(res.body.lane.tool, "codex");
});
it("POST /api/lanes/ensure rejects an unknown tool", async () => {
const res = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/bad-tool",
tool: "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}`);
});
});