From 56744b360dff9e05249ffbb7e8951b5ace85af0a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:38:25 +0700 Subject: [PATCH] feat(run): add tmux-backed run lifecycle (spawn/kill/list computed from tmux state) --- server/__tests__/pty-run.test.js | 138 ++++++++++++++++++++++++ server/lib/pty-run.js | 175 +++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 server/__tests__/pty-run.test.js create mode 100644 server/lib/pty-run.js diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js new file mode 100644 index 0000000..66b49a2 --- /dev/null +++ b/server/__tests__/pty-run.test.js @@ -0,0 +1,138 @@ +/** + * @file pty-run.test.js + * @description Unit tests for the tmux-backed run lifecycle. Injects a fake + * tmux exec implementation (via tmux.js's test seam) so no real tmux binary + * is invoked. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, beforeEach, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-")); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); + +const tmux = require("../lib/tmux"); +const pty = require("../lib/pty-run"); + +describe("pty-run", () => { + after(() => { + try { + fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch { + /* best-effort */ + } + }); + + beforeEach(() => { + tmux.__reset(); + }); + + it("spawnRun creates a new tmux session named ccam-lane- when none exists", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + if (args[0] === "has-session") { + const e = new Error("no such session"); + e.status = 1; + throw e; + } + return ""; + }); + const handle = pty.spawnRun({ laneId: 42, cwd: "/tmp/repo", model: "opus" }); + assert.equal(handle.id, "ccam-lane-42"); + const newSessionCall = calls.find((c) => c[0] === "new-session"); + assert.ok(newSessionCall, "expected a new-session call"); + assert.deepEqual(newSessionCall.slice(0, 6), [ + "new-session", + "-d", + "-s", + "ccam-lane-42", + "-c", + "/tmp/repo", + ]); + assert.ok(newSessionCall.includes("claude")); + assert.ok(newSessionCall.includes("--model")); + assert.ok(newSessionCall.includes("opus")); + }); + + it("spawnRun is a no-op (adopts) when the tmux session already exists", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + return ""; // has-session succeeds → already running + }); + const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" }); + assert.equal(handle.id, "ccam-lane-7"); + assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session"); + }); + + it("spawnRun with resumeSessionId passes --resume in argv", () => { + let newSessionArgv = null; + tmux.__setExecImpl((args) => { + if (args[0] === "has-session") { + const e = new Error("gone"); + e.status = 1; + throw e; + } + if (args[0] === "new-session") newSessionArgv = args; + return ""; + }); + pty.spawnRun({ laneId: 1, cwd: "/tmp/repo", resumeSessionId: "abc12345" }); + assert.ok(newSessionArgv.includes("--resume")); + assert.ok(newSessionArgv.includes("abc12345")); + }); + + it("spawnRun appends a positional initial prompt after argv flags", () => { + let newSessionArgv = null; + tmux.__setExecImpl((args) => { + if (args[0] === "has-session") { + const e = new Error("gone"); + e.status = 1; + throw e; + } + if (args[0] === "new-session") newSessionArgv = args; + return ""; + }); + pty.spawnRun({ laneId: 3, cwd: "/tmp/repo", initialPrompt: "fix the bug" }); + assert.equal(newSessionArgv[newSessionArgv.length - 1], "fix the bug"); + }); + + it("killRun calls tmux kill-session with the run id", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + return ""; + }); + assert.equal(pty.killRun("ccam-lane-5"), true); + assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "ccam-lane-5")); + }); + + it("listRuns reflects live tmux-session state, not cached memory", () => { + tmux.__setExecImpl((args) => { + if (args[0] === "list-sessions") return "ccam-lane-1\nccam-lane-2\n"; + return ""; + }); + const first = pty.listRuns(); + assert.deepEqual(first.map((r) => r.id).sort(), ["ccam-lane-1", "ccam-lane-2"]); + + // Session killed out-of-band (not through killRun) — next list() call + // must self-correct, proving state is computed, not stored. + tmux.__setExecImpl((args) => { + if (args[0] === "list-sessions") return "ccam-lane-1\n"; + return ""; + }); + const second = pty.listRuns(); + assert.deepEqual( + second.map((r) => r.id), + ["ccam-lane-1"] + ); + }); + + it("laneIdFromRunId parses the numeric lane id back out", () => { + assert.equal(pty.laneIdFromRunId("ccam-lane-42"), 42); + assert.equal(pty.laneIdFromRunId("not-a-run-id"), null); + }); +}); diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js new file mode 100644 index 0000000..c9a218b --- /dev/null +++ b/server/lib/pty-run.js @@ -0,0 +1,175 @@ +/** + * @file pty-run.js + * @description Owns the tmux-backed run lifecycle for the dashboard's + * terminal-run feature: Start (create-or-adopt), Resume (`--resume`), Kill, + * and List. Unlike the old run-spawner.js, there is no in-memory handle Map — + * `listRuns`/`getRun` are computed fresh from `tmux list-sessions` on every + * call, the same "computed fact, never a stored one" principle this repo + * already applies to lane runtime up/down (see CLAUDE.md). A session killed + * out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects + * on the next read instead of leaving a ghost "running" row. + * + * Every session is named `ccam-lane-` so a real terminal can attach + * to the exact same session (`tmux attach -t ccam-lane-`, or + * `ccam lanes shell`) — that's the whole point: the dashboard both creates + * the session (one-click Start/Resume) and is just one of possibly several + * attached clients tmux already keeps in sync. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +const tmux = require("./tmux"); + +let dashboardRuns = null; +try { + dashboardRuns = require("./dashboard-runs"); +} catch { + /* db-less environment, skip persistence */ +} + +const RUN_ID_RE = /^ccam-lane-(\d+)$/; +const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); +const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); + +function runIdForLane(laneId) { + return `ccam-lane-${laneId}`; +} + +function laneIdFromRunId(id) { + const m = typeof id === "string" ? id.match(RUN_ID_RE) : null; + return m ? Number(m[1]) : null; +} + +function makeErr(code, message) { + const err = new Error(message); + err.code = code; + return err; +} + +/** + * Build the pane's command argv. Unlike the old stream-json spawner there is + * no headless/conversation split — every run is a live interactive pane, so + * an initial prompt (when given) is a trailing POSITIONAL argument: `claude` + * treats a bare positional as the first turn's message and stays interactive + * afterward (unlike `-p`, which forces print-and-exit and closes stdin). + */ +function buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) { + const argv = ["claude"]; + argv.push("--permission-mode", permissionMode || "acceptEdits"); + if (model) argv.push("--model", model); + if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort); + if (resumeSessionId) argv.push("--resume", resumeSessionId); + if (initialPrompt) argv.push(initialPrompt); + return argv; +} + +/** + * @param {object} args + * @param {number} args.laneId + * @param {string} args.cwd + * @param {string} [args.model] + * @param {string} [args.permissionMode] + * @param {string} [args.effort] + * @param {string} [args.resumeSessionId] + * @param {string} [args.initialPrompt] + */ +function spawnRun(args) { + const { laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt } = args || {}; + if (typeof laneId !== "number" || !Number.isInteger(laneId)) { + throw makeErr("EBADLANE", "laneId must be an integer"); + } + if (typeof cwd !== "string" || !cwd) { + throw makeErr("EBADCWD", "cwd is required"); + } + if (permissionMode != null && !ALLOWED_PERMISSION_MODES.has(permissionMode)) { + throw makeErr( + "EBADMODE", + `permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}` + ); + } + if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) { + throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`); + } + if ( + resumeSessionId != null && + (typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId)) + ) { + throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id"); + } + + const id = runIdForLane(laneId); + const startedAt = Date.now(); + + if (!tmux.hasSession(id)) { + const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }); + tmux.newSession({ name: id, cwd, argv }); + if (dashboardRuns) { + dashboardRuns.recordRun({ + id, + sessionId: resumeSessionId || null, + mode: null, + cwd, + model: model || null, + permissionMode: permissionMode || "acceptEdits", + effort: effort || null, + resumeSessionId: resumeSessionId || null, + prompt: initialPrompt || "", + status: "running", + startedAt, + endedAt: null, + exitCode: null, + laneId, + }); + } + } + // Already running: adopt silently, same convention as this repo's server + // port-adoption logic — no error, no duplicate session. + + return getRun(id); +} + +function killRun(id) { + if (!id || !tmux.hasSession(id)) return false; + tmux.killSession(id); + if (dashboardRuns) { + dashboardRuns.patchRun({ id, status: "killed", endedAt: Date.now() }); + } + return true; +} + +function publicRun(id) { + const laneId = laneIdFromRunId(id); + const live = tmux.hasSession(id); + const row = dashboardRuns ? dashboardRuns.getRun(id) : null; + return { + id, + laneId, + status: live ? "running" : "gone", + cwd: row?.cwd || null, + model: row?.model || null, + permissionMode: row?.permission_mode || null, + effort: row?.effort || null, + resumeSessionId: row?.resume_session_id || null, + sessionId: row?.session_id || null, + startedAt: row?.started_at || null, + }; +} + +function getRun(id) { + if (!id) return null; + return publicRun(id); +} + +/** Computed fresh from tmux state every call — see file header. */ +function listRuns() { + return tmux.listSessions("ccam-lane-").map(publicRun); +} + +module.exports = { + spawnRun, + killRun, + getRun, + listRuns, + laneIdFromRunId, + runIdForLane, +};