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