feat(cc-config): update every plugin and marketplace at once, then reload the open sessions
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect. Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement. readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race. Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: a one-shot claude -p "/reload-plugins" would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command. Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action. Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted. Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @file Unit tests for typing `/reload-plugins` / `/reload-skills` into the
|
||||
* live Claude Code lane panes. Uses tmux.js's exec seam, so no real tmux binary
|
||||
* is invoked and no real session is ever typed into.
|
||||
*
|
||||
* The behaviours worth locking down are the ones that would silently do the
|
||||
* wrong thing: sending to a pane sitting at a shell prompt (the slash command
|
||||
* would run as a shell command instead), forwarding a caller-supplied string
|
||||
* into a REPL, and letting one dead pane fail the whole batch. The `tool ===
|
||||
* "claude"` filter is not covered here — it reads a dashboard-runs DB row,
|
||||
* which these stubs do not populate.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, 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(), "cc-reload-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const tmux = require("../lib/tmux");
|
||||
const { reloadOpenSessions, reloadableLanes } = require("../lib/cc-reload");
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stub tmux so `listRuns()` sees `sessions` and each pane reports the command
|
||||
* given in `panes` (defaulting to a live `claude`).
|
||||
* @returns {any[][]} the recorded argv arrays
|
||||
*/
|
||||
function stubTmux(sessions, panes = {}) {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "list-sessions") return sessions.join("\n");
|
||||
if (args[0] === "has-session") {
|
||||
if (sessions.includes(args[2])) return "";
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
if (args[0] === "display-message") {
|
||||
const name = args[3];
|
||||
const cmd = panes[name] ?? "claude";
|
||||
if (cmd === null) {
|
||||
const e = new Error("pane gone");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return `${cmd}\n`;
|
||||
}
|
||||
if (args[0] === "send-keys") {
|
||||
if (panes[args[2]] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("reloadableLanes", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("lists a lane whose pane is running claude", () => {
|
||||
stubTmux(["agenthub-lane-1"]);
|
||||
const lanes = reloadableLanes();
|
||||
assert.equal(lanes.length, 1);
|
||||
assert.equal(lanes[0].id, "agenthub-lane-1");
|
||||
assert.equal(lanes[0].laneId, 1);
|
||||
});
|
||||
|
||||
it("skips a pane sitting at a shell prompt", () => {
|
||||
// The claude process there has exited, so `/reload-plugins` would be typed
|
||||
// at a bash prompt and run as a shell command instead.
|
||||
stubTmux(["agenthub-lane-1"], { "agenthub-lane-1": "bash" });
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
|
||||
it("skips every shell name, not just bash", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"], {
|
||||
"agenthub-lane-1": "zsh",
|
||||
"agenthub-lane-2": "fish",
|
||||
"agenthub-lane-3": "dash",
|
||||
});
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
|
||||
it("returns nothing when no lane sessions exist at all", () => {
|
||||
stubTmux([]);
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadOpenSessions", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("types both reload commands, plugins before skills", () => {
|
||||
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
||||
// plugin itself has been reloaded.
|
||||
const calls = stubTmux(["agenthub-lane-7"]);
|
||||
const out = reloadOpenSessions();
|
||||
|
||||
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(out.lanes_total, 1);
|
||||
assert.deepEqual(out.lanes[0].sent, ["/reload-plugins", "/reload-skills"]);
|
||||
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins", "/reload-skills"]);
|
||||
// Each command must be submitted, or it just sits in the input box.
|
||||
assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2);
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", () => {
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["skills"] });
|
||||
assert.deepEqual(out.commands, ["/reload-skills"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-skills"]);
|
||||
});
|
||||
|
||||
it("ignores an unknown kind rather than typing it into a REPL", () => {
|
||||
// The command set is fixed in the module; a pane's stdin is not a place to
|
||||
// forward caller-supplied strings.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["plugins", "rm -rf /"] });
|
||||
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins"]);
|
||||
});
|
||||
|
||||
it("types into several lanes in one call", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
||||
const out = reloadOpenSessions();
|
||||
assert.equal(out.lanes_total, 2);
|
||||
assert.deepEqual(
|
||||
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
||||
[1, 2]
|
||||
);
|
||||
});
|
||||
|
||||
it("records a pane that dies mid-send without failing the others", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" });
|
||||
const out = reloadOpenSessions();
|
||||
const failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
||||
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
||||
assert.match(failed.error, /died mid-send/);
|
||||
assert.deepEqual(failed.sent, [], "nothing was delivered to the dead pane");
|
||||
assert.deepEqual(ok.sent, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(ok.error, undefined);
|
||||
});
|
||||
|
||||
it("reports an empty result instead of throwing when nothing is open", () => {
|
||||
stubTmux([]);
|
||||
const out = reloadOpenSessions();
|
||||
assert.equal(out.lanes_total, 0);
|
||||
assert.deepEqual(out.lanes, []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user