57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
859 lines
31 KiB
JavaScript
859 lines
31 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 { 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("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}`);
|
|
});
|
|
});
|
|
|
|
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 with a recorded-but-not-live run returns 409", async () => {
|
|
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
|
|
const id = c.body.lane.id;
|
|
// Patch the lane with a bogus run_id (never existed, so not live).
|
|
await request("PATCH", `/api/lanes/${id}`, { run_id: "nonexistent-run" });
|
|
const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
|
|
assert.equal(r.status, 409);
|
|
assert.equal(r.body.error.code, "ENORUN");
|
|
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(), "ccam-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(), "ccam-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}`);
|
|
});
|
|
});
|