From a66331f008dc2a3fa86b50f3a47d3a3ba47592a2 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 27 Aug 2026 11:00:13 +0700 Subject: [PATCH] feat(lanes): record which CLI tool drives a lane --- server/__tests__/lanes-lib.test.js | 21 +++++++++++++++++++++ server/db.js | 16 ++++++++++++++++ server/lib/dashboard-runs.js | 7 ++++--- server/lib/lanes.js | 14 +++++++++++++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index b25601b5..2135f3a7 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -236,6 +236,27 @@ describe("lanes lib", () => { assert.equal(p.pipeline_skill, "/ccam:ship-feature"); lanes.deleteLane(l.id); }); + + it("createLane defaults tool to claude and accepts codex", () => { + const a = lanes.createLane({ cwd: "/tmp/tool-default" }); + assert.equal(a.tool, "claude"); + const b = lanes.createLane({ cwd: "/tmp/tool-codex", tool: "codex" }); + assert.equal(b.tool, "codex"); + }); + + it("createLane rejects an unknown tool", () => { + assert.throws( + () => lanes.createLane({ cwd: "/tmp/tool-bad", tool: "cursor" }), + (err) => err.code === "EBADTOOL" + ); + }); + + it("updateLane never writes tool (immutable per lane)", () => { + const lane = lanes.createLane({ cwd: "/tmp/tool-immutable", tool: "codex" }); + const after = lanes.updateLane(lane.id, { tool: "claude", title: "x" }); + assert.equal(after.tool, "codex"); + assert.equal(after.title, "x"); + }); }); const { withLaneLock } = require("../lib/lane-lock"); diff --git a/server/db.js b/server/db.js index b9b3f081..e590d81c 100644 --- a/server/db.js +++ b/server/db.js @@ -486,6 +486,22 @@ try { db.prepare("ALTER TABLE lanes ADD COLUMN slug TEXT").run(); } +// Migrate: which CLI drives this lane — `claude` (default) or `codex`. Additive +// with a default, so every existing lane keeps today's behavior. A lane's tool +// is immutable (never in PATCHABLE): the tmux pane, the run history and the +// binary probe all key off it, so flipping it under a live pane would describe +// a run that isn't what's actually attached. +try { + db.prepare("SELECT tool FROM lanes LIMIT 1").get(); +} catch { + db.prepare("ALTER TABLE lanes ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run(); +} +try { + db.prepare("SELECT tool FROM dashboard_runs LIMIT 1").get(); +} catch { + db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run(); +} + // Migrate: label a dashboard run with the lane it was started through, so the // Workspace page can show one lane's run history. Additive and nullable — // POST /api/run keeps writing rows with no lane. One probe for the one column. diff --git a/server/lib/dashboard-runs.js b/server/lib/dashboard-runs.js index b4b9363b..6b3eb4b1 100644 --- a/server/lib/dashboard-runs.js +++ b/server/lib/dashboard-runs.js @@ -25,11 +25,11 @@ const insertStmt = db.prepare(` INSERT OR REPLACE INTO dashboard_runs ( id, session_id, mode, cwd, model, permission_mode, effort, resume_session_id, prompt_preview, status, exit_code, started_at, ended_at, - lane_id + lane_id, tool ) VALUES ( @id, @session_id, @mode, @cwd, @model, @permission_mode, @effort, @resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at, - @lane_id + @lane_id, @tool ) `); @@ -44,7 +44,7 @@ const updateStmt = db.prepare(` const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort, resume_session_id, prompt_preview, status, exit_code, - started_at, ended_at, lane_id`; + started_at, ended_at, lane_id, tool`; const listStmt = db.prepare(` SELECT ${RUN_COLUMNS} @@ -89,6 +89,7 @@ function recordRun(handle) { started_at: startedAt, ended_at: endedAt, lane_id: typeof handle.laneId === "number" ? handle.laneId : null, + tool: handle.tool === "codex" ? "codex" : "claude", }); } catch { /* persistence is best-effort */ diff --git a/server/lib/lanes.js b/server/lib/lanes.js index e1d8308b..7cbe2c4d 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -95,6 +95,14 @@ function validateKind(kind) { } } +const VALID_TOOLS = new Set(["claude", "codex"]); + +function validateTool(tool) { + if (!VALID_TOOLS.has(tool)) { + throw Object.assign(new Error(`unknown tool: ${tool}`), { code: "EBADTOOL" }); + } +} + /** * `getPipeline` falls back to the default template for an unknown id — correct * when READING (a lane must always render something), wrong when WRITING: a @@ -139,15 +147,17 @@ function createLane({ source_repo = null, base_branch = null, slug = null, + tool = "claude", } = {}) { if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) { throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" }); } validateKind(kind); validatePipeline(pipeline); + validateTool(tool); const info = db .prepare( - "INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) .run( title, @@ -158,6 +168,7 @@ function createLane({ source_repo, base_branch, slug, + tool, nowIso() ); return getLane(info.lastInsertRowid); @@ -599,6 +610,7 @@ function purgeLaneSessions(laneId) { module.exports = { DEAD_SEC, + VALID_TOOLS, createLane, listLanes, getLane,