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:
+121
-13
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user