perf: lighter, louder local install — vendored ReDoc, bootstrap failures shown in the session

- Plugin bootstrap: a failed npm install, a Node older than 22.5 or a failed UI
  build is recorded in runtime/last-failure.json; the next SessionStart prints
  it once as a systemMessage naming the log and /agenthub-doctor (a different
  failure is reported again; a successful bootstrap clears it). The foreground
  pass checks the Node version itself and spawns no worker on an old Node.
- ReDoc: the standalone bundle (redoc 2.5.4, MIT, licence files kept) is
  vendored under server/vendor/redoc/ and the `redoc` dependency dropped —
  a runtime install (npm ci --omit=dev --ignore-scripts) goes 126 MB -> 45 MB.
  vendor/ is excluded from the header audit and prettier.
- git missing from PATH: lane git actions answer 503 EGITMISSING with the
  install command instead of a bare ENOENT.
- package.json engines: node >=22.5.0 (node:sqlite), matching MIN_NODE.
- Docs: INSTALL.md is the one install/setup guide (SETUP.md merged in and now a
  pointer); no VS Code extension claim, one client-install hint, no
  windows-build-tools, better-sqlite3 notes scoped to checkouts, new
  "Plugin bootstrap never comes up" troubleshooting entry.
This commit is contained in:
2026-10-16 10:16:54 +07:00
parent ecbbb4dd8f
commit 9a4ed54cf7
26 changed files with 2667 additions and 1527 deletions
+121 -13
View File
@@ -220,6 +220,81 @@ function nodeVersionOk(version = process.versions.node) {
return true;
}
/* ------------------------------------------------------- last failure report */
/**
* The worker runs detached with its output discarded, so a failed install, a
* Node that is too old or a failed UI build used to live only in a log nobody
* opens — and every session silently retried. The last failure is kept in one
* small file; the NEXT foreground pass (which Claude Code reads) prints it once
* as a SessionStart `systemMessage`, and a bootstrap that succeeds removes it.
*/
function failurePath(rt = runtimeDir()) {
return path.join(rt, "last-failure.json");
}
function readFailure(rt = runtimeDir()) {
try {
return JSON.parse(fs.readFileSync(failurePath(rt), "utf8"));
} catch {
return null;
}
}
/** Record a failure; the same kind again keeps its `reported` flag (once per failure). */
function recordFailure(rt, kind, message, logFile) {
const prev = readFailure(rt);
const same = prev && prev.kind === kind && prev.message === message;
const rec = {
kind,
message,
log: logFile,
at: new Date().toISOString(),
reported: Boolean(same && prev.reported),
};
try {
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(failurePath(rt), JSON.stringify(rec, null, 2) + "\n", "utf8");
} catch {
/* best effort */
}
}
function clearFailure(rt = runtimeDir()) {
try {
fs.rmSync(failurePath(rt), { force: true });
} catch {
/* best effort */
}
}
/**
* The SessionStart output for an unreported failure, or null. Marks it
* reported, so a failure is shown once — not on every session until fixed.
*/
function takeFailureReport(rt = runtimeDir()) {
const rec = readFailure(rt);
if (!rec || rec.reported) return null;
try {
fs.writeFileSync(failurePath(rt), JSON.stringify({ ...rec, reported: true }, null, 2) + "\n");
} catch {
return null; // could not mark it: stay quiet rather than repeat forever
}
return {
systemMessage:
`AgentHub: ${rec.message}` +
(rec.log ? ` — details in ${rec.log}` : "") +
". Run /agenthub-doctor for a diagnosis.",
};
}
function nodeTooOldMessage(version = process.versions.node) {
return (
`Node ${version} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
`(the server stores data through node:sqlite). Upgrade Node, then start a new session`
);
}
/* -------------------------------------------------------------------- lock */
function lockPath(rt = runtimeDir()) {
@@ -704,11 +779,8 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
}
if (!nodeVersionOk()) {
log(
rt,
`Node ${process.versions.node} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
`(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
);
log(rt, nodeTooOldMessage());
recordFailure(rt, "node-too-old", nodeTooOldMessage(), path.join(rt, "bootstrap.log"));
return "node-too-old";
}
@@ -732,6 +804,12 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
rt,
`npm install failed — see ${path.join(rt, "npm-install.log")}; run /agenthub-doctor after fixing it`
);
recordFailure(
rt,
"install-failed",
"installing the dashboard's runtime dependencies failed",
path.join(rt, "npm-install.log")
);
return "install-failed";
}
}
@@ -761,6 +839,12 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
});
if (uiResult === "failed") {
log(rt, "dashboard UI build failed — the API still works; run /agenthub-open to retry");
recordFailure(
rt,
"ui-build-failed",
"the dashboard UI build failed (the API still works; /agenthub-open retries it)",
path.join(rt, "client-build.log")
);
} else if (uiResult === "built") {
log(rt, "built the dashboard UI bundle");
}
@@ -874,6 +958,8 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
rt
);
if (uiResult !== "failed") clearFailure(rt);
// After the state is durable, so a prune that goes wrong cannot also cost
// us the record of a bootstrap that went right.
pruneRetiredPluginRoots(rt);
@@ -906,13 +992,26 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
* milliseconds in the steady state, and otherwise hands off to a detached
* worker so the session never waits on an install.
*/
function main() {
if (notTheHost() || fastPathOk()) return;
const child = spawn(process.execPath, [__filename, "--worker"], {
detached: true,
stdio: "ignore",
});
child.unref();
function main(rt = runtimeDir()) {
if (notTheHost()) return null;
// Checked here too, not only in the worker: it costs nothing, the worker
// could only log it, and this pass can tell the user right now. A retired
// build stays silent — its worker would do nothing either.
if (!nodeVersionOk()) {
if (supersededBy()) return null;
recordFailure(rt, "node-too-old", nodeTooOldMessage(), null);
return takeFailureReport(rt);
}
if (!fastPathOk(rt)) {
const child = spawn(process.execPath, [__filename, "--worker"], {
detached: true,
stdio: "ignore",
});
child.unref();
}
// A failure the previous worker recorded (it ran after that session's
// foreground pass had already returned). Pure fs; writes only to mark it shown.
return takeFailureReport(rt);
}
// Exported before the entry block below, not after it: `bootstrap()` requires
@@ -935,6 +1034,12 @@ module.exports = {
depsHash,
fastPathOk,
nodeVersionOk,
failurePath,
readFailure,
recordFailure,
clearFailure,
takeFailureReport,
main,
lockPath,
lockIsStale,
acquireLock,
@@ -972,7 +1077,10 @@ if (require.main === module) {
}
} else {
try {
main();
const report = main();
// SessionStart reads a JSON object on stdout; `systemMessage` is shown
// to the user. Nothing at all is printed in the steady state.
if (report) process.stdout.write(JSON.stringify(report) + "\n");
} catch {
// A bootstrap failure must never break a Claude Code session.
}