ccf2fdb8eb
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.
128 lines
4.5 KiB
JavaScript
128 lines
4.5 KiB
JavaScript
/**
|
|
* @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/);
|
|
});
|
|
});
|