ruvnet-brain 4.3.36 → 4.3.38
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 +2 -2
- package/bin/install.mjs +32 -9
- package/kb/forge-update.mjs +1867 -0
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +6 -1
- package/plugin/hooks/hook-contracts.json +11 -9
- package/plugin/scripts/capability-claim-evidence.mjs +11 -2
- package/plugin/scripts/capacity-aware-parallel-work.mjs +71 -15
- package/plugin/scripts/completion-claim-evidence.mjs +262 -0
- package/plugin/scripts/continuation-gate.mjs +105 -21
- package/plugin/scripts/continuation-objective.mjs +15 -0
- package/plugin/scripts/continuity-hook-policy.mjs +10 -3
- package/plugin/scripts/decision-gate.mjs +22 -2
- package/plugin/scripts/duplicate-gate.mjs +503 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +339 -0
- package/plugin/scripts/grounding-turn-gate.mjs +77 -25
- package/plugin/scripts/grounding-turn-mark.mjs +61 -14
- package/plugin/scripts/hook-input.mjs +15 -0
- package/plugin/scripts/hook-shim.mjs +3 -1
- package/plugin/scripts/host-update.mjs +45 -0
- package/plugin/scripts/nightly-scheduler.mjs +34 -0
- package/plugin/scripts/session-snapshot-hook.mjs +11 -1
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +15 -2
- package/plugin/scripts/session-start-health.mjs +101 -1
- package/plugin/scripts/session-start-update-plane.mjs +71 -1
- package/plugin/scripts/turn-outcome-capture.mjs +292 -0
- package/scripts/completion-claim-replay.mjs +114 -0
- package/scripts/corpus-canary.mjs +399 -0
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +49 -0
- package/scripts/corpus-reconcile.mjs +27 -3
- package/scripts/corpus-watchdog.mjs +45 -6
- package/scripts/derive-passage-content-map.mjs +71 -0
- package/scripts/duplicate-gate-replay.mjs +98 -0
- package/scripts/grounding-turn-replay.mjs +131 -0
- package/scripts/nightly-watchdog.mjs +3 -3
- package/scripts/public-verification-inputs.mjs +24 -2
- package/scripts/release-transaction-provider.mjs +29 -6
- package/scripts/release.mjs +177 -74
- package/scripts/retrieval-canary.mjs +41 -4
- package/scripts/retrieval-passage-identity.mjs +58 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/wired-check.mjs +6 -0
|
@@ -0,0 +1,1867 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// forge-update.mjs — GENERALIZED EVERGREEN self-updater for any rvf-kb-forge bundle.
|
|
3
|
+
//
|
|
4
|
+
// Ships INSIDE the bundle next to SOURCE.json (written by forge-build.mjs with --canonical-url).
|
|
5
|
+
// A consumer who copied the bundle runs it in that dir. It reads the embedded provenance
|
|
6
|
+
// (SOURCE.json — "where I came from"), fetches the LIVE canonical build manifest, and reports
|
|
7
|
+
// whether their copy is current; --apply downloads + extracts + re-verifies with forge-guard.mjs.
|
|
8
|
+
//
|
|
9
|
+
// node forge-update.mjs (== --check) report only: UP TO DATE / BEHIND
|
|
10
|
+
// node forge-update.mjs --check same as above
|
|
11
|
+
// node forge-update.mjs --apply download canonical bundle, back up, extract over local,
|
|
12
|
+
// re-verify with forge-guard.mjs, print DONE
|
|
13
|
+
// node forge-update.mjs <name> limit to one store when SOURCE.json carries several
|
|
14
|
+
//
|
|
15
|
+
// Cron example (Mon 09:00, log result):
|
|
16
|
+
// 0 9 * * 1 cd /path/to/kb && /usr/bin/node forge-update.mjs --check >> forge-update.log 2>&1
|
|
17
|
+
//
|
|
18
|
+
// Zero dependencies. Node 18+ (global fetch). Network failures fail LOUD and CLEAN: clear
|
|
19
|
+
// message, non-zero exit, NO partial clobber. If --canonical-url was not set at build time the
|
|
20
|
+
// URLs are null and this prints a clear "self-update not configured for this build" message.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
|
+
import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
28
|
+
import { extractZip } from './zip-extract.mjs';
|
|
29
|
+
import { applyBrainProfile, discoverStoreFamilies, readBrainProfile } from './brain-profile.mjs';
|
|
30
|
+
import { acquireRefreshLock, releaseRefreshLock } from './refresh-run.mjs';
|
|
31
|
+
import { runStorageTransaction, treeIdentity, managedStorageInventory, storageDelta } from './update-storage-transaction.mjs';
|
|
32
|
+
import { pruneLifecycleEvidence } from './lifecycle-evidence-retention.mjs';
|
|
33
|
+
import {
|
|
34
|
+
isCorpusReleaseTag, assertCorpusReleaseCompatible, readInstalledRuntime,
|
|
35
|
+
recordCorpusTransportIdentity, recordCorpusGenerationIdentity,
|
|
36
|
+
readRejectedRelease, writeRejectedRelease, clearRejectedRelease,
|
|
37
|
+
releaseKind, parseCorpusGeneration,
|
|
38
|
+
} from './corpus-release-identity.mjs';
|
|
39
|
+
|
|
40
|
+
const KB_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const SOURCE_PATH = path.join(KB_DIR, 'SOURCE.json');
|
|
42
|
+
|
|
43
|
+
const argv = process.argv.slice(2);
|
|
44
|
+
const APPLY = argv.includes('--apply');
|
|
45
|
+
const RESTORE_COMPLETE = argv.includes('--restore-complete');
|
|
46
|
+
// `--staged-release <descriptor.json>` is the private-overlay recovery rail's own entry point
|
|
47
|
+
// (applyVerifiedStagedRelease, invoked by bin/install.mjs when the normal --apply flow above cannot
|
|
48
|
+
// complete). It bypasses main() and this module's own SOURCE.json/canonicalManifestUrl bootstrap
|
|
49
|
+
// entirely — discovery is supplied by the descriptor, not read from this KB tree.
|
|
50
|
+
const stagedReleaseIndex = argv.indexOf('--staged-release');
|
|
51
|
+
const STAGED_RELEASE_FILE = stagedReleaseIndex >= 0 && argv[stagedReleaseIndex + 1]
|
|
52
|
+
? path.resolve(argv[stagedReleaseIndex + 1]) : null;
|
|
53
|
+
const resultFileIndex = argv.indexOf('--result-file');
|
|
54
|
+
const RESULT_FILE = resultFileIndex >= 0 && argv[resultFileIndex + 1]
|
|
55
|
+
? path.resolve(argv[resultFileIndex + 1]) : (process.env.RUVNET_UPDATE_RESULT ? path.resolve(process.env.RUVNET_UPDATE_RESULT) : null);
|
|
56
|
+
const optionValueIndexes = new Set([
|
|
57
|
+
...(resultFileIndex >= 0 ? [resultFileIndex + 1] : []),
|
|
58
|
+
...(stagedReleaseIndex >= 0 ? [stagedReleaseIndex + 1] : []),
|
|
59
|
+
]);
|
|
60
|
+
const ONLY = argv.find((a, index) => !a.startsWith('--') && !optionValueIndexes.has(index));
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* EXIT CODES — anything scripting this (a cron line, a LaunchAgent, `npx ruvnet-brain --update`)
|
|
64
|
+
* reads only this number, so each one means exactly one thing:
|
|
65
|
+
*
|
|
66
|
+
* 0 --check: current · --apply: explicit applied or byte-exact noop result receipt
|
|
67
|
+
* 1 configuration/verification error; local copy may need the rollback beside it
|
|
68
|
+
* 2 network / canonical manifest unreachable — nothing was touched
|
|
69
|
+
* 3 the signature could not be fetched — refused to apply
|
|
70
|
+
* 4 signature verification FAILED — refused to apply
|
|
71
|
+
* 10 --check: a newer build exists
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
// The updater's trust root is part of the executable, not part of either the currently installed KB
|
|
75
|
+
// or the downloaded candidate. A missing auxiliary verifier/key must therefore never create a
|
|
76
|
+
// bootstrap bypass. Keep this byte-identical to keys/ruvnet-brain-signing.pub.pem; the release gate
|
|
77
|
+
// checks that identity.
|
|
78
|
+
const SIGNING_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
79
|
+
MCowBQYDK2VwAyEAgse9TAtehXUvUfTrJFY2CCHiCbmelR8yCgS//sen5/w=
|
|
80
|
+
-----END PUBLIC KEY-----`;
|
|
81
|
+
|
|
82
|
+
export function verifyDownloadedBundle(bundlePath, signaturePath) {
|
|
83
|
+
try {
|
|
84
|
+
if (!fs.existsSync(bundlePath)) return { ok: false, reason: `bundle not found: ${bundlePath}` };
|
|
85
|
+
if (!fs.existsSync(signaturePath)) return { ok: false, reason: 'signature missing (fail-closed)' };
|
|
86
|
+
const digest = createHash('sha256').update(fs.readFileSync(bundlePath)).digest('hex');
|
|
87
|
+
const ok = verifySignature(null, Buffer.from(digest, 'hex'), createPublicKey(SIGNING_PUBKEY_PEM), fs.readFileSync(signaturePath));
|
|
88
|
+
return ok ? { ok: true, reason: `signature valid (sha256 ${digest.slice(0, 12)}…)` }
|
|
89
|
+
: { ok: false, reason: 'signature does NOT match — bundle may be tampered' };
|
|
90
|
+
} catch (error) { return { ok: false, reason: `verify error: ${error.message}` }; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── ROLLBACK COPIES — ONE settlement point, on EVERY exit path ────────────────────────────────
|
|
94
|
+
// `process.exit()` does NOT run `finally` blocks, so a die() anywhere below the directory swap used
|
|
95
|
+
// to leave a multi-gigabyte rollback copy behind with nothing to release it. Issue #108 measured
|
|
96
|
+
// exactly that: ~1.6 GB stranded per night, ten copies (~16 GB) before the owner noticed — and the
|
|
97
|
+
// run that stranded them had actually SUCCEEDED. Every exit now passes through settleRollback(), so
|
|
98
|
+
// the copy is either RELEASED or deliberately KEPT and named. Never silently stranded.
|
|
99
|
+
const backupsMade = [];
|
|
100
|
+
let rollbackSettled = false;
|
|
101
|
+
let updateLock = null;
|
|
102
|
+
let updateOutcomeWritten = false;
|
|
103
|
+
let lifecycleRetention = null;
|
|
104
|
+
let legacyBackupRetention = null;
|
|
105
|
+
|
|
106
|
+
function writeUpdateOutcome(outcome) {
|
|
107
|
+
if (updateOutcomeWritten) return outcome;
|
|
108
|
+
if (legacyBackupRetention) outcome = { ...outcome, legacyBackupRetention };
|
|
109
|
+
if (!lifecycleRetention) {
|
|
110
|
+
try {
|
|
111
|
+
lifecycleRetention = pruneLifecycleEvidence({ brainHome: path.dirname(KB_DIR), kbDir: KB_DIR,
|
|
112
|
+
preserveRefreshRunIds: updateLock?.runId ? [updateLock.runId] : [],
|
|
113
|
+
preserveTransactionPaths: outcome.transactionReceipts ? [outcome.transactionReceipts] : [] });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
lifecycleRetention = { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention',
|
|
116
|
+
withinBudget: false, unsafe: [{ path: path.dirname(KB_DIR), reason: error.message }] };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const finalOutcome = lifecycleRetention.withinBudget === true ? { ...outcome, lifecycleRetention }
|
|
120
|
+
: { ...outcome, terminalVerdict: 'recovery-required', exitCode: 1,
|
|
121
|
+
reason: `lifecycle evidence retention failed: ${lifecycleRetention.unsafe?.map(({ reason }) => reason).join('; ') || 'budget exceeded'}`,
|
|
122
|
+
lifecycleRetention };
|
|
123
|
+
if (!RESULT_FILE) return finalOutcome;
|
|
124
|
+
fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });
|
|
125
|
+
atomicJson(RESULT_FILE, { schemaVersion: 1, kind: 'ruvnet-brain-update-result',
|
|
126
|
+
recordedAt: new Date().toISOString(), ...finalOutcome });
|
|
127
|
+
updateOutcomeWritten = true;
|
|
128
|
+
return finalOutcome;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// `--check` is a deliberately lightweight, side-effect-free poll: no lock, no rollback preflight, no
|
|
132
|
+
// candidate ever built. writeUpdateOutcome() cannot be reused for it — that function always runs
|
|
133
|
+
// pruneLifecycleEvidence() (a real filesystem GC pass) and references updateLock/legacyBackupRetention,
|
|
134
|
+
// both of which are apply-only concepts. This records ONLY the currency verdict, and only when the
|
|
135
|
+
// caller actually asked for a result (--result-file / RUVNET_UPDATE_RESULT) — matching --check's
|
|
136
|
+
// existing "does nothing unless asked" contract. S2: this is what lets --apply, bin/install.mjs, and
|
|
137
|
+
// the session-start banner read the SAME recorded verdict a --check run (e.g. the SessionStart
|
|
138
|
+
// heartbeat's detached poll) already produced, instead of each re-deriving their own comparison.
|
|
139
|
+
function writeCheckOutcome(outcome) {
|
|
140
|
+
const finalOutcome = { schemaVersion: 1, kind: 'ruvnet-brain-check-result', mode: 'check',
|
|
141
|
+
recordedAt: new Date().toISOString(), ...outcome };
|
|
142
|
+
if (!RESULT_FILE) return finalOutcome;
|
|
143
|
+
fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });
|
|
144
|
+
atomicJson(RESULT_FILE, finalOutcome);
|
|
145
|
+
return finalOutcome;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function acquireUpdateLock({ kbDir = KB_DIR, pid = process.pid, isAlive } = {}) {
|
|
149
|
+
return acquireRefreshLock({ kbDir, brainHome: path.dirname(path.resolve(kbDir)), action: 'update', pid,
|
|
150
|
+
...(isAlive === undefined ? {} : { isAlive }) });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function releaseUpdateLock(lock = updateLock) {
|
|
154
|
+
if (!lock) return false;
|
|
155
|
+
const released = releaseRefreshLock(lock);
|
|
156
|
+
if (released && lock === updateLock) updateLock = null;
|
|
157
|
+
return released;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
process.on('exit', () => { releaseUpdateLock(); });
|
|
161
|
+
function settleRollback({ reclaimable, keepReason = null, intentionallyRemovedStores = [] }) {
|
|
162
|
+
if (rollbackSettled) return;
|
|
163
|
+
rollbackSettled = true;
|
|
164
|
+
if (!reclaimable) {
|
|
165
|
+
// A rollback copy is only dead weight once the copy in place is known good. When it is NOT,
|
|
166
|
+
// this directory is the user's recovery — deleting it to "not strand resources" would be the
|
|
167
|
+
// far worse bug. Keep it, and say where it is and why.
|
|
168
|
+
for (const b of backupsMade) {
|
|
169
|
+
try { writeSnapshotReceipt(b, { state: 'RETAINED', reason: keepReason || 'live KB could not be verified' }); }
|
|
170
|
+
catch { /* the original update failure remains authoritative */ }
|
|
171
|
+
console.error(`\n ROLLBACK COPY KEPT: ${b}`);
|
|
172
|
+
console.error(` ${keepReason || 'the copy now in place could not be verified — restore this directory if the KB is broken,'}`);
|
|
173
|
+
console.error(` then remove it once you are satisfied (or re-run this updater after fixing the cause).`);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const { removed, kept, freed } = reclaimBackups({ kbDir: KB_DIR, backupsMade, intentionallyRemovedStores });
|
|
178
|
+
if (removed.length) {
|
|
179
|
+
console.log(`\nreleased ${removed.length} rollback ${removed.length === 1 ? 'copy' : 'copies'} — ${(freed / 1e9).toFixed(2)} GB reclaimed`);
|
|
180
|
+
console.log(` (the copy in place is intact; this exact build is re-downloadable at any time)`);
|
|
181
|
+
}
|
|
182
|
+
for (const [b, why] of kept) console.log(`\n KEPT ${b}\n ${why}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function die(msg, code = 1) {
|
|
186
|
+
console.error(`\n[forge-update] ERROR: ${msg}`);
|
|
187
|
+
try { writeUpdateOutcome({ terminalVerdict: 'failed', exitCode: code, reason: msg }); } catch { /* primary error wins */ }
|
|
188
|
+
// Cleanup must never mask the error that caused it.
|
|
189
|
+
try { settleRollback({ reclaimable: false }); } catch { /* ignore */ }
|
|
190
|
+
process.exit(code);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!fs.existsSync(SOURCE_PATH) && !STAGED_RELEASE_FILE) {
|
|
194
|
+
die(`no SOURCE.json next to this script (${SOURCE_PATH}). This bundle predates the evergreen ` +
|
|
195
|
+
`mechanism or SOURCE.json was removed. Re-download a current bundle to gain self-update.`);
|
|
196
|
+
}
|
|
197
|
+
let source;
|
|
198
|
+
try { source = fs.existsSync(SOURCE_PATH) ? JSON.parse(fs.readFileSync(SOURCE_PATH, 'utf8')) : {}; }
|
|
199
|
+
catch (e) {
|
|
200
|
+
// --staged-release never reads this module-level `source` (its own descriptor supplies liveDir/
|
|
201
|
+
// stagedDir explicitly); a corrupt SOURCE.json in whatever directory happens to be current when
|
|
202
|
+
// this script is invoked as a plain recovery executable must not block that rail.
|
|
203
|
+
if (!STAGED_RELEASE_FILE) die(`SOURCE.json is unreadable/corrupt: ${e.message}`);
|
|
204
|
+
source = {};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The RELEASE TAG IS A PROPERTY OF THE BUNDLE, and every store inside it shares that tag (issue
|
|
208
|
+
// #108 bug 2). It is written once, at the top level of SOURCE.json; the per-store entries never
|
|
209
|
+
// carry it. isBehind() short-circuits on `canon.releaseTag && local.releaseTag`, so with the local
|
|
210
|
+
// side always undefined that branch could never fire — every store fell through to a timestamp
|
|
211
|
+
// compare against the RELEASE's publish time, which is always later than the forge time of the KB
|
|
212
|
+
// inside it. Result: all 15 stores read BEHIND on every run, forever, immediately after a
|
|
213
|
+
// successful update. `--check` exited 10 permanently and was useless as a monitoring signal, and
|
|
214
|
+
// `--apply` re-downloaded half a gigabyte every night to change nothing. Inheriting the tag the
|
|
215
|
+
// bundle already records is the whole fix; a store that carries its own still wins.
|
|
216
|
+
// The CORPUS transport tag is a bundle property in exactly the same way, and for the same reason:
|
|
217
|
+
// every store in a corpus release arrived in the same archive. It lives in its own field because it
|
|
218
|
+
// is a different identity domain from `releaseTag` — see kb/corpus-release-identity.mjs.
|
|
219
|
+
const withBundleTag = (s) => {
|
|
220
|
+
if (!s) return s;
|
|
221
|
+
let out = s;
|
|
222
|
+
if (out.releaseTag == null && source.releaseTag != null) out = { ...out, releaseTag: source.releaseTag };
|
|
223
|
+
if (out.corpusReleaseTag == null && source.corpusReleaseTag != null) {
|
|
224
|
+
out = { ...out, corpusReleaseTag: source.corpusReleaseTag };
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
};
|
|
228
|
+
const stores = (Array.isArray(source.stores)
|
|
229
|
+
? source.stores
|
|
230
|
+
: (source.stores && typeof source.stores === 'object')
|
|
231
|
+
? Object.entries(source.stores).map(([kbName, v]) => ({ kbName, ...v }))
|
|
232
|
+
: [source]).map(withBundleTag);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Return only stores the public evergreen updater is allowed to replace.
|
|
236
|
+
*
|
|
237
|
+
* Private deployment overlays still belong in SOURCE.json for provenance, but they do not have a
|
|
238
|
+
* public release asset. `updateManaged: false` keeps those entries visible while preventing a
|
|
239
|
+
* public bundle update from treating them as downloadable targets.
|
|
240
|
+
*/
|
|
241
|
+
export function selectUpdateManagedStores(allStores, activeProfile = 'complete') {
|
|
242
|
+
const managed = (Array.isArray(allStores) ? allStores : [])
|
|
243
|
+
.filter((store) => store?.updateManaged !== false);
|
|
244
|
+
return activeProfile === 'ruvector'
|
|
245
|
+
? managed.filter((store) => store.kbName === 'ruvector')
|
|
246
|
+
: managed;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function sameJson(left, right) {
|
|
250
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function atomicJson(file, value) {
|
|
254
|
+
const temp = `${file}.tmp-${process.pid}`;
|
|
255
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
|
|
256
|
+
fs.renameSync(temp, file);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cardSections(markdown) {
|
|
260
|
+
const sections = new Map();
|
|
261
|
+
const matches = [...String(markdown || '').matchAll(/^## ([^\n]+)\n/gm)];
|
|
262
|
+
for (let index = 0; index < matches.length; index++) {
|
|
263
|
+
const start = matches[index].index;
|
|
264
|
+
const end = matches[index + 1]?.index ?? markdown.length;
|
|
265
|
+
sections.set(matches[index][1].trim(), markdown.slice(start, end).trimEnd());
|
|
266
|
+
}
|
|
267
|
+
return sections;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function mergePrivateEntries(publicEntries, privateEntries, label) {
|
|
271
|
+
const merged = { ...(publicEntries || {}) };
|
|
272
|
+
for (const [name, entry] of Object.entries(privateEntries || {})) {
|
|
273
|
+
if (Object.hasOwn(merged, name) && !sameJson(merged[name], entry)) {
|
|
274
|
+
throw new Error(`${label} collision for private store ${name}`);
|
|
275
|
+
}
|
|
276
|
+
merged[name] = entry;
|
|
277
|
+
}
|
|
278
|
+
return merged;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sha256File(file) {
|
|
282
|
+
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function loadTrustedCoverageValidator() {
|
|
286
|
+
// The recovery rail (applyVerifiedStagedRelease) can run from the installer's OWN repo checkout
|
|
287
|
+
// (bin/install.mjs's `REPO_ROOT/kb/forge-update.mjs`) rather than an installed KB tree, where the
|
|
288
|
+
// validator lives at its source location (plugin/scripts/) instead of beside this script.
|
|
289
|
+
const validatorPath = fs.existsSync(path.join(KB_DIR, 'coverage-integrity.mjs'))
|
|
290
|
+
? path.join(KB_DIR, 'coverage-integrity.mjs')
|
|
291
|
+
: path.join(path.dirname(KB_DIR), 'plugin', 'scripts', 'coverage-integrity.mjs');
|
|
292
|
+
if (!fs.existsSync(validatorPath)) {
|
|
293
|
+
throw new Error('installed coverage validator is missing; re-run the current installer before self-update');
|
|
294
|
+
}
|
|
295
|
+
const validator = await import(pathToFileURL(validatorPath).href);
|
|
296
|
+
if (typeof validator.validateCoverageDirectory !== 'function') {
|
|
297
|
+
throw new Error('installed coverage validator has no validateCoverageDirectory export');
|
|
298
|
+
}
|
|
299
|
+
return validator.validateCoverageDirectory;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* `expectedVersionOverride` is how "never silently install incompatible code" is actually enforced.
|
|
304
|
+
*
|
|
305
|
+
* Read from the tree's OWN SOURCE.json, `expectedVersion` is self-referential: a bundle asserting
|
|
306
|
+
* its own version proves nothing, which is exactly Dual's "preserving a version string alone is
|
|
307
|
+
* insufficient". For a CORPUS release the caller passes the version of the approved runtime this
|
|
308
|
+
* machine is measurably running (kb/corpus-release-identity.mjs re-hashes its executables), so the
|
|
309
|
+
* staged tree is judged against the client, not against itself.
|
|
310
|
+
*/
|
|
311
|
+
function validateReleaseCoverageTree(root, validateCoverageDirectory, expectedVersionOverride = null) {
|
|
312
|
+
let expectedVersion = expectedVersionOverride;
|
|
313
|
+
if (expectedVersion == null) {
|
|
314
|
+
try { expectedVersion = JSON.parse(fs.readFileSync(path.join(root, 'SOURCE.json'), 'utf8')).brainVersion || null; }
|
|
315
|
+
catch (error) { return { valid: false, failures: [`SOURCE.json is unreadable: ${error.message}`] }; }
|
|
316
|
+
}
|
|
317
|
+
return validateCoverageDirectory(root, { expectedVersion });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* THE PRIVATE-OVERLAY RECOVERY RAIL. Apply an already-authenticated release staged by the installer
|
|
322
|
+
* (bin/install.mjs's stageBundleForRecovery), for installations whose embedded canonicalManifestUrl
|
|
323
|
+
* is dead/missing or whose own updater otherwise cannot complete `main()`'s normal --apply flow.
|
|
324
|
+
* Discovery (which release, which bytes) is supplied by the caller; trust and activation remain
|
|
325
|
+
* owned by this package.
|
|
326
|
+
*
|
|
327
|
+
* ONE APPLY PATH (S1/S2): this reuses the exact same primitives main() uses for a normal apply —
|
|
328
|
+
* runStorageTransaction for the atomic candidate/rollback swap, restorePrivateFilesIntoCandidate for
|
|
329
|
+
* copying the private overlay onto the candidate, and recordCorpusTransportIdentity/
|
|
330
|
+
* recordCorpusGenerationIdentity for the currency stamps — rather than a second, parallel
|
|
331
|
+
* implementation of "how a private overlay survives an apply." It exists as a SEPARATE ENTRY POINT
|
|
332
|
+
* because it solves a different problem (recovery when the normal polling path cannot run at all,
|
|
333
|
+
* not a routine currency check), not because it needs its own apply mechanics.
|
|
334
|
+
*/
|
|
335
|
+
export async function applyVerifiedStagedRelease({
|
|
336
|
+
stagedDir, liveDir, bundlePath, signaturePath, transactionId = `${Date.now()}-${process.pid}`,
|
|
337
|
+
trustedRuntimeDir = KB_DIR, expectedRuntimeVersion = null, releaseTag = null, corpusGeneration = null,
|
|
338
|
+
bundleSha256 = null, packageIdentity = null, stageReceiptPath = `${stagedDir}.staged-release.json`,
|
|
339
|
+
validateCoverageDirectory = null,
|
|
340
|
+
}) {
|
|
341
|
+
const staged = path.resolve(stagedDir);
|
|
342
|
+
const live = path.resolve(liveDir);
|
|
343
|
+
if (!bundlePath || !signaturePath) throw new Error('staged recovery requires bundle and detached signature paths');
|
|
344
|
+
const signature = verifyDownloadedBundle(path.resolve(bundlePath), path.resolve(signaturePath));
|
|
345
|
+
if (!signature.ok) throw new Error(`staged release signature verification failed: ${signature.reason}`);
|
|
346
|
+
const actualBundleSha256 = sha256File(path.resolve(bundlePath));
|
|
347
|
+
if (bundleSha256 && actualBundleSha256 !== bundleSha256) {
|
|
348
|
+
throw new Error(`staged release bundle digest ${actualBundleSha256} differs from sealed identity ${bundleSha256}`);
|
|
349
|
+
}
|
|
350
|
+
if (packageIdentity != null && typeof packageIdentity !== 'string') {
|
|
351
|
+
throw new Error('staged recovery packageIdentity must be an immutable string');
|
|
352
|
+
}
|
|
353
|
+
if (!expectedRuntimeVersion || typeof expectedRuntimeVersion !== 'string') {
|
|
354
|
+
throw new Error('staged recovery requires the expected approved runtime version');
|
|
355
|
+
}
|
|
356
|
+
if (path.resolve(trustedRuntimeDir) !== KB_DIR) {
|
|
357
|
+
throw new Error('staged recovery trust root must be the executing package root');
|
|
358
|
+
}
|
|
359
|
+
const stageReceiptFile = path.resolve(stageReceiptPath);
|
|
360
|
+
if (!fs.existsSync(stageReceiptFile)) throw new Error('staged recovery authentication receipt is missing');
|
|
361
|
+
const stageReceipt = JSON.parse(fs.readFileSync(stageReceiptFile, 'utf8'));
|
|
362
|
+
if (stageReceipt.bundleSha256 !== actualBundleSha256) {
|
|
363
|
+
throw new Error('staged recovery directory is not bound to the signed bundle');
|
|
364
|
+
}
|
|
365
|
+
// The receipt is diagnostic only: bind the candidate cryptographically by independently
|
|
366
|
+
// extracting the authenticated archive and comparing the complete staged tree identity.
|
|
367
|
+
const proofRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-staged-proof-'));
|
|
368
|
+
try {
|
|
369
|
+
await extractZip(path.resolve(bundlePath), proofRoot);
|
|
370
|
+
const nested = path.join(proofRoot, 'ruvnet-brain');
|
|
371
|
+
if (fs.existsSync(path.join(nested, 'forge-mcp-all.mjs'))) {
|
|
372
|
+
for (const entry of fs.readdirSync(nested)) fs.renameSync(path.join(nested, entry), path.join(proofRoot, entry));
|
|
373
|
+
fs.rmdirSync(nested);
|
|
374
|
+
}
|
|
375
|
+
const trustedValidator = fs.existsSync(path.join(KB_DIR, 'coverage-integrity.mjs')) ? path.join(KB_DIR, 'coverage-integrity.mjs')
|
|
376
|
+
: path.join(path.dirname(KB_DIR), 'plugin', 'scripts', 'coverage-integrity.mjs');
|
|
377
|
+
fs.copyFileSync(trustedValidator, path.join(proofRoot, 'coverage-integrity.mjs'));
|
|
378
|
+
const runtimeIdentity = path.join(live, 'RUNTIME-IDENTITY.json');
|
|
379
|
+
if (fs.existsSync(runtimeIdentity)) fs.copyFileSync(runtimeIdentity, path.join(proofRoot, 'RUNTIME-IDENTITY.json'));
|
|
380
|
+
if (releaseTag) recordCorpusTransportIdentity(proofRoot, { releaseTag });
|
|
381
|
+
// Validator/runtime identity are installer-owned bindings added after extraction. Compare the
|
|
382
|
+
// authenticated archive projection while excluding those two local files.
|
|
383
|
+
const archiveIdentity = (root) => {
|
|
384
|
+
const identity = treeIdentity(root);
|
|
385
|
+
const entries = identity.entries.filter((entry) => !['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json'].includes(entry.path));
|
|
386
|
+
return { sha256: createHash('sha256').update(JSON.stringify(entries)).digest('hex'),
|
|
387
|
+
bytes: entries.reduce((sum, entry) => sum + (entry.bytes || 0), 0), fileCount: entries.filter((e) => e.type === 'file').length };
|
|
388
|
+
};
|
|
389
|
+
const stagedIdentity = archiveIdentity(staged);
|
|
390
|
+
const proofIdentity = archiveIdentity(proofRoot);
|
|
391
|
+
if (stagedIdentity.sha256 !== proofIdentity.sha256 || stagedIdentity.bytes !== proofIdentity.bytes || stagedIdentity.fileCount !== proofIdentity.fileCount) {
|
|
392
|
+
throw new Error('staged recovery directory bytes differ from independently extracted signed bundle');
|
|
393
|
+
}
|
|
394
|
+
} finally { fs.rmSync(proofRoot, { recursive: true, force: true }); }
|
|
395
|
+
for (const [dir, label] of [[staged, 'staged release'], [live, 'live KB']]) {
|
|
396
|
+
const stat = fs.lstatSync(dir);
|
|
397
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} is not a trusted directory: ${dir}`);
|
|
398
|
+
}
|
|
399
|
+
const validator = validateCoverageDirectory || await loadTrustedCoverageValidator();
|
|
400
|
+
const stagedCoverage = validateReleaseCoverageTree(staged, validator, expectedRuntimeVersion);
|
|
401
|
+
if (!stagedCoverage.valid) throw new Error(`staged ReleaseCoverage failed integrity: ${stagedCoverage.failures.join('; ')}`);
|
|
402
|
+
const liveSource = JSON.parse(fs.readFileSync(path.join(live, 'SOURCE.json'), 'utf8'));
|
|
403
|
+
const liveStores = Array.isArray(liveSource.stores)
|
|
404
|
+
? liveSource.stores
|
|
405
|
+
: Object.entries(liveSource.stores || {}).map(([kbName, value]) => ({ kbName, ...value }));
|
|
406
|
+
const overlay = capturePrivateOverlayState({ kbDir: live, allStores: liveStores });
|
|
407
|
+
const prepareCandidate = ({ candidateDir, liveDir }) => {
|
|
408
|
+
for (const name of ['coverage-integrity.mjs']) {
|
|
409
|
+
const trusted = fs.existsSync(path.join(KB_DIR, name)) ? path.join(KB_DIR, name)
|
|
410
|
+
: path.join(path.dirname(KB_DIR), 'plugin', 'scripts', name);
|
|
411
|
+
if (!fs.existsSync(trusted)) throw new Error(`trusted staged recovery runtime file is missing: ${name}`);
|
|
412
|
+
const trustedStat = fs.lstatSync(trusted);
|
|
413
|
+
if (!trustedStat.isFile() || trustedStat.isSymbolicLink()) throw new Error(`trusted staged recovery runtime file is not a regular package file: ${name}`);
|
|
414
|
+
fs.copyFileSync(trusted,
|
|
415
|
+
assertNoFollowPath(candidateDir, path.join(candidateDir, name)));
|
|
416
|
+
}
|
|
417
|
+
const runtimeIdentity = path.join(liveDir, 'RUNTIME-IDENTITY.json');
|
|
418
|
+
if (!fs.existsSync(runtimeIdentity)) throw new Error('trusted staged recovery runtime file is missing: RUNTIME-IDENTITY.json');
|
|
419
|
+
const runtime = JSON.parse(fs.readFileSync(runtimeIdentity, 'utf8'));
|
|
420
|
+
if (runtime.brainVersion !== expectedRuntimeVersion) throw new Error('installed runtime identity differs from the approved recovery runtime');
|
|
421
|
+
fs.copyFileSync(assertNoFollowPath(liveDir, runtimeIdentity),
|
|
422
|
+
assertNoFollowPath(candidateDir, path.join(candidateDir, 'RUNTIME-IDENTITY.json')));
|
|
423
|
+
const privateFence = path.join(liveDir, 'PRIVATE-STORES.json');
|
|
424
|
+
if (fs.existsSync(privateFence)) fs.copyFileSync(privateFence,
|
|
425
|
+
assertNoFollowPath(candidateDir, path.join(candidateDir, 'PRIVATE-STORES.json')));
|
|
426
|
+
// S1: ONE APPLY PATH — the same helper main()'s normal apply uses, not a second, duplicated
|
|
427
|
+
// copy-loop. It also adds the collision refusal main() previously lacked.
|
|
428
|
+
restorePrivateFilesIntoCandidate({ candidateDir, sourceDir: liveDir, overlay });
|
|
429
|
+
if (releaseTag) {
|
|
430
|
+
recordCorpusTransportIdentity(candidateDir, { releaseTag });
|
|
431
|
+
// S2: stamped atomically alongside the transport tag, exactly as main()'s prepareCandidate
|
|
432
|
+
// does. This recovery rail's caller does not currently thread a parsed corpus-generation value
|
|
433
|
+
// through (bin/install.mjs's resolveRelease() does not expose the release body it would come
|
|
434
|
+
// from) — passing null here is not a loss of safety: recordCorpusGenerationIdentity's own
|
|
435
|
+
// no-generation branch clears any stale stamp, so the NEXT ordinary --check/--apply reads
|
|
436
|
+
// UNKNOWN (apply allowed, never wrongly REFUSED, never wrongly CURRENT) rather than comparing
|
|
437
|
+
// against a generation this recovered tree does not actually carry.
|
|
438
|
+
recordCorpusGenerationIdentity(candidateDir, { corpusReleaseTag: releaseTag, generation: corpusGeneration });
|
|
439
|
+
}
|
|
440
|
+
const result = validateReleaseCoverageTree(candidateDir, validator, expectedRuntimeVersion);
|
|
441
|
+
if (!result.valid) throw new Error(`candidate public/private convergence failed: ${result.failures.join('; ')}`);
|
|
442
|
+
};
|
|
443
|
+
const validate = ({ dir }) => {
|
|
444
|
+
const result = validateReleaseCoverageTree(dir, validator, expectedRuntimeVersion);
|
|
445
|
+
return result.valid ? { valid: true, failures: [] } : result;
|
|
446
|
+
};
|
|
447
|
+
const lock = acquireUpdateLock({ kbDir: live });
|
|
448
|
+
try {
|
|
449
|
+
const transaction = runStorageTransaction({ liveDir: live, sourceDir: staged, transactionId,
|
|
450
|
+
prepareCandidate, validateCandidate: validate, validateLive: validate });
|
|
451
|
+
return { ...transaction, stagedRelease: { bundleSha256: actualBundleSha256,
|
|
452
|
+
packageIdentity, expectedRuntimeVersion, releaseTag } };
|
|
453
|
+
} finally { releaseUpdateLock(lock); }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function validateProfiledReleaseTree(root, profile, overlay) {
|
|
457
|
+
const failures = [];
|
|
458
|
+
try {
|
|
459
|
+
const coverage = JSON.parse(fs.readFileSync(path.join(root, 'COVERAGE.json'), 'utf8'));
|
|
460
|
+
const publicFile = path.join(root, 'PUBLIC-RVF-GENERATIONS.json');
|
|
461
|
+
const publicBytes = fs.readFileSync(publicFile);
|
|
462
|
+
const publicLedger = JSON.parse(publicBytes);
|
|
463
|
+
const runtimeLedger = JSON.parse(fs.readFileSync(path.join(root, 'RVF-GENERATIONS.json'), 'utf8'));
|
|
464
|
+
if (coverage.generationLedger?.file !== 'PUBLIC-RVF-GENERATIONS.json'
|
|
465
|
+
|| coverage.generationLedger.sha256 !== createHash('sha256').update(publicBytes).digest('hex')
|
|
466
|
+
|| coverage.generationLedger.bytes !== publicBytes.length) failures.push('immutable public ledger differs from ReleaseCoverage');
|
|
467
|
+
const privateNames = new Set(Object.keys(overlay?.sourceStores || {}));
|
|
468
|
+
const expectedPublic = profile === 'ruvector' ? new Set(['ruvector']) : new Set(Object.keys(publicLedger.stores || {}));
|
|
469
|
+
const actualFamilies = new Set(discoverStoreFamilies(root));
|
|
470
|
+
const runtimeNames = Object.keys(runtimeLedger.stores || {}).sort();
|
|
471
|
+
const expectedRuntime = [...expectedPublic, ...privateNames].sort();
|
|
472
|
+
if (JSON.stringify(runtimeNames) !== JSON.stringify(expectedRuntime)) failures.push('profiled runtime ledger store set differs');
|
|
473
|
+
for (const name of expectedPublic) {
|
|
474
|
+
if (JSON.stringify(runtimeLedger.stores?.[name]) !== JSON.stringify(publicLedger.stores?.[name])) {
|
|
475
|
+
failures.push(`profiled runtime public generation differs for ${name}`);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const generation = publicLedger.stores[name];
|
|
479
|
+
const file = path.join(root, String(generation?.file || ''));
|
|
480
|
+
if (!fs.existsSync(file) || fs.statSync(file).size !== generation.bytes || sha256File(file) !== generation.sha256) {
|
|
481
|
+
failures.push(`profiled public RVF differs for ${name}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const name of expectedRuntime) if (!actualFamilies.has(name)) failures.push(`profiled store family is missing: ${name}`);
|
|
485
|
+
for (const name of actualFamilies) if (!expectedRuntime.includes(name)) failures.push(`profiled tree has an unselected store family: ${name}`);
|
|
486
|
+
} catch (error) { failures.push(error.message); }
|
|
487
|
+
return { valid: failures.length === 0, failures };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function phaseEvidenceFor({ root, terminalVerdict, bundleSha256 = null, transactionReceipts = null,
|
|
491
|
+
overlay = null, storageDelta = null }) {
|
|
492
|
+
const coverage = JSON.parse(fs.readFileSync(path.join(root, 'COVERAGE.json'), 'utf8'));
|
|
493
|
+
const ledgerBytes = fs.readFileSync(path.join(root, 'PUBLIC-RVF-GENERATIONS.json'));
|
|
494
|
+
const currentRows = (coverage.rows || []).filter((row) => row.disposition === 'eligible' && row.status === 'CURRENT');
|
|
495
|
+
const evidence = {
|
|
496
|
+
'source-enumeration': { sourceObservationSha256: coverage.sourceObservationSha256,
|
|
497
|
+
rows: coverage.totals?.rows, terminal: coverage.enumerationReceipt?.terminal === true },
|
|
498
|
+
ingestion: { eligibleCurrent: currentRows.length, storeCount: coverage.generationLedger?.storeCount },
|
|
499
|
+
'local-overlay-restoration': { restoredStores: Object.keys(overlay?.sourceStores || {}).length },
|
|
500
|
+
'generation-ledger-reconciliation': { file: 'PUBLIC-RVF-GENERATIONS.json',
|
|
501
|
+
sha256: createHash('sha256').update(ledgerBytes).digest('hex'), bytes: ledgerBytes.length },
|
|
502
|
+
'coverage-generation': { releaseCoverageGeneration: coverage.releaseCoverageGeneration,
|
|
503
|
+
coverageSha256: sha256File(path.join(root, 'COVERAGE.json')) },
|
|
504
|
+
'bundle-assembly': { bundleSha256, version: coverage.releaseIdentity?.version,
|
|
505
|
+
sourceSnapshot: coverage.releaseIdentity?.sourceSnapshot },
|
|
506
|
+
update: { terminalVerdict, transactionReceipts, storageDelta },
|
|
507
|
+
};
|
|
508
|
+
// A consumer validates a published release; it does not rerun its upstream
|
|
509
|
+
// enumeration, ingestion, or assembly. A recent update cannot freshen that evidence.
|
|
510
|
+
return Object.fromEntries(Object.entries(evidence).map(([phase, detail]) => [phase, { ...detail,
|
|
511
|
+
execution: phase === 'update' || (phase === 'local-overlay-restoration' && overlay !== null)
|
|
512
|
+
? { kind: 'executed', runId: updateLock?.runId || null }
|
|
513
|
+
: phase === 'local-overlay-restoration'
|
|
514
|
+
? { kind: 'not-executed' }
|
|
515
|
+
: { kind: 'imported-release', sourceSnapshot: coverage.releaseIdentity?.sourceSnapshot || null,
|
|
516
|
+
upstreamFreshness: 'UNKNOWN' },
|
|
517
|
+
}]));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// SYMLINK POLICY IS PER-CALLER (issues #130/#131, fixed 2026-08-10).
|
|
521
|
+
//
|
|
522
|
+
// This threw on ANY symlink anywhere in the tree. That is exactly right when validating a governed
|
|
523
|
+
// store payload — a store file that is a symlink is an attack surface, and PR #124 hardened it for
|
|
524
|
+
// good reason. It is exactly WRONG when merely inventorying a backup, because a KB backup contains
|
|
525
|
+
// node_modules, and npm's `.bin` entries are ALWAYS symlinks. So the first `.bin/semver` link made
|
|
526
|
+
// every backup inventory "incomplete", reclaimBackups() fail closed, and the refusal was permanent
|
|
527
|
+
// rather than incidental:
|
|
528
|
+
//
|
|
529
|
+
// KEPT kb.bak-… — inventory is incomplete; refusing destructive reclaim
|
|
530
|
+
// (unreadable inventory tree: symbolic link is not a governed regular file: node_modules/…)
|
|
531
|
+
//
|
|
532
|
+
// Measured consequence on this machine: 63 backups, ~72 GB, every one refused for the same reason,
|
|
533
|
+
// growing by ~1.2 GB per nightly run. The refusal was safe and the scope was wrong — a guard that
|
|
534
|
+
// can never pass is not protecting anything, it is just leaking disk.
|
|
535
|
+
//
|
|
536
|
+
// `strict` (the default) keeps the original behaviour for every governed-payload caller. The
|
|
537
|
+
// inventory walk opts out — but ONLY for symlinks that cannot be a store file; a symlinked `.rvf`
|
|
538
|
+
// still throws, because that is the case the hardening exists for.
|
|
539
|
+
function relativeFiles(dir, prefix = '', { strict = true } = {}) {
|
|
540
|
+
const files = [];
|
|
541
|
+
for (const entry of fs.readdirSync(path.join(dir, prefix), { withFileTypes: true })) {
|
|
542
|
+
const relative = path.join(prefix, entry.name);
|
|
543
|
+
if (entry.isSymbolicLink()) {
|
|
544
|
+
// A symlinked store file is never acceptable, in either mode.
|
|
545
|
+
if (strict || /\.rvf$/i.test(entry.name)) {
|
|
546
|
+
throw new Error(`symbolic link is not a governed regular file: ${relative}`);
|
|
547
|
+
}
|
|
548
|
+
continue; // ordinary tooling symlink (npm .bin, etc.) — not ours to govern, not ours to follow
|
|
549
|
+
}
|
|
550
|
+
if (entry.isDirectory()) files.push(...relativeFiles(dir, relative, { strict }));
|
|
551
|
+
else if (entry.isFile()) files.push(relative);
|
|
552
|
+
}
|
|
553
|
+
return files;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Snapshot private deployment metadata before a public bundle overwrites shared registry files. */
|
|
557
|
+
export function capturePrivateOverlayState({ kbDir, allStores }) {
|
|
558
|
+
const privateSource = Object.fromEntries((Array.isArray(allStores) ? allStores : [])
|
|
559
|
+
.filter((store) => store?.updateManaged === false && store.kbName)
|
|
560
|
+
.map((store) => [store.kbName, { ...store }]));
|
|
561
|
+
const privateNames = new Set(Object.keys(privateSource));
|
|
562
|
+
if (!privateNames.size) return null;
|
|
563
|
+
|
|
564
|
+
const generations = JSON.parse(fs.readFileSync(path.join(kbDir, 'RVF-GENERATIONS.json'), 'utf8'));
|
|
565
|
+
const aliases = JSON.parse(fs.readFileSync(path.join(kbDir, 'repo-aliases.json'), 'utf8'));
|
|
566
|
+
const privateGenerations = {};
|
|
567
|
+
const privateArtifactFiles = new Set();
|
|
568
|
+
const privateArtifactPrefixes = [];
|
|
569
|
+
for (const name of privateNames) {
|
|
570
|
+
if (!generations.stores?.[name]) throw new Error(`private store ${name} has no RVF generation record`);
|
|
571
|
+
const generation = generations.stores[name];
|
|
572
|
+
if (typeof generation.file !== 'string' || !generation.file.trim()) {
|
|
573
|
+
throw new Error(`private store ${name} has no RVF generation file`);
|
|
574
|
+
}
|
|
575
|
+
const relative = path.normalize(generation.file);
|
|
576
|
+
const resolved = path.resolve(kbDir, relative);
|
|
577
|
+
if (path.isAbsolute(generation.file) || resolved === path.resolve(kbDir)
|
|
578
|
+
|| !resolved.startsWith(`${path.resolve(kbDir)}${path.sep}`)) {
|
|
579
|
+
throw new Error(`private store ${name} has unsafe RVF generation file: ${generation.file}`);
|
|
580
|
+
}
|
|
581
|
+
if (!fs.existsSync(resolved)) {
|
|
582
|
+
throw new Error(`private store ${name} RVF generation file is missing: ${generation.file}`);
|
|
583
|
+
}
|
|
584
|
+
const artifactStat = fs.lstatSync(resolved);
|
|
585
|
+
if (artifactStat.isSymbolicLink()) {
|
|
586
|
+
throw new Error(`private store ${name} RVF generation file is a symbolic link: ${generation.file}`);
|
|
587
|
+
}
|
|
588
|
+
if (!artifactStat.isFile()) {
|
|
589
|
+
throw new Error(`private store ${name} RVF generation file is not a regular file: ${generation.file}`);
|
|
590
|
+
}
|
|
591
|
+
const realKbDir = fs.realpathSync(kbDir);
|
|
592
|
+
const realArtifact = fs.realpathSync(resolved);
|
|
593
|
+
if (!realArtifact.startsWith(`${realKbDir}${path.sep}`)) {
|
|
594
|
+
throw new Error(`private store ${name} RVF generation file resolves outside the KB: ${generation.file}`);
|
|
595
|
+
}
|
|
596
|
+
privateGenerations[name] = generation;
|
|
597
|
+
privateArtifactFiles.add(relative);
|
|
598
|
+
const directory = path.dirname(relative);
|
|
599
|
+
const basename = path.basename(relative);
|
|
600
|
+
const stem = basename.replace(/(?:\.big)?\.rvf$/i, '');
|
|
601
|
+
privateArtifactPrefixes.push({ directory, basename, stem });
|
|
602
|
+
}
|
|
603
|
+
const privateAliases = Object.fromEntries(Object.entries(aliases).filter(([name, values]) =>
|
|
604
|
+
privateNames.has(name)
|
|
605
|
+
|| (Array.isArray(values) && values.some((value) => privateNames.has(value)))));
|
|
606
|
+
const privateCardNames = new Set([...privateNames, ...Object.keys(privateAliases)]);
|
|
607
|
+
const cardsFile = path.join(kbDir, 'capability-cards.md');
|
|
608
|
+
const cards = fs.existsSync(cardsFile) ? cardSections(fs.readFileSync(cardsFile, 'utf8')) : new Map();
|
|
609
|
+
const privateCards = Object.fromEntries([...cards].filter(([name]) => privateCardNames.has(name)));
|
|
610
|
+
// INVENTORY walk, not a governed-payload walk (policy above, :319-337): the live root carries the
|
|
611
|
+
// installer's own node_modules/.bin/* symlinks, and the first flagged private store (2026-09-12)
|
|
612
|
+
// turned that into "private overlay preflight failed" on a symlink no store owns. A symlinked
|
|
613
|
+
// `.rvf` still throws inside relativeFiles, and :383-386 re-checks every private artifact.
|
|
614
|
+
const privateFiles = Object.fromEntries(relativeFiles(kbDir, '', { strict: false })
|
|
615
|
+
.filter((relative) => privateArtifactFiles.has(relative)
|
|
616
|
+
|| [...privateNames].some((name) => {
|
|
617
|
+
const basename = path.basename(relative);
|
|
618
|
+
return basename === name || basename.startsWith(`${name}.`) || basename.startsWith(`${name}-`);
|
|
619
|
+
})
|
|
620
|
+
|| privateArtifactPrefixes.some((artifact) => {
|
|
621
|
+
if (path.dirname(relative) !== artifact.directory) return false;
|
|
622
|
+
const basename = path.basename(relative);
|
|
623
|
+
return basename === artifact.basename
|
|
624
|
+
|| basename.startsWith(`${artifact.basename}.`)
|
|
625
|
+
|| basename.startsWith(`${artifact.stem}.`)
|
|
626
|
+
|| basename.startsWith(`${artifact.stem}-`);
|
|
627
|
+
}))
|
|
628
|
+
.map((relative) => {
|
|
629
|
+
const file = path.join(kbDir, relative);
|
|
630
|
+
return [relative, { bytes: fs.statSync(file).size, sha256: sha256File(file) }];
|
|
631
|
+
}));
|
|
632
|
+
for (const relative of privateArtifactFiles) {
|
|
633
|
+
if (!Object.hasOwn(privateFiles, relative)) {
|
|
634
|
+
throw new Error(`private RVF generation file was not captured: ${relative}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return { sourceStores: privateSource, generationStores: privateGenerations, aliases: privateAliases, cards: privateCards, files: privateFiles };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Restore private metadata after public extraction, refusing collisions before writing anything. */
|
|
641
|
+
export function restorePrivateOverlayState({ kbDir, overlay }) {
|
|
642
|
+
if (!overlay) return { restored: 0 };
|
|
643
|
+
const sourceFile = path.join(kbDir, 'SOURCE.json');
|
|
644
|
+
const generationsFile = path.join(kbDir, 'RVF-GENERATIONS.json');
|
|
645
|
+
const aliasesFile = path.join(kbDir, 'repo-aliases.json');
|
|
646
|
+
const cardsFile = path.join(kbDir, 'capability-cards.md');
|
|
647
|
+
const source = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
|
|
648
|
+
const generations = JSON.parse(fs.readFileSync(generationsFile, 'utf8'));
|
|
649
|
+
const aliases = JSON.parse(fs.readFileSync(aliasesFile, 'utf8'));
|
|
650
|
+
const mergedSource = mergePrivateEntries(source.stores, overlay.sourceStores, 'SOURCE.json');
|
|
651
|
+
const mergedGenerations = mergePrivateEntries(generations.stores, overlay.generationStores, 'RVF-GENERATIONS.json');
|
|
652
|
+
const mergedAliases = mergePrivateEntries(aliases, overlay.aliases, 'repo-aliases.json');
|
|
653
|
+
for (const [relative, expected] of Object.entries(overlay.files || {})) {
|
|
654
|
+
const file = path.join(kbDir, relative);
|
|
655
|
+
if (!fs.existsSync(file)) throw new Error(`private file missing after update: ${relative}`);
|
|
656
|
+
if (fs.statSync(file).size !== expected.bytes || sha256File(file) !== expected.sha256) {
|
|
657
|
+
throw new Error(`private file changed during public update: ${relative}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const publicCardsText = fs.existsSync(cardsFile) ? fs.readFileSync(cardsFile, 'utf8') : '';
|
|
662
|
+
const publicCards = cardSections(publicCardsText);
|
|
663
|
+
for (const [name, section] of Object.entries(overlay.cards || {})) {
|
|
664
|
+
if (publicCards.has(name) && publicCards.get(name) !== section) {
|
|
665
|
+
throw new Error(`capability-cards.md collision for private store ${name}`);
|
|
666
|
+
}
|
|
667
|
+
publicCards.set(name, section);
|
|
668
|
+
}
|
|
669
|
+
const preambleEnd = publicCardsText.search(/^## /m);
|
|
670
|
+
const preamble = preambleEnd >= 0 ? publicCardsText.slice(0, preambleEnd).trimEnd() : publicCardsText.trimEnd();
|
|
671
|
+
const mergedCards = `${preamble}${preamble ? '\n\n' : ''}${[...publicCards.values()].join('\n\n')}\n`;
|
|
672
|
+
|
|
673
|
+
atomicJson(sourceFile, { ...source, stores: mergedSource });
|
|
674
|
+
atomicJson(generationsFile, { ...generations, stores: mergedGenerations });
|
|
675
|
+
atomicJson(aliasesFile, mergedAliases);
|
|
676
|
+
fs.writeFileSync(`${cardsFile}.tmp-${process.pid}`, mergedCards);
|
|
677
|
+
fs.renameSync(`${cardsFile}.tmp-${process.pid}`, cardsFile);
|
|
678
|
+
return { restored: Object.keys(overlay.sourceStores).length };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* ONE APPLY PATH (S1): copy the captured private overlay's artifact files from `sourceDir` (the
|
|
683
|
+
* live tree, still untouched at this point) onto `candidateDir` (the sibling tree
|
|
684
|
+
* `runStorageTransaction` builds from the freshly extracted public bundle), then restore the
|
|
685
|
+
* private registry entries with `restorePrivateOverlayState`.
|
|
686
|
+
*
|
|
687
|
+
* This is the ONLY place production code copies private files into a tree that is about to become
|
|
688
|
+
* live — `bin/install.mjs` and `kb/forge-update.mjs`'s `main()` both call this from inside
|
|
689
|
+
* `prepareCandidate`, before `runStorageTransaction` ever renames anything into place. There used to
|
|
690
|
+
* be a second, parallel implementation (`applyPublicBundlePreservingPrivate`) that operated on a
|
|
691
|
+
* full-tree copy-then-restore-from-backup model; it was never wired into `main()` — the real apply
|
|
692
|
+
* path already used `runStorageTransaction`'s rename-based candidate/rollback machinery — so it was
|
|
693
|
+
* exercised only by its own tests. Deleted rather than kept "for coverage": a second apply path that
|
|
694
|
+
* production code never calls is not a safety net, it is a second implementation to keep in sync
|
|
695
|
+
* (and the one place it silently diverged from the real path is the collision check below, which
|
|
696
|
+
* the real path had NOT been enforcing).
|
|
697
|
+
*
|
|
698
|
+
* Collision detection matters here specifically because it did not previously exist on the real
|
|
699
|
+
* path: `candidateDir` already holds the extracted public bundle's files (built by
|
|
700
|
+
* `fs.cpSync(sourceDir=extractDir, candidateDir, ...)` before `prepareCandidate` runs), so copying a
|
|
701
|
+
* private file over a same-named public one would silently discard the public bytes. Refusing BEFORE
|
|
702
|
+
* copying anything is the assertion `applyPublicBundlePreservingPrivate` had and the real path did
|
|
703
|
+
* not; it is preserved here rather than dropped.
|
|
704
|
+
*/
|
|
705
|
+
export function restorePrivateFilesIntoCandidate({ candidateDir, sourceDir, overlay }) {
|
|
706
|
+
if (!overlay) return { restored: 0 };
|
|
707
|
+
for (const relative of Object.keys(overlay.files || {})) {
|
|
708
|
+
const target = assertNoFollowPath(candidateDir, path.join(candidateDir, relative));
|
|
709
|
+
if (fs.existsSync(target)) {
|
|
710
|
+
throw new Error(`public bundle collides with private file ${relative}; refusing to copy`);
|
|
711
|
+
}
|
|
712
|
+
const source = assertNoFollowPath(sourceDir, path.join(sourceDir, relative));
|
|
713
|
+
if (!fs.existsSync(source) || !fs.lstatSync(source).isFile()) {
|
|
714
|
+
throw new Error(`private source file is missing or not regular: ${relative}`);
|
|
715
|
+
}
|
|
716
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
717
|
+
fs.copyFileSync(source, target);
|
|
718
|
+
}
|
|
719
|
+
return restorePrivateOverlayState({ kbDir: candidateDir, overlay });
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const manifestUrl = source.canonicalManifestUrl || stores.find((s) => s.canonicalManifestUrl)?.canonicalManifestUrl;
|
|
723
|
+
if (!manifestUrl) {
|
|
724
|
+
die(`self-update not configured for this build — SOURCE.json has no canonicalManifestUrl ` +
|
|
725
|
+
`(forge-build.mjs was run without --canonical-url). Provenance is still in SOURCE.json.`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function fetchJson(url) {
|
|
729
|
+
let res;
|
|
730
|
+
try { res = await fetch(url, { redirect: 'follow' }); }
|
|
731
|
+
catch (e) { die(`network failure fetching ${url}\n ${e.message} — nothing changed locally.`, 2); }
|
|
732
|
+
if (!res.ok) die(`canonical manifest returned HTTP ${res.status} for ${url} — nothing changed.`, 2);
|
|
733
|
+
try { return await res.json(); } catch (e) { die(`canonical manifest was not valid JSON: ${e.message}`, 2); }
|
|
734
|
+
}
|
|
735
|
+
async function fetchBuffer(url, { failureCode = 2, kind = 'bundle' } = {}) {
|
|
736
|
+
let res;
|
|
737
|
+
try { res = await fetch(url, { redirect: 'follow' }); }
|
|
738
|
+
catch (e) { die(`network failure downloading ${kind} ${url}\n ${e.message} — nothing changed locally.`, failureCode); }
|
|
739
|
+
if (!res.ok) die(`${kind} download returned HTTP ${res.status} for ${url} — nothing changed.`, failureCode);
|
|
740
|
+
return Buffer.from(await res.arrayBuffer());
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// The canonical manifest can be ONE of three shapes — handle all three:
|
|
744
|
+
// 1. a forge .last-built.json ({ generated, stores:{name:{sha,describe}} })
|
|
745
|
+
// 2. a SOURCE.json-shaped file ({ builtUtc, stores:{name:{builtUtc,sourceCommit,...}} })
|
|
746
|
+
// 3. a GitHub "releases/latest" payload ({ tag_name, published_at, target_commitish })
|
|
747
|
+
// Shape 3 is what this project actually publishes (the brain ships as a GitHub Release, not as
|
|
748
|
+
// committed files), so we detect it by the presence of tag_name and map its fields across.
|
|
749
|
+
function isGithubReleasePayload(canon) {
|
|
750
|
+
return Boolean(canon && typeof canon === 'object' && canon.tag_name);
|
|
751
|
+
}
|
|
752
|
+
function canonicalFor(canon, kbName) {
|
|
753
|
+
if (isGithubReleasePayload(canon)) {
|
|
754
|
+
// The whole Release advances together — every store shares the Release tag + publish time.
|
|
755
|
+
//
|
|
756
|
+
// WHICH IDENTITY DOMAIN the tag belongs to is decided here, once. A `corpus-sha256-<64 hex>`
|
|
757
|
+
// tag is a CONTENT address of a corpus archive; a `vX.Y.Z` tag is the version of a code
|
|
758
|
+
// release. Putting a corpus tag in `releaseTag` makes isBehind() compare a content address
|
|
759
|
+
// against a semver, which can never converge — that is the measured redownload loop
|
|
760
|
+
// (kb/corpus-release-identity.mjs's header records the four-download measurement).
|
|
761
|
+
const corpus = isCorpusReleaseTag(canon.tag_name);
|
|
762
|
+
return {
|
|
763
|
+
builtUtc: canon.published_at || canon.created_at || null,
|
|
764
|
+
// No per-store git sha in a Release payload; use the tag as the version identity instead.
|
|
765
|
+
sourceCommit: null,
|
|
766
|
+
sourceDescribe: canon.tag_name,
|
|
767
|
+
releaseTag: corpus ? null : canon.tag_name,
|
|
768
|
+
corpusReleaseTag: corpus ? canon.tag_name : null,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
const cs = (canon.stores && canon.stores[kbName]) || {};
|
|
772
|
+
return {
|
|
773
|
+
builtUtc: cs.builtUtc || canon.generated || canon.builtUtc || null,
|
|
774
|
+
sourceCommit: cs.sha || cs.sourceCommit || null,
|
|
775
|
+
sourceDescribe: cs.describe || cs.sourceDescribe || null,
|
|
776
|
+
releaseTag: null,
|
|
777
|
+
corpusReleaseTag: null,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/** The installed tree's own currency identity, read from its top-level SOURCE.json (`source`). */
|
|
781
|
+
export function installedCurrencyIdentity(src) {
|
|
782
|
+
return {
|
|
783
|
+
releaseTag: (src && typeof src.releaseTag === 'string' && src.releaseTag) || null,
|
|
784
|
+
corpusReleaseTag: (src && typeof src.corpusReleaseTag === 'string' && src.corpusReleaseTag) || null,
|
|
785
|
+
corpusGeneration: (src && typeof src.corpusGeneration === 'string' && src.corpusGeneration) || null,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The CANDIDATE's currency identity, derived from the live manifest/Release payload `main()` already
|
|
791
|
+
* fetched — BEFORE any download. `--check` never downloads, so this is the only data a verdict can
|
|
792
|
+
* ever be computed from pre-download; `--apply` deliberately reuses this exact same decision rather
|
|
793
|
+
* than a second, download-time comparison (the same "decide once, from the live fetch" discipline
|
|
794
|
+
* `resolveBundleUrl`/`verifyLanded` already apply elsewhere in this file).
|
|
795
|
+
*/
|
|
796
|
+
export function candidateCurrencyIdentity(canon) {
|
|
797
|
+
if (!isGithubReleasePayload(canon)) {
|
|
798
|
+
// A forge `.last-built.json` or SOURCE.json-shaped manifest (shapes 1/2 — see the comment above
|
|
799
|
+
// `isGithubReleasePayload`). Neither carries a code or corpus release tag, so there is no ordering
|
|
800
|
+
// key to compare by. This project in practice ships shape 3 only.
|
|
801
|
+
return { kind: 'other', tag: null, corpusReleaseTag: null, corpusGeneration: null, corpusGenerationEpoch: null };
|
|
802
|
+
}
|
|
803
|
+
const tag = canon.tag_name;
|
|
804
|
+
const kind = releaseKind(tag);
|
|
805
|
+
// The generation ordering key travels in the release's own body/notes — the same
|
|
806
|
+
// `Corpus generation:` line scripts/corpus-promotion.mjs already treats as the sole author-side
|
|
807
|
+
// ordering key for `releases/latest` promotion (never a locally-observed timestamp). `canon` is
|
|
808
|
+
// already the live, freshly-fetched Release payload from GitHub's API, the same trust boundary this
|
|
809
|
+
// file already extends to `canon.tag_name`/`canon.assets[]` — reusing its `body` field costs no
|
|
810
|
+
// extra network round trip and is available to `--check`, which never downloads the archive itself.
|
|
811
|
+
const parsed = kind === 'corpus' ? parseCorpusGeneration(canon.body) : null;
|
|
812
|
+
return {
|
|
813
|
+
kind,
|
|
814
|
+
tag: tag || null,
|
|
815
|
+
corpusReleaseTag: kind === 'corpus' ? tag : null,
|
|
816
|
+
corpusGeneration: parsed ? parsed.value : null,
|
|
817
|
+
corpusGenerationEpoch: parsed ? parsed.epoch : null,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* ONE CURRENCY VERDICT (S2). Replaces isBehind()'s three ad hoc fallback tiers (releaseTag ->
|
|
823
|
+
* builtUtc -> sourceCommit) with one explicit decision per release channel, made from an ordering key
|
|
824
|
+
* — NEVER a locally-observed timestamp. isBehind()'s builtUtc/sourceCommit tiers compared the
|
|
825
|
+
* candidate's PRE-FETCH manifest timestamp (a Release's publish time, always later than the KB inside
|
|
826
|
+
* it was forged) against the installed copy's own forge time — the exact redownload loop measured in
|
|
827
|
+
* this file's header and in kb/corpus-release-identity.mjs's header. That comparison is deleted
|
|
828
|
+
* outright here, not preserved as a fallback tier.
|
|
829
|
+
*
|
|
830
|
+
* @param {{releaseTag: string|null, corpusReleaseTag: string|null, corpusGeneration: string|null}} installed
|
|
831
|
+
* @param {{kind: 'code'|'corpus'|'other', tag: string|null, corpusReleaseTag: string|null, corpusGeneration: string|null, corpusGenerationEpoch: number|null}} candidate
|
|
832
|
+
* @returns {{verdict: 'CURRENT'|'UPDATE_AVAILABLE'|'UNKNOWN'|'REFUSED', reason: string}}
|
|
833
|
+
*
|
|
834
|
+
* CURRENT the candidate is exactly what is already installed.
|
|
835
|
+
* UPDATE_AVAILABLE the candidate genuinely supersedes what is installed.
|
|
836
|
+
* UNKNOWN no ordering key can be verified in either direction — e.g. today's 4.3.22-era
|
|
837
|
+
* installs, which carry no corpus generation stamp at all. NEVER refused, NEVER
|
|
838
|
+
* reported current: apply is allowed to proceed (main() treats it exactly like
|
|
839
|
+
* UPDATE_AVAILABLE for control flow; only the RECORDED verdict differs).
|
|
840
|
+
* REFUSED the candidate is a corpus generation strictly OLDER than the one installed —
|
|
841
|
+
* rollback protection. main() leaves the live tree untouched and exits 0.
|
|
842
|
+
*/
|
|
843
|
+
export function currencyVerdict(installed, candidate) {
|
|
844
|
+
if (candidate.kind === 'code') {
|
|
845
|
+
if (candidate.tag && candidate.tag === installed.releaseTag) {
|
|
846
|
+
return { verdict: 'CURRENT', reason: `code release ${candidate.tag} is already installed` };
|
|
847
|
+
}
|
|
848
|
+
// A code release supersedes whatever is installed, corpus or code — its bundle IS the corpus
|
|
849
|
+
// (recordCorpusTransportIdentity's own rationale). Code tags are owner-sequenced semver, not a
|
|
850
|
+
// content address, so there is no "candidate is older" ambiguity to protect against here.
|
|
851
|
+
return { verdict: 'UPDATE_AVAILABLE',
|
|
852
|
+
reason: `code release ${candidate.tag || '(unknown)'} supersedes ${installed.releaseTag || '(none)'}` };
|
|
853
|
+
}
|
|
854
|
+
if (candidate.kind === 'corpus') {
|
|
855
|
+
if (candidate.tag && candidate.tag === installed.corpusReleaseTag) {
|
|
856
|
+
return { verdict: 'CURRENT', reason: `corpus generation ${candidate.tag} is already installed` };
|
|
857
|
+
}
|
|
858
|
+
if (installed.corpusGeneration == null) {
|
|
859
|
+
return { verdict: 'UNKNOWN',
|
|
860
|
+
reason: 'installed tree carries no verifiable corpus generation stamp; cannot prove direction' };
|
|
861
|
+
}
|
|
862
|
+
const installedEpoch = Date.parse(installed.corpusGeneration);
|
|
863
|
+
if (!Number.isFinite(installedEpoch)) {
|
|
864
|
+
return { verdict: 'UNKNOWN', reason: 'installed corpus generation stamp is unparseable' };
|
|
865
|
+
}
|
|
866
|
+
if (candidate.corpusGeneration == null) {
|
|
867
|
+
// The candidate's own published record carries no readable ordering key — fall back to
|
|
868
|
+
// transport identity. Tag equality was already ruled out above, so a differing tag is still
|
|
869
|
+
// real evidence that something changed.
|
|
870
|
+
return { verdict: 'UPDATE_AVAILABLE',
|
|
871
|
+
reason: `candidate ${candidate.tag} carries no generation identity; falling back to transport tag (differs from installed ${installed.corpusReleaseTag || '(none)'})` };
|
|
872
|
+
}
|
|
873
|
+
if (candidate.corpusGenerationEpoch < installedEpoch) {
|
|
874
|
+
return { verdict: 'REFUSED',
|
|
875
|
+
reason: `candidate corpus generation ${candidate.corpusGeneration} predates installed generation ${installed.corpusGeneration} — refusing to move backward` };
|
|
876
|
+
}
|
|
877
|
+
return { verdict: 'UPDATE_AVAILABLE',
|
|
878
|
+
reason: `corpus generation ${candidate.corpusGeneration} supersedes installed ${installed.corpusGeneration}` };
|
|
879
|
+
}
|
|
880
|
+
// No recognizable release identity at all — never fall back to a locally-observed builtUtc/
|
|
881
|
+
// sourceCommit timestamp (the exact bug this function replaces).
|
|
882
|
+
return { verdict: 'UNKNOWN', reason: 'candidate carries no recognizable release identity (neither a code tag nor a corpus tag)' };
|
|
883
|
+
}
|
|
884
|
+
function short(s) { return s ? String(s).slice(0, 12) : '(none)'; }
|
|
885
|
+
function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
|
|
886
|
+
function assertNoFollowPath(root, target) {
|
|
887
|
+
const rootPath = path.resolve(root);
|
|
888
|
+
const targetPath = path.resolve(target);
|
|
889
|
+
const relative = path.relative(rootPath, targetPath);
|
|
890
|
+
if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) {
|
|
891
|
+
throw new Error(`path escapes KB root: ${target}`);
|
|
892
|
+
}
|
|
893
|
+
const rootStat = fs.lstatSync(rootPath);
|
|
894
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error(`KB root is not a real directory: ${root}`);
|
|
895
|
+
let current = rootPath;
|
|
896
|
+
for (const part of relative ? relative.split(path.sep) : []) {
|
|
897
|
+
current = path.join(current, part);
|
|
898
|
+
let stat;
|
|
899
|
+
try { stat = fs.lstatSync(current); }
|
|
900
|
+
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
901
|
+
if (stat.isSymbolicLink()) throw new Error(`symlink destination is not allowed: ${path.relative(rootPath, current)}`);
|
|
902
|
+
}
|
|
903
|
+
return targetPath;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function copyTree(srcDir, dstDir, root = dstDir, prefix = '') {
|
|
907
|
+
for (const ent of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
908
|
+
if (ent.isSymbolicLink()) throw new Error(`source bundle contains a symbolic link: ${path.join(prefix, ent.name)}`);
|
|
909
|
+
const relative = path.join(prefix, ent.name);
|
|
910
|
+
const s = path.join(srcDir, ent.name), d = assertNoFollowPath(root, path.join(dstDir, ent.name));
|
|
911
|
+
if (ent.isDirectory()) { if (!fs.existsSync(d)) fs.mkdirSync(d); copyTree(s, d, root, relative); }
|
|
912
|
+
else { if (!fs.existsSync(path.dirname(d))) fs.mkdirSync(path.dirname(d), { recursive: true }); fs.copyFileSync(s, d); }
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Authoritative store identities in a directory, with recursive `.rvf` fallback for old backups. */
|
|
917
|
+
function storeInventory(dir) {
|
|
918
|
+
const stores = new Map();
|
|
919
|
+
const logical = new Map();
|
|
920
|
+
const declaredFiles = new Set();
|
|
921
|
+
let complete = true;
|
|
922
|
+
let reason = null;
|
|
923
|
+
const generationFile = path.join(dir, 'RVF-GENERATIONS.json');
|
|
924
|
+
const hasGenerationFile = fs.existsSync(generationFile);
|
|
925
|
+
try {
|
|
926
|
+
const generations = JSON.parse(fs.readFileSync(generationFile, 'utf8'));
|
|
927
|
+
for (const [name, generation] of Object.entries(generations.stores || {})) {
|
|
928
|
+
if (typeof generation?.file !== 'string' || !generation.file.trim() || path.isAbsolute(generation.file)) {
|
|
929
|
+
complete = false; reason = `invalid generation path for ${name}`; continue;
|
|
930
|
+
}
|
|
931
|
+
const root = path.resolve(dir);
|
|
932
|
+
const file = path.resolve(root, path.normalize(generation.file));
|
|
933
|
+
if (file === root || !file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file)) {
|
|
934
|
+
complete = false; reason = `missing or escaping generation file for ${name}`; continue;
|
|
935
|
+
}
|
|
936
|
+
const stat = fs.lstatSync(file);
|
|
937
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
938
|
+
complete = false; reason = `non-regular generation file for ${name}`; continue;
|
|
939
|
+
}
|
|
940
|
+
const realRoot = fs.realpathSync(root);
|
|
941
|
+
const realFile = fs.realpathSync(file);
|
|
942
|
+
if (!realFile.startsWith(`${realRoot}${path.sep}`)) {
|
|
943
|
+
complete = false; reason = `generation file escapes root for ${name}`; continue;
|
|
944
|
+
}
|
|
945
|
+
// Key by the governed artifact path, not by whether this particular generation metadata
|
|
946
|
+
// happened to declare it. Older backups can contain a valid local RVF as an undeclared
|
|
947
|
+
// fallback while the live KB declares the same bytes under a logical store name. Treating
|
|
948
|
+
// those as `file:<path>` versus `store:<name>` made one physical artifact look missing and
|
|
949
|
+
// permanently retained every full-KB rollback copy.
|
|
950
|
+
stores.set(path.normalize(generation.file), generation.file);
|
|
951
|
+
logical.set(name, path.normalize(generation.file));
|
|
952
|
+
declaredFiles.add(generation.file);
|
|
953
|
+
}
|
|
954
|
+
} catch (error) {
|
|
955
|
+
if (hasGenerationFile) { complete = false; reason = `unreadable RVF-GENERATIONS.json: ${error.message}`; }
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
// Inventory only: tolerate ordinary tooling symlinks (npm .bin). A symlinked .rvf still throws.
|
|
959
|
+
const files = relativeFiles(dir, '', { strict: false });
|
|
960
|
+
for (const relative of files.filter((name) => name.endsWith('.rvf'))) {
|
|
961
|
+
if (!declaredFiles.has(relative)) {
|
|
962
|
+
stores.set(path.normalize(relative), relative);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const legacyMetadata = new Set([
|
|
966
|
+
'SOURCE.json', 'repo-aliases.json', 'capability-cards.md', 'package.json', 'package-lock.json',
|
|
967
|
+
'forge-update.mjs', 'zip-extract.mjs', 'brain-profile.mjs', 'refresh-run.mjs',
|
|
968
|
+
'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'manifest.json',
|
|
969
|
+
'coverage-integrity.mjs', 'COVERAGE.json', 'CORPUS-COVERAGE.json', 'COVERAGE.md',
|
|
970
|
+
'.refresh-snapshot.json',
|
|
971
|
+
// ADR-086 step 16: the updater's own module graph grew one file, and the installer now writes
|
|
972
|
+
// one record beside the validator it already wrote. Both are metadata, not user stores —
|
|
973
|
+
// omitting them here would make a legacy KB read as "unclassified non-RVF files" and refuse.
|
|
974
|
+
'corpus-release-identity.mjs', 'RUNTIME-IDENTITY.json',
|
|
975
|
+
]);
|
|
976
|
+
if (!hasGenerationFile && files.some((name) => !name.endsWith('.rvf') && !legacyMetadata.has(path.basename(name)))) {
|
|
977
|
+
complete = false; reason = 'legacy inventory contains unclassified non-RVF files';
|
|
978
|
+
}
|
|
979
|
+
} catch (error) {
|
|
980
|
+
complete = false; reason = `unreadable inventory tree: ${error.message}`;
|
|
981
|
+
}
|
|
982
|
+
return { stores, logical, complete, reason };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Recursive byte size, for honestly reporting how much was actually reclaimed. */
|
|
986
|
+
function dirSize(dir) {
|
|
987
|
+
try { if (fs.lstatSync(dir).isSymbolicLink()) return fs.lstatSync(dir).size; }
|
|
988
|
+
catch { return 0; }
|
|
989
|
+
let total = 0;
|
|
990
|
+
const walk = (d) => {
|
|
991
|
+
let entries; try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
992
|
+
for (const e of entries) {
|
|
993
|
+
const p = path.join(d, e.name);
|
|
994
|
+
if (e.isDirectory()) walk(p); else { try { total += fs.lstatSync(p).size; } catch { /* vanished mid-walk */ } }
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
walk(dir);
|
|
998
|
+
return total;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// A backup-looking name and matching RVF paths are not evidence that its other
|
|
1002
|
+
// bytes are disposable. Legacy backups have no authenticated complete ownership
|
|
1003
|
+
// receipt: reclaim only a tree whose every entry survives identically in live.
|
|
1004
|
+
function assertRedundantBackup(backup, live) {
|
|
1005
|
+
const compare = (prior, current) => {
|
|
1006
|
+
const a = fs.lstatSync(prior);
|
|
1007
|
+
const b = fs.lstatSync(current);
|
|
1008
|
+
if (a.isSymbolicLink() || b.isSymbolicLink()) {
|
|
1009
|
+
// Compare links themselves, never dereference them. Store symlinks are
|
|
1010
|
+
// already rejected by storeInventory; identical tooling links are safe.
|
|
1011
|
+
if (!a.isSymbolicLink() || !b.isSymbolicLink() || fs.readlinkSync(prior) !== fs.readlinkSync(current)) {
|
|
1012
|
+
throw new Error(`unclassified or different symbolic link: ${prior}`);
|
|
1013
|
+
}
|
|
1014
|
+
} else if (a.isDirectory() && b.isDirectory()) {
|
|
1015
|
+
for (const name of fs.readdirSync(prior)) compare(path.join(prior, name), path.join(current, name));
|
|
1016
|
+
} else if (a.isFile() && b.isFile()) {
|
|
1017
|
+
if (a.size !== b.size || sha256File(prior) !== sha256File(current)) {
|
|
1018
|
+
throw new Error(`different bytes: ${prior}`);
|
|
1019
|
+
}
|
|
1020
|
+
} else throw new Error(`unclassified or different entry type: ${prior}`);
|
|
1021
|
+
};
|
|
1022
|
+
compare(backup, live);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function rollbackRetentionPolicy(kbDir, env = process.env) {
|
|
1026
|
+
const liveBytes = dirSize(kbDir);
|
|
1027
|
+
const configuredSnapshots = Number(env.RUVNET_MAX_ROLLBACK_SNAPSHOTS || 1);
|
|
1028
|
+
const configuredBytes = Number(env.RUVNET_MAX_ROLLBACK_BYTES || liveBytes);
|
|
1029
|
+
if (!Number.isSafeInteger(configuredSnapshots) || configuredSnapshots < 0
|
|
1030
|
+
|| !Number.isSafeInteger(configuredBytes) || configuredBytes < 0) {
|
|
1031
|
+
throw new Error('rollback retention limits must be non-negative safe integers');
|
|
1032
|
+
}
|
|
1033
|
+
return { maxSnapshots: configuredSnapshots, maxBytes: configuredBytes, requiredSnapshotBytes: liveBytes };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function snapshotInventoryDigest(dir) {
|
|
1037
|
+
const inventory = storeInventory(dir);
|
|
1038
|
+
if (!inventory.complete) throw new Error(`snapshot inventory is incomplete (${inventory.reason || 'unknown'})`);
|
|
1039
|
+
const rows = [...inventory.stores].sort(([left], [right]) => left.localeCompare(right)).map(([identity, file]) => {
|
|
1040
|
+
const absolute = path.join(dir, file);
|
|
1041
|
+
return { identity, file, bytes: fs.statSync(absolute).size, sha256: sha256File(absolute) };
|
|
1042
|
+
});
|
|
1043
|
+
return createHash('sha256').update(JSON.stringify(rows)).digest('hex');
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function writeSnapshotReceipt(backupPath, { state, reason = null, recoveryCommand = null }) {
|
|
1047
|
+
const file = path.join(backupPath, '.refresh-snapshot.json');
|
|
1048
|
+
const receipt = {
|
|
1049
|
+
schemaVersion: 1,
|
|
1050
|
+
kind: 'ruvnet-brain-rollback-snapshot',
|
|
1051
|
+
snapshot: path.basename(backupPath),
|
|
1052
|
+
bytes: dirSize(backupPath),
|
|
1053
|
+
inventorySha256: snapshotInventoryDigest(backupPath),
|
|
1054
|
+
state,
|
|
1055
|
+
reason,
|
|
1056
|
+
recoveryCommand: recoveryCommand || `restore ${backupPath} to ${KB_DIR}`,
|
|
1057
|
+
updatedAt: new Date().toISOString(),
|
|
1058
|
+
};
|
|
1059
|
+
atomicJson(file, receipt);
|
|
1060
|
+
return receipt;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Release rollback copies after the new KB has verified (issue #35, Dr. Mark Allen).
|
|
1065
|
+
*
|
|
1066
|
+
* Exported and pure-ish because it DELETES MULTI-GIGABYTE DIRECTORIES — a bug here destroys user
|
|
1067
|
+
* data, so it is tested directly rather than exercised only through a full update run.
|
|
1068
|
+
*
|
|
1069
|
+
* Refuses to delete any backup holding a `.rvf` store the live KB does not have. That is the
|
|
1070
|
+
* private/local-store case: the public bundle does not ship those, the update replaces the directory,
|
|
1071
|
+
* and forge-guard still passes because it verifies the store it was asked about — not what went
|
|
1072
|
+
* missing. In that situation the backup is the only surviving copy, so it is kept and reported.
|
|
1073
|
+
*
|
|
1074
|
+
* @returns {{removed: string[], kept: [string, string][], freed: number}}
|
|
1075
|
+
*/
|
|
1076
|
+
export function reclaimBackups({
|
|
1077
|
+
kbDir,
|
|
1078
|
+
backupsMade = [],
|
|
1079
|
+
env = process.env,
|
|
1080
|
+
intentionallyRemovedStores = [],
|
|
1081
|
+
dryRun = false,
|
|
1082
|
+
}) {
|
|
1083
|
+
const parent = path.dirname(kbDir);
|
|
1084
|
+
// Older updater and recovery paths used three different names for the same full-KB rollback
|
|
1085
|
+
// copy. Sweeping only `kb.bak-*` left those copies outside retention, which is how issue #235
|
|
1086
|
+
// accumulated 74 directories / 129 GiB. Keep the allowlist narrow: these are exact historical
|
|
1087
|
+
// names owned by this updater, and unrelated siblings must remain untouched.
|
|
1088
|
+
//
|
|
1089
|
+
// `.install-preserved-` (bin/install.mjs) is the installer's copy of the whole prior generation.
|
|
1090
|
+
// It was "not eligible for automatic cleanup" by name alone, so it outlived every proof that could
|
|
1091
|
+
// have released it — measured 2026-09-11: a 1.2 GB brain held three times on one machine. It is a
|
|
1092
|
+
// candidate under EXACTLY the same redundancy proof as every other copy: never deleted unless
|
|
1093
|
+
// every byte survives in the live brain.
|
|
1094
|
+
const base = path.basename(kbDir);
|
|
1095
|
+
const prefixes = [
|
|
1096
|
+
`${base}.bak-`,
|
|
1097
|
+
`${base}.pre-reset-backup-`,
|
|
1098
|
+
`${base}.agent-harness-generator-backup-`,
|
|
1099
|
+
`${base}-pre-gap-rebuild-backup-`,
|
|
1100
|
+
`${base}.install-preserved-`,
|
|
1101
|
+
];
|
|
1102
|
+
const prefixFor = (entry) => prefixes.find((prefix) => entry.startsWith(prefix)) || null;
|
|
1103
|
+
let stranded = [];
|
|
1104
|
+
try { stranded = fs.readdirSync(parent).filter((n) => prefixFor(n)).map((n) => path.join(parent, n)); }
|
|
1105
|
+
catch { /* unreadable parent — nothing to sweep */ }
|
|
1106
|
+
|
|
1107
|
+
const all = [...new Set([...backupsMade, ...stranded])];
|
|
1108
|
+
const removed = []; const wouldRemove = []; const kept = []; let freed = 0;
|
|
1109
|
+
const safePreserved = new Map();
|
|
1110
|
+
const retentionPolicy = rollbackRetentionPolicy(kbDir, env);
|
|
1111
|
+
const liveInventory = storeInventory(kbDir);
|
|
1112
|
+
|
|
1113
|
+
// A store the live release's own COVERAGE.json marks ineligible is absent from live BY POLICY
|
|
1114
|
+
// (excluded-no-corpus, fork, archived…), not lost by an update; its presence in a backup must not
|
|
1115
|
+
// pin that backup forever. Only rows with a non-eligible disposition qualify — an eligible row that
|
|
1116
|
+
// merely has no artifact (MISSING) is not a decision to drop the store.
|
|
1117
|
+
const readJsonQuietly = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
1118
|
+
const coverageRows = readJsonQuietly(path.join(kbDir, 'COVERAGE.json'))?.rows;
|
|
1119
|
+
const policyExcluded = (Array.isArray(coverageRows) ? coverageRows : [])
|
|
1120
|
+
.filter((row) => row && row.kind === 'repository' && row.disposition && row.disposition !== 'eligible')
|
|
1121
|
+
.map((row) => String(row.artifact?.store || row.name || '').toLowerCase()).filter(Boolean);
|
|
1122
|
+
// PRIVATE-fenced stores (PRIVATE-STORES.json, read from live AND from the backup itself, since a
|
|
1123
|
+
// backup knows what was private when it was made) are never disposable: a backup holding one the
|
|
1124
|
+
// live brain lacks is the only copy outside the fence. It is pinned and reported by name, and no
|
|
1125
|
+
// caller can authorize it away through `intentionallyRemovedStores`.
|
|
1126
|
+
const fencedNames = (dirs) => new Set(dirs.flatMap((dir) => {
|
|
1127
|
+
const list = readJsonQuietly(path.join(dir, 'PRIVATE-STORES.json'))?.privateStores;
|
|
1128
|
+
return Array.isArray(list) ? list.map((name) => String(name).toLowerCase()) : [];
|
|
1129
|
+
}));
|
|
1130
|
+
const storeStem = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i, '').toLowerCase();
|
|
1131
|
+
// Retaining a copy is safe for the NEXT update only when every entry is measured: regular files,
|
|
1132
|
+
// plus symlinks that cannot be a store file AND stay inside the copy (npm's `.bin` links — the
|
|
1133
|
+
// installed brain always carries `node_modules/.bin/semver -> ../semver/bin/semver.js`). A link
|
|
1134
|
+
// that escapes the tree is not measured — its target is what a receipt would silently be counting
|
|
1135
|
+
// — and stays a blocker, exactly as before. A symlinked `.rvf` already fails the inventory above.
|
|
1136
|
+
const pinnedPrivate = new Set();
|
|
1137
|
+
const markMeasured = (b) => {
|
|
1138
|
+
try {
|
|
1139
|
+
const identity = treeIdentity(b);
|
|
1140
|
+
const root = path.resolve(b);
|
|
1141
|
+
const measured = identity.entries.every((entry) => {
|
|
1142
|
+
if (entry.type === 'file') return true;
|
|
1143
|
+
if (entry.type !== 'symlink' || /\.rvf$/i.test(entry.path) || path.isAbsolute(entry.target)) return false;
|
|
1144
|
+
const relative = entry.path.split('/').join(path.sep);
|
|
1145
|
+
return path.resolve(root, path.dirname(relative), entry.target).startsWith(`${root}${path.sep}`);
|
|
1146
|
+
});
|
|
1147
|
+
if (measured) safePreserved.set(b, identity.bytes);
|
|
1148
|
+
} catch { /* retained, but not safe to proceed past recovery preflight */ }
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
for (const b of all) {
|
|
1152
|
+
if (!fs.existsSync(b)) continue;
|
|
1153
|
+
if (env.RUVNET_KEEP_BACKUP === '1') { kept.push([b, 'RUVNET_KEEP_BACKUP=1 is set']); continue; }
|
|
1154
|
+
try {
|
|
1155
|
+
if (path.dirname(path.resolve(b)) !== path.resolve(parent) || !prefixFor(path.basename(b))) {
|
|
1156
|
+
throw new Error('target is not an exact backup sibling');
|
|
1157
|
+
}
|
|
1158
|
+
for (const dir of [parent, kbDir, b]) {
|
|
1159
|
+
const stat = fs.lstatSync(dir);
|
|
1160
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`not a real directory: ${dir}`);
|
|
1161
|
+
}
|
|
1162
|
+
} catch (error) { kept.push([b, `unsafe reclaim target: ${error.message}`]); continue; }
|
|
1163
|
+
const backupInventory = storeInventory(b);
|
|
1164
|
+
if (!liveInventory.complete || !backupInventory.complete) {
|
|
1165
|
+
kept.push([b, `inventory is incomplete; refusing destructive reclaim (${backupInventory.reason || liveInventory.reason || 'unknown'})`]);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
const privateNames = fencedNames([kbDir, b]);
|
|
1169
|
+
const removedStores = [...new Set([...intentionallyRemovedStores, ...policyExcluded])]
|
|
1170
|
+
.filter((store) => !privateNames.has(String(store).toLowerCase()));
|
|
1171
|
+
const allowedMissing = new Set(removedStores.flatMap((store) => [`${store}.rvf`, `${store}.big.rvf`])
|
|
1172
|
+
.map((file) => path.normalize(file)));
|
|
1173
|
+
for (const store of removedStores) {
|
|
1174
|
+
const governedPath = backupInventory.logical.get(store);
|
|
1175
|
+
if (governedPath) allowedMissing.add(governedPath);
|
|
1176
|
+
}
|
|
1177
|
+
const lost = [...backupInventory.stores].filter(([identity]) => !liveInventory.stores.has(identity) && !allowedMissing.has(identity));
|
|
1178
|
+
const identityNames = new Map();
|
|
1179
|
+
for (const [name, identity] of backupInventory.logical) {
|
|
1180
|
+
identityNames.set(identity, [...(identityNames.get(identity) || []), String(name).toLowerCase()]);
|
|
1181
|
+
}
|
|
1182
|
+
const isFenced = ([identity, file]) => (identityNames.get(identity) || []).some((name) => privateNames.has(name))
|
|
1183
|
+
|| privateNames.has(storeStem(file));
|
|
1184
|
+
const lostOther = lost.filter((entry) => !isFenced(entry));
|
|
1185
|
+
const lostPrivate = lost.filter(isFenced);
|
|
1186
|
+
if (lostOther.length) {
|
|
1187
|
+
const labels = lostOther.map(([, file]) => file);
|
|
1188
|
+
const privateNote = lostPrivate.length ? `; also pins PRIVATE: ${lostPrivate.map(([, file]) => file).join(', ')}` : '';
|
|
1189
|
+
kept.push([b, `it holds ${lostOther.length} store(s) the new copy does NOT have: ${labels.slice(0, 3).join(', ')}${lostOther.length > 3 ? '…' : ''}${privateNote}`]);
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
if (lostPrivate.length) {
|
|
1193
|
+
const labels = lostPrivate.map(([, file]) => file);
|
|
1194
|
+
kept.push([b, `PRIVATE store(s) pinned — the only copy outside the fence: ${labels.join(', ')}; retained, never reclaimed automatically`]);
|
|
1195
|
+
pinnedPrivate.add(b);
|
|
1196
|
+
markMeasured(b);
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
try { assertRedundantBackup(b, kbDir); }
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
kept.push([b, `PRESERVED_UNCLASSIFIED: complete byte redundancy is not proven; ${error.message}`]);
|
|
1202
|
+
// Preservation does not itself require blocking an isolated transaction. Only a measured
|
|
1203
|
+
// inventory establishes safe retention; missing stores, unsafe roots and unreadable bytes
|
|
1204
|
+
// remain blockers.
|
|
1205
|
+
markMeasured(b);
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
const size = dirSize(b);
|
|
1209
|
+
if (dryRun) { wouldRemove.push(b); freed += size; continue; }
|
|
1210
|
+
try { fs.rmSync(b, { recursive: true, force: true }); removed.push(b); freed += size; }
|
|
1211
|
+
catch (e) { kept.push([b, `could not remove: ${e.message}`]); }
|
|
1212
|
+
}
|
|
1213
|
+
const retained = all.filter((backup) => fs.existsSync(backup) && !wouldRemove.includes(backup)).map((backup) => {
|
|
1214
|
+
let inventorySha256 = null;
|
|
1215
|
+
let inventoryError = null;
|
|
1216
|
+
try {
|
|
1217
|
+
if (fs.lstatSync(backup).isSymbolicLink()) throw new Error('backup root is a symbolic link');
|
|
1218
|
+
inventorySha256 = snapshotInventoryDigest(backup);
|
|
1219
|
+
}
|
|
1220
|
+
catch (error) { inventoryError = error.message; }
|
|
1221
|
+
return { path: backup, bytes: safePreserved.get(backup) ?? dirSize(backup), inventorySha256, inventoryError,
|
|
1222
|
+
safeToRetainDuringUpdate: safePreserved.has(backup), automaticCleanupEligible: false,
|
|
1223
|
+
retention: pinnedPrivate.has(backup) ? 'private-pinned' : safePreserved.has(backup) ? 'unclassified' : 'unmeasured' };
|
|
1224
|
+
});
|
|
1225
|
+
const retainedBytes = retained.reduce((sum, snapshot) => sum + snapshot.bytes, 0);
|
|
1226
|
+
const retention = { ...retentionPolicy, observedSnapshots: retained.length, observedBytes: retainedBytes,
|
|
1227
|
+
withinBudget: retained.length <= retentionPolicy.maxSnapshots && retainedBytes <= retentionPolicy.maxBytes };
|
|
1228
|
+
// Two facts, kept apart. `blockingRetained` is what an update must not proceed past: a copy that is
|
|
1229
|
+
// unmeasured or holds a store nothing accounts for — and, over budget, any copy retained for no
|
|
1230
|
+
// stated reason (an unclassified one), which is the existing contract (data-safety tests) and stays.
|
|
1231
|
+
// The ONE exemption is a copy pinned for an accounted reason — a fenced PRIVATE store the live
|
|
1232
|
+
// brain lacks: it is user data, measured, and named; exceeding the budget with it is reported as
|
|
1233
|
+
// `overBudget`, not treated as unresolved rollback state, because this update adds no persistent
|
|
1234
|
+
// copy of its own.
|
|
1235
|
+
const blockingRetained = retained.filter((entry) => !entry.safeToRetainDuringUpdate
|
|
1236
|
+
|| (!retention.withinBudget && entry.retention !== 'private-pinned'));
|
|
1237
|
+
const overBudget = retention.withinBudget ? null : { snapshots: retained.length, maxSnapshots: retentionPolicy.maxSnapshots,
|
|
1238
|
+
bytes: retainedBytes, maxBytes: retentionPolicy.maxBytes };
|
|
1239
|
+
return { removed, wouldRemove, dryRun, kept, freed, retained, blockingRetained, overBudget,
|
|
1240
|
+
retentionPolicy: retention, withinBudget: retention.withinBudget,
|
|
1241
|
+
updateMayProceed: retention.withinBudget && retained.every((entry) => entry.safeToRetainDuringUpdate) };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Decide which URL to actually download the replacement bundle from (issue #35 item 1, Dr. Mark
|
|
1246
|
+
* Allen / @mamd69).
|
|
1247
|
+
*
|
|
1248
|
+
* The OLD code (line 218 before this fix) always used `local.canonicalBundleUrl` — the URL
|
|
1249
|
+
* literally written into the copy of SOURCE.json that is BEING REPLACED. That value can only ever
|
|
1250
|
+
* point BACKWARD: it was correct on the day this copy was forged, and every day after is a day it
|
|
1251
|
+
* could go stale. Mark's machine re-downloaded the same June v0.5.0-dev asset for three weeks
|
|
1252
|
+
* because that pinned URL never moved even though newer releases existed on GitHub the whole time.
|
|
1253
|
+
*
|
|
1254
|
+
* This resolves the URL from `canon` — the live "latest release" (or manifest) payload `main()`
|
|
1255
|
+
* already fetched fresh, moments ago, over the network — instead of the stale local copy:
|
|
1256
|
+
* - Shape 3 (a GitHub `releases/latest` payload — what this project actually publishes): the
|
|
1257
|
+
* release carries real `assets[]` with `browser_download_url`s that GitHub resolves NOW, not
|
|
1258
|
+
* whatever was true when this local copy was built. Prefer the asset whose name matches the
|
|
1259
|
+
* pinned URL's basename; this project in practice ships ONE combined zip per release (not one
|
|
1260
|
+
* per KB store, despite forge-build.mjs's per-store naming convention — the two drifted apart),
|
|
1261
|
+
* so if there's exactly one `.zip` asset and no name match, that unambiguous single zip IS it.
|
|
1262
|
+
* - Shape 1/2 (a forge `.last-built.json` or SOURCE.json-shaped manifest): these can carry the
|
|
1263
|
+
* same `canonicalBundleUrl` field per store, but THIS copy was just fetched fresh over the
|
|
1264
|
+
* network, so it reflects the manifest's CURRENT contents — still a live resolution, not a
|
|
1265
|
+
* pinned local guess.
|
|
1266
|
+
* - Only when neither live source resolves an asset does this fall back to the URL pinned in the
|
|
1267
|
+
* local SOURCE.json — and it says so. Falling back to that value SILENTLY is issue #35 item 3
|
|
1268
|
+
* (the "known-good" bundle applied with zero warning); callers MUST surface `warning` when set.
|
|
1269
|
+
*
|
|
1270
|
+
* @returns {{ url: string|null, origin: 'latest-release-asset'|'live-manifest'|'pinned-fallback'|'none', assetName: string|null, digest: string|null, warning: string|null }}
|
|
1271
|
+
*/
|
|
1272
|
+
export function resolveBundleUrl({ canon, local, source }) {
|
|
1273
|
+
const pinned = (local && local.canonicalBundleUrl) || (source && source.canonicalBundleUrl) || null;
|
|
1274
|
+
|
|
1275
|
+
if (canon && Array.isArray(canon.assets) && canon.assets.length) {
|
|
1276
|
+
const wantName = pinned ? path.basename(pinned) : null;
|
|
1277
|
+
let asset = wantName ? canon.assets.find((a) => a && a.name === wantName) : null;
|
|
1278
|
+
if (!asset) {
|
|
1279
|
+
const zips = canon.assets.filter((a) => a && typeof a.name === 'string' && a.name.endsWith('.zip'));
|
|
1280
|
+
if (zips.length === 1) asset = zips[0];
|
|
1281
|
+
}
|
|
1282
|
+
if (asset && (asset.browser_download_url || asset.url)) {
|
|
1283
|
+
return {
|
|
1284
|
+
url: asset.browser_download_url || asset.url,
|
|
1285
|
+
origin: 'latest-release-asset',
|
|
1286
|
+
assetName: asset.name,
|
|
1287
|
+
digest: asset.digest || null,
|
|
1288
|
+
warning: null,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (canon && canon.stores && typeof canon.stores === 'object' && !Array.isArray(canon.stores) && local) {
|
|
1294
|
+
const cs = canon.stores[local.kbName];
|
|
1295
|
+
if (cs && cs.canonicalBundleUrl) {
|
|
1296
|
+
return { url: cs.canonicalBundleUrl, origin: 'live-manifest', assetName: path.basename(cs.canonicalBundleUrl), digest: null, warning: null };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (pinned) {
|
|
1301
|
+
const staleness = local
|
|
1302
|
+
? `this copy's own record (built ${local.builtUtc || '?'}${local.sourceDescribe ? `, ${local.sourceDescribe}` : local.sourceCommit ? `, ${short(local.sourceCommit)}` : ''})`
|
|
1303
|
+
: `this copy's own record`;
|
|
1304
|
+
return {
|
|
1305
|
+
url: pinned,
|
|
1306
|
+
origin: 'pinned-fallback',
|
|
1307
|
+
assetName: path.basename(pinned),
|
|
1308
|
+
digest: null,
|
|
1309
|
+
warning: `could not resolve a bundle asset from the LIVE manifest ` +
|
|
1310
|
+
`(${canon && canon.tag_name ? `release ${canon.tag_name} has no matching/unambiguous .zip asset` : 'the manifest is not a GitHub Release payload and carries no live canonicalBundleUrl'}); ` +
|
|
1311
|
+
`falling back to the URL PINNED inside ${staleness}: ${pinned} — this can only point BACKWARD (issue #35) and may be stale.`,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return { url: null, origin: 'none', assetName: null, digest: null, warning: null };
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* The identity of a WHOLE bundle, as recorded at the top level of its SOURCE.json.
|
|
1320
|
+
*
|
|
1321
|
+
* This is what advances when a new bundle is published, regardless of which individual stores were
|
|
1322
|
+
* re-forged into it — which is precisely why the "did anything land" question belongs here and not
|
|
1323
|
+
* on a single store (issue #108). Returns null when a SOURCE.json carries no such identity at all
|
|
1324
|
+
* (bundles predating `releaseTag`/`brainVersion`, or a plain forge manifest), so callers can tell
|
|
1325
|
+
* "identical" apart from "no signal to compare".
|
|
1326
|
+
*/
|
|
1327
|
+
export function bundleIdentity(src) {
|
|
1328
|
+
if (!src || typeof src !== 'object') return null;
|
|
1329
|
+
// `corpusReleaseTag` is part of bundle identity for the same reason the other three are: it is the
|
|
1330
|
+
// one field that advances when a corpus-only release lands. Without it, two corpus generations
|
|
1331
|
+
// that happened to share a builtUtc would read as "nothing landed" on a genuinely new corpus.
|
|
1332
|
+
const parts = [src.releaseTag, src.brainVersion, src.builtUtc, src.corpusReleaseTag].map((v) => (v == null ? '' : String(v)));
|
|
1333
|
+
return parts.some(Boolean) ? parts.join('|') : null;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Confirm the download+extraction actually changed what is on disk (issue #35 item 2, Dr. Mark
|
|
1338
|
+
* Allen / @mamd69).
|
|
1339
|
+
*
|
|
1340
|
+
* The OLD code's final "DONE" message (line 296 before this fix) was built from `canon.tag_name` —
|
|
1341
|
+
* a lookup made BEFORE anything was downloaded — regardless of what the download actually
|
|
1342
|
+
* contained. Mark's run printed "KB updated to the canonical build (v3.4.21-dev)" while his
|
|
1343
|
+
* SOURCE.json on disk still read v0.5.0-dev, because nothing ever re-read it afterward.
|
|
1344
|
+
*
|
|
1345
|
+
* This deliberately does NOT reuse `isBehind()` for the pass/fail decision: `isBehind()` compares
|
|
1346
|
+
* against `canon.builtUtc`, which for a GitHub Release is the RELEASE's publish timestamp — always
|
|
1347
|
+
* a few minutes AFTER the KB inside it was actually forged. Confirmed LIVE against this repo's own
|
|
1348
|
+
* kb/SOURCE.json (2026-07-20): running `--check` against a store forged 2 minutes before its own
|
|
1349
|
+
* release was published already reads BEHIND. Reusing that comparison here would make EVERY
|
|
1350
|
+
* successful update fail this guard too — crying wolf on success is as dishonest as silence on
|
|
1351
|
+
* failure. Instead this checks something isBehind() cannot: does the on-disk identity now differ
|
|
1352
|
+
* from what it was immediately BEFORE this update ran? `builtUtc` is regenerated at every forge
|
|
1353
|
+
* build, so a genuine new build always changes it — an unchanged fingerprint after a "successful"
|
|
1354
|
+
* download IS the bug (identical bytes re-fetched, exactly Mark's report). When the resolved asset
|
|
1355
|
+
* carried a real digest, this also verifies the downloaded bytes against it — the one place a
|
|
1356
|
+
* directly comparable "resolved vs. landed" fact actually exists in a GitHub Release payload.
|
|
1357
|
+
*
|
|
1358
|
+
* THE QUESTION IS ASKED OF THE BUNDLE, NOT OF ONE STORE (issue #108). The first version compared a
|
|
1359
|
+
* PER-STORE fingerprint and treated equality as fatal — but stores are forged INDEPENDENTLY, and a
|
|
1360
|
+
* store whose upstream repo did not move is re-shipped byte-identical inside a genuinely new
|
|
1361
|
+
* bundle. On the reporter's copy 8 of 15 stores shared one stamp, so the first unchanged store in
|
|
1362
|
+
* iteration order aborted the entire run: nightly updates "failed" for three weeks while actually
|
|
1363
|
+
* succeeding, and the abort skipped the rollback release, stranding ~1.6 GB a night. Whitelisting
|
|
1364
|
+
* the store would not have helped — the next unchanged one simply takes its place.
|
|
1365
|
+
*
|
|
1366
|
+
* So the fatal question is the one issue #35 actually asked: did ANYTHING land? That is bundle
|
|
1367
|
+
* identity (releaseTag / brainVersion / top-level builtUtc), which advances whenever a new bundle
|
|
1368
|
+
* is published. Per-store equality is now what it always was in reality — ordinary, and reported
|
|
1369
|
+
* as `storeUnchanged` rather than raised as a failure. A bundle whose identity did NOT move AND
|
|
1370
|
+
* whose store did not move either is the real no-op, and is still refused (issue #106).
|
|
1371
|
+
*
|
|
1372
|
+
* `kind` says what a caller may do about a failure: 'noop' means the KB in place is intact and the
|
|
1373
|
+
* rollback copy is redundant; 'damaged' means the copy in place is suspect and the rollback must
|
|
1374
|
+
* be kept.
|
|
1375
|
+
*
|
|
1376
|
+
* @returns {{ok: boolean, reason: string|null, landed: object|null, kind: 'noop'|'damaged'|null,
|
|
1377
|
+
* storeUnchanged: boolean, bundleChanged: boolean|null}}
|
|
1378
|
+
*/
|
|
1379
|
+
export function verifyLanded({ kbDir, kbName, before, beforeBundle = null, expectedDigest = null, downloadedBuffer = null }) {
|
|
1380
|
+
const damaged = (reason, landed = null) => ({ ok: false, kind: 'damaged', reason, landed, storeUnchanged: false, bundleChanged: null });
|
|
1381
|
+
const p = path.join(kbDir, 'SOURCE.json');
|
|
1382
|
+
if (!fs.existsSync(p)) {
|
|
1383
|
+
return damaged(`no SOURCE.json found at ${p} after extraction — cannot confirm what actually landed`);
|
|
1384
|
+
}
|
|
1385
|
+
let landedSource;
|
|
1386
|
+
try { landedSource = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
1387
|
+
catch (e) { return damaged(`SOURCE.json on disk after extraction is unreadable/corrupt: ${e.message}`); }
|
|
1388
|
+
|
|
1389
|
+
const list = Array.isArray(landedSource.stores)
|
|
1390
|
+
? landedSource.stores
|
|
1391
|
+
: (landedSource.stores && typeof landedSource.stores === 'object')
|
|
1392
|
+
? Object.entries(landedSource.stores).map(([n, v]) => ({ kbName: n, ...v }))
|
|
1393
|
+
: [landedSource];
|
|
1394
|
+
// The single-item, no-kbName-field fallback exists ONLY for the legacy flat schema (a SOURCE.json
|
|
1395
|
+
// predating the multi-store `stores` object — see the identical pattern at the top of this file,
|
|
1396
|
+
// lines 46-50). It must NOT swallow a genuine name mismatch: if the one store present names
|
|
1397
|
+
// itself something else, that is a real "wrong store landed" error, not a format quirk.
|
|
1398
|
+
// THE UPGRADE DIRECTION MATTERS TOO. The lookup above assumes the CALLER knows its store name —
|
|
1399
|
+
// but a legacy flat SOURCE.json has no `stores` object at all, so `stores = [source]` (lines 46-50)
|
|
1400
|
+
// yields an entry whose kbName is undefined, and `kbName` arrives here as undefined. Landing a
|
|
1401
|
+
// modern multi-store bundle over it then matched neither branch, and main() turned that into
|
|
1402
|
+
// "UPDATE MISMATCH — REFUSING to report success" on an update that had genuinely worked.
|
|
1403
|
+
//
|
|
1404
|
+
// That is the worst possible false failure: it permanently blocks self-update for people still on
|
|
1405
|
+
// an OLD bundle — precisely the stale installs this whole issue exists to rescue, and precisely the
|
|
1406
|
+
// users reporting "I'm still on 0.5". A guard that bricks the upgrade path is worse than the bug.
|
|
1407
|
+
const legacyCaller = kbName == null || kbName === 'undefined';
|
|
1408
|
+
const landed = list.find((s) => s.kbName === kbName)
|
|
1409
|
+
|| (list.length === 1 && list[0].kbName == null ? { kbName, ...list[0] } : null)
|
|
1410
|
+
// Legacy caller upgrading into the modern schema: any single landed store is unambiguous.
|
|
1411
|
+
|| (legacyCaller && list.length === 1 ? { kbName: list[0].kbName, ...list[0] } : null);
|
|
1412
|
+
if (!landed) {
|
|
1413
|
+
return damaged(`SOURCE.json on disk after extraction has no entry for store "${kbName}"`);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Bytes that do not match what the release declared are a HARD failure whatever the identities
|
|
1417
|
+
// say, and the copy now in place is suspect — so this is checked before anything else.
|
|
1418
|
+
if (expectedDigest && downloadedBuffer) {
|
|
1419
|
+
const algo = expectedDigest.includes(':') ? expectedDigest.split(':')[0] : 'sha256';
|
|
1420
|
+
const actual = `${algo}:${createHash(algo).update(downloadedBuffer).digest('hex')}`;
|
|
1421
|
+
if (actual !== expectedDigest) {
|
|
1422
|
+
return damaged(`downloaded bundle digest ${actual} does not match the release-declared digest ${expectedDigest}`, landed);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const fingerprint = (r) => `${r.builtUtc || ''}|${r.sourceCommit || ''}|${r.sourceDescribe || ''}`;
|
|
1427
|
+
const storeUnchanged = Boolean(before) && fingerprint(landed) === fingerprint(before);
|
|
1428
|
+
|
|
1429
|
+
const landedBundle = bundleIdentity(landedSource);
|
|
1430
|
+
const priorBundle = bundleIdentity(beforeBundle);
|
|
1431
|
+
// null = one side carries no bundle identity at all (a pre-releaseTag bundle, or a plain forge
|
|
1432
|
+
// manifest). There is then nothing to compare, so the per-store fingerprint is the ONLY signal
|
|
1433
|
+
// available and the original behaviour stands — a fallback, never the primary test.
|
|
1434
|
+
const bundleChanged = (landedBundle && priorBundle) ? landedBundle !== priorBundle : null;
|
|
1435
|
+
|
|
1436
|
+
const nothingMoved = bundleChanged === null ? storeUnchanged : (!bundleChanged && storeUnchanged);
|
|
1437
|
+
if (nothingMoved) {
|
|
1438
|
+
const detail = bundleChanged === null
|
|
1439
|
+
? `store "${kbName}" on disk is IDENTICAL to before the update (built ${landed.builtUtc || '?'}` +
|
|
1440
|
+
`${landed.sourceDescribe ? `, ${landed.sourceDescribe}` : ''}), and this bundle carries no top-level identity to cross-check`
|
|
1441
|
+
: `the BUNDLE on disk is IDENTICAL to before the update (${landedBundle}) and store "${kbName}" did not move either`;
|
|
1442
|
+
return {
|
|
1443
|
+
ok: false,
|
|
1444
|
+
kind: 'noop',
|
|
1445
|
+
reason: `${detail} — nothing actually changed. Bytes were replaced with an identical copy while the ` +
|
|
1446
|
+
`download reported success: issue #35, and the reason issue #106 must not exit 0.`,
|
|
1447
|
+
landed,
|
|
1448
|
+
storeUnchanged,
|
|
1449
|
+
bundleChanged,
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
return { ok: true, kind: null, reason: null, landed, storeUnchanged, bundleChanged };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function main() {
|
|
1457
|
+
if (APPLY) {
|
|
1458
|
+
try { updateLock = acquireUpdateLock(); }
|
|
1459
|
+
catch (error) { die(`update lock refused this run: ${error.message}`); }
|
|
1460
|
+
|
|
1461
|
+
// Cleanup is part of every apply, including an already-current run. Otherwise a redundant
|
|
1462
|
+
// multi-GB rollback can survive forever simply because there is no newer release to trigger
|
|
1463
|
+
// the old behind-only preflight.
|
|
1464
|
+
const preflightRollbacks = reclaimBackups({ kbDir: KB_DIR });
|
|
1465
|
+
legacyBackupRetention = preflightRollbacks;
|
|
1466
|
+
if (preflightRollbacks.removed.length) {
|
|
1467
|
+
console.log(`\nreleased ${preflightRollbacks.removed.length} redundant rollback ${preflightRollbacks.removed.length === 1 ? 'copy' : 'copies'} before update check`);
|
|
1468
|
+
}
|
|
1469
|
+
// Only an UNMEASURED or genuinely lost copy is recovery state. Being over the retention budget
|
|
1470
|
+
// with copies that are measured and pinned for a stated reason (a fenced private store, bytes
|
|
1471
|
+
// the proof could not match) is reported, not fatal — this update adds no persistent copy of
|
|
1472
|
+
// its own, and a refusal here was what kept --apply from ever running (measured 2026-09-11).
|
|
1473
|
+
const reasonFor = (backup) => preflightRollbacks.kept.find(([b]) => b === backup)?.[1] || 'not measured';
|
|
1474
|
+
if (preflightRollbacks.blockingRetained.length) {
|
|
1475
|
+
const detail = preflightRollbacks.blockingRetained.map(({ path: backup }) => ` ${backup}: ${reasonFor(backup)}`).join('\n');
|
|
1476
|
+
die(`unresolved rollback state exists; refusing to create another full-KB copy.\n${detail}\n Restore or reconcile that copy first, then re-run.`);
|
|
1477
|
+
}
|
|
1478
|
+
if (preflightRollbacks.overBudget) {
|
|
1479
|
+
const { snapshots, maxSnapshots, bytes, maxBytes } = preflightRollbacks.overBudget;
|
|
1480
|
+
console.log(`\nOVER_BUDGET: ${snapshots} retained full-KB ${snapshots === 1 ? 'copy' : 'copies'} (${bytes} bytes) exceed the budget of ${maxSnapshots} (${maxBytes} bytes). Each is measured and pinned for the reason below; this update proceeds without adding a persistent copy.`);
|
|
1481
|
+
}
|
|
1482
|
+
for (const retained of preflightRollbacks.retained) {
|
|
1483
|
+
console.log(`\nRETAINED: ${retained.path} (${retained.bytes} bytes) — ${reasonFor(retained.path)}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
const canon = await fetchJson(manifestUrl);
|
|
1487
|
+
|
|
1488
|
+
// ── CORPUS-RELEASE COMPATIBILITY GATE (ADR-086 step 16) ────────────────────────────────────────
|
|
1489
|
+
// Runs BEFORE the behind/current report, so `--check` refuses on exactly the same terms `--apply`
|
|
1490
|
+
// does, and before a single byte of bundle is fetched. Two tiers:
|
|
1491
|
+
// 1. this brain cannot prove which approved runtime it is running -> refuse, zero bandwidth;
|
|
1492
|
+
// 2. this exact tag was already refused and nothing has changed -> refuse, zero bandwidth.
|
|
1493
|
+
// The third tier (the staged bundle was built by a different runtime) can only be decided from
|
|
1494
|
+
// the downloaded bytes, and is enforced after extraction — where it also writes the ledger tier 2
|
|
1495
|
+
// reads, so a refusal costs one download ONCE rather than one download a night.
|
|
1496
|
+
const canonTag = isGithubReleasePayload(canon) ? canon.tag_name : null;
|
|
1497
|
+
const corpusRelease = isCorpusReleaseTag(canonTag);
|
|
1498
|
+
let installedRuntimeVersion = null;
|
|
1499
|
+
if (corpusRelease) {
|
|
1500
|
+
const runtime = readInstalledRuntime(KB_DIR);
|
|
1501
|
+
if (!runtime.ok) {
|
|
1502
|
+
die(`INCOMPATIBLE corpus release ${canonTag}\n`
|
|
1503
|
+
+ ` ${runtime.reason}\n`
|
|
1504
|
+
+ ` A corpus release carries knowledge for ONE approved runtime. This brain cannot prove which\n`
|
|
1505
|
+
+ ` runtime it is running, so nothing was downloaded and nothing on disk was changed.\n`
|
|
1506
|
+
+ ` Fix it with: npx ruvnet-brain (re-runs the installer, which re-stamps the approved runtime)`, 5);
|
|
1507
|
+
}
|
|
1508
|
+
installedRuntimeVersion = runtime.brainVersion;
|
|
1509
|
+
const remembered = readRejectedRelease(KB_DIR);
|
|
1510
|
+
if (remembered && remembered.tag === canonTag && remembered.installedRuntime === installedRuntimeVersion) {
|
|
1511
|
+
die(`corpus release ${canonTag} was already rejected by this brain (${remembered.rejectedUtc})\n`
|
|
1512
|
+
+ ` ${remembered.reason}\n`
|
|
1513
|
+
+ ` Nothing was downloaded. This refusal is remembered on purpose: rediscovering it every night\n`
|
|
1514
|
+
+ ` would be a download loop with extra steps. It clears itself when a compatible release is\n`
|
|
1515
|
+
+ ` published, or when this brain moves to a different runtime.\n`
|
|
1516
|
+
+ ` Fix it with: npx ruvnet-brain (installs the code release that corpus was built for)`, 5);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const activeProfile = RESTORE_COMPLETE ? 'complete' : readBrainProfile();
|
|
1521
|
+
const profileStores = selectUpdateManagedStores(stores, activeProfile);
|
|
1522
|
+
if (activeProfile === 'ruvector' && profileStores.length === 0) {
|
|
1523
|
+
die(`SOURCE.json has no ruvector store, so the selected RuVector Only profile cannot update safely.`);
|
|
1524
|
+
}
|
|
1525
|
+
const targets = ONLY ? profileStores.filter((s) => s.kbName === ONLY) : profileStores;
|
|
1526
|
+
if (ONLY && targets.length === 0) die(`SOURCE.json has no store named "${ONLY}". Known: ${stores.map((s) => s.kbName).join(', ')}`);
|
|
1527
|
+
|
|
1528
|
+
const canonLabel = canon.tag_name
|
|
1529
|
+
? `${canon.tag_name} (published ${canon.published_at || canon.created_at || '?'})`
|
|
1530
|
+
: canon.generated || canon.builtUtc || '(unknown)';
|
|
1531
|
+
console.log(`\n=== rvf-kb-forge evergreen check ===`);
|
|
1532
|
+
console.log(`canonical manifest: ${manifestUrl}`);
|
|
1533
|
+
console.log(`canonical built: ${canonLabel}\n`);
|
|
1534
|
+
|
|
1535
|
+
// ── ONE CURRENCY VERDICT (S2) ──────────────────────────────────────────────────────────────────
|
|
1536
|
+
// Computed ONCE for the whole bundle, from data already fetched — before a single byte of the
|
|
1537
|
+
// archive is downloaded. --check, --apply, and every store within a single run share this exact
|
|
1538
|
+
// decision (bundleIdentity() already established that every store in a release shares its identity;
|
|
1539
|
+
// the mixed-generation refusal a few lines below enforces that the resolved download target agrees).
|
|
1540
|
+
const installedIdentity = installedCurrencyIdentity(source);
|
|
1541
|
+
const candidateIdentity = candidateCurrencyIdentity(canon);
|
|
1542
|
+
const verdict = RESTORE_COMPLETE
|
|
1543
|
+
? { verdict: 'UPDATE_AVAILABLE', reason: '--restore-complete forces a full profile restore' }
|
|
1544
|
+
: currencyVerdict(installedIdentity, candidateIdentity);
|
|
1545
|
+
console.log(`currency verdict: ${verdict.verdict} — ${verdict.reason}\n`);
|
|
1546
|
+
|
|
1547
|
+
// REFUSED is rollback protection: the candidate is a corpus generation strictly OLDER than what is
|
|
1548
|
+
// installed. Nothing is downloaded, the live tree is untouched, and this is a clean success (exit
|
|
1549
|
+
// 0) in BOTH modes — never exit 10, which would invite --apply into refusing again.
|
|
1550
|
+
if (verdict.verdict === 'REFUSED') {
|
|
1551
|
+
console.log(`REFUSED — ${verdict.reason}`);
|
|
1552
|
+
console.log('Nothing was downloaded; the live brain is untouched.');
|
|
1553
|
+
const refusedOutcome = APPLY
|
|
1554
|
+
? writeUpdateOutcome({ terminalVerdict: 'refused', reason: verdict.reason,
|
|
1555
|
+
currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
|
|
1556
|
+
storeCount: targets.length })
|
|
1557
|
+
: writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
|
|
1558
|
+
candidateKind: candidateIdentity.kind, storeCount: targets.length });
|
|
1559
|
+
if (refusedOutcome?.terminalVerdict === 'recovery-required') die(refusedOutcome.reason);
|
|
1560
|
+
process.exit(0);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
let anyBehind = false; const behindStores = [];
|
|
1564
|
+
for (const local of targets) {
|
|
1565
|
+
const c = canonicalFor(canon, local.kbName);
|
|
1566
|
+
const behind = verdict.verdict !== 'CURRENT';
|
|
1567
|
+
anyBehind = anyBehind || behind;
|
|
1568
|
+
if (behind) {
|
|
1569
|
+
behindStores.push({ local });
|
|
1570
|
+
console.log(`[${local.kbName}] BEHIND`);
|
|
1571
|
+
console.log(` canonical: built ${c.builtUtc} from ${short(c.sourceCommit)}${c.sourceDescribe ? ` (${c.sourceDescribe})` : ''}`);
|
|
1572
|
+
console.log(` yours: built ${local.builtUtc} from ${short(local.sourceCommit)}${local.sourceDescribe ? ` (${local.sourceDescribe})` : ''}`);
|
|
1573
|
+
} else {
|
|
1574
|
+
console.log(`[${local.kbName}] UP TO DATE (built ${local.builtUtc || '?'} from ${short(local.sourceCommit)})`);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (!APPLY) {
|
|
1579
|
+
writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
|
|
1580
|
+
candidateKind: candidateIdentity.kind, storeCount: targets.length });
|
|
1581
|
+
if (anyBehind) { console.log(`\nA newer build exists. Run: node forge-update.mjs --apply`); process.exit(10); }
|
|
1582
|
+
console.log(`\nAll stores current. Nothing to do.`); process.exit(0);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (!anyBehind) {
|
|
1586
|
+
const inventoryBefore = managedStorageInventory(KB_DIR);
|
|
1587
|
+
let validateCoverageDirectory;
|
|
1588
|
+
try { validateCoverageDirectory = await loadTrustedCoverageValidator(); }
|
|
1589
|
+
catch (error) { die(`${error.message}. The live KB is untouched.`); }
|
|
1590
|
+
const installed = activeProfile === 'complete'
|
|
1591
|
+
? validateReleaseCoverageTree(KB_DIR, validateCoverageDirectory)
|
|
1592
|
+
: validateProfiledReleaseTree(KB_DIR, activeProfile, capturePrivateOverlayState({ kbDir: KB_DIR, allStores: stores }));
|
|
1593
|
+
if (!installed.valid) die(`already-current KB failed integrity: ${installed.failures.join('; ')}`);
|
|
1594
|
+
// No transaction paths are created: the inventory still counts every retained managed copy.
|
|
1595
|
+
const measuredDelta = storageDelta({ live: KB_DIR }, { prior: inventoryBefore.active, inventoryBefore });
|
|
1596
|
+
const noopOutcome = writeUpdateOutcome({ terminalVerdict: 'noop', reason: 'already-current', storeCount: targets.length,
|
|
1597
|
+
currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
|
|
1598
|
+
storageDelta: measuredDelta,
|
|
1599
|
+
phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'noop', storageDelta: measuredDelta }) });
|
|
1600
|
+
if (noopOutcome?.terminalVerdict === 'recovery-required') die(noopOutcome.reason);
|
|
1601
|
+
console.log(`\nNothing to apply — already current.`); process.exit(0);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
let validateCoverageDirectory;
|
|
1605
|
+
try { validateCoverageDirectory = await loadTrustedCoverageValidator(); }
|
|
1606
|
+
catch (error) { die(`${error.message}. The live KB is untouched.`); }
|
|
1607
|
+
|
|
1608
|
+
// What actually landed for each store, so the final message (below RECLAIM) can be built from
|
|
1609
|
+
// the real on-disk artifact instead of the `canon` lookup made at the top of this run — issue
|
|
1610
|
+
// #35 item 2. Populated by verifyLanded() as each store is applied; main() dies loudly before
|
|
1611
|
+
// reaching the summary if any store's landed copy does not check out.
|
|
1612
|
+
const landedByStore = new Map();
|
|
1613
|
+
// Stores that landed byte-identical because their upstream repo did not move. ORDINARY, and named
|
|
1614
|
+
// in the summary so "unchanged" never has to be inferred from silence (issue #108).
|
|
1615
|
+
const unchangedStores = [];
|
|
1616
|
+
// Stores whose bundle demonstrably did not move at all. Non-zero exit, counted in the summary
|
|
1617
|
+
// rather than only in the mid-log line a cron job never reads (issue #106).
|
|
1618
|
+
let privateOverlay;
|
|
1619
|
+
try { privateOverlay = capturePrivateOverlayState({ kbDir: KB_DIR, allStores: stores }); }
|
|
1620
|
+
catch (e) { die(`private overlay preflight failed: ${e.message} — refusing to update.`); }
|
|
1621
|
+
const resolvedTargets = behindStores.map(({ local }) => ({ local, resolved: resolveBundleUrl({ canon, local, source }) }));
|
|
1622
|
+
for (const { local, resolved } of resolvedTargets) {
|
|
1623
|
+
if (!resolved.url) die(`[${local.kbName}] no canonical bundle URL is resolvable from the live manifest.`);
|
|
1624
|
+
if (resolved.warning) console.warn(`\n ⚠ ${resolved.warning}`);
|
|
1625
|
+
}
|
|
1626
|
+
const bundleIdentities = new Set(resolvedTargets.map(({ resolved }) => JSON.stringify({ url: resolved.url, digest: resolved.digest || null })));
|
|
1627
|
+
if (bundleIdentities.size !== 1) {
|
|
1628
|
+
die(`selected stores resolve to divergent combined bundle identities; refusing a mixed-generation update.`);
|
|
1629
|
+
}
|
|
1630
|
+
const resolved = resolvedTargets[0].resolved;
|
|
1631
|
+
const originLabel = resolved.origin === 'latest-release-asset' ? `live release asset "${resolved.assetName}"`
|
|
1632
|
+
: resolved.origin === 'live-manifest' ? 'live manifest' : 'PINNED FALLBACK (see warning above)';
|
|
1633
|
+
console.log(`\n[${behindStores.length} store(s)] downloading ${resolved.url}\n (source: ${originLabel}) ...`);
|
|
1634
|
+
const buf = await fetchBuffer(resolved.url);
|
|
1635
|
+
const sigBuf = await fetchBuffer(`${resolved.url}.sig`, { failureCode: 3, kind: 'signature' });
|
|
1636
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-update-release-'));
|
|
1637
|
+
const zipPath = path.join(tmp, 'bundle.zip');
|
|
1638
|
+
const sigPath = path.join(tmp, 'bundle.zip.sig');
|
|
1639
|
+
const extractDir = path.join(tmp, 'extracted');
|
|
1640
|
+
fs.writeFileSync(zipPath, buf);
|
|
1641
|
+
fs.writeFileSync(sigPath, sigBuf);
|
|
1642
|
+
fs.mkdirSync(extractDir);
|
|
1643
|
+
console.log(` downloaded ${(buf.length / 1e6).toFixed(1)} MB.`);
|
|
1644
|
+
const signature = verifyDownloadedBundle(zipPath, sigPath);
|
|
1645
|
+
if (!signature.ok) { fs.rmSync(tmp, { recursive: true, force: true }); die(`✗ SIGNATURE VERIFICATION FAILED: ${signature.reason}`, 4); }
|
|
1646
|
+
console.log(` ✓ signature verified — ${signature.reason}`);
|
|
1647
|
+
try { await extractZip(zipPath, extractDir); }
|
|
1648
|
+
catch (error) { fs.rmSync(tmp, { recursive: true, force: true }); die(`extraction failed: ${error.message} — local files untouched.`); }
|
|
1649
|
+
// ── TIER 3: the staged bundle names the runtime that built it; this client names the runtime it
|
|
1650
|
+
// measurably runs. They must be equal, or this corpus is not for this brain. Refused BEFORE the
|
|
1651
|
+
// storage transaction, so the live tree is never touched, and REMEMBERED so the next run refuses
|
|
1652
|
+
// without downloading again.
|
|
1653
|
+
if (corpusRelease) {
|
|
1654
|
+
let stagedRuntimeVersion = null;
|
|
1655
|
+
try { stagedRuntimeVersion = JSON.parse(fs.readFileSync(path.join(extractDir, 'SOURCE.json'), 'utf8')).brainVersion || null; }
|
|
1656
|
+
catch (error) {
|
|
1657
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1658
|
+
die(`corpus release ${canonTag} has no readable SOURCE.json: ${error.message} — local files untouched.`);
|
|
1659
|
+
}
|
|
1660
|
+
const compatible = assertCorpusReleaseCompatible({ kbDir: KB_DIR, offeredRuntimeVersion: stagedRuntimeVersion });
|
|
1661
|
+
if (!compatible.ok) {
|
|
1662
|
+
writeRejectedRelease(KB_DIR, { tag: canonTag, reason: compatible.reason, installedRuntime: installedRuntimeVersion });
|
|
1663
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1664
|
+
die(`INCOMPATIBLE corpus release ${canonTag}\n`
|
|
1665
|
+
+ ` ${compatible.reason}\n`
|
|
1666
|
+
+ ` Nothing was installed and the live brain is untouched. Installing it would have put knowledge\n`
|
|
1667
|
+
+ ` built for a different runtime behind this one's reader.\n`
|
|
1668
|
+
+ ` This refusal is now remembered, so tonight's check will not download it again.\n`
|
|
1669
|
+
+ ` Fix it with: npx ruvnet-brain (installs the code release that corpus was built for)`, 5);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
const stagedCoverage = validateReleaseCoverageTree(extractDir, validateCoverageDirectory, installedRuntimeVersion);
|
|
1673
|
+
if (!stagedCoverage.valid) {
|
|
1674
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1675
|
+
die(`staged ReleaseCoverage failed integrity: ${stagedCoverage.failures.join('; ')} — local files untouched.`);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const privateNames = Object.keys(privateOverlay?.sourceStores || {});
|
|
1679
|
+
let profileResult = null;
|
|
1680
|
+
const finalVerificationByStore = new Map();
|
|
1681
|
+
const validateFinalTree = ({ dir, phase }) => {
|
|
1682
|
+
const coverageResult = activeProfile === 'complete'
|
|
1683
|
+
? validateReleaseCoverageTree(dir, validateCoverageDirectory, installedRuntimeVersion)
|
|
1684
|
+
: validateProfiledReleaseTree(dir, activeProfile, privateOverlay);
|
|
1685
|
+
if (!coverageResult.valid) return coverageResult;
|
|
1686
|
+
const guard = path.join(dir, 'forge-guard.mjs');
|
|
1687
|
+
if (!fs.existsSync(guard)) return { valid: false, failures: ['forge-guard.mjs is missing'] };
|
|
1688
|
+
try {
|
|
1689
|
+
for (const { local, resolved: storeResolution } of resolvedTargets) {
|
|
1690
|
+
execFileSync(process.execPath, [guard, '--dir', dir, '--name', local.kbName], { cwd: dir, stdio: 'pipe' });
|
|
1691
|
+
const verified = verifyLanded({ kbDir: dir, kbName: local.kbName, before: local, beforeBundle: source,
|
|
1692
|
+
expectedDigest: storeResolution.digest, downloadedBuffer: buf });
|
|
1693
|
+
if (!verified.ok && verified.kind !== 'noop') return { valid: false, failures: [verified.reason] };
|
|
1694
|
+
if (phase === 'live') finalVerificationByStore.set(local.kbName, verified);
|
|
1695
|
+
}
|
|
1696
|
+
return { valid: true, failures: [] };
|
|
1697
|
+
} catch (error) { return { valid: false, failures: [`forge-guard failed: ${error.message}`] }; }
|
|
1698
|
+
};
|
|
1699
|
+
let transaction;
|
|
1700
|
+
try {
|
|
1701
|
+
// The installer starts this child inside KB_DIR. Windows holds that directory open
|
|
1702
|
+
// until cwd leaves it, preventing the atomic swap. Inputs (including RESULT_FILE)
|
|
1703
|
+
// are already resolved; all transaction and recovery paths remain absolute.
|
|
1704
|
+
const cwdWithinKb = path.relative(KB_DIR, process.cwd());
|
|
1705
|
+
if (cwdWithinKb === '' || (!path.isAbsolute(cwdWithinKb)
|
|
1706
|
+
&& cwdWithinKb !== '..' && !cwdWithinKb.startsWith(`..${path.sep}`))) {
|
|
1707
|
+
process.chdir(path.dirname(KB_DIR));
|
|
1708
|
+
}
|
|
1709
|
+
transaction = runStorageTransaction({ liveDir: KB_DIR, sourceDir: extractDir,
|
|
1710
|
+
transactionId: `${Date.now()}-${process.pid}`,
|
|
1711
|
+
prepareCandidate: ({ candidateDir, liveDir }) => {
|
|
1712
|
+
// The trusted coverage validator is installer-provided and never ships inside the bundle it
|
|
1713
|
+
// judges (build-bundle cannot see this file's dynamic load). Carry the LIVE copy into the
|
|
1714
|
+
// candidate — never the bundle's: a promoted generation without it strands the next --apply
|
|
1715
|
+
// on "installed coverage validator is missing", and a byte-identical bundle would stop
|
|
1716
|
+
// reading as a no-op merely because live holds the one file the bundle cannot. Measured
|
|
1717
|
+
// 2026-09-12: every 4.3.21 brain lacked it, so this branch had never once run to completion.
|
|
1718
|
+
const liveValidator = path.join(liveDir, 'coverage-integrity.mjs');
|
|
1719
|
+
if (fs.existsSync(liveValidator)) {
|
|
1720
|
+
fs.copyFileSync(assertNoFollowPath(liveDir, liveValidator),
|
|
1721
|
+
assertNoFollowPath(candidateDir, path.join(candidateDir, 'coverage-integrity.mjs')));
|
|
1722
|
+
}
|
|
1723
|
+
// RUNTIME-IDENTITY.json is installer-written and, like the validator above, never ships
|
|
1724
|
+
// inside a bundle — so an exact-tree promotion would DELETE it and the very next corpus
|
|
1725
|
+
// check would refuse with "no installed runtime identity". Same lesson, same fix: carry the
|
|
1726
|
+
// LIVE copy into the candidate. (It pins coverage-integrity.mjs, which was just carried
|
|
1727
|
+
// across unchanged, so the pin still verifies on the promoted tree.)
|
|
1728
|
+
const liveRuntimeIdentity = path.join(liveDir, 'RUNTIME-IDENTITY.json');
|
|
1729
|
+
if (fs.existsSync(liveRuntimeIdentity)) {
|
|
1730
|
+
fs.copyFileSync(assertNoFollowPath(liveDir, liveRuntimeIdentity),
|
|
1731
|
+
assertNoFollowPath(candidateDir, path.join(candidateDir, 'RUNTIME-IDENTITY.json')));
|
|
1732
|
+
}
|
|
1733
|
+
// node_modules (the ONNX embedder and RVF readers) is installer-placed and never ships inside a
|
|
1734
|
+
// bundle, the same class as the two files above. The candidate is validated by forge-guard in a
|
|
1735
|
+
// SIBLING directory with no parent node_modules, so without it the guard cannot load the
|
|
1736
|
+
// embedder and every apply fails; and the exact-tree swap would delete it from the live KB.
|
|
1737
|
+
// Reflink clone where the filesystem supports it (APFS/btrfs), plain copy otherwise.
|
|
1738
|
+
const liveModules = path.join(liveDir, 'node_modules');
|
|
1739
|
+
if (fs.existsSync(liveModules) && !fs.existsSync(path.join(candidateDir, 'node_modules'))) {
|
|
1740
|
+
fs.cpSync(assertNoFollowPath(liveDir, liveModules), path.join(candidateDir, 'node_modules'),
|
|
1741
|
+
{ recursive: true, verbatimSymlinks: true, mode: fs.constants.COPYFILE_FICLONE });
|
|
1742
|
+
}
|
|
1743
|
+
restorePrivateFilesIntoCandidate({ candidateDir, sourceDir: liveDir, overlay: privateOverlay });
|
|
1744
|
+
// ATOMIC WITH INSTALLATION, not after it. The transport identity is written INTO the
|
|
1745
|
+
// candidate, so the storage transaction's single rename either promotes the bytes AND the
|
|
1746
|
+
// record of where they came from, or promotes neither. A crash here cannot leave a tree
|
|
1747
|
+
// whose contents and whose declared provenance disagree — which is the whole reason this is
|
|
1748
|
+
// not a second write against the live tree once the swap has happened.
|
|
1749
|
+
if (canonTag) {
|
|
1750
|
+
recordCorpusTransportIdentity(candidateDir, { releaseTag: canonTag });
|
|
1751
|
+
// S2: the generation ordering key is stamped ATOMICALLY alongside the transport tag — same
|
|
1752
|
+
// candidate directory, same single rename into place. A crash between the two can never
|
|
1753
|
+
// leave a tree whose transport tag and generation ordering key disagree.
|
|
1754
|
+
recordCorpusGenerationIdentity(candidateDir, { corpusReleaseTag: canonTag,
|
|
1755
|
+
generation: candidateIdentity.corpusGeneration });
|
|
1756
|
+
}
|
|
1757
|
+
const fullCoverage = validateReleaseCoverageTree(candidateDir, validateCoverageDirectory, installedRuntimeVersion);
|
|
1758
|
+
if (!fullCoverage.valid) throw new Error(`candidate public/private convergence failed: ${fullCoverage.failures.join('; ')}`);
|
|
1759
|
+
if (activeProfile !== 'complete') profileResult = applyBrainProfile(candidateDir, activeProfile, { preserveStores: privateNames });
|
|
1760
|
+
},
|
|
1761
|
+
validateCandidate: validateFinalTree,
|
|
1762
|
+
validateLive: validateFinalTree,
|
|
1763
|
+
});
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1766
|
+
die(`storage transaction failed: ${error.message}`);
|
|
1767
|
+
}
|
|
1768
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1769
|
+
console.log(` storage transaction: ${transaction.terminalVerdict}`);
|
|
1770
|
+
if (transaction.terminalVerdict === 'cleanup-pending') {
|
|
1771
|
+
const bundleSha256 = createHash('sha256').update(buf).digest('hex');
|
|
1772
|
+
const cleanupOutcome = writeUpdateOutcome({ terminalVerdict: 'cleanup-pending', storageDelta: transaction.storageDelta,
|
|
1773
|
+
transactionReceipts: transaction.paths.receipts, bundleSha256,
|
|
1774
|
+
phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'cleanup-pending', bundleSha256,
|
|
1775
|
+
transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
|
|
1776
|
+
storageDelta: transaction.storageDelta }) });
|
|
1777
|
+
if (cleanupOutcome?.terminalVerdict === 'recovery-required') die(cleanupOutcome.reason);
|
|
1778
|
+
console.error('\nVerified live generation is active, but redundant rollback cleanup is pending.');
|
|
1779
|
+
process.exitCode = 12;
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
for (const { local, resolved: storeResolution } of resolvedTargets) {
|
|
1783
|
+
const verified = finalVerificationByStore.get(local.kbName)
|
|
1784
|
+
|| verifyLanded({ kbDir: KB_DIR, kbName: local.kbName, before: local, beforeBundle: source,
|
|
1785
|
+
expectedDigest: storeResolution.digest, downloadedBuffer: buf });
|
|
1786
|
+
if (verified.storeUnchanged || verified.kind === 'noop') unchangedStores.push(local.kbName);
|
|
1787
|
+
landedByStore.set(local.kbName, { landed: verified.landed, origin: storeResolution.origin, assetName: storeResolution.assetName });
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
const intentionallyRemovedStores = profileResult?.removedStores || [];
|
|
1791
|
+
if (profileResult) {
|
|
1792
|
+
console.log(`\nprofile ${activeProfile}: kept ${profileResult.stores.join(', ')}; removed ${profileResult.removed.length} unselected artifact(s).`);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// ── RECLAIM THE ROLLBACK COPY (issue #35, Dr. Mark Allen) ──────────────────────────────────────
|
|
1796
|
+
// The rollback copy exists to survive the SWAP, not to live on disk forever. Every update used to
|
|
1797
|
+
// leave a full ~2.5 GB copy behind and never remove it; Mark accumulated SEVEN (~14 GB) before
|
|
1798
|
+
// noticing. By this point forge-guard has PROVEN the new copy answers, and the bundle it came from
|
|
1799
|
+
// is a signed, versioned, re-downloadable artifact — so the old copy is dead weight. Released here,
|
|
1800
|
+
// and any copies stranded by earlier runs are swept with it.
|
|
1801
|
+
//
|
|
1802
|
+
// THE ONE CASE WHERE IT IS NOT DEAD WEIGHT, and why this is a check and not an `rm`: a KB can hold
|
|
1803
|
+
// stores the public bundle does not ship (private/local ones). The update replaces the directory, so
|
|
1804
|
+
// if such a store is absent from the new copy, the backup is its ONLY remaining copy — and
|
|
1805
|
+
// forge-guard would still pass, because it verifies the store it was asked about, not whatever went
|
|
1806
|
+
// missing. Deleting there would destroy the only copy of a user's private data. So: compare store
|
|
1807
|
+
// inventories first, and keep any backup holding something the new copy lost.
|
|
1808
|
+
//
|
|
1809
|
+
// Routed through settleRollback() so this is the SAME release the failure paths use, rather than a
|
|
1810
|
+
// happy-path-only call that a die() can step over — that step-over is issue #108.
|
|
1811
|
+
settleRollback({ reclaimable: true, intentionallyRemovedStores });
|
|
1812
|
+
|
|
1813
|
+
// A release actually landed, so any remembered refusal is spent history — never a permanent
|
|
1814
|
+
// blocklist. Clearing it here (and only here) means the ledger can only ever suppress a repeat of
|
|
1815
|
+
// the exact refusal that produced it.
|
|
1816
|
+
clearRejectedRelease(KB_DIR);
|
|
1817
|
+
|
|
1818
|
+
// ── FINAL MESSAGE — DERIVED FROM WHAT LANDED, NOT FROM THE TAG LOOKUP (issue #35 item 2) ────────
|
|
1819
|
+
// The old line above printed `canon.tag_name` regardless of what the download actually contained
|
|
1820
|
+
// — that is verbatim the bug: "printed 'KB updated to the canonical build (v3.4.21-dev)' [...]
|
|
1821
|
+
// SOURCE.json still said v0.5.0-dev afterward." Every store reaching this line already passed
|
|
1822
|
+
// verifyLanded() above (main() dies before this point otherwise), so what follows is read back
|
|
1823
|
+
// from the real file on disk, not asserted.
|
|
1824
|
+
console.log(transaction.terminalVerdict === 'noop'
|
|
1825
|
+
? `\n=== DONE — exact no-op; installed bytes already equal the validated candidate ===`
|
|
1826
|
+
: `\n=== DONE — ${behindStores.length} store(s) updated ===`);
|
|
1827
|
+
console.log(`resolved target (live manifest, checked BEFORE downloading): ${canonLabel}`);
|
|
1828
|
+
for (const { local } of behindStores) {
|
|
1829
|
+
const r = landedByStore.get(local.kbName);
|
|
1830
|
+
const l = r.landed;
|
|
1831
|
+
console.log(`[${local.kbName}] SOURCE.json on disk now reads: built ${l.builtUtc || '?'} from ${short(l.sourceCommit)}${l.sourceDescribe ? ` (${l.sourceDescribe})` : ''}`);
|
|
1832
|
+
console.log(` fetched from: ${r.origin === 'latest-release-asset' ? `release asset "${r.assetName}"` : r.origin === 'live-manifest' ? 'live manifest entry' : 'PINNED FALLBACK — see warning above'}`);
|
|
1833
|
+
}
|
|
1834
|
+
if (unchangedStores.length) {
|
|
1835
|
+
// NAMED, not silent. Stores are forged independently, so a store whose upstream repo did not
|
|
1836
|
+
// move is re-shipped byte-identical inside a genuinely new bundle — normal, and the thing that
|
|
1837
|
+
// used to abort the whole run (issue #108).
|
|
1838
|
+
console.log(`\n${unchangedStores.length} of ${behindStores.length} store(s) were already at the canonical build and did not change: ${unchangedStores.join(', ')}`);
|
|
1839
|
+
console.log(` (stores are forged independently — an unchanged store means its upstream repo did not move, not a failed update.)`);
|
|
1840
|
+
}
|
|
1841
|
+
const finalOutcome = writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length,
|
|
1842
|
+
currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
|
|
1843
|
+
storageDelta: transaction.storageDelta,
|
|
1844
|
+
transactionReceipts: transaction.paths.receipts,
|
|
1845
|
+
bundleSha256: createHash('sha256').update(buf).digest('hex'),
|
|
1846
|
+
coverageSha256: sha256File(path.join(KB_DIR, 'COVERAGE.json')),
|
|
1847
|
+
phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: transaction.terminalVerdict,
|
|
1848
|
+
bundleSha256: createHash('sha256').update(buf).digest('hex'),
|
|
1849
|
+
transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
|
|
1850
|
+
storageDelta: transaction.storageDelta }) });
|
|
1851
|
+
if (finalOutcome?.terminalVerdict === 'recovery-required') die(finalOutcome.reason);
|
|
1852
|
+
console.log(`\n(the above is read back from disk, verified — not a tag lookup)`);
|
|
1853
|
+
process.exit(0);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// Run ONLY when executed directly. `reclaimBackups` is exported for its own tests, and without this
|
|
1857
|
+
// guard merely importing this file would start a live update — a network fetch, a directory swap, and
|
|
1858
|
+
// a process.exit() inside whatever imported it. (Found exactly that way: the reclaim test's import
|
|
1859
|
+
// began racing a real update against the test run.)
|
|
1860
|
+
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1861
|
+
if (invokedDirectly && STAGED_RELEASE_FILE) {
|
|
1862
|
+
(async () => {
|
|
1863
|
+
const input = JSON.parse(fs.readFileSync(STAGED_RELEASE_FILE, 'utf8'));
|
|
1864
|
+
const result = await applyVerifiedStagedRelease(input);
|
|
1865
|
+
console.log(JSON.stringify({ schemaVersion: 1, kind: 'ruvnet-brain-staged-recovery', ...result }));
|
|
1866
|
+
})().catch((e) => die(`staged recovery failed: ${e.message}`));
|
|
1867
|
+
} else if (invokedDirectly) main().catch((e) => die(`unexpected: ${e.message}`));
|