198fbb7980
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js, workflow-stats.js); idx_events_session restored, idx_events_tool added - compat-sqlite gains function/pluck/readonly so a plugin install without a better-sqlite3 build boots again; antigravity-usage uses db.js's Database - install-hooks adds no checkout hooks while the agenthub plugin is enabled (events were doubled) - terminal attach runs the live tmux server's own binary; the pane scrollbar gets a visible 2-column style, and claude panes start with CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll - Codex sessions' conversation in GET /api/sessions/:id/transcript - Codex/Antigravity sessions claimed by their workspace without hooks - UI-audit follow-ups, docs and OpenAPI
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @file plugin-doctor.js
|
|
* @description Reports the health of a plugin-installed AgentHub: Node version,
|
|
* npm, free disk on the data dir, bootstrap state, runtime dependencies (every
|
|
* declared package, not just that node_modules exists), server liveness,
|
|
* duplicate hook entries (the one failure that silently doubles every token and
|
|
* cost figure), the `agenthub` CLI launcher and its PATH, the `tmux` binary
|
|
* every browser-started run depends on, and the dashboard bundle — quoting the
|
|
* last build error when it is missing.
|
|
* Read-only — it diagnoses, it never repairs.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
|
|
const boot = require("./plugin-bootstrap");
|
|
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
|
|
|
|
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
|
|
|
/** @returns {{level:"ok"|"warn"|"fail", label:string, detail:string}[]} */
|
|
function diagnose() {
|
|
const rt = boot.runtimeDir();
|
|
const out = [];
|
|
const add = (level, label, detail) => out.push({ level, label, detail });
|
|
|
|
add(
|
|
boot.nodeVersionOk() ? "ok" : "fail",
|
|
"Node",
|
|
boot.nodeVersionOk()
|
|
? `v${process.versions.node}`
|
|
: `v${process.versions.node} — AgentHub needs >= ${boot.MIN_NODE.join(".")} (node:sqlite)`
|
|
);
|
|
|
|
add("ok", "Plugin root", PLUGIN_ROOT);
|
|
add("ok", "Data dir", getDataDir());
|
|
|
|
const state = boot.readState(rt);
|
|
if (!state) {
|
|
add(
|
|
"warn",
|
|
"Bootstrap",
|
|
`no state recorded — start a new session, or check ${rt}/bootstrap.log`
|
|
);
|
|
} else {
|
|
const moved = state.pluginRoot !== PLUGIN_ROOT;
|
|
add(
|
|
moved ? "warn" : "ok",
|
|
"Bootstrap",
|
|
moved
|
|
? `recorded against a previous plugin version (${state.pluginRoot}) — run /agenthub-update`
|
|
: `last run ${state.updatedAt}`
|
|
);
|
|
}
|
|
|
|
add(...runtimeDepsStatus(rt));
|
|
add(...npmStatus());
|
|
add(...diskStatus());
|
|
|
|
const live = boot.liveServers();
|
|
add(
|
|
live.length ? "ok" : "fail",
|
|
"Server",
|
|
live.length
|
|
? `listening on ${live.map((s) => `http://localhost:${s.port}`).join(", ")}`
|
|
: `not running — see ${path.join(rt, "server.log")}`
|
|
);
|
|
|
|
const dup = countLegacyHookEntries();
|
|
add(
|
|
dup ? "fail" : "ok",
|
|
"Hooks",
|
|
dup
|
|
? `${dup} entr${dup === 1 ? "y" : "ies"} in ${getSettingsPath()} duplicate the plugin's hooks — ` +
|
|
`every event is counted twice. Remove them (a new session does it automatically).`
|
|
: "provided by the plugin only"
|
|
);
|
|
|
|
add(...cliStatus());
|
|
add(...systemdStatus());
|
|
add(...tmuxStatus());
|
|
|
|
add(...dashboardUiStatus(rt));
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* `node_modules` existing proves nothing: an interrupted install or a truncated
|
|
* cache entry leaves a directory that npm reports as installed and that is
|
|
* missing packages — the failure mode that took the client build down with
|
|
* TS2318. Check every declared runtime dependency actually landed.
|
|
*
|
|
* Only `dependencies` count. `optionalDependencies` (better-sqlite3) are allowed
|
|
* to be absent by design — the server falls back to `node:sqlite`.
|
|
*/
|
|
function runtimeDepsStatus(rt) {
|
|
const nm = path.join(rt, "node_modules");
|
|
if (!fs.existsSync(nm)) return ["fail", "Runtime deps", "missing — run /agenthub-update"];
|
|
|
|
let declared;
|
|
try {
|
|
declared = Object.keys(
|
|
JSON.parse(fs.readFileSync(path.join(rt, "package.json"), "utf8")).dependencies || {}
|
|
);
|
|
} catch {
|
|
return ["warn", "Runtime deps", `${nm} — no manifest beside it, cannot verify the tree`];
|
|
}
|
|
|
|
const missing = declared.filter((d) => !fs.existsSync(path.join(nm, d, "package.json")));
|
|
if (missing.length) {
|
|
return [
|
|
"fail",
|
|
"Runtime deps",
|
|
`${missing.join(", ")} declared but not installed — the tree is incomplete. ` +
|
|
`Delete ${nm} and run /agenthub-update`,
|
|
];
|
|
}
|
|
return ["ok", "Runtime deps", `${declared.length} packages in ${nm}`];
|
|
}
|
|
|
|
/** No npm, no dashboard bundle and no runtime install — both go through it. */
|
|
function npmStatus() {
|
|
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], {
|
|
encoding: "utf8",
|
|
shell: process.platform === "win32",
|
|
});
|
|
if (res.status !== 0) {
|
|
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
|
|
}
|
|
return ["ok", "npm", `v${res.stdout.trim()}`];
|
|
}
|
|
|
|
/**
|
|
* A full disk is the quiet cause of half-written installs and a SQLite file that
|
|
* refuses writes, and it reads as unrelated breakage everywhere else.
|
|
*/
|
|
function diskStatus() {
|
|
const dir = getDataDir();
|
|
let free;
|
|
try {
|
|
const s = fs.statfsSync(dir);
|
|
free = s.bavail * s.bsize;
|
|
} catch {
|
|
return ["ok", "Disk", "not measurable on this platform"];
|
|
}
|
|
const mb = Math.round(free / 1024 / 1024);
|
|
if (free < 200 * 1024 * 1024) {
|
|
return ["fail", "Disk", `${mb} MB free on ${dir} — installs and the database will truncate`];
|
|
}
|
|
if (free < 1024 * 1024 * 1024) return ["warn", "Disk", `${mb} MB free on ${dir}`];
|
|
return ["ok", "Disk", `${Math.round(mb / 1024)} GB free`];
|
|
}
|
|
|
|
/**
|
|
* When the bundle is missing the useful thing is not "run /agenthub-open" — it
|
|
* is why the last build failed, which sits in `client-build.log` and which
|
|
* nobody thinks to open.
|
|
*/
|
|
function dashboardUiStatus(rt) {
|
|
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
|
|
if (fs.existsSync(path.join(dist, "index.html"))) return ["ok", "Dashboard UI", dist];
|
|
|
|
const err = lastBuildError(path.join(rt, "client-build.log"));
|
|
return [
|
|
"warn",
|
|
"Dashboard UI",
|
|
err
|
|
? `not built — the last build failed with: ${err} (full log: ${path.join(rt, "client-build.log")})`
|
|
: "not built yet — run /agenthub-open",
|
|
];
|
|
}
|
|
|
|
/** First compiler/npm error line of the log's last build, or null. */
|
|
function lastBuildError(logPath) {
|
|
let lines;
|
|
try {
|
|
lines = fs.readFileSync(logPath, "utf8").split("\n");
|
|
} catch {
|
|
return null;
|
|
}
|
|
const hit = lines.filter((l) => /\berror\b|npm ERR!/i.test(l)).pop();
|
|
return hit ? hit.trim().slice(0, 200) : null;
|
|
}
|
|
|
|
/**
|
|
* The failure this row exists for is silent by construction: a unit still
|
|
* naming a retired plugin cache directory keeps a running dashboard looking
|
|
* perfectly healthy, and only bites at the next reboot — when the server does
|
|
* not come back, from a path Claude Code deleted weeks earlier. Nothing else
|
|
* in this report would show it.
|
|
*
|
|
* WARN, never FAIL: the dashboard in front of you is working, and on macOS,
|
|
* Windows or a Linux box without systemd there is no unit to have an opinion
|
|
* about.
|
|
*/
|
|
function systemdStatus() {
|
|
const svc = require("./service-manager");
|
|
const unit = svc.unitPath("agenthub");
|
|
const row = svc.kind;
|
|
|
|
if (!svc.unitIsOurs("agenthub")) {
|
|
if (fs.existsSync(unit)) {
|
|
return ["warn", row, `${unit} exists but was not written by AgentHub — left untouched`];
|
|
}
|
|
return [
|
|
"ok",
|
|
row,
|
|
svc.systemctlAvailable()
|
|
? "no unit — the server is started per session (install one with `agenthub service install`)"
|
|
: "not available on this platform — the server is started per session",
|
|
];
|
|
}
|
|
|
|
if (svc.unitIsStale("agenthub", PLUGIN_ROOT)) {
|
|
return [
|
|
"warn",
|
|
row,
|
|
`${unit} still points at a previous plugin version — the dashboard will NOT come back after a reboot. ` +
|
|
`Starting a new Claude Code session repairs it; or run: node ${path.join(PLUGIN_ROOT, "scripts", "plugin-bootstrap.js")} --force`,
|
|
];
|
|
}
|
|
|
|
if (!svc.unitIsActive("agenthub")) {
|
|
return [
|
|
"warn",
|
|
row,
|
|
svc.kind === "launchd"
|
|
? "LaunchAgent is installed and current but not running — start it with `agenthub service install`"
|
|
: "unit is installed and current but not running — start it with `systemctl --user start agenthub`",
|
|
];
|
|
}
|
|
return ["ok", row, `${svc.label} is current and running — the dashboard survives reboots`];
|
|
}
|
|
|
|
/**
|
|
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
|
|
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
|
|
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
|
|
* sessions, analytics, lanes and the CLI keep working. A partial feature
|
|
* loss must not fail a doctor run.
|
|
*
|
|
* This checks the doctor's own PATH. A server started as a systemd `--user`
|
|
* service can still see a different one — `GET /api/run/tmux` is the server's
|
|
* own answer, and `agenthub doctor` reports that.
|
|
*
|
|
* Also WARN below tmux 3.7, the first with pane scrollbars (`tmux.js` turns
|
|
* them on), and when a new binary still talks to an older running server — a
|
|
* server keeps its version until every tmux session has closed. The doctor
|
|
* only says so: it never installs or restarts tmux.
|
|
*/
|
|
const hasScrollbars = (version) => {
|
|
const m = /(\d+)\.(\d+)/.exec(version || "");
|
|
return !!m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 7));
|
|
};
|
|
|
|
function tmuxStatus() {
|
|
const tmux = require("../server/lib/tmux");
|
|
const client = tmux.clientVersion();
|
|
if (!client) {
|
|
return [
|
|
"warn",
|
|
"tmux",
|
|
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " +
|
|
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
|
|
];
|
|
}
|
|
if (!hasScrollbars(client)) {
|
|
return [
|
|
"warn",
|
|
"tmux",
|
|
`${client} works, but terminal panes get a scrollbar only from tmux 3.7. ` +
|
|
"Upgrade: brew upgrade tmux (macOS); on Linux, when the distro ships an older one " +
|
|
"(Ubuntu 24.04 has 3.4), build the release from https://github.com/tmux/tmux/releases " +
|
|
"(needs libevent-dev libncurses-dev bison: ./configure && make && sudo make install)",
|
|
];
|
|
}
|
|
const server = tmux.serverVersion();
|
|
if (server && !hasScrollbars(server)) {
|
|
return [
|
|
"warn",
|
|
"tmux",
|
|
`${client} installed, but the running tmux server is ${server} — panes get their ` +
|
|
"scrollbar once every tmux session has closed (or after a reboot)",
|
|
];
|
|
}
|
|
return ["ok", "tmux", `${client} on PATH — the run console can start sessions, with scrollbars`];
|
|
}
|
|
|
|
function countLegacyHookEntries() {
|
|
try {
|
|
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
|
|
if (!settings.hooks) return 0;
|
|
const isLegacy = boot.isCheckoutHookEntry();
|
|
return Object.values(settings.hooks)
|
|
.filter(Array.isArray)
|
|
.reduce((n, entries) => n + entries.filter(isLegacy).length, 0);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function cliStatus() {
|
|
const dir = path.join(os.homedir(), ".local", "bin");
|
|
const file = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
|
|
if (!fs.existsSync(file)) return ["warn", "agenthub CLI", `no launcher at ${file}`];
|
|
const onPath = (process.env.PATH || "")
|
|
.split(path.delimiter)
|
|
.some((p) => path.resolve(p) === path.resolve(dir));
|
|
return onPath
|
|
? ["ok", "agenthub CLI", file]
|
|
: [
|
|
"warn",
|
|
"agenthub CLI",
|
|
`${file} exists but ${dir} is not on PATH — add${
|
|
process.platform === "darwin" ? " to ~/.zshrc" : ""
|
|
}: export PATH="${dir}:$PATH"`,
|
|
];
|
|
}
|
|
|
|
function report() {
|
|
const marks = { ok: "OK ", warn: "WARN", fail: "FAIL" };
|
|
const rows = diagnose();
|
|
for (const r of rows) console.log(`${marks[r.level]} ${r.label.padEnd(14)} ${r.detail}`);
|
|
return rows.some((r) => r.level === "fail") ? 1 : 0;
|
|
}
|
|
|
|
if (require.main === module) process.exitCode = report();
|
|
|
|
module.exports = { diagnose, report, countLegacyHookEntries };
|