ruvnet-brain 4.3.9 → 4.3.11
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 +26 -9
- package/bin/install.mjs +646 -366
- package/bin/nightly-refresh.mjs +115 -0
- package/kb/brain-profile.mjs +118 -32
- package/kb/lifecycle-evidence-retention.mjs +237 -0
- package/kb/refresh-run.mjs +367 -0
- package/kb/retrieval-result.mjs +39 -0
- package/kb/update-storage-transaction.mjs +439 -0
- package/package.json +12 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -165
- package/plugin/hooks/hook-contracts.json +4 -65
- package/plugin/hooks/hooks.json +2 -208
- package/plugin/scripts/adr-currency-gate.mjs +29 -19
- package/plugin/scripts/capability-registry.mjs +10 -83
- package/plugin/scripts/codex-hook-adapter.mjs +62 -9
- package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
- package/plugin/scripts/continuation-gate.mjs +30 -28
- package/plugin/scripts/continuation-objective.mjs +34 -0
- package/plugin/scripts/development-maintenance.mjs +49 -0
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/learn-flush.mjs +6 -1
- package/plugin/scripts/lesson-gate.mjs +5 -2
- package/plugin/scripts/lesson-presentation.mjs +5 -0
- package/plugin/scripts/md-stamp.mjs +109 -6
- package/plugin/scripts/memory-doctor.mjs +3 -7
- package/plugin/scripts/nightly-controller.mjs +9 -27
- package/plugin/scripts/nightly-scheduler.mjs +494 -0
- package/plugin/scripts/project-progression-contract.mjs +4 -4
- package/plugin/scripts/project-progression-store.mjs +35 -6
- package/plugin/scripts/ruflo-bin.mjs +20 -0
- package/plugin/scripts/session-snapshot-contract.mjs +6 -1
- package/plugin/scripts/version-bump-gate.sh +14 -2
- package/plugin/skills/release-proof/SKILL.md +6 -2
- package/plugin/skills/release-proof/references/receipt-contract.md +31 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +11 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
- package/scripts/build-bundle.mjs +8 -0
- package/scripts/build-primer.mjs +2 -3
- package/scripts/candidate-host-evidence.mjs +59 -41
- package/scripts/ci/build-fixture-kb.mjs +59 -2
- package/scripts/ci/mutate-hook-timeout.mjs +24 -47
- package/scripts/ci/stranger-scenario.mjs +13 -28
- package/scripts/claims-verify.mjs +219 -21
- package/scripts/console-engine.mjs +4 -18
- package/scripts/console-runtime-identity.mjs +6 -0
- package/scripts/development-maintenance.mjs +47 -0
- package/scripts/development-push-check.mjs +35 -0
- package/scripts/distill-project.mjs +24 -47
- package/scripts/doc-currency.mjs +91 -19
- package/scripts/git-hooks/pre-push +4 -162
- package/scripts/health-repair.mjs +22 -4
- package/scripts/hook-retirement-check.mjs +22 -0
- package/scripts/host-install-matrix.mjs +125 -61
- package/scripts/integration-evidence.mjs +11 -6
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/nightly-package-observation.mjs +67 -0
- package/scripts/nightly-two-run-proof.mjs +489 -0
- package/scripts/nightly-watchdog.mjs +17 -2
- package/scripts/npm-invocation.mjs +20 -0
- package/scripts/prepublication-evidence.mjs +42 -6
- package/scripts/primer-grounding.mjs +20 -0
- package/scripts/product-integrity-contract.mjs +22 -2
- package/scripts/public-verification-abandon.mjs +101 -0
- package/scripts/public-verification-aggregate.mjs +61 -19
- package/scripts/public-verification-finalizer.mjs +8 -2
- package/scripts/public-verification-lane.mjs +64 -8
- package/scripts/publication-receipt.mjs +156 -50
- package/scripts/published-surface-probe.mjs +10 -2
- package/scripts/qa-contract.mjs +57 -0
- package/scripts/qa-lanes.mjs +40 -0
- package/scripts/qa-runner.mjs +54 -64
- package/scripts/qe/ux-suite.mjs +10 -34
- package/scripts/qualified-candidate-check.mjs +124 -0
- package/scripts/release-abort-stale.mjs +2 -2
- package/scripts/release-projection.mjs +20 -1
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +135 -0
- package/scripts/release-transaction.mjs +81 -1
- package/scripts/remedy-registry.mjs +6 -13
- package/scripts/retrieval-canary.mjs +91 -18
- package/scripts/selfcheck.mjs +12 -4
- package/scripts/snapshot-freshness.mjs +58 -0
- package/scripts/stack-sync.mjs +41 -43
- package/scripts/staged-host-verifier.mjs +63 -14
- package/scripts/wired-check.mjs +31 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { digest } from './coverage-integrity.mjs';
|
|
8
|
+
import { sourceIdentity } from './qa-contract.mjs';
|
|
9
|
+
import { RELEASE_REQUIREMENTS } from './release-qualification-contract.mjs';
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const PLATFORM = { linux: 'linux', darwin: 'macos', win32: 'windows' }[process.platform];
|
|
13
|
+
|
|
14
|
+
export function qualificationPlan(suite = 'source') {
|
|
15
|
+
const requirements = RELEASE_REQUIREMENTS[suite];
|
|
16
|
+
if (!requirements?.length) throw new Error('unknown qualification suite');
|
|
17
|
+
const files = requirements.flatMap((entry) => entry.files);
|
|
18
|
+
if (new Set(files).size !== files.length || files.some((file) => !file.startsWith('tests/')
|
|
19
|
+
|| !file.endsWith('.test.mjs') || file.includes('..'))) throw new Error('invalid qualification inventory');
|
|
20
|
+
return { requirements, files, contractSha256: digest(requirements) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assessTestReport(report, files, root = ROOT, platform = PLATFORM) {
|
|
24
|
+
if (!['linux', 'macos', 'windows'].includes(platform)) throw new Error('invalid test report platform');
|
|
25
|
+
const paths = platform === 'windows' ? path.win32 : path.posix;
|
|
26
|
+
if (typeof root !== 'string' || !paths.isAbsolute(root)) throw new Error('test report checkout root must be absolute');
|
|
27
|
+
const results = report?.testResults;
|
|
28
|
+
if (!Array.isArray(results) || results.length !== files.length) throw new Error('test report file inventory differs');
|
|
29
|
+
const expected = new Set(files.map((file) => paths.resolve(root, file)));
|
|
30
|
+
if (results.some(file => typeof file.name !== 'string' || !paths.isAbsolute(file.name))) throw new Error('test report file inventory requires absolute names');
|
|
31
|
+
const names = results.map((file) => paths.resolve(file.name));
|
|
32
|
+
if (new Set(names).size !== files.length || names.some((name) => !expected.has(name))) {
|
|
33
|
+
throw new Error('test report contains missing, duplicate, or unexpected files');
|
|
34
|
+
}
|
|
35
|
+
const cases = results.flatMap((file) => file.assertionResults || []);
|
|
36
|
+
if (!cases.length || results.some((file) => file.status !== 'passed' || !file.assertionResults?.length)
|
|
37
|
+
|| cases.some((test) => test.status !== 'passed') || report.success !== true
|
|
38
|
+
|| report.numFailedTests !== 0 || report.numFailedTestSuites !== 0
|
|
39
|
+
|| report.numPendingTests !== 0 || (report.numTodoTests || 0) !== 0
|
|
40
|
+
|| report.numTotalTests !== cases.length || report.numPassedTests !== cases.length) {
|
|
41
|
+
throw new Error('reviewed release cases did not all execute and pass');
|
|
42
|
+
}
|
|
43
|
+
return { total: cases.length, passed: cases.length, failed: 0, skipped: 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateQualificationReceipt(report, { suite, platform, sourceSha }) {
|
|
47
|
+
const plan = qualificationPlan(suite);
|
|
48
|
+
const { receiptSha256, ...payload } = report || {};
|
|
49
|
+
if (report?.schemaVersion !== 1 || report.kind !== 'ruvnet-brain-release-qualification'
|
|
50
|
+
|| report.suite !== suite || report.platform !== platform || report.status !== 'PASS'
|
|
51
|
+
|| digest(payload) !== receiptSha256 || report.contractSha256 !== plan.contractSha256
|
|
52
|
+
|| digest(report.files) !== digest(plan.files)
|
|
53
|
+
|| digest(report.requirements) !== digest(plan.requirements.map(row => row.id))) {
|
|
54
|
+
throw new Error('qualification receipt identity, digest, or reviewed contract differs');
|
|
55
|
+
}
|
|
56
|
+
if (!/^[a-f0-9]{40}$/.test(String(sourceSha)) || report.source?.sha !== sourceSha
|
|
57
|
+
|| report.sourceAfter?.sha !== sourceSha || report.source.dirty !== false || report.sourceAfter.dirty !== false
|
|
58
|
+
|| !/^[a-f0-9]{64}$/.test(report.source.digest || '') || report.source.digest !== report.sourceAfter.digest
|
|
59
|
+
|| report.sourceStable !== true) throw new Error('qualification source is not clean and unchanged');
|
|
60
|
+
if (!Array.isArray(report.results) || !report.results.length
|
|
61
|
+
|| report.results.some(row => row.status !== 'PASS' || row.exitCode !== 0)) {
|
|
62
|
+
throw new Error('qualification command did not complete successfully');
|
|
63
|
+
}
|
|
64
|
+
if (typeof report.checkoutRoot !== 'string' || !report.checkoutRoot) throw new Error('qualification checkout root is missing');
|
|
65
|
+
const tests = assessTestReport(report.testReport, plan.files, report.checkoutRoot, platform);
|
|
66
|
+
if (digest(tests) !== digest(report.tests)) throw new Error('qualification test summary differs from executed cases');
|
|
67
|
+
return tests;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function runQualification({ suite = 'source', platform = PLATFORM, report: output, root = ROOT } = {}) {
|
|
71
|
+
if (platform !== PLATFORM) throw new Error(`cannot produce ${platform} evidence on ${PLATFORM}`);
|
|
72
|
+
if (!output || fs.existsSync(output)) throw new Error('a new --report path is required');
|
|
73
|
+
const plan = qualificationPlan(suite);
|
|
74
|
+
for (const file of plan.files) if (!fs.statSync(path.resolve(root, file)).isFile()) throw new Error(`missing test ${file}`);
|
|
75
|
+
const source = sourceIdentity(root);
|
|
76
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'release-qualification-'));
|
|
77
|
+
const results = [];
|
|
78
|
+
let testReport = null;
|
|
79
|
+
let tests = { total: 0, passed: 0, failed: 0, skipped: 0 };
|
|
80
|
+
const execute = (name, args) => {
|
|
81
|
+
const run = spawnSync(process.execPath, ['scripts/ci/step-watchdog.mjs', '--name', name,
|
|
82
|
+
'--timeout-ms', '900000', '--receipt-dir', temp, '--', process.execPath, ...args],
|
|
83
|
+
{ cwd: root, env: { ...process.env, ...(suite === 'integration' ? { RUVNET_REQUIRE_CODEX_DISCOVERY: '1' } : {}) },
|
|
84
|
+
stdio: 'inherit' });
|
|
85
|
+
const ok = !run.error && run.status === 0;
|
|
86
|
+
results.push({ name, status: ok ? 'PASS' : 'FAIL', exitCode: run.status });
|
|
87
|
+
if (!ok) throw new Error(`${name} failed`);
|
|
88
|
+
};
|
|
89
|
+
let failure = null;
|
|
90
|
+
try {
|
|
91
|
+
if (suite === 'source') {
|
|
92
|
+
execute('release-version', ['scripts/sync-version.mjs', '--check']);
|
|
93
|
+
execute('release-source-identity', ['scripts/convergence-manifest.mjs']);
|
|
94
|
+
execute('automatic-hook-retirement', ['scripts/hook-retirement-check.mjs']);
|
|
95
|
+
}
|
|
96
|
+
const testPath = path.join(temp, 'tests.json');
|
|
97
|
+
execute(`release-${suite}-${platform}`, ['node_modules/vitest/vitest.mjs', 'run', ...plan.files,
|
|
98
|
+
'--reporter=json', '--outputFile', testPath, '--maxWorkers=1']);
|
|
99
|
+
testReport = JSON.parse(fs.readFileSync(testPath, 'utf8'));
|
|
100
|
+
tests = assessTestReport(testReport, plan.files, root);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
failure = error.message;
|
|
103
|
+
const testPath = path.join(temp, 'tests.json');
|
|
104
|
+
if (fs.existsSync(testPath)) {
|
|
105
|
+
try {
|
|
106
|
+
testReport = JSON.parse(fs.readFileSync(testPath, 'utf8'));
|
|
107
|
+
tests = { total: testReport.numTotalTests || 0, passed: testReport.numPassedTests || 0,
|
|
108
|
+
failed: testReport.numFailedTests || 0,
|
|
109
|
+
skipped: (testReport.numPendingTests || 0) + (testReport.numTodoTests || 0) };
|
|
110
|
+
} catch { /* failed report remains a failure */ }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const sourceAfter = sourceIdentity(root);
|
|
114
|
+
const sourceStable = source.digest === sourceAfter.digest;
|
|
115
|
+
const receipt = { schemaVersion: 1, kind: 'ruvnet-brain-release-qualification', suite, platform, checkoutRoot: path.resolve(root),
|
|
116
|
+
source, sourceAfter, sourceStable, status: !failure && sourceStable ? 'PASS' : 'FAIL',
|
|
117
|
+
requirements: plan.requirements.map((entry) => entry.id), contractSha256: plan.contractSha256,
|
|
118
|
+
files: plan.files, tests, testReport, results, failure, observedAt: new Date().toISOString() };
|
|
119
|
+
receipt.receiptSha256 = digest(receipt);
|
|
120
|
+
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
121
|
+
fs.writeFileSync(output, JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
122
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
123
|
+
return receipt;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
127
|
+
const args = process.argv.slice(2);
|
|
128
|
+
const value = (flag, fallback) => { const i = args.indexOf(flag); return i < 0 ? fallback : args[i + 1]; };
|
|
129
|
+
try {
|
|
130
|
+
const receipt = runQualification({ suite: value('--suite', 'source'), platform: value('--platform', PLATFORM),
|
|
131
|
+
report: value('--report') });
|
|
132
|
+
console.log(JSON.stringify({ status: receipt.status, tests: receipt.tests, receiptSha256: receipt.receiptSha256 }));
|
|
133
|
+
process.exitCode = receipt.status === 'PASS' ? 0 : 1;
|
|
134
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
135
|
+
}
|
|
@@ -68,7 +68,7 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
|
|
|
68
68
|
compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
69
69
|
'defaults-promoted': ['finalize-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
70
70
|
'finalize-intent': ['channels-converged', 'manual-intervention-required', ...ABORTABLE],
|
|
71
|
-
'manual-intervention-required': [],
|
|
71
|
+
'manual-intervention-required': [...ABORTABLE],
|
|
72
72
|
'channels-converged': ['install-verified', 'abandoned', 'superseded'],
|
|
73
73
|
'install-verified': [],
|
|
74
74
|
abandoned: [],
|
|
@@ -506,11 +506,18 @@ export async function abortReleaseTransaction({ identity, receipts, reason, auth
|
|
|
506
506
|
export async function finalizeReleaseTransaction({
|
|
507
507
|
identity,
|
|
508
508
|
aggregate,
|
|
509
|
+
verifierSha,
|
|
510
|
+
workflowRunId,
|
|
509
511
|
adapter,
|
|
510
512
|
privateKey,
|
|
511
513
|
publicKey,
|
|
512
514
|
aggregatePublicKey,
|
|
513
515
|
} = {}) {
|
|
516
|
+
if (!/^[1-9][0-9]*$/.test(String(workflowRunId || ''))) throw new Error('expected workflow run ID is required');
|
|
517
|
+
if (verifierSha !== undefined && (!/^[a-f0-9]{40}$/.test(String(verifierSha))
|
|
518
|
+
|| aggregate?.identity?.verifierSha !== verifierSha)) {
|
|
519
|
+
throw new Error('public verification verifier SHA differs from the expected verifier');
|
|
520
|
+
}
|
|
514
521
|
if (!adapter || typeof adapter.discover !== 'function' || typeof adapter.observeSnapshot !== 'function'
|
|
515
522
|
|| typeof adapter.materializePublicVerificationAggregate !== 'function'
|
|
516
523
|
|| typeof adapter.appendReceipt !== 'function' || typeof adapter.readReceipt !== 'function') {
|
|
@@ -518,6 +525,7 @@ export async function finalizeReleaseTransaction({
|
|
|
518
525
|
}
|
|
519
526
|
const expectedAggregateIdentity = {
|
|
520
527
|
...aggregate?.identity,
|
|
528
|
+
workflowRunId: String(workflowRunId),
|
|
521
529
|
sourceSha: identity?.candidateSha,
|
|
522
530
|
version: identity?.version,
|
|
523
531
|
tag: identity?.tag,
|
|
@@ -560,6 +568,7 @@ export async function finalizeReleaseTransaction({
|
|
|
560
568
|
verdict: 'INSTALL_VERIFIED',
|
|
561
569
|
publicVerification: {
|
|
562
570
|
aggregateSha256: aggregate.aggregateSha256,
|
|
571
|
+
...(aggregate.identity.verifierSha === undefined ? {} : { verifierSha: aggregate.identity.verifierSha }),
|
|
563
572
|
aggregateAssetSha256,
|
|
564
573
|
signatureAssetSha256,
|
|
565
574
|
lanes: aggregate.metrics.leaves,
|
|
@@ -580,3 +589,74 @@ export async function finalizeReleaseTransaction({
|
|
|
580
589
|
validateReceiptChain([...chain, persisted], identity, publicKey);
|
|
581
590
|
return persisted;
|
|
582
591
|
}
|
|
592
|
+
|
|
593
|
+
// Close an unsuccessful public verification attempt without changing public bytes or truth.
|
|
594
|
+
export async function abandonPublicVerificationTransaction({ identity, expected, reason, authorization,
|
|
595
|
+
recovery, failure, adapter, privateKey, publicKey } = {}) {
|
|
596
|
+
const hex = (value, length) => new RegExp(`^[a-f0-9]{${length}}$`).test(value || '');
|
|
597
|
+
const bounded = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
|
598
|
+
if (!expected || expected.transactionId !== transactionIdFor(identity)
|
|
599
|
+
|| !Number.isSafeInteger(expected.sequence) || expected.sequence < 0 || !hex(expected.receiptDigest, 64)) {
|
|
600
|
+
throw new Error('abandonment requires exact expected transaction, sequence, and digest');
|
|
601
|
+
}
|
|
602
|
+
if (!bounded(reason, 1024) || !bounded(authorization?.actor, 100) || !bounded(authorization?.reference, 1024)
|
|
603
|
+
|| authorization.event !== 'repository_dispatch' || authorization.action !== 'abandon-public-verification'
|
|
604
|
+
|| !Number.isSafeInteger(authorization.dispatchRunId) || authorization.dispatchRunId <= 0
|
|
605
|
+
|| !hex(authorization.verifierSha, 40)) throw new Error('explicit abandonment authorization provenance is required');
|
|
606
|
+
if (recovery?.event !== 'repository_dispatch' || !Number.isSafeInteger(recovery.workflowId) || recovery.workflowId <= 0
|
|
607
|
+
|| !Number.isSafeInteger(recovery.repositoryId) || recovery.repositoryId <= 0
|
|
608
|
+
|| recovery.repository !== identity.repository || recovery.workflow !== '.github/workflows/recover-public-verification.yml'
|
|
609
|
+
|| recovery.conclusion !== 'failure' || !Number.isSafeInteger(recovery.runId) || recovery.runId <= 0
|
|
610
|
+
|| !Number.isSafeInteger(recovery.artifactId) || recovery.artifactId <= 0 || !hex(recovery.verifierSha, 40)
|
|
611
|
+
|| recovery.artifactName !== `recovered-public-verification-${failure?.os}-${identity.candidateSha}-${recovery.runId}`) {
|
|
612
|
+
throw new Error('failed recovery provenance differs from the candidate');
|
|
613
|
+
}
|
|
614
|
+
const { failureSha256, ...failurePayload } = failure || {};
|
|
615
|
+
if (Buffer.byteLength(canonicalJson(failure || {})) > 131072 || failure?.schemaVersion !== 1
|
|
616
|
+
|| failure.kind !== 'ruvnet-brain-public-verification-failure' || !['linux', 'macos', 'windows'].includes(failure.os)
|
|
617
|
+
|| failure.sourceSha !== identity.candidateSha || failure.artifactSha256 !== identity.packageSha256
|
|
618
|
+
|| failure.bundleSha256 !== identity.bundleSha256 || !Array.isArray(failure.failures)
|
|
619
|
+
|| failure.failures.length < 1 || failure.failures.length > 3
|
|
620
|
+
|| failure.failures.some((entry) => !bounded(entry.reason, 4096))
|
|
621
|
+
|| (failure.verifierSha !== undefined && failure.verifierSha !== recovery.verifierSha)
|
|
622
|
+
|| !hex(failureSha256, 64)
|
|
623
|
+
|| crypto.createHash('sha256').update(canonicalJson(failurePayload)).digest('hex') !== failureSha256) {
|
|
624
|
+
throw new Error('bounded failed recovery evidence is invalid or belongs to another candidate');
|
|
625
|
+
}
|
|
626
|
+
const intent = { expected, reason, authorization: { actor: authorization.actor, reference: authorization.reference }, recovery, failureSha256 };
|
|
627
|
+
const intentSha256 = crypto.createHash('sha256').update(canonicalJson(intent)).digest('hex');
|
|
628
|
+
if (!adapter || ['discover', 'observeSnapshot', 'appendReceipt', 'readReceipt'].some((key) => typeof adapter[key] !== 'function')) {
|
|
629
|
+
throw new Error('abandonment adapter is incomplete');
|
|
630
|
+
}
|
|
631
|
+
const discovered = await adapter.discover(identity);
|
|
632
|
+
const chain = validateReceiptChain(discovered.receipts || [], identity, publicKey);
|
|
633
|
+
const current = chain.at(-1);
|
|
634
|
+
if (current?.state === 'abandoned') {
|
|
635
|
+
if (current.observation?.abandonment?.intentSha256 !== intentSha256) throw new Error('conflicting abandonment intent');
|
|
636
|
+
return current;
|
|
637
|
+
}
|
|
638
|
+
if (current?.schemaVersion !== 3 || current.state !== 'channels-converged'
|
|
639
|
+
|| current.observation?.verdict !== 'PUBLISHED_NOT_VERIFIED'
|
|
640
|
+
|| current.sequence !== expected.sequence || current.receiptDigest !== expected.receiptDigest) {
|
|
641
|
+
throw new Error('abandonment expected receipt is not the current published-unverified transaction');
|
|
642
|
+
}
|
|
643
|
+
const snapshot = await adapter.observeSnapshot(identity, discovered.matchingDrafts?.[0], { forceAssets: true });
|
|
644
|
+
if (snapshot.readError || !npmLatestIsB(snapshot, identity) || !npmCandidateExact(snapshot, identity)
|
|
645
|
+
|| !githubIsB(snapshot, identity) || snapshot.github?.published !== true || snapshot.github?.latest !== true
|
|
646
|
+
|| snapshot.github?.assetsExact !== true
|
|
647
|
+
|| canonicalJson(snapshot.npm) !== canonicalJson(current.observation.npm)
|
|
648
|
+
|| canonicalJson(snapshot.github) !== canonicalJson(current.observation.github)) {
|
|
649
|
+
throw new Error('public artifacts or channels changed before abandonment');
|
|
650
|
+
}
|
|
651
|
+
const receipt = stateReceipt({ identity, prior: current, state: 'abandoned', privateKey,
|
|
652
|
+
observation: { verdict: 'PUBLISHED_NOT_VERIFIED', abandonment: { intentSha256, ...intent, authorization, failure },
|
|
653
|
+
npm: snapshot.npm, github: snapshot.github } });
|
|
654
|
+
verifyReceipt(receipt, publicKey);
|
|
655
|
+
const name = `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`;
|
|
656
|
+
await adapter.appendReceipt(discovered.matchingDrafts?.[0] || null, receipt, name);
|
|
657
|
+
const persisted = await adapter.readReceipt(discovered.matchingDrafts?.[0] || null, receipt.sequence);
|
|
658
|
+
verifyReceipt(persisted, publicKey);
|
|
659
|
+
if (canonicalJson(persisted) !== canonicalJson(receipt)) throw new Error('abandoned receipt readback differs');
|
|
660
|
+
validateReceiptChain([...chain, persisted], identity, publicKey);
|
|
661
|
+
return persisted;
|
|
662
|
+
}
|
|
@@ -103,26 +103,19 @@ export const REMEDIES = [
|
|
|
103
103
|
inverse: () => ({ kind: K.RESTORE_STORE_BACKUPS }),
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// is the executor behind it. See distill-project.mjs's header for why THIS script and not bare
|
|
109
|
-
// `ruflo memory distill run`: the wrapper snapshots first, fails closed on a receipt-write
|
|
110
|
-
// failure, and its `--restore` is the tested inverse (proven 644→648→644→648, 2026-07-24).
|
|
106
|
+
// Retained for explicit execution and existing receipts, not offered as reversible.
|
|
107
|
+
// The restore command refuses unsafe replacement; historical roundtrips do not authorize it.
|
|
111
108
|
key: 'enable-memory-distillation',
|
|
112
|
-
autoEligible:
|
|
113
|
-
summary: "mine this project's stored memories into reusable patterns (snapshots first;
|
|
109
|
+
autoEligible: false,
|
|
110
|
+
summary: "mine this project's stored memories into reusable patterns (snapshots first; automatic undo unavailable)",
|
|
114
111
|
match: (id) => (id === 'enable:memory-distillation' ? {} : null),
|
|
115
112
|
// This console instance is always scoped to ONE project — the directory it was started in — the
|
|
116
113
|
// same assumption the memory-index/learning-flush/learning-train remedies above already make.
|
|
117
114
|
// `usesServerProject` asks onboarding-console.mjs (impure, process-aware) to supply that directory
|
|
118
115
|
// at call time; this file stays pure and never reads process.cwd() itself (see header).
|
|
119
116
|
plan: () => ({ script: 'scripts/distill-project.mjs', args: [], usesServerProject: true }),
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// which snapshot to restore here, instead of asking the tool that took it, is the kind of
|
|
123
|
-
// duplicate implementation this project has already been burned by once (ADR-047's rejected
|
|
124
|
-
// "offered command and promised undo live on different execution paths" bug).
|
|
125
|
-
inverse: () => ({ kind: K.RESTORE_PROJECT_DISTILL }),
|
|
117
|
+
inverse: () => ({ kind: K.RESTORE_PROJECT_DISTILL, available: false,
|
|
118
|
+
human: 'automatic undo is unavailable; retain the snapshot and coordinate offline SQLite recovery with all database users stopped' }),
|
|
126
119
|
},
|
|
127
120
|
{
|
|
128
121
|
key: 'stack-sync',
|
|
@@ -2,10 +2,74 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import readline from 'node:readline';
|
|
5
6
|
import { spawnSync } from 'node:child_process';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { canonicalJson, digest, validateCoverageLedger } from './coverage-integrity.mjs';
|
|
8
9
|
|
|
10
|
+
// Both release phases resolve against an explicit installed context, never the checkout.
|
|
11
|
+
export async function resolveInstalledCanaryCitation({ kbDir, matched, expected, passageFileDigests = new Map() }) {
|
|
12
|
+
if (!path.isAbsolute(kbDir || '')) throw new Error('installed canary KB path must be absolute');
|
|
13
|
+
if (String(matched?.repo || '').toLowerCase() !== expected.repo || matched?.path !== expected.path) return { resolved: false };
|
|
14
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(expected.repo)) throw new Error('installed citation repository violates containment');
|
|
15
|
+
const rootStat = fs.lstatSync(kbDir);
|
|
16
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || fs.realpathSync(kbDir) !== path.resolve(kbDir)) {
|
|
17
|
+
throw new Error('installed canary KB root or parent is a symlink; canonical containment required');
|
|
18
|
+
}
|
|
19
|
+
const files = [path.join(kbDir, `${expected.repo}.passages.jsonl`), path.join(kbDir, `${expected.repo}.big.passages.jsonl`)]
|
|
20
|
+
.filter((file) => { try { fs.lstatSync(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } });
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
const stat = fs.lstatSync(file);
|
|
23
|
+
if (!stat.isFile() || stat.isSymbolicLink() || path.dirname(fs.realpathSync(file)) !== kbDir) {
|
|
24
|
+
throw new Error('installed citation file is a symlink or violates containment');
|
|
25
|
+
}
|
|
26
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
27
|
+
const stream = fs.createReadStream(file, { fd, autoClose: false });
|
|
28
|
+
const hash = crypto.createHash('sha256');
|
|
29
|
+
stream.on('data', (bytes) => hash.update(bytes));
|
|
30
|
+
const rows = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
31
|
+
let passageSha256 = null;
|
|
32
|
+
try {
|
|
33
|
+
for await (const line of rows) {
|
|
34
|
+
let record;
|
|
35
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
36
|
+
if (record?.path !== expected.path) continue;
|
|
37
|
+
if (digest(record) !== expected.passageSha256) continue;
|
|
38
|
+
const text = record.fullText || record.text;
|
|
39
|
+
if (typeof text !== 'string' || !text || typeof matched.text !== 'string' || !matched.text.includes(text)) continue;
|
|
40
|
+
passageSha256 = expected.passageSha256;
|
|
41
|
+
}
|
|
42
|
+
const after = fs.lstatSync(file);
|
|
43
|
+
const opened = fs.fstatSync(fd);
|
|
44
|
+
if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino
|
|
45
|
+
|| opened.ino !== stat.ino || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs
|
|
46
|
+
|| after.ctimeMs !== stat.ctimeMs) throw new Error('installed citation changed during verification');
|
|
47
|
+
const passageFileSha256 = hash.digest('hex');
|
|
48
|
+
passageFileDigests.set(file, passageFileSha256);
|
|
49
|
+
if (passageSha256) return { resolved: true, evidence: { passageSha256, passageFileSha256,
|
|
50
|
+
hitContentSha256: crypto.createHash('sha256').update(matched.text).digest('hex') } };
|
|
51
|
+
} finally {
|
|
52
|
+
rows.close();
|
|
53
|
+
await new Promise((resolve, reject) => stream.close((error) => error ? reject(error) : resolve()));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { resolved: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function expectedSources(expected) {
|
|
60
|
+
return [{ path: expected.path, passageSha256: expected.passageSha256 }, ...(expected.alternatives || [])];
|
|
61
|
+
}
|
|
62
|
+
function validExpectedSources(expected) {
|
|
63
|
+
if (!expected || !['passageSha256,path', 'alternatives,passageSha256,path'].includes(Object.keys(expected).sort().join(','))) return false;
|
|
64
|
+
if (expected.alternatives !== undefined && (!Array.isArray(expected.alternatives)
|
|
65
|
+
|| expected.alternatives.length < 1 || expected.alternatives.length > 8)) return false;
|
|
66
|
+
const sources = expectedSources(expected);
|
|
67
|
+
return new Set(sources.map((source) => source?.path)).size === sources.length && sources.every((source) =>
|
|
68
|
+
source && Object.keys(source).sort().join(',') === 'passageSha256,path'
|
|
69
|
+
&& typeof source.path === 'string' && source.path && !path.isAbsolute(source.path)
|
|
70
|
+
&& !source.path.split(/[\\/]/).includes('..') && HEX64.test(String(source.passageSha256 || '')));
|
|
71
|
+
}
|
|
72
|
+
|
|
9
73
|
const HEX40 = /^[a-f0-9]{40}$/;
|
|
10
74
|
const HEX64 = /^[a-f0-9]{64}$/;
|
|
11
75
|
|
|
@@ -96,10 +160,7 @@ export function validateRetrievalQueryEvidence(evidence) {
|
|
|
96
160
|
if (Object.keys(row || {}).sort().join(',') !== 'expected,query,recordSha256'
|
|
97
161
|
|| typeof row.query !== 'string' || row.query !== row.query.trim().replace(/\s+/g, ' ')
|
|
98
162
|
|| normalized.length < 24 || seenQueries.has(normalized)
|
|
99
|
-
|| !
|
|
100
|
-
|| typeof expected.path !== 'string' || !expected.path || path.isAbsolute(expected.path)
|
|
101
|
-
|| expected.path.split(/[\\/]/).includes('..')
|
|
102
|
-
|| !HEX64.test(String(expected.passageSha256 || ''))
|
|
163
|
+
|| !validExpectedSources(expected)
|
|
103
164
|
|| row.recordSha256 !== digest({ store, query: row.query, expected })) {
|
|
104
165
|
throw new Error(`independent retrieval query evidence for ${store} is malformed`);
|
|
105
166
|
}
|
|
@@ -187,7 +248,8 @@ export function validateRetrievalCanaryPlan(plan) {
|
|
|
187
248
|
throw new Error('oracle denominator differs from eligible coverage');
|
|
188
249
|
}
|
|
189
250
|
if (new Set(ids).size !== ids.length) throw new Error('retrieval canary plan has duplicate case ids');
|
|
190
|
-
|
|
251
|
+
const hasDelta = plan.cases.some(({ cohort }) => cohort === 'delta');
|
|
252
|
+
if ((!hasDelta && plan.noDelta !== true) || (hasDelta && plan.noDelta === true)
|
|
191
253
|
|| !plan.cases.some(({ cohort }) => cohort === 'legacy')) {
|
|
192
254
|
throw new Error('retrieval canary plan requires delta and legacy cohorts');
|
|
193
255
|
}
|
|
@@ -316,12 +378,15 @@ export function buildRetrievalCanaryPlan({ coverage, baseline, candidate, covera
|
|
|
316
378
|
if (!strata.has(stratum)) strata.set(stratum, []);
|
|
317
379
|
strata.get(stratum).push(entry);
|
|
318
380
|
});
|
|
381
|
+
// Source-only releases retain the same corpus sample; the plan still seals exact release bytes.
|
|
382
|
+
const samplingGeneration = coverage.kind === 'ruvnet-brain-release-coverage'
|
|
383
|
+
? coverage.corpusCoverage.coverageGeneration : coverageGeneration;
|
|
319
384
|
const sampleCount = Math.min(legacyPool.length, legacySampleSize ?? Math.max(10, Math.ceil(eligible.length * 0.1)));
|
|
320
385
|
if (sampleCount < strata.size) throw new Error(`legacy sample must cover all ${strata.size} passage-count strata`);
|
|
321
386
|
const selected = [];
|
|
322
387
|
for (const [stratum, entries] of strata) {
|
|
323
|
-
entries.sort((a, b) => digest(`${
|
|
324
|
-
.localeCompare(digest(`${
|
|
388
|
+
entries.sort((a, b) => digest(`${samplingGeneration}:${stratum}:${storeOf(a.row)}`)
|
|
389
|
+
.localeCompare(digest(`${samplingGeneration}:${stratum}:${storeOf(b.row)}`)));
|
|
325
390
|
selected.push({ ...entries.shift(), stratum });
|
|
326
391
|
}
|
|
327
392
|
while (selected.length < sampleCount) {
|
|
@@ -338,9 +403,8 @@ export function buildRetrievalCanaryPlan({ coverage, baseline, candidate, covera
|
|
|
338
403
|
const store = storeOf(row);
|
|
339
404
|
const observedPassageCount = passageCount ?? passages.get(store).length;
|
|
340
405
|
const evidence = queryEvidence.queries[store];
|
|
341
|
-
|
|
342
|
-
&& digest(row) ===
|
|
343
|
-
if (!evidence || matches.length !== 1) {
|
|
406
|
+
if (!evidence || expectedSources(evidence.expected).some((source) =>
|
|
407
|
+
passages.get(store).filter((row) => row.path === source.path && digest(row) === source.passageSha256).length !== 1)) {
|
|
344
408
|
throw new Error(`${store} has no sealed independent query evidence`);
|
|
345
409
|
}
|
|
346
410
|
return {
|
|
@@ -430,10 +494,12 @@ export function validateRetrievalCanaryReceipt(receipt, { plan, requireAcceptanc
|
|
|
430
494
|
for (const row of receipt.cases) {
|
|
431
495
|
const expected = plan.cases.find(({ id }) => id === row.id)?.expected;
|
|
432
496
|
const ranked = row.retrievalHit ? row.citations?.[row.rank - 1] : null;
|
|
497
|
+
const accepted = expectedSources(expected).find((source) => source.path === ranked?.path);
|
|
433
498
|
if (row.retrievalHit && (String(ranked?.repo || '').toLowerCase() !== expected.repo
|
|
434
|
-
||
|
|
435
|
-
|| (row.citationResolved === true && (row.citationEvidence?.passageSha256 !==
|
|
436
|
-
|| !HEX64.test(String(row.citationEvidence?.passageFileSha256 || ''))
|
|
499
|
+
|| !accepted
|
|
500
|
+
|| (row.citationResolved === true && (row.citationEvidence?.passageSha256 !== accepted.passageSha256
|
|
501
|
+
|| !HEX64.test(String(row.citationEvidence?.passageFileSha256 || ''))
|
|
502
|
+
|| (ranked.contentSha256 !== undefined && row.citationEvidence?.hitContentSha256 !== ranked.contentSha256))))) {
|
|
437
503
|
throw new Error(`retrieval canary ${row.id} hit or citation evidence differs from the sealed plan`);
|
|
438
504
|
}
|
|
439
505
|
}
|
|
@@ -448,7 +514,9 @@ export function validateRetrievalCanaryReceipt(receipt, { plan, requireAcceptanc
|
|
|
448
514
|
deltaHits: delta.filter(({ retrievalHit }) => retrievalHit === true).length,
|
|
449
515
|
deltaCitations: delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length,
|
|
450
516
|
recallAt10: hits / receipt.cases.length,
|
|
451
|
-
deltaCitationRate: delta.length
|
|
517
|
+
deltaCitationRate: delta.length
|
|
518
|
+
? delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length / delta.length
|
|
519
|
+
: plan.noDelta === true ? 1 : 0,
|
|
452
520
|
unknown,
|
|
453
521
|
skipped,
|
|
454
522
|
};
|
|
@@ -478,12 +546,14 @@ export async function runRetrievalCanaries({ plan, sourceSha, artifactSha256, ca
|
|
|
478
546
|
const rows = resultRows(await search({ query: canary.query, k: 10 }));
|
|
479
547
|
const top = rows.slice(0, 10);
|
|
480
548
|
const rank = top.findIndex((row) => String(row?.repo || '').toLowerCase() === canary.expected.repo
|
|
481
|
-
&& row?.path ===
|
|
549
|
+
&& expectedSources(canary.expected).some((source) => row?.path === source.path));
|
|
482
550
|
const matched = rank >= 0 ? top[rank] : null;
|
|
483
|
-
const
|
|
551
|
+
const accepted = matched ? expectedSources(canary.expected).find((source) => source.path === matched.path) : null;
|
|
552
|
+
const resolved = matched ? await citationResolver(matched, { repo: canary.expected.repo, ...accepted }) : { resolved: false };
|
|
484
553
|
cases[index] = { id: canary.id, cohort: canary.cohort, status: 'COMPLETED', retrievalHit: rank >= 0,
|
|
485
554
|
citationResolved: resolved?.resolved === true, citationEvidence: resolved?.evidence || null,
|
|
486
|
-
rank: rank >= 0 ? rank + 1 : null, citations: top.map(({ repo, path: hitPath }) => ({ repo, path: hitPath
|
|
555
|
+
rank: rank >= 0 ? rank + 1 : null, citations: top.map(({ repo, path: hitPath, contentSha256 }) => ({ repo, path: hitPath,
|
|
556
|
+
...(contentSha256 ? { contentSha256 } : {}) })) };
|
|
487
557
|
} catch (error) {
|
|
488
558
|
cases[index] = { id: canary.id, cohort: canary.cohort, status: 'UNKNOWN', retrievalHit: false,
|
|
489
559
|
citationResolved: false, citationEvidence: null, rank: null, citations: [], error: error.message };
|
|
@@ -494,7 +564,10 @@ export async function runRetrievalCanaries({ plan, sourceSha, artifactSha256, ca
|
|
|
494
564
|
const hits = cases.filter(({ retrievalHit }) => retrievalHit).length;
|
|
495
565
|
const metrics = { total: cases.length, hits, deltaTotal: delta.length, deltaHits: delta.filter(({ retrievalHit }) => retrievalHit).length,
|
|
496
566
|
deltaCitations: delta.filter(({ retrievalHit, citationResolved }) => retrievalHit && citationResolved).length,
|
|
497
|
-
recallAt10: hits / cases.length,
|
|
567
|
+
recallAt10: hits / cases.length,
|
|
568
|
+
deltaCitationRate: delta.length
|
|
569
|
+
? delta.filter(({ retrievalHit, citationResolved }) => retrievalHit && citationResolved).length / delta.length
|
|
570
|
+
: plan.noDelta === true ? 1 : 0,
|
|
498
571
|
unknown: cases.filter(({ status }) => status === 'UNKNOWN').length,
|
|
499
572
|
skipped: cases.filter(({ status }) => status === 'SKIPPED').length };
|
|
500
573
|
const payload = { schemaVersion: 1, kind: 'ruvnet-brain-retrieval-canary-receipt', sourceSha, artifactSha256,
|
package/scripts/selfcheck.mjs
CHANGED
|
@@ -477,7 +477,7 @@ export function assertContract({ rec, measurement, mode, timeoutSec }) {
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
// ── §4 THE BATTERY: every installed registration × every stdin regime ───────────────────────────
|
|
480
|
-
export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tmpdir(), regimes = STDIN_REGIMES, surface = null, env = {} } = {}) {
|
|
480
|
+
export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tmpdir(), regimes = STDIN_REGIMES, surface = null, env = {}, inspectOnly = false } = {}) {
|
|
481
481
|
const reg = await loadRegistry();
|
|
482
482
|
const s = surface ?? resolveInstalledSurface({ home, repo });
|
|
483
483
|
if (!s.ok) return { ok: false, reason: s.reason, violations: [], results: [] };
|
|
@@ -487,6 +487,7 @@ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tm
|
|
|
487
487
|
const { contracts } = reg.loadContracts(s.root);
|
|
488
488
|
|
|
489
489
|
const registrations = readInstalledRegistrations(s.hooksFile);
|
|
490
|
+
if (inspectOnly) return { ok: true, surface: s, registrations, violations: [], results: [] };
|
|
490
491
|
const violations = [];
|
|
491
492
|
const results = [];
|
|
492
493
|
|
|
@@ -614,13 +615,20 @@ export async function selfCheck({ home = os.homedir(), repo = null, cwd = os.tmp
|
|
|
614
615
|
}
|
|
615
616
|
|
|
616
617
|
// (b) THE BATTERY
|
|
617
|
-
const battery = await runBattery({ home, repo, cwd, regimes });
|
|
618
|
+
const battery = await runBattery({ home, repo, cwd, regimes, inspectOnly: true });
|
|
618
619
|
if (!battery.ok) {
|
|
619
620
|
lines.push(`hooks: ${battery.reason}`);
|
|
620
621
|
violations.push({ kind: 'no-plugin', where: 'hooks', detail: battery.reason });
|
|
621
622
|
} else {
|
|
622
623
|
violations.push(...battery.violations);
|
|
623
|
-
|
|
624
|
+
if (battery.registrations.length !== 0) {
|
|
625
|
+
violations.push({
|
|
626
|
+
kind: 'automatic-registration',
|
|
627
|
+
where: battery.surface.source,
|
|
628
|
+
detail: `${battery.registrations.length} retired Brain lifecycle registration(s) remain installed`,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
lines.push(`hooks: ${battery.registrations.length} automatic registrations from ${battery.surface.source}; zero is the required retired state`);
|
|
624
632
|
}
|
|
625
633
|
|
|
626
634
|
// (c) COEXISTENCE — reported, never charged to the user
|
|
@@ -656,7 +664,7 @@ export function formatVerdict(result, { color = null } = {}) {
|
|
|
656
664
|
const out = [];
|
|
657
665
|
for (const l of result.lines) out.push(` ${c.dim(l)}`);
|
|
658
666
|
if (!result.violations.length) {
|
|
659
|
-
out.push(` ${c.green('✓ Self-check passed.')}
|
|
667
|
+
out.push(` ${c.green('✓ Self-check passed.')} The installed Brain surface has zero automatic lifecycle registrations.`);
|
|
660
668
|
return out.join('\n');
|
|
661
669
|
}
|
|
662
670
|
out.push(` ${c.red(`✗ Self-check FAILED — ${result.violations.length} contract violation(s):`)}`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* snapshot-freshness.mjs — "a claimed backup is not a found backup" as a shared, pure function.
|
|
4
|
+
*
|
|
5
|
+
* `distill-project.mjs` established this discipline (PR #192): `ruflo memory backup` exiting 0
|
|
6
|
+
* proves nothing about whether a NEW file actually landed — a stale snapshot from a prior run
|
|
7
|
+
* sitting in the same directory is otherwise indistinguishable from a genuine fresh one, and a
|
|
8
|
+
* caller that trusts the exit code alone can proceed to mutate state on a false undo guarantee.
|
|
9
|
+
* Extracted here so `health-repair.mjs`'s fleet distillation (a second, independent caller of
|
|
10
|
+
* `ruflo memory backup` per store) can share the exact same proof instead of re-deriving it —
|
|
11
|
+
* duplicating this logic once already cost a real, shipped defect (see the 2026-08-29 Dream Cycle
|
|
12
|
+
* report, "Next steps #1").
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
// Some filesystems truncate mtime to whole seconds (FAT32, some overlay/network mounts), so a file
|
|
18
|
+
// written a moment after `sinceMs` was captured can still report an mtime slightly before it. This
|
|
19
|
+
// grace window is tolerance for that truncation, not a loophole — it stays far smaller than the gap
|
|
20
|
+
// between one run and the next.
|
|
21
|
+
export const MTIME_GRACE_MS = 1500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The newest matching snapshot file in `dir`, or null. With `sinceMs`, only a file whose mtime is
|
|
25
|
+
* no older than that moment (minus the grace window) counts. Mutating callers also provide a
|
|
26
|
+
* pre-backup inventory, so an unchanged prior snapshot cannot count within that grace. Callers that
|
|
27
|
+
* legitimately want the newest snapshot ever (e.g. a restore path) pass no `sinceMs`.
|
|
28
|
+
*/
|
|
29
|
+
export function snapshotInventory(dir) {
|
|
30
|
+
let root;
|
|
31
|
+
try { root = fs.lstatSync(dir); }
|
|
32
|
+
catch (error) { if (error.code === 'ENOENT') return new Map(); throw error; }
|
|
33
|
+
if (!root.isDirectory() || root.isSymbolicLink()) throw new Error('Backup destination is not a regular directory');
|
|
34
|
+
const inventory = new Map();
|
|
35
|
+
for (const name of fs.readdirSync(dir)) {
|
|
36
|
+
if (!/\.(db|sqlite|bak)$/i.test(name) && !/memory.*\d/.test(name)) continue;
|
|
37
|
+
const file = path.join(dir, name);
|
|
38
|
+
try {
|
|
39
|
+
const stat = fs.lstatSync(file);
|
|
40
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !stat.size) continue;
|
|
41
|
+
inventory.set(file, {
|
|
42
|
+
path: file, mtimeMs: stat.mtimeMs,
|
|
43
|
+
identity: [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':'),
|
|
44
|
+
});
|
|
45
|
+
} catch { /* a disappearing candidate is not a located backup */ }
|
|
46
|
+
}
|
|
47
|
+
return inventory;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function newestSnapshot(dir, sinceMs = 0, graceMs = MTIME_GRACE_MS, before = null) {
|
|
51
|
+
try {
|
|
52
|
+
const files = [...snapshotInventory(dir).values()]
|
|
53
|
+
.filter(file => file.mtimeMs >= sinceMs - graceMs
|
|
54
|
+
&& (!before || before.get(file.path)?.identity !== file.identity))
|
|
55
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
56
|
+
return files[0]?.path ?? null;
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|