a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
223 lines
8.5 KiB
JavaScript
223 lines
8.5 KiB
JavaScript
/**
|
|
* @file Tests scripts/systemd-service.js, and specifically the split that
|
|
* exists because of one incident: the plugin self-update killed the dashboard
|
|
* and left it dead.
|
|
*
|
|
* The bootstrap that performs an update is spawned by the running server, so
|
|
* it lives in that server's systemd cgroup. The old code SIGTERM-ed the server
|
|
* from inside that cgroup, which (a) exits 0, and `Restart=on-failure` reads a
|
|
* clean exit as "meant to stop", and (b) trips `KillMode=control-group`, so
|
|
* systemd reaped the bootstrap before it could write state or repoint the unit
|
|
* at the new plugin root. The dashboard then stayed down until some later
|
|
* Claude Code session happened to bootstrap again.
|
|
*
|
|
* Hence: writing the unit must not start anything, restarting must not block,
|
|
* and a clean exit must no longer be read as "leave it stopped".
|
|
* @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 fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-systemd-"));
|
|
const prevHome = process.env.HOME;
|
|
process.env.HOME = TMP_HOME;
|
|
|
|
const svc = require("../../scripts/systemd-service");
|
|
|
|
/** Every systemctl invocation this test saw, as flat argv strings. */
|
|
let calls = [];
|
|
|
|
beforeEach(() => {
|
|
calls = [];
|
|
// Each test starts from "no unit on disk" — otherwise a previous test's
|
|
// write turns the next one's `written` into `unchanged`.
|
|
fs.rmSync(svc.unitPath("agenthub"), { force: true });
|
|
svc.__setExecImpl((args) => {
|
|
// `--version` is the availability probe every entry point runs first, not
|
|
// an action — recording it would bury the calls these tests are about.
|
|
if (args[0] !== "--version") calls.push(args.join(" "));
|
|
return { status: 0 };
|
|
});
|
|
});
|
|
|
|
after(() => {
|
|
svc.__reset();
|
|
if (prevHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
|
});
|
|
|
|
const UNIT = {
|
|
name: "agenthub",
|
|
description: "AgentHub dashboard server (Claude Code plugin)",
|
|
workingDir: "/plugin/root/new",
|
|
execStart: "/usr/bin/node /plugin/root/new/server/index.js",
|
|
env: { NODE_ENV: "production" },
|
|
};
|
|
|
|
function unitFile() {
|
|
return fs.readFileSync(svc.unitPath("agenthub"), "utf8");
|
|
}
|
|
|
|
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
|
|
|
|
describe("systemd-service — unit content", linuxOnly, () => {
|
|
it("restarts always, because a graceful SIGTERM exits 0", () => {
|
|
svc.writeUserService(UNIT);
|
|
const body = unitFile();
|
|
|
|
// `on-failure` is what let one stray kill (or an out-of-band stop during
|
|
// an update) leave the dashboard down indefinitely.
|
|
assert.match(body, /^Restart=always$/m);
|
|
assert.doesNotMatch(body, /^Restart=on-failure$/m);
|
|
});
|
|
|
|
it("stops only the server process, so an update never takes the lane panes with it", () => {
|
|
// tmux forks its server from whichever process first asks for a session —
|
|
// the dashboard, after a boot with autostart on — and that server sits in
|
|
// this unit's cgroup. The default KillMode=control-group would kill it,
|
|
// and every running agent, on each update's restart.
|
|
svc.writeUserService(UNIT);
|
|
assert.match(unitFile(), /^KillMode=process$/m);
|
|
});
|
|
|
|
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
|
|
svc.writeUserService(UNIT);
|
|
assert.match(unitFile(), /agenthub-managed-unit/);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — writeUserService", linuxOnly, () => {
|
|
it("writes and enables the unit but starts NOTHING", () => {
|
|
// The point of the split: the update must be able to repoint the unit at
|
|
// the new plugin root while the old server is still serving requests.
|
|
const result = svc.writeUserService(UNIT);
|
|
|
|
assert.equal(result, "written");
|
|
assert.ok(
|
|
calls.some((c) => c.startsWith("daemon-reload")),
|
|
"a rewritten unit must be reloaded"
|
|
);
|
|
assert.ok(
|
|
calls.some((c) => c === "enable agenthub.service"),
|
|
"it must still be enabled for boot"
|
|
);
|
|
assert.ok(
|
|
!calls.some((c) => c.includes("--now") || c.startsWith("restart") || c.startsWith("start")),
|
|
`nothing may be started here, got: ${JSON.stringify(calls)}`
|
|
);
|
|
});
|
|
|
|
it("reports an identical unit as unchanged and touches systemd not at all", () => {
|
|
svc.writeUserService(UNIT);
|
|
calls = [];
|
|
|
|
assert.equal(svc.writeUserService(UNIT), "unchanged");
|
|
assert.deepEqual(calls, [], "an unchanged unit needs no systemctl call");
|
|
});
|
|
|
|
it("refuses a unit it did not write", () => {
|
|
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
|
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nExecStart=/opt/mine\n", "utf8");
|
|
|
|
assert.equal(svc.writeUserService(UNIT), "foreign");
|
|
assert.equal(svc.unitIsOurs("agenthub"), false);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — restartUserService", linuxOnly, () => {
|
|
it("never blocks, because the caller is inside the cgroup being restarted", () => {
|
|
assert.equal(svc.restartUserService("agenthub"), "restarted");
|
|
assert.deepEqual(calls, ["restart --no-block agenthub.service"]);
|
|
});
|
|
|
|
it("reports failure rather than throwing when systemctl refuses", () => {
|
|
// Available, but the restart itself is rejected — distinct from "no
|
|
// systemd here", which must not be reported as a failure.
|
|
svc.__setExecImpl((args) => ({ status: args[0] === "--version" ? 0 : 1 }));
|
|
assert.equal(svc.restartUserService("agenthub"), "failed");
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — unitIsStale", linuxOnly, () => {
|
|
it("is true when the unit names a plugin root that is no longer the current one", () => {
|
|
// Invisible while the server runs, fatal at the next reboot: the unit
|
|
// starts a path Claude Code garbage-collected after the update.
|
|
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
|
|
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), true);
|
|
});
|
|
|
|
it("is false for a unit already naming the current root", () => {
|
|
svc.writeUserService(UNIT);
|
|
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
|
|
});
|
|
|
|
it("is false when there is no unit, and for one we do not own", () => {
|
|
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
|
|
|
|
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
|
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
|
|
assert.equal(
|
|
svc.unitIsStale("agenthub", "/plugin/root/new"),
|
|
false,
|
|
"a foreign unit is not ours to call stale, or to rewrite"
|
|
);
|
|
});
|
|
|
|
it("spawns nothing — the bootstrap fast path runs it on every SessionStart", () => {
|
|
svc.writeUserService(UNIT);
|
|
calls = [];
|
|
|
|
svc.unitIsStale("agenthub", "/plugin/root/new");
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — unitRoot", linuxOnly, () => {
|
|
it("reports the directory the unit runs from", () => {
|
|
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
|
|
assert.equal(svc.unitRoot("agenthub"), "/plugin/root/old");
|
|
});
|
|
|
|
it("reports a FOREIGN unit's directory too", () => {
|
|
// Not ours to rewrite, but still a directory the cache pruner may not
|
|
// delete out from under a running service.
|
|
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
|
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
|
|
assert.equal(svc.unitRoot("agenthub"), "/opt/mine");
|
|
});
|
|
|
|
it("is null when there is no unit", () => {
|
|
assert.equal(svc.unitRoot("agenthub"), null);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — unitIsActive", linuxOnly, () => {
|
|
it("is true only for `active`", () => {
|
|
svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));
|
|
assert.equal(svc.unitIsActive("agenthub"), true);
|
|
|
|
svc.__setExecImpl((args) => ({
|
|
status: 3,
|
|
stdout: args[0] === "is-active" ? "inactive\n" : "",
|
|
}));
|
|
assert.equal(svc.unitIsActive("agenthub"), false);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — unitIsOurs", linuxOnly, () => {
|
|
it("is false when no unit exists at all", () => {
|
|
fs.rmSync(svc.unitPath("agenthub"), { force: true });
|
|
assert.equal(svc.unitIsOurs("agenthub"), false);
|
|
});
|
|
|
|
it("is true for a unit this code wrote", () => {
|
|
svc.writeUserService(UNIT);
|
|
assert.equal(svc.unitIsOurs("agenthub"), true);
|
|
});
|
|
});
|