feat(run): add tmux-backed run lifecycle (spawn/kill/list computed from tmux state)

This commit is contained in:
2026-08-12 09:38:25 +07:00
parent 1dd18fe98c
commit 56744b360d
2 changed files with 313 additions and 0 deletions
+138
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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-<id> 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);
});
});
+175
View File
@@ -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-<laneId>` so a real terminal can attach
* to the exact same session (`tmux attach -t ccam-lane-<id>`, 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ĩ <vinnt@smartgift.vn>
*/
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,
};