ruvnet-brain 4.4.1 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/install.mjs +612 -113
- package/console/app.js +178 -81
- package/console/index.html +1 -1
- package/console/install-architecture.html +1 -0
- package/console/scope.css +4 -1
- package/console/style.css +13 -0
- package/console/tips.html +4 -4
- package/kb/brain-profile.mjs +1 -0
- package/kb/corpus-release-identity.mjs +1 -1
- package/kb/forge-update.mjs +41 -17
- package/kb/model-requirements.mjs +4 -1
- package/kb/update-storage-transaction.mjs +79 -0
- package/kb/zip-extract.mjs +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +5 -4
- package/plugin/commands/configure.md +5 -4
- package/plugin/commands/rnb-brief.md +41 -0
- package/plugin/commands/rnb.md +80 -0
- package/plugin/commands/rnbc.md +80 -0
- package/plugin/commands/rvbc.md +5 -4
- package/plugin/commands/rvcb.md +5 -4
- package/plugin/commands/whats-new.md +4 -4
- package/plugin/mcp/server.mjs +10 -2
- package/plugin/scripts/advocacy-route.mjs +59 -23
- package/plugin/scripts/anticipate.sh +4 -0
- package/plugin/scripts/brain-confirmation.mjs +258 -0
- package/plugin/scripts/brain-footprint.mjs +494 -0
- package/plugin/scripts/brain-location.mjs +47 -0
- package/plugin/scripts/capability-registry.mjs +11 -1
- package/plugin/scripts/continuity-brief.mjs +324 -0
- package/plugin/scripts/continuity-events.mjs +327 -0
- package/plugin/scripts/continuity-journal.mjs +500 -0
- package/plugin/scripts/decision-gate.mjs +56 -3
- package/plugin/scripts/footprint-io.mjs +186 -0
- package/plugin/scripts/ground-before-write.sh +8 -1
- package/plugin/scripts/ground-ruvnet.sh +103 -12
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-substance.mjs +1 -1
- package/plugin/scripts/grounding-turn-evidence.mjs +17 -2
- package/plugin/scripts/hook-input.mjs +78 -4
- package/plugin/scripts/kb-copy-proof.mjs +148 -0
- package/plugin/scripts/lesson-bridge.mjs +6 -2
- package/plugin/scripts/nightly-controller.mjs +8 -1
- package/plugin/scripts/node-sqlite.mjs +41 -0
- package/plugin/scripts/package-cards.json +797 -0
- package/plugin/scripts/package-cards.rvf +0 -0
- package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
- package/plugin/scripts/package-cards.rvf.meta.json +1 -0
- package/plugin/scripts/package-recommender-client.mjs +138 -0
- package/plugin/scripts/package-recommender-flag.mjs +30 -0
- package/plugin/scripts/package-recommender.mjs +391 -0
- package/plugin/scripts/project-progression-outbox.mjs +26 -8
- package/plugin/scripts/project-progression-reader.mjs +4 -2
- package/plugin/scripts/protect-brain-state.sh +4 -1
- package/plugin/scripts/session-snapshot-hook.mjs +27 -3
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +34 -4
- package/plugin/scripts/session-start-health.mjs +7 -1
- package/plugin/scripts/session-start-update-plane.mjs +35 -0
- package/plugin/scripts/turn-outcome-capture.mjs +12 -1
- package/plugin/scripts/unprompted-runtime.mjs +2 -2
- package/plugin/skills/brain-console/SKILL.md +3 -3
- package/plugin/skills/rnbc/SKILL.md +24 -0
- package/plugin/skills/rvbc/SKILL.md +2 -2
- package/scripts/approved-runtime.mjs +2 -2
- package/scripts/ci/warm-brain-models.mjs +28 -0
- package/scripts/codex-hook-trust.mjs +94 -0
- package/scripts/console-runtime-identity.mjs +5 -0
- package/scripts/corpus-canary.mjs +46 -6
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +1 -1
- package/scripts/hook-qualify-hosts.mjs +15 -3
- package/scripts/host-install-matrix.mjs +63 -2
- package/scripts/human-approval-phrases.mjs +46 -0
- package/scripts/installed-brain-health.mjs +53 -0
- package/scripts/move-brain.mjs +310 -0
- package/scripts/onboarding-console.mjs +93 -10
- package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
- package/scripts/oracle/abstain-trace.mjs +139 -0
- package/scripts/oracle/doc2query-generate.mjs +162 -0
- package/scripts/oracle/doc2query-reach.mjs +110 -0
- package/scripts/oracle/judge-train.mjs +158 -0
- package/scripts/oracle/need-set-split.mjs +48 -0
- package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
- package/scripts/package-cards.mjs +374 -0
- package/scripts/publication-receipt.mjs +37 -9
- package/scripts/recommendation-e2e.mjs +110 -0
- package/scripts/recommendation-eval.mjs +105 -0
- package/scripts/recommendation-floor.mjs +56 -0
- package/scripts/recommendation-judge-score.mjs +74 -0
- package/scripts/recommendation-latency.mjs +95 -0
- package/scripts/recommendation-real-host-score.mjs +76 -0
- package/scripts/recommendation-real-host.mjs +137 -0
- package/scripts/release-channel-kind.mjs +1 -1
- package/scripts/release-environment-policy.mjs +33 -0
- package/scripts/single-source-check.mjs +15 -10
- package/scripts/sync-commands.mjs +5 -2
- package/scripts/wired-check.mjs +17 -2
|
@@ -37,6 +37,24 @@ export const SELF_STORE_PROOF_K = 1;
|
|
|
37
37
|
export const RELEASE_SEARCH_QUERY = 'repo:ruvnet-brain How does RuvNet Brain prove a public release artifact?';
|
|
38
38
|
export const HOST_WARMUP_TIMEOUT_MS = 300_000;
|
|
39
39
|
export const RELEASE_SEARCH_DEADLINE_MS = 30_000;
|
|
40
|
+
|
|
41
|
+
// RETRIEVAL-CANARY FIRST-PASS BOUND, PER OS (4.5, approved by the release coordinator 2026-10-01).
|
|
42
|
+
// This bounds a CI runner's COLD canary search — the first time one warm MCP worker meets each of the
|
|
43
|
+
// 19 canary repos. It is NOT a product latency target (CONTRIBUTING.md, "Hosts and verification").
|
|
44
|
+
// RULE: the bound is the worst measured first-pass query x 1.5, rounded up to whole seconds, and never
|
|
45
|
+
// below RELEASE_SEARCH_DEADLINE_MS. The measurement is the input, so the bound cannot drift silently:
|
|
46
|
+
// changing it means changing the evidence below. Worst first-pass query, one warm worker, public 4.4.1:
|
|
47
|
+
// macOS 33,351 ms legacy:metaharness, macos-latest (ci-probe run 36886413597); 7 samples over runs
|
|
48
|
+
// 36885062160 / 36886413597 / 36887732228 on macos-latest, -15, -14: maxima
|
|
49
|
+
// 22.4, 21.7, 22.6, 33.4, 21.5, 22.9, 30.9 s; second-pass maxima 13.5-17.5 s
|
|
50
|
+
// Linux 11,741 ms ubuntu-latest (run 36885062160) -> 17.6 s, so the 30 s floor holds
|
|
51
|
+
// Windows 10,679 ms windows-latest (run 36885062160) -> 16.0 s, so the 30 s floor holds
|
|
52
|
+
export const CANARY_WORST_FIRST_PASS_MS = Object.freeze({ darwin: 33_351, linux: 11_741, win32: 10_679 });
|
|
53
|
+
export function canarySearchDeadlineMs(platform = process.platform) {
|
|
54
|
+
const worst = CANARY_WORST_FIRST_PASS_MS[platform];
|
|
55
|
+
if (!Number.isFinite(worst)) return RELEASE_SEARCH_DEADLINE_MS;
|
|
56
|
+
return Math.max(RELEASE_SEARCH_DEADLINE_MS, Math.ceil((worst * 1.5) / 1000) * 1000);
|
|
57
|
+
}
|
|
40
58
|
// The shared model-cache prewarm's own candidate pool size, named so the test asserting on it
|
|
41
59
|
// derives from this constant instead of restating the digit as a second, driftable literal.
|
|
42
60
|
export const PREWARM_POOL_SIZE = 8;
|
|
@@ -282,7 +300,10 @@ export async function runHostMatrixAsync({
|
|
|
282
300
|
try {
|
|
283
301
|
const serverPath = resolveMcpServer(context);
|
|
284
302
|
// One installed worker per host: model/store state survives smoke and every sealed case.
|
|
285
|
-
session = runMcpSearch === runInstalledMcpSearch ?
|
|
303
|
+
session = runMcpSearch === runInstalledMcpSearch ? createRestartingMcpSession({
|
|
304
|
+
open: () => createInstalledMcpSession({ serverPath, env: context.env }),
|
|
305
|
+
warm: (fresh) => fresh.search({ query: SELF_STORE_PROOF_QUERY, k: SELF_STORE_PROOF_K, timeoutMs: HOST_WARMUP_TIMEOUT_MS }),
|
|
306
|
+
}) : null;
|
|
286
307
|
const searchMcp = session ? (args) => session.search(args) : runMcpSearch;
|
|
287
308
|
// The installed shell warms its in-process models and stores asynchronously from MCP
|
|
288
309
|
// initialize. Candidate qualification must give that same worker the same cited readiness
|
|
@@ -313,7 +334,7 @@ export async function runHostMatrixAsync({
|
|
|
313
334
|
warmupGrounding, error: `MCP search grounding unproven for ${context.mode}` };
|
|
314
335
|
let receipt;
|
|
315
336
|
if (retrieval) {
|
|
316
|
-
receipt = await runRetrievalCanaries({ ...retrieval, searchTimeoutMs:
|
|
337
|
+
receipt = await runRetrievalCanaries({ ...retrieval, searchTimeoutMs: canarySearchDeadlineMs(),
|
|
317
338
|
search: async ({ query, k, timeoutMs }) => {
|
|
318
339
|
const result = await searchMcp({ mode: context.mode, serverPath, env: context.env, query, k, timeoutMs });
|
|
319
340
|
if (result.error || result.status !== 0) throw new Error(`canary MCP search failed: ${processDiagnostic(result)}`);
|
|
@@ -478,6 +499,46 @@ export function createInstalledMcpSession({ serverPath, env, timeout = 300_000,
|
|
|
478
499
|
};
|
|
479
500
|
}
|
|
480
501
|
|
|
502
|
+
// ONE SLOW QUERY MUST FAIL ONLY ITSELF. A search that times out or fails closes its session — the
|
|
503
|
+
// worker may be wedged mid-rerank — and a closed session answers every later call with that same
|
|
504
|
+
// stored error, instantly. So one slow canary case became every remaining case "timed out" (4.4.1
|
|
505
|
+
// preflight attempt 1: cases 9-18 ETIMEDOUT; recovery 36881395598: cases 14-18). This keeps one warm
|
|
506
|
+
// worker for the whole lane, as a customer has, and only after a failure opens a fresh one, warmed
|
|
507
|
+
// on the warm-up bound (never on the next case's deadline) before that case is timed.
|
|
508
|
+
export function createRestartingMcpSession({ open, warm = null }) {
|
|
509
|
+
let session = open();
|
|
510
|
+
let closed = false;
|
|
511
|
+
let restarts = 0;
|
|
512
|
+
let queue = Promise.resolve();
|
|
513
|
+
const failed = (result) => Boolean(result?.error) || result?.status !== 0;
|
|
514
|
+
const run = async (args) => {
|
|
515
|
+
if (closed) return { status: null, signal: null, error: new Error('MCP session closed'), stdout: '', stderr: '' };
|
|
516
|
+
if (!session) {
|
|
517
|
+
session = open();
|
|
518
|
+
restarts += 1;
|
|
519
|
+
if (warm) {
|
|
520
|
+
const warmed = await warm(session);
|
|
521
|
+
if (failed(warmed)) { const dead = session; session = null; await dead.close(); return warmed; }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const result = await session.search(args);
|
|
525
|
+
if (failed(result)) { const dead = session; session = null; await dead.close(); }
|
|
526
|
+
return result;
|
|
527
|
+
};
|
|
528
|
+
return {
|
|
529
|
+
search(args) {
|
|
530
|
+
const result = queue.then(() => run(args));
|
|
531
|
+
queue = result.then(() => undefined, () => undefined);
|
|
532
|
+
return result;
|
|
533
|
+
},
|
|
534
|
+
async close() {
|
|
535
|
+
closed = true;
|
|
536
|
+
if (session) { const live = session; session = null; await live.close(); }
|
|
537
|
+
},
|
|
538
|
+
get restarts() { return restarts; },
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
481
542
|
async function runInstalledMcpSearch(options) {
|
|
482
543
|
const session = createInstalledMcpSession(options);
|
|
483
544
|
try { return await session.search(options); } finally { await session.close(); }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// human-approval-phrases.mjs — the matcher behind single-source B6 (tracked instructions) and B12 (the
|
|
2
|
+
// local CLAUDE.md / AGENTS.md): no instruction may put a person clicking in GitHub back into the release
|
|
3
|
+
// path (CONTRIBUTING.md, "What replaces a human approval").
|
|
4
|
+
//
|
|
5
|
+
// Two gaps the 4.3.39 review measured, both closed here:
|
|
6
|
+
// • Markdown emphasis broke the match: "Stuart's approval** of the …" did not match "Stuart's approval of".
|
|
7
|
+
// Lines are matched after stripping `*` and backticks.
|
|
8
|
+
// • B6 exempted any line containing "never" ANYWHERE, so "The owner approves the Production deployment;
|
|
9
|
+
// never skip it." passed. A negation now counts only inside the same clause as the matched phrase.
|
|
10
|
+
|
|
11
|
+
/** Phrases that re-introduce a person as a release gate (B6, tracked instruction files). */
|
|
12
|
+
export const HUMAN_APPROVAL_STEP = /(owner|stuart'?s?|maintainer) (approves?|approval|click|must approve)\b[^.;]*(deployment|release|publish|gate)|approves? the Production|hand (it|the work) over[^.;]*click|required[- ]reviewers?\b|standing authori[sz]ation permits|owner[- ]approved/i;
|
|
13
|
+
|
|
14
|
+
/** B12: the same, plus the local-file drifts that file has carried (npx ruflo, retired npm scripts). */
|
|
15
|
+
export const LOCAL_INSTRUCTION_DRIFT = /npx (-y )?(@claude-flow|claude-flow|ruflo)\b|standing authori[sz]ation permits|owner (approves?|click)|Stuart's approval (of|in GitHub)|approves? the Production|hand (it|the work) over[^.]*click|npm run (build|dev|test:integration|test:coverage|test:security)\b|not direct Agent tool|owner[- ]approved/i;
|
|
16
|
+
|
|
17
|
+
const NEGATION = /\b(no|not|never|nobody|none|without|cannot|isn't|is not|removed|retired)\b/i;
|
|
18
|
+
|
|
19
|
+
/** Markdown emphasis and code ticks are formatting, not words. */
|
|
20
|
+
export const plainText = (line) => String(line).replace(/[*`]/g, '');
|
|
21
|
+
|
|
22
|
+
/** The clause around [start, end): bounded by . ; : ! ? or the line ends. */
|
|
23
|
+
function clauseAround(text, start, end) {
|
|
24
|
+
const before = text.slice(0, start);
|
|
25
|
+
const after = text.slice(end);
|
|
26
|
+
const from = Math.max(...['.', ';', ':', '!', '?'].map((c) => before.lastIndexOf(c))) + 1;
|
|
27
|
+
const stops = ['.', ';', ':', '!', '?'].map((c) => after.indexOf(c)).filter((i) => i >= 0);
|
|
28
|
+
const to = end + (stops.length ? Math.min(...stops) : after.length);
|
|
29
|
+
return text.slice(from, to);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hits of `pattern` in `line` that are not negated in their own clause.
|
|
34
|
+
* @returns {string[]} the matched phrases that count
|
|
35
|
+
*/
|
|
36
|
+
export function approvalHits(line, pattern = HUMAN_APPROVAL_STEP, { allowNegation = true } = {}) {
|
|
37
|
+
const text = plainText(line);
|
|
38
|
+
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
39
|
+
const hits = [];
|
|
40
|
+
for (const match of text.matchAll(global)) {
|
|
41
|
+
const clause = clauseAround(text, match.index, match.index + match[0].length);
|
|
42
|
+
if (allowNegation && NEGATION.test(clause)) continue;
|
|
43
|
+
hits.push(match[0]);
|
|
44
|
+
}
|
|
45
|
+
return hits;
|
|
46
|
+
}
|
|
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
import { readInstalledRuntime, isCorpusReleaseTag } from '../kb/corpus-release-identity.mjs';
|
|
6
6
|
import { cmpVersion } from './stack-sync.mjs';
|
|
7
7
|
import { isRuntimeFile } from './approved-runtime.mjs';
|
|
8
|
+
import { modelCacheReady, requiredEmbedderModels, RERANKER_MODEL } from '../kb/model-requirements.mjs';
|
|
8
9
|
|
|
9
10
|
const SEARCH_FILES = ['forge-mcp-all.mjs', 'forge-ask-all.mjs', 'forge-rerank.mjs', 'card-lane.mjs'];
|
|
10
11
|
// Keep the doctor probe answerable and bounded. A health probe must exercise a real indexed
|
|
@@ -14,6 +15,58 @@ export function doctorSmokeArgs(cacheDir) {
|
|
|
14
15
|
return ['forge-ask-all.mjs', '--dir', cacheDir, '--q', DOCTOR_SMOKE_QUERY,
|
|
15
16
|
'--repos', 'ruvnet-brain', '--k', '3', '--pool', '8', '--bounded'];
|
|
16
17
|
}
|
|
18
|
+
|
|
19
|
+
// The models the doctor's question loads whose local copy is not complete. A partial download is
|
|
20
|
+
// cold, not ready: the reader would fetch it again inside the timed question.
|
|
21
|
+
export function coldModels(kbDir, modelCache) {
|
|
22
|
+
return [...requiredEmbedderModels(kbDir), RERANKER_MODEL]
|
|
23
|
+
.filter((model, i, all) => all.indexOf(model) === i && !modelCacheReady(modelCache, model));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One-time model download + load + first forward pass, kept OUT of the timed question so the
|
|
27
|
+
// question's limit measures answering, not fetching. Exits 3 on a bundle that predates the hooks.
|
|
28
|
+
export const MODEL_WARMUP_SCRIPT = [
|
|
29
|
+
"const ask = await import('./forge-ask.mjs');",
|
|
30
|
+
"const rr = await import('./forge-rerank.mjs');",
|
|
31
|
+
"if (typeof ask.warmQueryEmbedder !== 'function' || typeof rr.warmReranker !== 'function') process.exit(3);",
|
|
32
|
+
'await ask.warmQueryEmbedder();',
|
|
33
|
+
'await rr.warmReranker();',
|
|
34
|
+
'process.exit(0);',
|
|
35
|
+
].join('\n');
|
|
36
|
+
export const MODEL_WARMUP_TIMEOUT_MS = 300_000;
|
|
37
|
+
|
|
38
|
+
// WHY the doctor's question produced no answer, from what spawnSync returned. "slow" is the reader's
|
|
39
|
+
// own deadline: kb/query-deadline.mjs describeDeadline() prints this line, naming the phase still
|
|
40
|
+
// running, only on that path (that module is not in the npm package, so its text is the contract
|
|
41
|
+
// here). The reader works and was mid-answer — a different fault, with different advice, from a crash.
|
|
42
|
+
export function classifySmokeFailure({ error, signal, status, stderr = '', secs, limitSecs }) {
|
|
43
|
+
// spawnSync delivers its own timeout as signal SIGTERM AND error ETIMEDOUT: a timeout, not a launch failure.
|
|
44
|
+
if (error?.code === 'ETIMEDOUT') return { kind: 'timeout', cause: `timed out after ${secs}s (240s limit) with no answer` };
|
|
45
|
+
if (error) return { kind: 'launch', cause: `could not launch the reader: ${error.message}` };
|
|
46
|
+
if (signal === 'SIGTERM') return { kind: 'timeout', cause: `timed out after ${secs}s (240s limit) with no answer` };
|
|
47
|
+
if (signal) return { kind: 'killed', cause: `the reader was killed by ${signal} after ${secs}s` };
|
|
48
|
+
const phase = (String(stderr).match(/QUERY DEADLINE EXCEEDED — phase "([^"]+)"/) || [])[1];
|
|
49
|
+
if (status !== 0 && phase) {
|
|
50
|
+
return { kind: 'slow', phase,
|
|
51
|
+
cause: `still answering (phase "${phase}") when the ${limitSecs}s limit ran out, after ${secs}s — slow on this machine, not broken` };
|
|
52
|
+
}
|
|
53
|
+
if (status !== 0) return { kind: 'crash', cause: `the reader exited ${status} after ${secs}s` };
|
|
54
|
+
return { kind: 'empty', cause: `the reader exited 0 after ${secs}s but printed nothing` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Why the model warm-up did not finish (re-review B1). spawnSync's own timeout is signal SIGTERM WITH error
|
|
59
|
+
* ETIMEDOUT — a slow machine, advisory. A signal with NO error is the child dying on its own: SIGABRT,
|
|
60
|
+
* SIGSEGV, an OOM SIGKILL — a broken model runtime or cache, a failure with its own cause. The old test,
|
|
61
|
+
* `signal && !error`, had both backwards.
|
|
62
|
+
*/
|
|
63
|
+
export function classifyWarmupFailure({ error, signal, status, secs, limitSecs }) {
|
|
64
|
+
if (error?.code === 'ETIMEDOUT') return { kind: 'timeout', advisory: true, cause: `ran out of time after ${secs}s (${limitSecs}s limit) — slow on this machine, not broken` };
|
|
65
|
+
if (error) return { kind: 'launch', advisory: false, cause: `could not start: ${error.message}` };
|
|
66
|
+
if (signal) return { kind: 'crash', advisory: false, cause: `crashed (${signal}) after ${secs}s — the model runtime or its cache is broken` };
|
|
67
|
+
return { kind: 'exit', advisory: false, cause: `exited ${status} after ${secs}s` };
|
|
68
|
+
}
|
|
69
|
+
|
|
17
70
|
const version = value => typeof value === 'string' && /^v?\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(value)
|
|
18
71
|
? value.replace(/^v/, '') : null;
|
|
19
72
|
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// move-brain.mjs — `npx ruvnet-brain --move-brain <dir>`: put the whole Brain on another disk with one
|
|
2
|
+
// command, and `--move-brain --back` to bring it home.
|
|
3
|
+
//
|
|
4
|
+
// Design (decided 2026-10-01): the Brain stays addressed by its default path, ~/.cache/ruvnet-brain, which
|
|
5
|
+
// becomes a symlink (a junction on Windows) to the new location. Every reader, hook, the MCP server and the
|
|
6
|
+
// nightly keep their path and follow the link, so nothing else needs configuring (an env var would not reach
|
|
7
|
+
// GUI-launched hosts or launchd). The move is: take the refresh lock the updater takes, preflight space, copy,
|
|
8
|
+
// PROVE every source file arrived byte-identical, swap the link, then remove the old copy. Any failure before
|
|
9
|
+
// the swap leaves the Brain exactly where it was and removes the copy; a failure DURING the swap is rolled
|
|
10
|
+
// back step by step, and the message says plainly where the Brain is.
|
|
11
|
+
//
|
|
12
|
+
// What the proof ignores, and why (review S1/S2, reproduced on a real exFAT image 2026-10-01):
|
|
13
|
+
// - VOLUME METADATA, on either side. macOS writes an AppleDouble `._<name>` beside every copied file that
|
|
14
|
+
// has extended attributes on exFAT/FAT/NTFS/SMB, and volumes grow .DS_Store/.Spotlight-V100/.fseventsd/
|
|
15
|
+
// .Trashes. They are the filesystem's, not the Brain's (no release ships one; coverage-integrity skips
|
|
16
|
+
// `._*` too). So they are never copied — `--back` from exFAT used to bring `._ruvector.rvf` home, where
|
|
17
|
+
// every reader saw a fake `._ruvector` store — and never compared or required on either side.
|
|
18
|
+
// - TRANSIENT IPC in run/. A search worker's recommender socket (run/recommend-<pid>.sock) is a live
|
|
19
|
+
// endpoint, not data; a SIGKILLed worker leaves it behind. Stale ones are swept exactly as the endpoint
|
|
20
|
+
// sweeps them on start (kb/recommend-endpoint.mjs sweepStale — not shipped in the npm package, so the
|
|
21
|
+
// rule is repeated here and held equal by tests/unit/move-brain.test.mjs); live ones are not copied.
|
|
22
|
+
// Any other socket, FIFO or device anywhere in the Brain refuses the move by name, before anything is copied.
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { brainLocation, defaultBrainHome } from '../plugin/scripts/brain-location.mjs';
|
|
28
|
+
import { checkDiskSpace, directoryBytes } from '../kb/update-storage-transaction.mjs';
|
|
29
|
+
import { acquireRefreshLock, releaseRefreshLock } from '../kb/refresh-run.mjs';
|
|
30
|
+
|
|
31
|
+
// The plugin updater's own locks (plugin/scripts/update-apply.mjs, session-start-health.mjs). The refresh
|
|
32
|
+
// lock (kb/refresh-run.mjs) is not merely checked: the move HOLDS it, so no update can start mid-move.
|
|
33
|
+
const PLUGIN_LOCK_NAMES = ['.update.lock', 'auto-update.lock'];
|
|
34
|
+
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
|
|
35
|
+
const VOLUME_METADATA = /^(?:\._.+|\.DS_Store|\.Spotlight-V100|\.fseventsd|\.Trashes|\.TemporaryItems|\.apdisk|System Volume Information|\$RECYCLE\.BIN)$/;
|
|
36
|
+
const STALE_ENDPOINT = /^recommend-(\d+)\.(json|sock)$/;
|
|
37
|
+
|
|
38
|
+
class MoveRefused extends Error {}
|
|
39
|
+
export { MoveRefused };
|
|
40
|
+
|
|
41
|
+
const refuse = (message) => { throw new MoveRefused(message); };
|
|
42
|
+
const describe = (error) => (error?.code ? `${error.code}: ${error.message}` : String(error?.message || error));
|
|
43
|
+
|
|
44
|
+
function isEmptyDir(dir) {
|
|
45
|
+
try { return fs.lstatSync(dir).isDirectory() && fs.readdirSync(dir).length === 0; } catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code === 'EPERM'; } };
|
|
49
|
+
|
|
50
|
+
/** The endpoint's own stale rule (kb/recommend-endpoint.mjs sweepStale): a descriptor or socket whose pid is dead. */
|
|
51
|
+
export function sweepStaleEndpoints(dir) {
|
|
52
|
+
let names = [];
|
|
53
|
+
try { names = fs.readdirSync(dir); } catch { return 0; }
|
|
54
|
+
let removed = 0;
|
|
55
|
+
for (const n of names) {
|
|
56
|
+
const m = n.match(STALE_ENDPOINT);
|
|
57
|
+
if (!m || pidAlive(Number(m[1]))) continue;
|
|
58
|
+
try { fs.rmSync(path.join(dir, n), { force: true }); removed++; } catch { /* raced */ }
|
|
59
|
+
}
|
|
60
|
+
return removed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk a Brain tree without following links. Returns Map<relative, {type, size?, target?}> and the list of
|
|
65
|
+
* transient run/ entries (sockets/FIFOs) that are left behind. Any other special file refuses by name.
|
|
66
|
+
*/
|
|
67
|
+
function inventory(root, { strict = true } = {}) {
|
|
68
|
+
const entries = new Map();
|
|
69
|
+
const transient = [];
|
|
70
|
+
const walk = (rel) => {
|
|
71
|
+
for (const name of fs.readdirSync(path.join(root, rel)).sort()) {
|
|
72
|
+
const relative = rel ? path.join(rel, name) : name;
|
|
73
|
+
if (VOLUME_METADATA.test(name)) continue; // the volume's, not the Brain's (header)
|
|
74
|
+
const stat = fs.lstatSync(path.join(root, relative));
|
|
75
|
+
if (stat.isSymbolicLink()) entries.set(relative, { type: 'symlink', target: fs.readlinkSync(path.join(root, relative)) });
|
|
76
|
+
else if (stat.isDirectory()) { entries.set(relative, { type: 'dir' }); walk(relative); }
|
|
77
|
+
else if (stat.isFile()) entries.set(relative, { type: 'file', size: stat.size });
|
|
78
|
+
else if ((stat.isSocket() || stat.isFIFO()) && path.dirname(relative) === 'run') transient.push(relative);
|
|
79
|
+
else if (strict) refuse(`${path.join(root, relative)} is a ${stat.isSocket() ? 'socket' : stat.isFIFO() ? 'named pipe' : 'device or special file'}, `
|
|
80
|
+
+ 'which cannot be copied to another disk. Stop whatever created it (or remove it if nothing is using it), then retry. Nothing was moved.');
|
|
81
|
+
else entries.set(relative, { type: 'special' });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
walk('');
|
|
85
|
+
return { entries, transient };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fileSha256(file) {
|
|
89
|
+
const hash = crypto.createHash('sha256');
|
|
90
|
+
const fd = fs.openSync(file, 'r');
|
|
91
|
+
try {
|
|
92
|
+
const buffer = Buffer.allocUnsafe(1 << 20);
|
|
93
|
+
for (let n; (n = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0;) hash.update(buffer.subarray(0, n));
|
|
94
|
+
} finally { fs.closeSync(fd); }
|
|
95
|
+
return hash.digest('hex');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every source entry must exist at the copy with the same type, and every file the same bytes; the copy may
|
|
100
|
+
* carry nothing else. Volume metadata is invisible to both inventories. Returns null, or what differs.
|
|
101
|
+
*/
|
|
102
|
+
export function verifyCopy(src, copy, { transient = [] } = {}) {
|
|
103
|
+
const skipped = new Set(transient);
|
|
104
|
+
const source = inventory(src, { strict: false }).entries;
|
|
105
|
+
const landed = inventory(copy, { strict: false }).entries;
|
|
106
|
+
const problems = [];
|
|
107
|
+
for (const [relative, entry] of source) {
|
|
108
|
+
if (skipped.has(relative)) continue;
|
|
109
|
+
const other = landed.get(relative);
|
|
110
|
+
if (!other) problems.push(`${relative} is missing from the copy`);
|
|
111
|
+
else if (other.type !== entry.type) problems.push(`${relative} is a ${other.type} in the copy but a ${entry.type} in the original`);
|
|
112
|
+
else if (entry.type === 'symlink' && other.target !== entry.target) problems.push(`link ${relative} points elsewhere in the copy`);
|
|
113
|
+
else if (entry.type === 'file' && (other.size !== entry.size || fileSha256(path.join(copy, relative)) !== fileSha256(path.join(src, relative)))) {
|
|
114
|
+
problems.push(`${relative} differs (${entry.size} bytes in the original, ${other.size} in the copy)`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const relative of landed.keys()) {
|
|
118
|
+
if (!source.has(relative)) problems.push(`${relative} is in the copy but not in the original`);
|
|
119
|
+
}
|
|
120
|
+
return problems.length ? `${problems.slice(0, 3).join('; ')}${problems.length > 3 ? `; and ${problems.length - 3} more` : ''}` : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The default fs operations the swap uses — a seam so a test can make any one step fail. */
|
|
124
|
+
export const defaultOps = Object.freeze({
|
|
125
|
+
renameSync: (a, b) => fs.renameSync(a, b),
|
|
126
|
+
symlinkSync: (target, link, type) => fs.symlinkSync(target, link, type),
|
|
127
|
+
rmdirSync: (dir) => fs.rmdirSync(dir),
|
|
128
|
+
mkdirSync: (dir) => fs.mkdirSync(dir),
|
|
129
|
+
rmSync: (dir) => fs.rmSync(dir, { recursive: true, force: true }),
|
|
130
|
+
copyTree: (src, dest, filter) => fs.cpSync(src, dest, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true,
|
|
131
|
+
errorOnExist: true, force: false, filter }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {{ home?: string, to?: string, back?: boolean, available?: Function, log?: Function, ops?: object,
|
|
136
|
+
* lock?: Function }} options
|
|
137
|
+
* @returns {{ from: string, to: string, bytes: number, link: string, warnings: string[] }}
|
|
138
|
+
*/
|
|
139
|
+
export function moveBrain({ home = os.homedir(), to, back = false, available, log = () => {}, ops: opsOverride = {},
|
|
140
|
+
lock = (brainHome) => acquireRefreshLock({ kbDir: path.join(brainHome, 'kb'), brainHome, action: 'move-brain' }) } = {}) {
|
|
141
|
+
const ops = { ...defaultOps, ...opsOverride };
|
|
142
|
+
const brainHome = defaultBrainHome(home);
|
|
143
|
+
const where = brainLocation({ home });
|
|
144
|
+
if (where.state === 'unmounted') refuse(where.message);
|
|
145
|
+
if (where.state === 'absent') refuse(`no RuvNet Brain is installed at ${brainHome}; nothing to move.`);
|
|
146
|
+
const src = where.real;
|
|
147
|
+
for (const name of PLUGIN_LOCK_NAMES) {
|
|
148
|
+
if (fs.existsSync(path.join(src, name))) refuse(`an update is running (${path.join(src, name)}); retry when it finishes. Nothing was moved.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let dest;
|
|
152
|
+
if (back) {
|
|
153
|
+
if (where.state === 'local') refuse(`the Brain is already at its default location, ${brainHome}.`);
|
|
154
|
+
dest = brainHome;
|
|
155
|
+
} else {
|
|
156
|
+
if (!to || !path.isAbsolute(to)) refuse('--move-brain needs an absolute directory, e.g. --move-brain /Volumes/SanDisk/ruvnet-brain');
|
|
157
|
+
dest = path.resolve(to);
|
|
158
|
+
if (dest === path.resolve(brainHome)) refuse('that is the default location; use --move-brain --back to return there.');
|
|
159
|
+
if (!fs.existsSync(path.dirname(dest))) refuse(`the target's parent ${path.dirname(dest)} does not exist (is the disk mounted?). Nothing was moved.`);
|
|
160
|
+
if (fs.existsSync(dest) && fs.realpathSync(dest) === src) refuse(`the Brain is already at ${dest}.`);
|
|
161
|
+
const rel = path.relative(src, dest);
|
|
162
|
+
if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) refuse('the target is inside the Brain itself.');
|
|
163
|
+
if (fs.existsSync(dest) && !isEmptyDir(dest)) refuse(`the target ${dest} exists and is not empty; choose a new or empty directory.`);
|
|
164
|
+
// The copy is staged beside the target and renamed into place, so the target must be a folder ON the disk,
|
|
165
|
+
// never the disk's own root: staging would land on the parent's filesystem (measured there, copied there).
|
|
166
|
+
if (fs.existsSync(dest) && fs.statSync(dest).dev !== fs.statSync(path.dirname(dest)).dev) {
|
|
167
|
+
refuse(`${dest} is the top of a disk; choose a folder on it, e.g. --move-brain ${path.join(dest, 'ruvnet-brain')}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let held;
|
|
172
|
+
try { held = lock(brainHome); } catch (error) {
|
|
173
|
+
refuse(`an update is running or its lock is unclear (${describe(error)}); retry when it finishes. Nothing was moved.`);
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
return moveLocked({ where, brainHome, src, dest, back, available, log, ops });
|
|
177
|
+
} finally {
|
|
178
|
+
// Through the default path: after a successful swap the held lock directory lives in the new copy.
|
|
179
|
+
if (held) releaseRefreshLock(held);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function moveLocked({ where, brainHome, src, dest, back, available, log, ops }) {
|
|
184
|
+
const warnings = [];
|
|
185
|
+
sweepStaleEndpoints(path.join(src, 'run'));
|
|
186
|
+
const { transient } = inventory(src); // refuses, by name, on any socket/FIFO/device outside run/
|
|
187
|
+
const bytes = directoryBytes(src);
|
|
188
|
+
const stagingDir = path.dirname(dest);
|
|
189
|
+
try {
|
|
190
|
+
const space = checkDiskSpace([{ dir: stagingDir, bytes, purpose: 'Brain copy' }],
|
|
191
|
+
{ what: 'move the Brain', bigger: 'choose a folder on a bigger disk', ...(available ? { available } : {}) });
|
|
192
|
+
if (!space.ok) refuse(space.message);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error instanceof MoveRefused) throw error;
|
|
195
|
+
// Node < 18.15 has no fs.statfsSync; an unmeasurable disk is not a refusal. The copy is still verified,
|
|
196
|
+
// and running out of space part-way removes the copy and leaves the Brain where it was.
|
|
197
|
+
const line = `could not measure free space on ${stagingDir} (${describe(error)}); copying anyway — a full disk stops the move safely`;
|
|
198
|
+
warnings.push(line);
|
|
199
|
+
log(line);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const staging = path.join(stagingDir, `.${path.basename(dest)}.moving-${process.pid}`);
|
|
203
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
204
|
+
const skip = new Set(transient.map((relative) => path.join(src, relative)));
|
|
205
|
+
log(`copying ${src} -> ${dest} …`);
|
|
206
|
+
try {
|
|
207
|
+
try { ops.copyTree(src, staging, (from) => !skip.has(from) && (from === src || !VOLUME_METADATA.test(path.basename(from)))); } catch (error) {
|
|
208
|
+
refuse(error?.code === 'ENOSPC'
|
|
209
|
+
? `${stagingDir} ran out of space while copying. The partial copy was removed and the Brain is unchanged at ${src}.`
|
|
210
|
+
: `copying to ${stagingDir} failed (${describe(error)}). The partial copy was removed and the Brain is unchanged at ${src}.`);
|
|
211
|
+
}
|
|
212
|
+
const drift = verifyCopy(src, staging, { transient });
|
|
213
|
+
if (drift) {
|
|
214
|
+
refuse(`the copy does not match the original: ${drift}. Either a file changed while it was being copied (retry when no `
|
|
215
|
+
+ `search or update is running) or ${stagingDir} did not store it faithfully. The copy was removed and the Brain is unchanged at ${src}.`);
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
swap({ where, brainHome, src, dest, staging, back, ops });
|
|
223
|
+
const landed = fs.realpathSync(brainHome);
|
|
224
|
+
// The previous copy goes only once the default path is proven to resolve to the new one.
|
|
225
|
+
const previous = back || where.state === 'linked' ? src : `${brainHome}.old-${process.pid}`;
|
|
226
|
+
try { if (fs.existsSync(previous)) ops.rmSync(previous); } catch (error) {
|
|
227
|
+
const line = `the previous copy at ${previous} could not be removed (${describe(error)}); it is no longer used — delete it by hand.`;
|
|
228
|
+
warnings.push(line);
|
|
229
|
+
log(line);
|
|
230
|
+
}
|
|
231
|
+
log(`the Brain now lives at ${landed}${back ? '' : ` (${brainHome} links to it)`}`);
|
|
232
|
+
return { from: src, to: landed, bytes, link: brainHome, warnings };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Point the default path at the verified copy. Every step is recorded and undone in reverse on failure, so
|
|
237
|
+
* the only outcomes are "moved" or "unchanged" — and the message says which, and what is left where.
|
|
238
|
+
*/
|
|
239
|
+
function swap({ where, brainHome, src, dest, staging, back, ops }) {
|
|
240
|
+
const undo = [];
|
|
241
|
+
const step = (name, run, revert) => { run(); undo.push({ name, revert }); };
|
|
242
|
+
const asideLink = `${brainHome}.link-old-${process.pid}`;
|
|
243
|
+
const tmpLink = `${brainHome}.link-${process.pid}`;
|
|
244
|
+
let failedAt = null;
|
|
245
|
+
let originalAt = src; // where the original Brain's bytes are right now (a local brain home is set aside mid-swap)
|
|
246
|
+
try {
|
|
247
|
+
if (back) {
|
|
248
|
+
failedAt = 'set the old link aside';
|
|
249
|
+
step(failedAt, () => ops.renameSync(brainHome, asideLink), () => ops.renameSync(asideLink, brainHome));
|
|
250
|
+
failedAt = `move the copy into ${brainHome}`;
|
|
251
|
+
step(failedAt, () => ops.renameSync(staging, brainHome), () => ops.renameSync(brainHome, staging));
|
|
252
|
+
} else {
|
|
253
|
+
if (isEmptyDir(dest)) {
|
|
254
|
+
failedAt = `replace the empty folder ${dest}`;
|
|
255
|
+
step(failedAt, () => ops.rmdirSync(dest), () => ops.mkdirSync(dest));
|
|
256
|
+
}
|
|
257
|
+
failedAt = `move the copy into ${dest}`;
|
|
258
|
+
step(failedAt, () => ops.renameSync(staging, dest), () => ops.renameSync(dest, staging));
|
|
259
|
+
failedAt = `create the link ${tmpLink}`;
|
|
260
|
+
fs.rmSync(tmpLink, { force: true });
|
|
261
|
+
step(failedAt, () => ops.symlinkSync(dest, tmpLink, linkType), () => fs.rmSync(tmpLink, { force: true }));
|
|
262
|
+
if (where.state === 'local') {
|
|
263
|
+
const old = `${brainHome}.old-${process.pid}`;
|
|
264
|
+
failedAt = `set ${brainHome} aside`;
|
|
265
|
+
step(failedAt, () => { ops.renameSync(brainHome, old); originalAt = old; },
|
|
266
|
+
() => { ops.renameSync(old, brainHome); originalAt = src; });
|
|
267
|
+
failedAt = `put the link at ${brainHome}`;
|
|
268
|
+
step(failedAt, () => ops.renameSync(tmpLink, brainHome), () => ops.renameSync(brainHome, tmpLink));
|
|
269
|
+
} else {
|
|
270
|
+
failedAt = `replace the link at ${brainHome}`;
|
|
271
|
+
try {
|
|
272
|
+
step(failedAt, () => ops.renameSync(tmpLink, brainHome), () => ops.renameSync(brainHome, tmpLink)); // atomic on POSIX
|
|
273
|
+
} catch (error) {
|
|
274
|
+
// Windows will not rename over an existing junction (EPERM/EEXIST): set the old link aside, then rename.
|
|
275
|
+
if (!['EPERM', 'EEXIST', 'EACCES', 'ENOTEMPTY', 'EISDIR'].includes(error?.code)) throw error;
|
|
276
|
+
failedAt = 'set the old link aside';
|
|
277
|
+
step(failedAt, () => ops.renameSync(brainHome, asideLink), () => ops.renameSync(asideLink, brainHome));
|
|
278
|
+
failedAt = `put the link at ${brainHome}`;
|
|
279
|
+
step(failedAt, () => ops.renameSync(tmpLink, brainHome), () => ops.renameSync(brainHome, tmpLink));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
failedAt = `prove ${brainHome} resolves to ${dest}`;
|
|
284
|
+
const landed = fs.realpathSync(brainHome);
|
|
285
|
+
if (landed !== fs.realpathSync(dest)) throw new Error(`${brainHome} resolves to ${landed}`);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
const unrestored = [];
|
|
288
|
+
for (const { name, revert } of undo.reverse()) {
|
|
289
|
+
try { revert(); } catch (revertError) { unrestored.push(`${name} (${describe(revertError)})`); }
|
|
290
|
+
}
|
|
291
|
+
// The staged copy is a verified duplicate; the original is intact either way, so it can always go.
|
|
292
|
+
let leftover = '';
|
|
293
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { leftover = staging; }
|
|
294
|
+
try {
|
|
295
|
+
if (!leftover && !back && !isEmptyDir(dest) && fs.realpathSync(dest) !== fs.realpathSync(originalAt)) leftover = dest;
|
|
296
|
+
} catch { /* dest absent: nothing left there */ }
|
|
297
|
+
if (unrestored.length) {
|
|
298
|
+
refuse(`moving the Brain failed while trying to ${failedAt} (${describe(error)}), and undoing it also failed at: `
|
|
299
|
+
+ `${unrestored.join('; ')}. The original Brain is intact at ${originalAt}; ${brainHome} must point to it again `
|
|
300
|
+
+ '(make it a link to that folder, or move that folder back to it) before the Brain is used.'
|
|
301
|
+
+ (leftover ? ` The unused copy at ${leftover} can be removed.` : ''));
|
|
302
|
+
}
|
|
303
|
+
// Undone: the default path is exactly as it was.
|
|
304
|
+
try { fs.rmSync(asideLink, { force: true }); } catch { /* only exists if a revert failed, reported above */ }
|
|
305
|
+
refuse(`moving the Brain failed while trying to ${failedAt} (${describe(error)}). Everything was put back: the Brain is `
|
|
306
|
+
+ `unchanged at ${src}${where.state === 'linked' ? ` (${brainHome} links to it)` : ''}.`
|
|
307
|
+
+ (leftover ? ` A copy at ${leftover} is not used and can be removed.` : ''));
|
|
308
|
+
}
|
|
309
|
+
try { fs.rmSync(asideLink, { force: true }); } catch { /* an unused old link; harmless */ }
|
|
310
|
+
}
|