c90dd7b147
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.
- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
server keeps running and the corrected unit takes effect at the next
start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
detached-spawn fallback is guarded by needsServer so a stale unit alone
cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
stopped owns nothing: skipping the stop there and asking systemd to start
would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
come back after a reboot") and the fix. WARN, never FAIL — the dashboard
in front of you works, and having no unit at all is a normal setup.
The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
122 lines
4.2 KiB
JavaScript
122 lines
4.2 KiB
JavaScript
/**
|
|
* @file Tests the `systemd` row of scripts/plugin-doctor.js.
|
|
*
|
|
* The row exists for a failure nothing else in the report can see: a unit still
|
|
* naming a retired plugin cache directory leaves a running, healthy-looking
|
|
* dashboard that simply does not come back after the next reboot — it starts a
|
|
* path Claude Code deleted during the update. Every other row would read OK.
|
|
*
|
|
* WARN and never FAIL: the dashboard in front of the user works, and plenty of
|
|
* machines legitimately have no unit at all.
|
|
* @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-doctor-systemd-"));
|
|
const prevHome = process.env.HOME;
|
|
// Before anything resolves a unit path: `unitPath()` reads HOME, and a path
|
|
// computed against the real one makes these tests rewrite the developer's own
|
|
// ~/.config/systemd/user/agenthub.service.
|
|
process.env.HOME = TMP_HOME;
|
|
process.env.CLAUDE_HOME = path.join(TMP_HOME, ".claude");
|
|
|
|
const svc = require("../../scripts/systemd-service");
|
|
const { diagnose } = require("../../scripts/plugin-doctor");
|
|
|
|
const UNIT = svc.unitPath("agenthub");
|
|
const PLUGIN_ROOT = path.resolve(__dirname, "..", "..");
|
|
|
|
assert.ok(UNIT.startsWith(TMP_HOME + path.sep), `refusing to touch ${UNIT}`);
|
|
|
|
beforeEach(() => {
|
|
fs.rmSync(UNIT, { force: true });
|
|
// Claim systemd exists and the unit is running, so each test varies exactly
|
|
// one thing: the unit file on disk.
|
|
svc.__setExecImpl((args) => ({
|
|
status: 0,
|
|
stdout: args[0] === "is-active" ? "active\n" : "",
|
|
}));
|
|
});
|
|
|
|
after(() => {
|
|
svc.__reset();
|
|
if (prevHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
delete process.env.CLAUDE_HOME;
|
|
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
|
});
|
|
|
|
function row() {
|
|
const found = diagnose().find((r) => r.label === "systemd");
|
|
assert.ok(found, "the doctor must always emit a systemd row");
|
|
return found;
|
|
}
|
|
|
|
function writeUnit(workingDir) {
|
|
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
|
|
fs.writeFileSync(
|
|
UNIT,
|
|
"[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" +
|
|
`[Service]\nWorkingDirectory=${workingDir}\n`,
|
|
"utf8"
|
|
);
|
|
}
|
|
|
|
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
|
|
|
|
describe("plugin-doctor: systemd row", linuxOnly, () => {
|
|
it("warns, never fails, when the unit names a retired plugin root", () => {
|
|
writeUnit("/plugins/cache/sg/agenthub/deadbeef");
|
|
const r = row();
|
|
|
|
assert.equal(r.level, "warn", "a working dashboard must not fail the doctor run");
|
|
assert.match(r.detail, /previous plugin version/i);
|
|
// The consequence is the whole point — a reader who does not know it will
|
|
// ignore the row.
|
|
assert.match(r.detail, /reboot/i);
|
|
assert.match(r.detail, /plugin-bootstrap\.js --force|new Claude Code session/);
|
|
});
|
|
|
|
it("passes when the unit is current and running", () => {
|
|
writeUnit(PLUGIN_ROOT);
|
|
const r = row();
|
|
|
|
assert.equal(r.level, "ok", r.detail);
|
|
assert.match(r.detail, /current and running/i);
|
|
});
|
|
|
|
it("warns when the unit is current but stopped", () => {
|
|
writeUnit(PLUGIN_ROOT);
|
|
svc.__setExecImpl((args) => ({
|
|
status: 3,
|
|
stdout: args[0] === "is-active" ? "inactive\n" : "",
|
|
}));
|
|
|
|
const r = row();
|
|
assert.equal(r.level, "warn");
|
|
assert.match(r.detail, /not running/i);
|
|
});
|
|
|
|
it("reports no unit as fine, not as a problem", () => {
|
|
// A plugin install without `agenthub service install` is a normal setup:
|
|
// the server is started per session.
|
|
const r = row();
|
|
assert.equal(r.level, "ok", r.detail);
|
|
assert.match(r.detail, /no unit/i);
|
|
});
|
|
|
|
it("warns about a unit it did not write, and never claims it is stale", () => {
|
|
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
|
|
fs.writeFileSync(UNIT, "[Service]\nExecStart=/opt/someone-elses-agenthub\n", "utf8");
|
|
|
|
const r = row();
|
|
assert.equal(r.level, "warn");
|
|
assert.match(r.detail, /not written by AgentHub|left untouched/i);
|
|
});
|
|
});
|