feat(lanes): let claude and codex lanes coexist at the same cwd
Repo/Adopt mode (POST /api/lanes/ensure) treated lanes.cwd as globally unique, so adopting a path already driven by one CLI silently ignored a different tool choice. Uniqueness moves to the composite (cwd, tool): one directory can now back a claude lane and a codex lane side by side. - server/db.js: drop the column-level UNIQUE on cwd, migrate existing DBs via table rebuild, add idx_lanes_cwd_tool. - resolveLaneByCwd gains an optional tool filter; hook ingestion always pins to "claude" since Codex has no hook mechanism here, so a hook event never touches a codex lane sharing the path. - POST /ensure resolves the existing owner by (cwd, tool) instead of cwd alone, so a different tool creates a sibling lane. - LaneConsolePane's cwd-only lane lookups now also match tool, so a shared cwd doesn't attach a run to the wrong sibling lane. Worktree mode is unaffected (already allocates a unique directory per lane). docs/LANES.md and CLAUDE.md updated to describe the new composite uniqueness.
This commit is contained in:
@@ -928,6 +928,49 @@ describe("detection attributes work to the lane the work touched", () => {
|
||||
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", () => {
|
||||
@@ -1007,4 +1050,250 @@ describe("/api/lanes tool tests", () => {
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "EBADTOOL");
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure creates a second lane for the same cwd on a different tool", async () => {
|
||||
const first = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/claude-lane",
|
||||
tool: "claude",
|
||||
});
|
||||
assert.equal(first.status, 201);
|
||||
|
||||
const second = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/claude-lane",
|
||||
tool: "codex",
|
||||
});
|
||||
assert.equal(second.status, 201);
|
||||
assert.equal(second.body.created, true);
|
||||
assert.notEqual(second.body.lane.id, first.body.lane.id);
|
||||
assert.equal(second.body.lane.cwd, first.body.lane.cwd);
|
||||
assert.equal(second.body.lane.tool, "codex");
|
||||
assert.equal(first.body.lane.tool, "claude");
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure re-ensuring the same (cwd, tool) pair is a no-op", async () => {
|
||||
const first = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/claude-lane-2",
|
||||
tool: "claude",
|
||||
});
|
||||
assert.equal(first.status, 201);
|
||||
|
||||
const second = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/claude-lane-2",
|
||||
tool: "claude",
|
||||
});
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(second.body.created, false);
|
||||
assert.equal(second.body.lane.id, first.body.lane.id);
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure re-ensuring with the same tool is a no-op", async () => {
|
||||
const first = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/same-tool-lane",
|
||||
tool: "codex",
|
||||
});
|
||||
assert.equal(first.status, 201);
|
||||
|
||||
const second = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/same-tool-lane",
|
||||
tool: "codex",
|
||||
});
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(second.body.created, false);
|
||||
assert.equal(second.body.lane.tool, "codex");
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure defaults to 'claude' tool when tool is not specified", async () => {
|
||||
const res = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/default-tool-lane",
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.body.lane.tool, "claude");
|
||||
});
|
||||
|
||||
it("GET /api/lanes returns both lanes for a shared cwd with distinct IDs and tools", async () => {
|
||||
const claudeLane = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-list-cwd",
|
||||
tool: "claude",
|
||||
});
|
||||
const codexLane = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-list-cwd",
|
||||
tool: "codex",
|
||||
});
|
||||
const claudeId = claudeLane.body.lane.id;
|
||||
const codexId = codexLane.body.lane.id;
|
||||
|
||||
const list = await request("GET", "/api/lanes");
|
||||
assert.equal(list.status, 200);
|
||||
|
||||
const dualCwdLanes = list.body.lanes.filter((l) => l.cwd === "/tmp/dual-list-cwd");
|
||||
assert.equal(dualCwdLanes.length, 2, "expected both lanes to appear in the list");
|
||||
|
||||
const found = {
|
||||
claude: dualCwdLanes.find((l) => l.tool === "claude"),
|
||||
codex: dualCwdLanes.find((l) => l.tool === "codex"),
|
||||
};
|
||||
assert.ok(found.claude, "claude lane must be in the list");
|
||||
assert.ok(found.codex, "codex lane must be in the list");
|
||||
assert.equal(found.claude.id, claudeId);
|
||||
assert.equal(found.codex.id, codexId);
|
||||
assert.notEqual(found.claude.id, found.codex.id);
|
||||
|
||||
await request("DELETE", `/api/lanes/${claudeId}`);
|
||||
await request("DELETE", `/api/lanes/${codexId}`);
|
||||
});
|
||||
|
||||
it("DELETE one lane of a (cwd, tool) pair leaves the sibling lane untouched", async () => {
|
||||
const claudeLane = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-delete-cwd",
|
||||
tool: "claude",
|
||||
});
|
||||
const codexLane = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-delete-cwd",
|
||||
tool: "codex",
|
||||
});
|
||||
const claudeId = claudeLane.body.lane.id;
|
||||
const codexId = codexLane.body.lane.id;
|
||||
|
||||
// Delete the codex lane
|
||||
const deleteRes = await request("DELETE", `/api/lanes/${codexId}`);
|
||||
assert.equal(deleteRes.status, 200);
|
||||
|
||||
// Verify the codex lane is gone
|
||||
assert.equal((await request("GET", `/api/lanes/${codexId}`)).status, 404);
|
||||
|
||||
// Verify the claude lane still exists and is untouched
|
||||
const claudeAfterDelete = await request("GET", `/api/lanes/${claudeId}`);
|
||||
assert.equal(claudeAfterDelete.status, 200);
|
||||
assert.equal(claudeAfterDelete.body.lane.tool, "claude");
|
||||
assert.equal(claudeAfterDelete.body.lane.cwd, "/tmp/dual-delete-cwd");
|
||||
|
||||
await request("DELETE", `/api/lanes/${claudeId}`);
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure rejects an invalid tool even when two valid lanes already exist at that cwd", async () => {
|
||||
const claudeLane = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-invalid-tool-cwd",
|
||||
tool: "claude",
|
||||
});
|
||||
await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-invalid-tool-cwd",
|
||||
tool: "codex",
|
||||
});
|
||||
|
||||
const badRes = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-invalid-tool-cwd",
|
||||
tool: "cursor", // invalid
|
||||
});
|
||||
assert.equal(badRes.status, 400);
|
||||
assert.equal(badRes.body.error.code, "EBADTOOL");
|
||||
|
||||
await request("DELETE", `/api/lanes/${claudeLane.body.lane.id}`);
|
||||
});
|
||||
|
||||
it("POST /api/lanes/worktree with tool parameter creates a lane with the specified tool", async () => {
|
||||
const sourceRepo = require("path").join(
|
||||
require("os").tmpdir(),
|
||||
`ccam-worktree-src-${Date.now()}`
|
||||
);
|
||||
const fs = require("fs");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
require("child_process").execSync("git init", { cwd: sourceRepo });
|
||||
|
||||
const res = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo,
|
||||
slug: "test-codex-worktree",
|
||||
tool: "codex",
|
||||
});
|
||||
|
||||
assert.equal(res.status, 202, "worktree create should return 202 Accepted");
|
||||
assert.equal(res.body.lane.tool, "codex");
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(sourceRepo, { recursive: true, force: true });
|
||||
|
||||
// Note: the actual provisioning happens asynchronously, so we don't verify it here.
|
||||
// Just verify the lane was created with the right tool.
|
||||
if (res.body.lane.id) {
|
||||
await request("DELETE", `/api/lanes/${res.body.lane.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/lanes/worktree defaults to 'claude' tool when tool is not specified", async () => {
|
||||
const sourceRepo = require("path").join(
|
||||
require("os").tmpdir(),
|
||||
`ccam-worktree-default-${Date.now()}`
|
||||
);
|
||||
const fs = require("fs");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
require("child_process").execSync("git init", { cwd: sourceRepo });
|
||||
|
||||
const res = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo,
|
||||
slug: "test-default-worktree",
|
||||
});
|
||||
|
||||
assert.equal(res.status, 202);
|
||||
assert.equal(res.body.lane.tool, "claude");
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(sourceRepo, { recursive: true, force: true });
|
||||
|
||||
if (res.body.lane.id) {
|
||||
await request("DELETE", `/api/lanes/${res.body.lane.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure with tool parameter preserves title on re-ensure", async () => {
|
||||
const first = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/ensure-title-cwd",
|
||||
tool: "claude",
|
||||
title: "My Claude Lane",
|
||||
});
|
||||
assert.equal(first.body.lane.title, "My Claude Lane");
|
||||
|
||||
// Re-ensure without title should preserve the original
|
||||
const second = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/ensure-title-cwd",
|
||||
tool: "claude",
|
||||
});
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(second.body.created, false);
|
||||
assert.equal(second.body.lane.title, "My Claude Lane");
|
||||
|
||||
await request("DELETE", `/api/lanes/${first.body.lane.id}`);
|
||||
});
|
||||
|
||||
it("two lanes at the same cwd with different tools have independent session_id tracking", async () => {
|
||||
const claudeLane = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-session-cwd",
|
||||
tool: "claude",
|
||||
});
|
||||
const codexLane = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-session-cwd",
|
||||
tool: "codex",
|
||||
});
|
||||
|
||||
const claudeId = claudeLane.body.lane.id;
|
||||
const codexId = codexLane.body.lane.id;
|
||||
|
||||
// Simulate a hook event that should only update the claude lane
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "PostToolUse",
|
||||
data: {
|
||||
session_id: "sess-dual-session-test",
|
||||
cwd: "/tmp/dual-session-cwd",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "npm run test:server" },
|
||||
},
|
||||
});
|
||||
|
||||
const claudeAfter = await request("GET", `/api/lanes/${claudeId}`);
|
||||
const codexAfter = await request("GET", `/api/lanes/${codexId}`);
|
||||
|
||||
assert.equal(claudeAfter.body.lane.session_id, "sess-dual-session-test");
|
||||
assert.equal(codexAfter.body.lane.session_id, null);
|
||||
|
||||
await request("DELETE", `/api/lanes/${claudeId}`);
|
||||
await request("DELETE", `/api/lanes/${codexId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @file Verifies the legacy `lanes_new` rebuild in db.js (triggered when the
|
||||
* lanes table's stored SQL still contains the old single-column
|
||||
* `cwd TEXT NOT NULL UNIQUE`) drops that constraint in favor of the composite
|
||||
* `idx_lanes_cwd_tool` unique index, preserves existing rows, and recreates
|
||||
* idx_lanes_session / idx_lanes_slot.
|
||||
*
|
||||
* Regression coverage for the codex+claude coexistence feature: a cwd used to
|
||||
* back at most one lane; now one cwd can back a `claude` lane and a `codex`
|
||||
* lane at once, which requires dropping the old column-level UNIQUE.
|
||||
*
|
||||
* @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 fs = require("fs");
|
||||
const os = require("os");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
let TEST_DB;
|
||||
|
||||
before(() => {
|
||||
TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-legacy-${Date.now()}-${process.pid}.db`);
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
|
||||
// Hand-build a legacy lanes table using the original single-column UNIQUE
|
||||
// cwd, none of the columns added by later migrations (kind, source_repo,
|
||||
// base_branch, slug, tool, detected_*, slot, ports, active_feature_id).
|
||||
// This is the exact shape the "cwd TEXT NOT NULL UNIQUE" detector is
|
||||
// looking for.
|
||||
const raw = new Database(TEST_DB);
|
||||
raw.pragma("foreign_keys = OFF");
|
||||
raw.exec(`
|
||||
CREATE TABLE lanes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
cwd TEXT NOT NULL UNIQUE,
|
||||
branch TEXT,
|
||||
pipeline TEXT NOT NULL DEFAULT 'default',
|
||||
session_id TEXT,
|
||||
run_id TEXT,
|
||||
stage TEXT NOT NULL DEFAULT 'idle',
|
||||
stage_since TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
gate_decision TEXT,
|
||||
ci_status TEXT,
|
||||
needs_action TEXT,
|
||||
links TEXT NOT NULL DEFAULT '{}',
|
||||
stages TEXT NOT NULL DEFAULT '{}',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
raw
|
||||
.prepare("INSERT INTO lanes (title, cwd) VALUES (?, ?)")
|
||||
.run("legacy lane", "/tmp/legacy-lane-cwd");
|
||||
raw.close();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
delete require.cache[require.resolve("../db")];
|
||||
} catch {}
|
||||
for (const suffix of ["", "-wal", "-shm"]) {
|
||||
try {
|
||||
fs.unlinkSync(TEST_DB + suffix);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe("legacy lanes_new rebuild (pre-tool, single-column cwd UNIQUE DB)", () => {
|
||||
it("loads db.js against the legacy DB without throwing", () => {
|
||||
delete require.cache[require.resolve("../db")];
|
||||
assert.doesNotThrow(() => require("../db"));
|
||||
});
|
||||
|
||||
it("preserves the pre-existing lane row and defaults tool to claude", () => {
|
||||
const { db } = require("../db");
|
||||
const row = db.prepare("SELECT * FROM lanes WHERE cwd = ?").get("/tmp/legacy-lane-cwd");
|
||||
assert.ok(row, "expected the legacy lane row to survive the rebuild");
|
||||
assert.equal(row.title, "legacy lane");
|
||||
assert.equal(row.tool, "claude");
|
||||
});
|
||||
|
||||
it("recreates idx_lanes_session and idx_lanes_slot", () => {
|
||||
const { db } = require("../db");
|
||||
for (const name of ["idx_lanes_session", "idx_lanes_slot"]) {
|
||||
const idx = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?")
|
||||
.get(name);
|
||||
assert.ok(idx, `expected ${name} to exist after the rebuild`);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the single-column cwd UNIQUE, allowing a second lane at the same cwd on a different tool", () => {
|
||||
const { db } = require("../db");
|
||||
const legacy = db.prepare("SELECT id FROM lanes WHERE cwd = ?").get("/tmp/legacy-lane-cwd");
|
||||
assert.doesNotThrow(() => {
|
||||
db.prepare("INSERT INTO lanes (title, cwd, tool) VALUES (?, ?, ?)").run(
|
||||
"codex sibling",
|
||||
"/tmp/legacy-lane-cwd",
|
||||
"codex"
|
||||
);
|
||||
});
|
||||
const both = db
|
||||
.prepare("SELECT id, tool FROM lanes WHERE cwd = ? ORDER BY id")
|
||||
.all("/tmp/legacy-lane-cwd");
|
||||
assert.equal(both.length, 2);
|
||||
assert.equal(both[0].id, legacy.id);
|
||||
assert.equal(both[0].tool, "claude");
|
||||
assert.equal(both[1].tool, "codex");
|
||||
});
|
||||
|
||||
it("still enforces uniqueness within the same (cwd, tool) pair", () => {
|
||||
const { db } = require("../db");
|
||||
assert.throws(() => {
|
||||
db.prepare("INSERT INTO lanes (title, cwd, tool) VALUES (?, ?, ?)").run(
|
||||
"duplicate claude",
|
||||
"/tmp/legacy-lane-cwd",
|
||||
"claude"
|
||||
);
|
||||
}, /UNIQUE/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user