ruvnet-brain 4.7.22 → 4.7.24
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 +1 -1
- package/bin/install.mjs +59 -1
- package/kb/lifecycle-evidence-retention.mjs +1 -1
- package/kb/update-storage-transaction.mjs +161 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/brain-footprint.mjs +5 -1
- package/plugin/scripts/learn-capture.mjs +26 -3
- package/scripts/generation-census.mjs +220 -0
- package/scripts/issue-autofix-agents.mjs +172 -0
- package/scripts/issue-autofix-comment.mjs +224 -0
- package/scripts/issue-autofix-diff.mjs +108 -0
- package/scripts/issue-autofix-exec.mjs +128 -0
- package/scripts/issue-autofix-fix.mjs +104 -0
- package/scripts/issue-autofix-gate.mjs +137 -0
- package/scripts/issue-autofix-night.mjs +164 -0
- package/scripts/issue-autofix-notes.mjs +346 -0
- package/scripts/issue-autofix-policy.mjs +349 -0
- package/scripts/issue-autofix-prompts.mjs +105 -0
- package/scripts/issue-autofix-release-ship.mjs +226 -0
- package/scripts/issue-autofix-release-util.mjs +141 -0
- package/scripts/issue-autofix-release.mjs +272 -0
- package/scripts/issue-autofix-report.mjs +121 -0
- package/scripts/issue-autofix-runner.mjs +75 -0
- package/scripts/issue-autofix-sandbox.mjs +108 -0
- package/scripts/issue-autofix-security.mjs +346 -0
- package/scripts/issue-autofix-selftest.mjs +101 -0
- package/scripts/issue-autofix-state.mjs +61 -0
- package/scripts/issue-autofix.mjs +382 -0
- package/scripts/issue-fix-lock.mjs +40 -0
- package/scripts/issue-fix.mjs +33 -20
- package/scripts/no-silent-substitution.mjs +1 -1
- package/scripts/ntfy-inbox.mjs +15 -0
- package/scripts/onboarding-console.mjs +6 -1
- package/scripts/release-prepush-checks.mjs +251 -0
- package/scripts/release-prepush.mjs +168 -0
- package/scripts/release-qualification-contract.mjs +32 -0
- package/scripts/wired-check.mjs +11 -5
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
9
9
|
|
|
10
10
|
### Discover the stack. Read the source. Build with confidence.
|
|
11
11
|
|
|
12
|
-
[](plugin/.claude-plugin/plugin.json)
|
|
13
13
|
|
|
14
14
|
**A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
|
|
15
15
|
|
package/bin/install.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
|
|
|
25
25
|
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
|
|
26
26
|
recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
|
|
27
27
|
import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
28
|
-
import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
28
|
+
import { checkDiskSpace, disposeRecoveryRequired, previewRecoveryRequired, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
29
29
|
import { saveUpdateSource, stableNode } from '../plugin/scripts/automatic-update.mjs';
|
|
30
30
|
import { acquireDeveloperLock } from '../plugin/scripts/developer-update-lock.mjs';
|
|
31
31
|
import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
@@ -172,6 +172,13 @@ const MOVE_BRAIN_TO = (() => { const i = argv.indexOf('--move-brain'); const v =
|
|
|
172
172
|
const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
|
|
173
173
|
const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
|
|
174
174
|
const FLAG_ARCHIVE = argv.includes('--archive'); // with --clean only (#335): archive files unique to old KB copies, then release them
|
|
175
|
+
// --recover-update (#335): show every update transaction stuck at RECOVERY_REQUIRED, changing nothing.
|
|
176
|
+
// --recover-update --keep-live <sha256> [--transaction <id>]: keep that exact live tree, move the
|
|
177
|
+
// transaction's other trees intact into .kb.recovered/<id>/, and record a terminal DISPOSED receipt.
|
|
178
|
+
const FLAG_RECOVER_UPDATE = argv.includes('--recover-update');
|
|
179
|
+
const argValue = (flag) => { const i = argv.indexOf(flag); const v = i === -1 ? null : argv[i + 1]; return v && !v.startsWith('-') ? v : null; };
|
|
180
|
+
const RECOVER_KEEP_LIVE = argValue('--keep-live');
|
|
181
|
+
const RECOVER_TRANSACTION = argValue('--transaction');
|
|
175
182
|
// ── onboarding-experience flags (all optional; every offer is safe to decline) ──
|
|
176
183
|
const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
|
|
177
184
|
const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
|
|
@@ -4072,6 +4079,50 @@ export function acquireHostConvergenceLock(lockPath, {
|
|
|
4072
4079
|
return { acquired: false, error: 'host synchronization lock changed concurrently' };
|
|
4073
4080
|
}
|
|
4074
4081
|
|
|
4082
|
+
/**
|
|
4083
|
+
* `--recover-update` (#335). Preview is read-only; disposition needs the exact live sha256 the preview
|
|
4084
|
+
* printed, so nothing is decided for the operator. Returns the exit code.
|
|
4085
|
+
*/
|
|
4086
|
+
export function runRecoverUpdate({ kbDir = physicalPath(resolvedKbDir()), keepLive = RECOVER_KEEP_LIVE,
|
|
4087
|
+
transaction = RECOVER_TRANSACTION, json = FLAG_JSON, log = console.log } = {}) {
|
|
4088
|
+
const stuck = previewRecoveryRequired(kbDir);
|
|
4089
|
+
const short = (identity) => identity?.sha256 ? `${identity.sha256} (${identity.fileCount} files, ${identity.bytes} bytes)` : 'absent';
|
|
4090
|
+
if (!keepLive) {
|
|
4091
|
+
if (json) log(JSON.stringify({ kbDir, stuck }, null, 2));
|
|
4092
|
+
else if (!stuck.length) log('No update is stuck at RECOVERY_REQUIRED. Nothing to recover.');
|
|
4093
|
+
else {
|
|
4094
|
+
for (const t of stuck) {
|
|
4095
|
+
log(`Update ${t.transactionId} needs recovery: ${t.reason || 'no reason recorded'}`);
|
|
4096
|
+
log(` sealed prior: ${short(t.sealed.prior)}`);
|
|
4097
|
+
log(` sealed candidate: ${short(t.sealed.candidate)}`);
|
|
4098
|
+
for (const [name, tree] of Object.entries(t.trees)) {
|
|
4099
|
+
const match = tree.matchesPrior ? ' = sealed prior' : tree.matchesCandidate ? ' = sealed candidate' : tree.exists ? ' matches no sealed identity' : '';
|
|
4100
|
+
log(` ${name.padEnd(9)} ${tree.exists ? short(tree.identity) : 'absent'}${match}`);
|
|
4101
|
+
}
|
|
4102
|
+
log(` To keep the live tree above and close this update (other trees are moved, never deleted):`);
|
|
4103
|
+
log(` npx ruvnet-brain --recover-update --transaction ${t.transactionId} --keep-live ${t.trees.live.identity?.sha256 || '<live sha256>'}`);
|
|
4104
|
+
}
|
|
4105
|
+
log('Nothing was changed.');
|
|
4106
|
+
}
|
|
4107
|
+
return stuck.length ? 1 : 0;
|
|
4108
|
+
}
|
|
4109
|
+
const targets = transaction ? stuck.filter((t) => t.transactionId === transaction) : stuck;
|
|
4110
|
+
if (!targets.length) { log(transaction ? `No stuck update named ${transaction}.` : 'No update is stuck at RECOVERY_REQUIRED.'); return 1; }
|
|
4111
|
+
if (targets.length > 1) { log('More than one update is stuck; name one with --transaction <id>. Nothing was changed.'); return 1; }
|
|
4112
|
+
try {
|
|
4113
|
+
const result = disposeRecoveryRequired(kbDir, targets[0].transactionId, { keepLiveSha256: keepLive });
|
|
4114
|
+
if (json) log(JSON.stringify(result, null, 2));
|
|
4115
|
+
else {
|
|
4116
|
+
log(`Closed update ${result.transactionId}: kept the live tree ${result.live.sha256}`
|
|
4117
|
+
+ ` (${result.liveMatchesPrior ? 'it equals the sealed prior' : result.liveMatchesCandidate ? 'it equals the sealed candidate' : 'it matches no sealed identity'}).`);
|
|
4118
|
+
for (const p of result.preserved) log(` moved ${p.tree} intact to ${p.to}`);
|
|
4119
|
+
if (!result.preserved.length) log(' the update owned no other tree.');
|
|
4120
|
+
log('Run npx ruvnet-brain --update next; nothing was deleted.');
|
|
4121
|
+
}
|
|
4122
|
+
return 0;
|
|
4123
|
+
} catch (error) { log(`Not recovered: ${error.message}. Nothing was deleted.`); return 1; }
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4075
4126
|
export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
4076
4127
|
sourceRoot = REPO_ROOT,
|
|
4077
4128
|
brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
|
|
@@ -6829,6 +6880,12 @@ Usage:
|
|
|
6829
6880
|
footprint within budget, no cruft. --doctor --json prints only that, as JSON.
|
|
6830
6881
|
npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
|
|
6831
6882
|
private files the live brain lacks — old installers, over-cap logs), then confirm.
|
|
6883
|
+
npx ruvnet-brain --recover-update
|
|
6884
|
+
Read-only: list any update stuck at RECOVERY_REQUIRED, with the measured
|
|
6885
|
+
identity of the live tree and every tree that update owns.
|
|
6886
|
+
npx ruvnet-brain --recover-update --keep-live <sha256> [--transaction <id>]
|
|
6887
|
+
Keep that exact live tree, move the update's other trees intact into
|
|
6888
|
+
.kb.recovered/<id>/ (nothing is deleted) and close the update for good.
|
|
6832
6889
|
npx ruvnet-brain --clean --archive
|
|
6833
6890
|
Same, and for a kept copy: copy only the files it alone holds into
|
|
6834
6891
|
kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
|
|
@@ -6942,6 +6999,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6942
6999
|
// and `echo $?` → 0 coexisted for so long.
|
|
6943
7000
|
// `--doctor --json`: the positive-confirmation object ONLY (no narration), exit 0 iff every line that
|
|
6944
7001
|
// can be proven here is green — for scripts, CI, and agents (ADR-0098).
|
|
7002
|
+
if (FLAG_RECOVER_UPDATE) { process.exitCode = runRecoverUpdate(); return; }
|
|
6945
7003
|
if (FLAG_DOCTOR) { process.exitCode = await doctor({ json: FLAG_JSON }); return; }
|
|
6946
7004
|
// `--clean`: enforce the footprint guarantee now, then confirm. Exit 0 only when exactly one KB copy
|
|
6947
7005
|
// remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { physicalPath } from './refresh-run.mjs';
|
|
5
5
|
|
|
6
6
|
const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
|
|
7
|
-
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
7
|
+
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']);
|
|
8
8
|
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
9
9
|
const PHASE_FILE = /^\d{3}-[A-Z_]+\.json$/;
|
|
10
10
|
const QUARANTINE = /^\.prune-[A-Za-z0-9._-]+-[a-f0-9]{16}$/;
|
|
@@ -15,8 +15,13 @@ const STATES = Object.freeze([
|
|
|
15
15
|
'COMMITTED',
|
|
16
16
|
'ROLLED_BACK',
|
|
17
17
|
'RECOVERY_REQUIRED',
|
|
18
|
+
'DISPOSED',
|
|
18
19
|
]);
|
|
19
20
|
|
|
21
|
+
// Terminal states: recovery never acts on a transaction whose newest receipt is one of these.
|
|
22
|
+
// DISPOSED is the operator's explicit, evidence-preserving end to a RECOVERY_REQUIRED transaction (#335).
|
|
23
|
+
export const TERMINAL_TRANSACTION_STATES = Object.freeze(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']);
|
|
24
|
+
|
|
20
25
|
// ── DISK-SPACE PREFLIGHT ──────────────────────────────────────────────────────────────────────────
|
|
21
26
|
// An apply needs room for the unpacked bundle (temp), the candidate generation beside the live one
|
|
22
27
|
// (the bundle plus the live node_modules carried into it) and its receipts; measured ~3.3 GB growth and
|
|
@@ -337,6 +342,7 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
337
342
|
const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
|
|
338
343
|
if (!phaseFiles.length) throw new Error(`storage transaction receipt is empty: ${receipts}`);
|
|
339
344
|
const latest = JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
|
|
345
|
+
if (latest.state === 'DISPOSED') continue;
|
|
340
346
|
if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) {
|
|
341
347
|
// A quarantined unsealed candidate is kept for ONE full update cycle, then released on the next
|
|
342
348
|
// run — but only if its bytes are exactly what was sealed when it was quarantined.
|
|
@@ -354,7 +360,10 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
354
360
|
}
|
|
355
361
|
continue;
|
|
356
362
|
}
|
|
357
|
-
if (latest.state === 'RECOVERY_REQUIRED')
|
|
363
|
+
if (latest.state === 'RECOVERY_REQUIRED') {
|
|
364
|
+
throw new Error(`storage transaction requires manual recovery: ${transactionId} `
|
|
365
|
+
+ `(preview with npx ruvnet-brain --recover-update ; nothing is changed by the preview)`);
|
|
366
|
+
}
|
|
358
367
|
const paths = latest.paths;
|
|
359
368
|
const expectedPaths = transactionPaths(live, transactionId);
|
|
360
369
|
if (!paths || ['live', 'candidate', 'rollback', 'failed'].some((name) =>
|
|
@@ -567,3 +576,154 @@ export function runStorageTransaction({
|
|
|
567
576
|
record('COMMITTED', { terminalVerdict: 'applied', prior, candidate, storageDelta: delta });
|
|
568
577
|
return { terminalVerdict: 'applied', prior, candidate, paths, storageDelta: delta };
|
|
569
578
|
}
|
|
579
|
+
|
|
580
|
+
// ── OPERATOR RECOVERY FOR A RECOVERY_REQUIRED TRANSACTION (#335) ──────────────────────────────────
|
|
581
|
+
// Automatic recovery stops at RECOVERY_REQUIRED on purpose: the trees no longer match what the receipts
|
|
582
|
+
// sealed, so no receipt can say which bytes are disposable. These two functions give the operator a
|
|
583
|
+
// supported way out without weakening that check:
|
|
584
|
+
// 1. previewRecoveryRequired() measures every tree the transaction owns and compares each with the
|
|
585
|
+
// identities its receipts sealed. It changes nothing.
|
|
586
|
+
// 2. disposeRecoveryRequired() keeps the CURRENT live tree in service only when the operator names its
|
|
587
|
+
// exact sha256 from the preview, moves every other tree the transaction owns intact into
|
|
588
|
+
// `.<kb>.recovered/<transaction>/` (nothing is deleted), proves each moved tree is byte-identical
|
|
589
|
+
// after the move, and records a terminal DISPOSED receipt with what was measured. It never infers
|
|
590
|
+
// that live equals the prior sealed tree: it records whether it does.
|
|
591
|
+
|
|
592
|
+
const SAFE_TRANSACTION_ID = /^[A-Za-z0-9._-]+$/;
|
|
593
|
+
const OWNED_TREES = Object.freeze(['candidate', 'rollback', 'failed']);
|
|
594
|
+
|
|
595
|
+
function receiptsRootFor(live) {
|
|
596
|
+
return path.join(path.dirname(live), `.${path.basename(live)}.update-transactions`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Where a disposition keeps the trees it moved. Sibling of the live tree, so a move is a rename. */
|
|
600
|
+
export function recoveredRootFor(liveDir) {
|
|
601
|
+
const live = path.resolve(liveDir);
|
|
602
|
+
return path.join(path.dirname(live), `.${path.basename(live)}.recovered`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function latestReceipt(receipts) {
|
|
606
|
+
const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
|
|
607
|
+
if (!phaseFiles.length) return null;
|
|
608
|
+
return JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function sealedReceipts(receipts) {
|
|
612
|
+
// Every identity any receipt of this transaction sealed, newest last.
|
|
613
|
+
const out = { prior: null, candidate: null };
|
|
614
|
+
for (const name of fs.readdirSync(receipts).filter((n) => /^\d{3}-[A-Z_]+\.json$/.test(n)).sort()) {
|
|
615
|
+
try {
|
|
616
|
+
const r = JSON.parse(fs.readFileSync(path.join(receipts, name), 'utf8'));
|
|
617
|
+
if (r.prior?.sha256) out.prior = r.prior;
|
|
618
|
+
if (r.candidate?.sha256) out.candidate = r.candidate;
|
|
619
|
+
} catch { /* an unreadable receipt seals nothing */ }
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const sameIdentity = (a, b) => Boolean(a?.sha256 && b?.sha256 && a.sha256 === b.sha256
|
|
625
|
+
&& a.bytes === b.bytes && a.fileCount === b.fileCount);
|
|
626
|
+
|
|
627
|
+
function measureTree(dir, sealed) {
|
|
628
|
+
let st = null;
|
|
629
|
+
try { st = fs.lstatSync(dir); } catch { /* absent */ }
|
|
630
|
+
if (!st) return { path: dir, exists: false, identity: null, matchesPrior: false, matchesCandidate: false };
|
|
631
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
632
|
+
return { path: dir, exists: true, directory: false, identity: null, matchesPrior: false, matchesCandidate: false };
|
|
633
|
+
}
|
|
634
|
+
const identity = identitySummary(treeIdentity(dir));
|
|
635
|
+
return { path: dir, exists: true, directory: true, identity,
|
|
636
|
+
matchesPrior: sameIdentity(identity, sealed.prior), matchesCandidate: sameIdentity(identity, sealed.candidate) };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Mutation-free report of every transaction whose newest receipt is RECOVERY_REQUIRED.
|
|
641
|
+
* @returns {Array<{transactionId:string, reason:string|null, recordedAt:string|null,
|
|
642
|
+
* sealed:{prior:object|null,candidate:object|null}, trees:Record<string,object>}>}
|
|
643
|
+
*/
|
|
644
|
+
export function previewRecoveryRequired(liveDir) {
|
|
645
|
+
const live = path.resolve(liveDir);
|
|
646
|
+
const root = receiptsRootFor(live);
|
|
647
|
+
if (!fs.existsSync(root)) return [];
|
|
648
|
+
const out = [];
|
|
649
|
+
for (const transactionId of fs.readdirSync(root).sort()) {
|
|
650
|
+
if (!SAFE_TRANSACTION_ID.test(transactionId) || transactionId.startsWith('.')) continue;
|
|
651
|
+
const receipts = path.join(root, transactionId);
|
|
652
|
+
if (!fs.lstatSync(receipts).isDirectory()) continue;
|
|
653
|
+
const latest = latestReceipt(receipts);
|
|
654
|
+
if (latest?.state !== 'RECOVERY_REQUIRED') continue;
|
|
655
|
+
const sealed = sealedReceipts(receipts);
|
|
656
|
+
const expected = transactionPaths(live, transactionId);
|
|
657
|
+
const trees = { live: measureTree(live, sealed) };
|
|
658
|
+
for (const name of OWNED_TREES) trees[name] = measureTree(expected[name], sealed);
|
|
659
|
+
out.push({ transactionId, reason: latest.reason || null, recordedAt: latest.recordedAt || null,
|
|
660
|
+
sealed: { prior: sealed.prior ? identitySummary(sealed.prior) : null,
|
|
661
|
+
candidate: sealed.candidate ? identitySummary(sealed.candidate) : null }, trees });
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The operator's explicit disposition: keep the current live tree, preserve every other tree intact.
|
|
668
|
+
* @param {string} liveDir
|
|
669
|
+
* @param {string} transactionId
|
|
670
|
+
* @param {{ keepLiveSha256: string, rename?: Function }} options keepLiveSha256 must be the live tree's
|
|
671
|
+
* sha256 exactly as the preview reported it; any change since the preview refuses.
|
|
672
|
+
*/
|
|
673
|
+
export function disposeRecoveryRequired(liveDir, transactionId, { keepLiveSha256, rename = fs.renameSync } = {}) {
|
|
674
|
+
const live = path.resolve(liveDir);
|
|
675
|
+
if (!SAFE_TRANSACTION_ID.test(String(transactionId || '')) || String(transactionId).startsWith('.')) {
|
|
676
|
+
throw new Error(`not a transaction id: ${transactionId}`);
|
|
677
|
+
}
|
|
678
|
+
if (!/^[a-f0-9]{64}$/.test(String(keepLiveSha256 || ''))) {
|
|
679
|
+
throw new Error('name the live tree to keep by the exact sha256 the preview printed (--keep-live <sha256>)');
|
|
680
|
+
}
|
|
681
|
+
const receipts = path.join(receiptsRootFor(live), transactionId);
|
|
682
|
+
if (!fs.existsSync(receipts) || !fs.lstatSync(receipts).isDirectory()) throw new Error(`no such transaction: ${transactionId}`);
|
|
683
|
+
const latest = latestReceipt(receipts);
|
|
684
|
+
if (latest?.state !== 'RECOVERY_REQUIRED') {
|
|
685
|
+
throw new Error(`transaction ${transactionId} is ${latest?.state || 'empty'}, not RECOVERY_REQUIRED; nothing to dispose`);
|
|
686
|
+
}
|
|
687
|
+
const expected = transactionPaths(live, transactionId);
|
|
688
|
+
if (latest.paths && ['live', ...OWNED_TREES].some((name) => latest.paths[name] && path.resolve(latest.paths[name]) !== expected[name])) {
|
|
689
|
+
throw new Error(`transaction ${transactionId} names paths that belong to another live tree; refusing`);
|
|
690
|
+
}
|
|
691
|
+
assertDirectory(live, 'live tree');
|
|
692
|
+
const sealed = sealedReceipts(receipts);
|
|
693
|
+
const liveIdentity = identitySummary(treeIdentity(live));
|
|
694
|
+
if (liveIdentity.sha256 !== keepLiveSha256) {
|
|
695
|
+
throw new Error(`the live tree changed since the preview (now ${liveIdentity.sha256}); run the preview again`);
|
|
696
|
+
}
|
|
697
|
+
const archiveDir = path.join(recoveredRootFor(live), transactionId);
|
|
698
|
+
const moves = [];
|
|
699
|
+
for (const name of OWNED_TREES) {
|
|
700
|
+
const source = expected[name];
|
|
701
|
+
if (!fs.existsSync(source)) continue;
|
|
702
|
+
assertDirectory(source, `transaction ${name} tree`);
|
|
703
|
+
const target = path.join(archiveDir, name);
|
|
704
|
+
if (fs.existsSync(target)) throw new Error(`recovered tree already exists: ${target}`);
|
|
705
|
+
moves.push({ name, source, target, before: identitySummary(treeIdentity(source)) });
|
|
706
|
+
}
|
|
707
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
708
|
+
const preserved = [];
|
|
709
|
+
for (const move of moves) {
|
|
710
|
+
rename(move.source, move.target);
|
|
711
|
+
const after = identitySummary(treeIdentity(move.target));
|
|
712
|
+
if (!sameIdentity(after, move.before)) {
|
|
713
|
+
throw new Error(`${move.name} tree changed while it was moved to ${move.target}; it is kept there, nothing was deleted`);
|
|
714
|
+
}
|
|
715
|
+
preserved.push({ tree: move.name, from: move.source, to: move.target, identity: after,
|
|
716
|
+
matchesPrior: sameIdentity(after, sealed.prior), matchesCandidate: sameIdentity(after, sealed.candidate) });
|
|
717
|
+
}
|
|
718
|
+
const receipt = appendRecoveryReceipt(receipts, 'DISPOSED', {
|
|
719
|
+
terminalVerdict: 'operator-kept-live',
|
|
720
|
+
live: liveIdentity,
|
|
721
|
+
liveMatchesPrior: sameIdentity(liveIdentity, sealed.prior),
|
|
722
|
+
liveMatchesCandidate: sameIdentity(liveIdentity, sealed.candidate),
|
|
723
|
+
preserved,
|
|
724
|
+
recoveredFrom: { reason: latest.reason || null, recordedAt: latest.recordedAt || null },
|
|
725
|
+
reason: 'operator kept the current live tree; every other tree this transaction owned was moved intact, not deleted',
|
|
726
|
+
});
|
|
727
|
+
return { transactionId, terminalVerdict: 'operator-kept-live', live: liveIdentity,
|
|
728
|
+
liveMatchesPrior: receipt.liveMatchesPrior, liveMatchesCandidate: receipt.liveMatchesCandidate, preserved, archiveDir };
|
|
729
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.24",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"deferral:replay": "node scripts/deferral-replay.mjs",
|
|
78
78
|
"hooks:check": "node scripts/hook-retirement-check.mjs",
|
|
79
79
|
"release:qualify": "node scripts/release-qualification.mjs",
|
|
80
|
+
"release:prepush": "node scripts/release-prepush.mjs",
|
|
80
81
|
"rehearse:corpus": "node scripts/rehearse-corpus-pipeline.mjs",
|
|
81
82
|
"single-source:check": "node scripts/single-source-check.mjs",
|
|
82
83
|
"host:codex:execution-proof": "node scripts/codex-host-execution-proof.mjs"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.7.
|
|
4
|
+
"version": "4.7.24",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -70,7 +70,7 @@ const RUFLO_DEBRIS = new Set(RUFLO_CWD_ARTIFACTS);
|
|
|
70
70
|
// Directories in the brain home that hold data another tool owns: measured, shown, never budgeted or removed.
|
|
71
71
|
const FOREIGN = { 'ruvector-mcp': 'RuVector MCP server working store (its own live memory)',
|
|
72
72
|
'worktree-recovery': 'hand-made recovery archives, not created by the Brain' };
|
|
73
|
-
const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
73
|
+
const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']); // DISPOSED: operator recovery (#335)
|
|
74
74
|
|
|
75
75
|
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
76
76
|
const lstat = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
|
|
@@ -190,6 +190,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
|
|
|
190
190
|
}
|
|
191
191
|
};
|
|
192
192
|
const classifySibling = (name, full, st) => {
|
|
193
|
+
if (name === `.${base}.recovered` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --recover-update --keep-live
|
|
194
|
+
return void add({ id: 'kb-recovered', path: full, class: 'may-exist', kind: 'kb-recovered', action: 'keep', bytes: bytes(full),
|
|
195
|
+
reason: 'trees an operator-disposed update transaction owned, moved intact (--recover-update); never removed automatically' });
|
|
196
|
+
}
|
|
193
197
|
if (name === `${base}.archive` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --clean --archive
|
|
194
198
|
return void add({ id: 'kb-archive', path: full, class: 'may-exist', kind: 'kb-archive', action: 'keep', bytes: bytes(full),
|
|
195
199
|
reason: 'files unique to released KB copies, archived on request (--clean --archive); never removed automatically' });
|
|
@@ -24,6 +24,21 @@ const learnDir = (brainHome, sub) => { const dir = path.join(brainHome, 'learn-c
|
|
|
24
24
|
export function recentlyCaptured(brainHome, key, now = Date.now()) {
|
|
25
25
|
try { return now - fs.statSync(path.join(learnDir(brainHome, 'recent'), key)).mtimeMs < COALESCE_MS; } catch { return false; }
|
|
26
26
|
}
|
|
27
|
+
// #377 residual (sparkling, 2026-10-30): the parent used to call markCaptured() BEFORE the detached child stored
|
|
28
|
+
// anything, so a child whose store or exact readback failed left the key acknowledged as captured for the whole
|
|
29
|
+
// COALESCE_MS window. Now the parent only marks the key IN FLIGHT (`<key>.pending`, which is never an
|
|
30
|
+
// acknowledgement and expires with the child's slot), and the child acknowledges with markCaptured() only after
|
|
31
|
+
// recordLearningObservation() has completed its exact readback. A failed child clears the in-flight mark.
|
|
32
|
+
const inFlightMarker = (brainHome, key) => path.join(learnDir(brainHome, 'recent'), `${key}.pending`);
|
|
33
|
+
export function captureInFlight(brainHome, key, now = Date.now()) {
|
|
34
|
+
try { return now - fs.statSync(inFlightMarker(brainHome, key)).mtimeMs < SLOT_STALE_MS; } catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
export function markInFlight(brainHome, key) {
|
|
37
|
+
try { fs.writeFileSync(inFlightMarker(brainHome, key), '', { mode: 0o600 }); } catch { /* best effort: coalescing only */ }
|
|
38
|
+
}
|
|
39
|
+
export function clearInFlight(brainHome, key) {
|
|
40
|
+
try { fs.unlinkSync(inFlightMarker(brainHome, key)); } catch { /* already gone */ }
|
|
41
|
+
}
|
|
27
42
|
export function markCaptured(brainHome, key, now = Date.now()) {
|
|
28
43
|
const dir = learnDir(brainHome, 'recent');
|
|
29
44
|
try { fs.writeFileSync(path.join(dir, key), '', { mode: 0o600 }); } catch { return; }
|
|
@@ -105,12 +120,13 @@ export function captureLearning(input, { env = process.env, binary = resolveRufl
|
|
|
105
120
|
const observation = { tool, action, session: String(input.session_id || '') };
|
|
106
121
|
const key = observationKey(observation);
|
|
107
122
|
if (recentlyCaptured(brainHome, key)) return true; // identical observation already captured this window: no process
|
|
123
|
+
if (detach && captureInFlight(brainHome, key)) return true; // the same observation is being stored right now
|
|
108
124
|
// Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
|
|
109
125
|
// tool call returns at once; that child writes the ledger row if the write is refused or unverified.
|
|
110
126
|
if (detach) {
|
|
111
127
|
const slot = acquireObserveSlot(brainHome);
|
|
112
128
|
if (!slot) return shedObservation(brainHome);
|
|
113
|
-
|
|
129
|
+
markInFlight(brainHome, key); // NOT an acknowledgement: only the child's exact readback acknowledges (#377)
|
|
114
130
|
return detachLearning('--observe', { env: { ...env, RUFLO_BIN: binary }, brainHome, observation, slot });
|
|
115
131
|
}
|
|
116
132
|
recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
|
|
@@ -136,6 +152,8 @@ export function detachLearning(mode, { env = process.env, brainHome, observation
|
|
|
136
152
|
}
|
|
137
153
|
|
|
138
154
|
function runDetached(mode) {
|
|
155
|
+
const brainHome = defaultBrainHome(process.env);
|
|
156
|
+
let observation = null;
|
|
139
157
|
const fail = (error) => recordHookFailure({ hook: mode === '--distill' ? 'learn-distill' : 'learn-capture', event: mode === '--distill' ? 'SessionEnd' : 'PostToolUse', error: String(error?.message || error).slice(0, 160) });
|
|
140
158
|
try {
|
|
141
159
|
const context = learningContext(); // consent is re-read: a withdrawn consent writes nothing
|
|
@@ -145,8 +163,13 @@ function runDetached(mode) {
|
|
|
145
163
|
if (mode === '--distill') { distillObservations(binary, context, { env: process.env }); return; }
|
|
146
164
|
const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
|
|
147
165
|
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
observation = { tool: o.tool, action: o.action, session: String(o.session || '') };
|
|
167
|
+
recordLearningObservation(binary, context, observation, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
|
|
168
|
+
markCaptured(brainHome, observationKey(observation)); // acknowledged only after the exact readback succeeded (#377)
|
|
169
|
+
} catch (error) { fail(error); } finally {
|
|
170
|
+
if (observation) clearInFlight(brainHome, observationKey(observation));
|
|
171
|
+
if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT);
|
|
172
|
+
}
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
const isMain = isDirectInvocation(import.meta.url);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// DISTINCT-FROM: kb/zip-extract.mjs — that extracts a whole local archive from a file descriptor; this reads only the idmap sidecars of a REMOTE release asset over HTTP Range and never fetches the ~750 MB body.
|
|
3
|
+
//
|
|
4
|
+
// scripts/generation-census.mjs — the public census (public stores, chunks) of a published corpus
|
|
5
|
+
// generation, read straight from its release asset without downloading it.
|
|
6
|
+
//
|
|
7
|
+
// Release QE requires the committed claim surfaces (README, explainer/*) to quote the exact census of
|
|
8
|
+
// the NEWEST corpus generation, and the corpus republishes nightly. Assembling a candidate to learn
|
|
9
|
+
// two numbers costs ~750 MB and a minute; the numbers live in ~200 small `*.big.rvf.idmap.json`
|
|
10
|
+
// members of the zip. So: Range-read the end-of-central-directory, the central directory, and each
|
|
11
|
+
// idmap member; inflate, CRC-check, count `idToLabel` keys; exclude the private fence exactly as
|
|
12
|
+
// scripts/claims-verify.mjs computePublicChunkTotal does. Anything unexpected (zip64, another
|
|
13
|
+
// compression method, a CRC mismatch, a short read) fails loudly — a wrong census is worse than none.
|
|
14
|
+
//
|
|
15
|
+
// node scripts/generation-census.mjs --repo owner/name --tag corpus-sha256-<digest>
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
export const ARCHIVE_ASSET = 'ruvnet-brain.zip';
|
|
27
|
+
export const IDMAP_SUFFIX = '.big.rvf.idmap.json';
|
|
28
|
+
const TAIL_BYTES = 66_000; // EOCD (22) + the largest possible comment (65,535) + the zip64 locator (20)
|
|
29
|
+
const SIG = { eocd: 0x06054b50, zip64Locator: 0x07064b50, central: 0x02014b50, local: 0x04034b50 };
|
|
30
|
+
const U16 = 0xffff;
|
|
31
|
+
const U32 = 0xffffffff;
|
|
32
|
+
const CONCURRENCY = 8;
|
|
33
|
+
|
|
34
|
+
const zip64 = (what) => new Error(`zip64 ${what}: this reader refuses zip64 archives rather than guess at their layout`);
|
|
35
|
+
|
|
36
|
+
/** End-of-central-directory from the archive's last bytes. */
|
|
37
|
+
export function parseEocd(tail) {
|
|
38
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
39
|
+
if (tail.readUInt32LE(i) !== SIG.eocd) continue;
|
|
40
|
+
if (i >= 20 && tail.readUInt32LE(i - 20) === SIG.zip64Locator) throw zip64('end-of-central-directory locator');
|
|
41
|
+
const disk = tail.readUInt16LE(i + 4), cdDisk = tail.readUInt16LE(i + 6);
|
|
42
|
+
const onDisk = tail.readUInt16LE(i + 8), total = tail.readUInt16LE(i + 10);
|
|
43
|
+
const cdSize = tail.readUInt32LE(i + 12), cdOffset = tail.readUInt32LE(i + 16);
|
|
44
|
+
if (total === U16 || onDisk === U16 || cdSize === U32 || cdOffset === U32) throw zip64('marker in the end-of-central-directory');
|
|
45
|
+
if (disk !== 0 || cdDisk !== 0 || onDisk !== total) throw new Error('multi-disk zip archives are not supported');
|
|
46
|
+
return { total, cdSize, cdOffset };
|
|
47
|
+
}
|
|
48
|
+
throw new Error('no end-of-central-directory record in the archive tail');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every central-directory entry: name, method, flags, crc, sizes, local-header offset. */
|
|
52
|
+
export function parseCentralDirectory(cd, expected) {
|
|
53
|
+
const entries = [];
|
|
54
|
+
let p = 0;
|
|
55
|
+
while (p < cd.length) {
|
|
56
|
+
if (p + 46 > cd.length || cd.readUInt32LE(p) !== SIG.central) throw new Error(`malformed central directory at byte ${p}`);
|
|
57
|
+
const nameLength = cd.readUInt16LE(p + 28), extraLength = cd.readUInt16LE(p + 30), commentLength = cd.readUInt16LE(p + 32);
|
|
58
|
+
const entry = {
|
|
59
|
+
name: cd.subarray(p + 46, p + 46 + nameLength).toString('utf8'),
|
|
60
|
+
flags: cd.readUInt16LE(p + 8), method: cd.readUInt16LE(p + 10), crc: cd.readUInt32LE(p + 16),
|
|
61
|
+
compressedSize: cd.readUInt32LE(p + 20), size: cd.readUInt32LE(p + 24), offset: cd.readUInt32LE(p + 42),
|
|
62
|
+
extraLength,
|
|
63
|
+
};
|
|
64
|
+
if (entry.compressedSize === U32 || entry.size === U32 || entry.offset === U32) throw zip64(`marker on entry ${entry.name}`);
|
|
65
|
+
const extra = cd.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength);
|
|
66
|
+
for (let q = 0; q + 4 <= extra.length; q += 4 + extra.readUInt16LE(q + 2)) {
|
|
67
|
+
if (extra.readUInt16LE(q) === 0x0001) throw zip64(`extra field on entry ${entry.name}`);
|
|
68
|
+
}
|
|
69
|
+
entries.push(entry);
|
|
70
|
+
p += 46 + nameLength + extraLength + commentLength;
|
|
71
|
+
}
|
|
72
|
+
if (Number.isInteger(expected) && entries.length !== expected) {
|
|
73
|
+
throw new Error(`central directory lists ${entries.length} entries; the end record says ${expected}`);
|
|
74
|
+
}
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The public idmap members: no resource forks, no private store, no store counted twice. */
|
|
79
|
+
export function selectIdmapEntries(entries, privateStores = []) {
|
|
80
|
+
const fence = new Set(privateStores);
|
|
81
|
+
const seen = new Map();
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.name.endsWith(IDMAP_SUFFIX) || entry.name.split('/').includes('__MACOSX')) continue;
|
|
84
|
+
const base = path.posix.basename(entry.name);
|
|
85
|
+
if (base.startsWith('._')) continue;
|
|
86
|
+
const store = base.slice(0, -IDMAP_SUFFIX.length);
|
|
87
|
+
if (fence.has(store)) continue;
|
|
88
|
+
if (seen.has(store)) throw new Error(`store ${store} has two idmap members (${seen.get(store).name}, ${entry.name})`);
|
|
89
|
+
seen.set(store, { ...entry, store });
|
|
90
|
+
}
|
|
91
|
+
return [...seen.values()];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One member's bytes from a buffer that starts at its local header; null when the buffer is too short. */
|
|
95
|
+
export function decodeEntry(entry, local) {
|
|
96
|
+
if (local.length < 30 || local.readUInt32LE(0) !== SIG.local) throw new Error(`no local header for ${entry.name}`);
|
|
97
|
+
const start = 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
|
|
98
|
+
if (local.length < start + entry.compressedSize) return { need: start + entry.compressedSize };
|
|
99
|
+
const raw = local.subarray(start, start + entry.compressedSize);
|
|
100
|
+
let data;
|
|
101
|
+
if (entry.method === 0) data = raw;
|
|
102
|
+
else if (entry.method === 8) data = zlib.inflateRawSync(raw);
|
|
103
|
+
else throw new Error(`${entry.name} uses compression method ${entry.method}; only stored (0) and deflate (8) are read`);
|
|
104
|
+
if (typeof zlib.crc32 !== 'function') throw new Error('zlib.crc32 is unavailable (Node >= 20.15 / 22.2 required) — cannot verify members');
|
|
105
|
+
if (data.length !== entry.size) throw new Error(`${entry.name} inflated to ${data.length} bytes; the directory says ${entry.size}`);
|
|
106
|
+
const crc = zlib.crc32(data) >>> 0;
|
|
107
|
+
if (crc !== entry.crc >>> 0) throw new Error(`${entry.name} failed its CRC-32 check (expected ${entry.crc.toString(16)}, got ${crc.toString(16)})`);
|
|
108
|
+
return { data };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function countIdmap(buffer, name) {
|
|
112
|
+
const { idToLabel } = JSON.parse(buffer.toString('utf8'));
|
|
113
|
+
if (!idToLabel || typeof idToLabel !== 'object' || Array.isArray(idToLabel)) throw new Error(`${name} has no idToLabel object`);
|
|
114
|
+
return Object.keys(idToLabel).length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readPrivateStores(file = path.join(ROOT, 'kb', 'PRIVATE-STORES.json')) {
|
|
118
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
119
|
+
if (!Array.isArray(parsed?.privateStores)) throw new Error(`${file} lacks a privateStores array`);
|
|
120
|
+
return [...parsed.privateStores].sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The release asset row (id, size, url) from the GitHub API. */
|
|
124
|
+
async function resolveAsset({ repo, tag, fetch, token }) {
|
|
125
|
+
const headers = { Accept: 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
|
126
|
+
const response = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`, { headers });
|
|
127
|
+
if (!response.ok) throw new Error(`release ${tag}: GitHub API answered HTTP ${response.status}`);
|
|
128
|
+
const assets = ((await response.json())?.assets || []).filter((asset) => asset?.name === ARCHIVE_ASSET);
|
|
129
|
+
if (assets.length !== 1) throw new Error(`release ${tag} has ${assets.length} ${ARCHIVE_ASSET} assets; expected exactly one`);
|
|
130
|
+
return { id: assets[0].id, size: assets[0].size, url: assets[0].browser_download_url };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Range reads against the asset; the redirect is resolved once and re-resolved if the signed URL lapses. */
|
|
134
|
+
function rangeReader({ url, size, fetch }) {
|
|
135
|
+
let target = url;
|
|
136
|
+
let fetched = 0;
|
|
137
|
+
const read = async (start, end, retried = false) => {
|
|
138
|
+
const response = await fetch(target, { headers: { Range: `bytes=${start}-${end}` }, redirect: 'follow' });
|
|
139
|
+
if (response.status !== 206) {
|
|
140
|
+
if (!retried && target !== url) { target = url; return read(start, end, true); }
|
|
141
|
+
throw new Error(`range ${start}-${end} of ${url}: expected HTTP 206, got ${response.status}`);
|
|
142
|
+
}
|
|
143
|
+
const total = Number(/\/(\d+)$/.exec(response.headers.get('content-range') || '')?.[1]);
|
|
144
|
+
if (total !== size) throw new Error(`asset is ${total} bytes; the release says ${size}`);
|
|
145
|
+
if (response.url) target = response.url;
|
|
146
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
147
|
+
if (buffer.length !== end - start + 1) throw new Error(`short range read ${start}-${end}: ${buffer.length} bytes`);
|
|
148
|
+
fetched += buffer.length;
|
|
149
|
+
return buffer;
|
|
150
|
+
};
|
|
151
|
+
return { read, fetched: () => fetched };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function pool(items, limit, work) {
|
|
155
|
+
const out = new Array(items.length);
|
|
156
|
+
let next = 0;
|
|
157
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
158
|
+
while (next < items.length) { const i = next++; out[i] = await work(items[i]); }
|
|
159
|
+
}));
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function rangeCensus({ repo, tag, bytes = null, privateStoresFile, fetch = globalThis.fetch,
|
|
164
|
+
cacheDir = os.tmpdir(), token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null, asset = null,
|
|
165
|
+
concurrency = CONCURRENCY } = {}) {
|
|
166
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('repo must be owner/name');
|
|
167
|
+
if (!/^[A-Za-z0-9._-]+$/.test(String(tag || ''))) throw new Error(`invalid release tag: ${tag}`);
|
|
168
|
+
const privateStores = readPrivateStores(privateStoresFile);
|
|
169
|
+
const resolved = asset || await resolveAsset({ repo, tag, fetch, token });
|
|
170
|
+
if (!Number.isSafeInteger(resolved.size) || resolved.size < 22) throw new Error(`release ${tag} asset has no usable size`);
|
|
171
|
+
if (bytes !== null && bytes !== resolved.size) throw new Error(`release ${tag} asset is ${resolved.size} bytes; expected ${bytes}`);
|
|
172
|
+
|
|
173
|
+
const key = crypto.createHash('sha256').update(JSON.stringify({ repo, tag, assetId: resolved.id, size: resolved.size, privateStores }))
|
|
174
|
+
.digest('hex').slice(0, 32);
|
|
175
|
+
const cacheFile = path.join(cacheDir, 'ruvnet-brain-generation-census', `${key}.json`);
|
|
176
|
+
try {
|
|
177
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
178
|
+
if (cached?.tag === tag && Number.isSafeInteger(cached.chunks)) return { ...cached, cached: true };
|
|
179
|
+
} catch { /* no usable cache entry: measure */ }
|
|
180
|
+
|
|
181
|
+
const started = Date.now();
|
|
182
|
+
const { read, fetched } = rangeReader({ url: resolved.url || `https://github.com/${repo}/releases/download/${tag}/${ARCHIVE_ASSET}`,
|
|
183
|
+
size: resolved.size, fetch });
|
|
184
|
+
const tailStart = Math.max(0, resolved.size - TAIL_BYTES);
|
|
185
|
+
const eocd = parseEocd(await read(tailStart, resolved.size - 1));
|
|
186
|
+
if (eocd.cdOffset + eocd.cdSize > resolved.size) throw new Error('central directory lies outside the archive');
|
|
187
|
+
const entries = parseCentralDirectory(await read(eocd.cdOffset, eocd.cdOffset + eocd.cdSize - 1), eocd.total);
|
|
188
|
+
const selected = selectIdmapEntries(entries, privateStores);
|
|
189
|
+
if (!selected.length) throw new Error(`release ${tag} contains no public *${IDMAP_SUFFIX} members`);
|
|
190
|
+
const counts = await pool(selected, concurrency, async (entry) => {
|
|
191
|
+
const guess = 30 + Buffer.byteLength(entry.name) + entry.extraLength + entry.compressedSize + 256;
|
|
192
|
+
let local = await read(entry.offset, Math.min(resolved.size, entry.offset + guess) - 1);
|
|
193
|
+
let decoded = decodeEntry(entry, local);
|
|
194
|
+
if (decoded.need) {
|
|
195
|
+
local = await read(entry.offset, entry.offset + decoded.need - 1);
|
|
196
|
+
decoded = decodeEntry(entry, local);
|
|
197
|
+
if (decoded.need) throw new Error(`${entry.name}: local header disagrees with the central directory`);
|
|
198
|
+
}
|
|
199
|
+
return countIdmap(decoded.data, entry.name);
|
|
200
|
+
});
|
|
201
|
+
const result = { tag, publicStores: selected.length, chunks: counts.reduce((a, b) => a + b, 0), builtStores: selected.length,
|
|
202
|
+
assetId: resolved.id ?? null, archiveBytes: resolved.size, fetchedBytes: fetched(), entries: entries.length,
|
|
203
|
+
seconds: Math.round((Date.now() - started) / 100) / 10 };
|
|
204
|
+
try {
|
|
205
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
206
|
+
const temp = `${cacheFile}.${process.pid}.tmp`;
|
|
207
|
+
fs.writeFileSync(temp, `${JSON.stringify(result, null, 2)}\n`);
|
|
208
|
+
fs.renameSync(temp, cacheFile);
|
|
209
|
+
} catch { /* a cache is an optimisation; the measured result stands */ }
|
|
210
|
+
return { ...result, cached: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const arg = (argv, name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
214
|
+
|
|
215
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
216
|
+
const argv = process.argv.slice(2);
|
|
217
|
+
rangeCensus({ repo: arg(argv, '--repo') || process.env.GITHUB_REPOSITORY, tag: arg(argv, '--tag') })
|
|
218
|
+
.then((result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`))
|
|
219
|
+
.catch((error) => { console.error(`[generation-census] ${error.message}`); process.exitCode = 1; });
|
|
220
|
+
}
|