ruvnet-brain 4.7.19 → 4.7.21
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 +494 -97
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall.mjs +15 -5
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +56 -12
- package/plugin/scripts/continuation-gate.mjs +28 -9
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/session-snapshot-hook.mjs +24 -4
- package/plugin/scripts/session-start-core.mjs +8 -16
- package/plugin/scripts/session-start-health.mjs +97 -10
- package/plugin/scripts/session-start-issue-alert.mjs +105 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +164 -47
- package/scripts/job-heartbeat.sh +145 -13
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +30 -21
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +549 -0
- package/scripts/onboarding-console.mjs +47 -8
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +71 -26
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +11 -5
- package/scripts/worktree-integrity.mjs +2 -2
package/kb/forge-update.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import os from 'node:os';
|
|
|
25
25
|
import { execFileSync } from 'node:child_process';
|
|
26
26
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
27
|
import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
28
|
-
import { fetchJsonWithRetry, fetchBytesWithRetry } from './download-retry.mjs';
|
|
28
|
+
import { fetchJsonWithRetry, fetchBytesWithRetry, downloadFileWithRetry } from './download-retry.mjs';
|
|
29
29
|
import { extractZip, zipDeclaredBytes } from './zip-extract.mjs';
|
|
30
30
|
import { applyBrainProfile, discoverStoreFamilies, readBrainProfile } from './brain-profile.mjs';
|
|
31
31
|
import { acquireRefreshLock, releaseRefreshLock } from './refresh-run.mjs';
|
|
@@ -80,11 +80,26 @@ const SIGNING_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
|
80
80
|
MCowBQYDK2VwAyEAgse9TAtehXUvUfTrJFY2CCHiCbmelR8yCgS//sen5/w=
|
|
81
81
|
-----END PUBLIC KEY-----`;
|
|
82
82
|
|
|
83
|
+
// Hash a file in fixed-size chunks: the bundle is ~745 MB and must never be held in memory whole.
|
|
84
|
+
function digestFile(file, algorithms = ['sha256']) {
|
|
85
|
+
const hashes = algorithms.map((algorithm) => [algorithm, createHash(algorithm)]);
|
|
86
|
+
const chunk = Buffer.allocUnsafe(8 * 1024 * 1024);
|
|
87
|
+
const fd = fs.openSync(file, 'r');
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
try {
|
|
90
|
+
for (let read; (read = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0;) {
|
|
91
|
+
bytes += read;
|
|
92
|
+
for (const [, hash] of hashes) hash.update(chunk.subarray(0, read));
|
|
93
|
+
}
|
|
94
|
+
} finally { fs.closeSync(fd); }
|
|
95
|
+
return { bytes, digests: Object.fromEntries(hashes.map(([algorithm, hash]) => [algorithm, hash.digest('hex')])) };
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
export function verifyDownloadedBundle(bundlePath, signaturePath) {
|
|
84
99
|
try {
|
|
85
100
|
if (!fs.existsSync(bundlePath)) return { ok: false, reason: `bundle not found: ${bundlePath}` };
|
|
86
101
|
if (!fs.existsSync(signaturePath)) return { ok: false, reason: 'signature missing (fail-closed)' };
|
|
87
|
-
const digest =
|
|
102
|
+
const digest = digestFile(bundlePath).digests.sha256;
|
|
88
103
|
const ok = verifySignature(null, Buffer.from(digest, 'hex'), createPublicKey(SIGNING_PUBKEY_PEM), fs.readFileSync(signaturePath));
|
|
89
104
|
return ok ? { ok: true, reason: `signature valid (sha256 ${digest.slice(0, 12)}…)` }
|
|
90
105
|
: { ok: false, reason: 'signature does NOT match — bundle may be tampered' };
|
|
@@ -103,11 +118,20 @@ let updateLock = null;
|
|
|
103
118
|
let updateOutcomeWritten = false;
|
|
104
119
|
let lifecycleRetention = null;
|
|
105
120
|
let legacyBackupRetention = null;
|
|
121
|
+
// The bundle's temp directory (zip, signature, extraction). Removed on EVERY exit, including die() and a signal.
|
|
122
|
+
let downloadDir = null;
|
|
106
123
|
|
|
107
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Lifecycle-evidence retention is housekeeping of receipts ABOUT updates. It never changes the verdict of
|
|
126
|
+
* the update itself (4.7.21 RC2.3): a `.DS_Store` or a stranded quarantine used to turn every applied and
|
|
127
|
+
* no-op run into `recovery-required` exit 1, and the installer then re-downloaded 745 MB every night into
|
|
128
|
+
* the same wall. Over budget or unsafe evidence is recorded as `retentionAdvisory`, and the verdict and exit
|
|
129
|
+
* code stay exactly what the update produced.
|
|
130
|
+
*/
|
|
131
|
+
function writeUpdateOutcome(outcome, { prune = true } = {}) {
|
|
108
132
|
if (updateOutcomeWritten) return outcome;
|
|
109
133
|
if (legacyBackupRetention) outcome = { ...outcome, legacyBackupRetention };
|
|
110
|
-
if (!lifecycleRetention) {
|
|
134
|
+
if (!lifecycleRetention && prune) {
|
|
111
135
|
try {
|
|
112
136
|
lifecycleRetention = pruneLifecycleEvidence({ brainHome: path.dirname(KB_DIR), kbDir: KB_DIR,
|
|
113
137
|
preserveRefreshRunIds: updateLock?.runId ? [updateLock.runId] : [],
|
|
@@ -117,10 +141,14 @@ function writeUpdateOutcome(outcome) {
|
|
|
117
141
|
withinBudget: false, unsafe: [{ path: path.dirname(KB_DIR), reason: error.message }] };
|
|
118
142
|
}
|
|
119
143
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
lifecycleRetention };
|
|
144
|
+
let finalOutcome = lifecycleRetention ? { ...outcome, lifecycleRetention } : outcome;
|
|
145
|
+
if (lifecycleRetention && lifecycleRetention.withinBudget !== true) {
|
|
146
|
+
const retentionAdvisory = { withinBudget: false,
|
|
147
|
+
reason: `lifecycle evidence retention is over budget or unsafe: ${lifecycleRetention.unsafe?.map(({ path: entry, reason }) => `${entry}: ${reason}`).join('; ') || 'budget exceeded'}`,
|
|
148
|
+
unsafe: lifecycleRetention.unsafe?.length || 0, foreign: lifecycleRetention.foreign?.length || 0 };
|
|
149
|
+
finalOutcome = { ...finalOutcome, retentionAdvisory };
|
|
150
|
+
console.error(`\n ⚠ advisory (the update verdict is unchanged): ${retentionAdvisory.reason}`);
|
|
151
|
+
}
|
|
124
152
|
if (!RESULT_FILE) return finalOutcome;
|
|
125
153
|
fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });
|
|
126
154
|
atomicJson(RESULT_FILE, { schemaVersion: 1, kind: 'ruvnet-brain-update-result',
|
|
@@ -158,7 +186,24 @@ export function releaseUpdateLock(lock = updateLock) {
|
|
|
158
186
|
return released;
|
|
159
187
|
}
|
|
160
188
|
|
|
161
|
-
process.on('exit', () => {
|
|
189
|
+
process.on('exit', () => {
|
|
190
|
+
releaseUpdateLock();
|
|
191
|
+
if (downloadDir) { try { fs.rmSync(downloadDir, { recursive: true, force: true }); } catch { /* best effort at exit */ } }
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// A time-limit kill (SessionStart TTL, the nightly runner) sends SIGTERM to the process group. Without a
|
|
195
|
+
// handler Node exits without running 'exit' listeners, so the update lock stayed behind and the result
|
|
196
|
+
// file never said why. Settle the same way die() does: record the outcome, keep any rollback copy, exit.
|
|
197
|
+
function interruptedBySignal(signal) {
|
|
198
|
+
const exitCode = signal === 'SIGINT' ? 130 : 143;
|
|
199
|
+
console.error(`\n[forge-update] interrupted by ${signal} — the live KB is left as the storage transaction left it.`);
|
|
200
|
+
// --check records only its currency verdict; an interrupted poll has none to record.
|
|
201
|
+
if (APPLY || STAGED_RELEASE_FILE) {
|
|
202
|
+
try { writeUpdateOutcome({ terminalVerdict: 'interrupted', exitCode, reason: `interrupted by ${signal}` }, { prune: false }); } catch { /* exit wins */ }
|
|
203
|
+
}
|
|
204
|
+
try { settleRollback({ reclaimable: false, keepReason: `the update was interrupted by ${signal}` }); } catch { /* exit wins */ }
|
|
205
|
+
process.exit(exitCode);
|
|
206
|
+
}
|
|
162
207
|
function settleRollback({ reclaimable, keepReason = null, intentionallyRemovedStores = [] }) {
|
|
163
208
|
if (rollbackSettled) return;
|
|
164
209
|
rollbackSettled = true;
|
|
@@ -781,12 +826,28 @@ async function fetchJson(url) {
|
|
|
781
826
|
die(`network failure fetching ${url}: ${error.message} — nothing changed locally.`, 2);
|
|
782
827
|
}
|
|
783
828
|
}
|
|
829
|
+
function downloadFailed(error, url, kind, failureCode) {
|
|
830
|
+
if (error.status) die(`${kind} download returned HTTP ${error.status} for ${url} — nothing changed.`, failureCode);
|
|
831
|
+
die(`network failure downloading ${kind} ${url}: ${error.message} — nothing changed locally.`, failureCode);
|
|
832
|
+
}
|
|
784
833
|
async function fetchBuffer(url, { failureCode = 2, kind = 'bundle' } = {}) {
|
|
785
|
-
try { return await fetchBytesWithRetry(url); }
|
|
786
|
-
catch (error) {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
834
|
+
try { return await fetchBytesWithRetry(url, { deadlineMs: 5 * 60_000 }); }
|
|
835
|
+
catch (error) { downloadFailed(error, url, kind, failureCode); }
|
|
836
|
+
}
|
|
837
|
+
// The bundle streams to disk: no-progress timeout, Range resume across retries, and a deadline scaled to
|
|
838
|
+
// its size (kb/download-retry.mjs) — never the old whole-attempt 5-minute cap over a buffer in memory.
|
|
839
|
+
async function fetchFile(url, destination, { failureCode = 2, kind = 'bundle' } = {}) {
|
|
840
|
+
let lastShown = -1;
|
|
841
|
+
try {
|
|
842
|
+
await downloadFileWithRetry(url, destination, {
|
|
843
|
+
headers: { 'User-Agent': 'ruvnet-brain-updater', Accept: 'application/octet-stream' },
|
|
844
|
+
onRetry: ({ attempt, error }) => console.error(` download interrupted (${error.message}); retry ${attempt + 1}/3, resuming where it stopped when the server allows`),
|
|
845
|
+
onProgress: ({ received, total }) => {
|
|
846
|
+
const pct = total ? Math.floor(received / total * 10) * 10 : -1;
|
|
847
|
+
if (total && pct !== lastShown) { lastShown = pct; console.log(` …${pct}% (${(received / 1e6).toFixed(0)} MB / ${(total / 1e6).toFixed(0)} MB)`); }
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
} catch (error) { downloadFailed(error, url, kind, failureCode); }
|
|
790
851
|
}
|
|
791
852
|
|
|
792
853
|
// The canonical manifest can be ONE of three shapes — handle all three:
|
|
@@ -1053,9 +1114,48 @@ function dirSize(dir) {
|
|
|
1053
1114
|
// A backup-looking name and matching RVF paths are not evidence that its other
|
|
1054
1115
|
// bytes are disposable. Legacy backups have no authenticated complete ownership
|
|
1055
1116
|
// receipt: reclaim only a tree whose every entry survives identically in live.
|
|
1056
|
-
|
|
1057
|
-
|
|
1117
|
+
//
|
|
1118
|
+
// #335: `archives` are `--clean --archive` trees (<kb>.archive/<stamp>/<copy>/). A regular file whose exact
|
|
1119
|
+
// bytes sit at the same relative path in one of them is accounted for even when live lacks or changed it.
|
|
1120
|
+
function assertRedundantBackup(backup, live, archives = []) {
|
|
1121
|
+
const quiet = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
|
|
1122
|
+
// A reference (live or archive) counts only as a regular file reached through real directories, no link at
|
|
1123
|
+
// the root or any ancestor, whose real path is outside the backup being judged: a link into the backup would
|
|
1124
|
+
// compare the backup with itself and approve deleting its only copy (cross-vendor review A).
|
|
1125
|
+
const realBackup = (() => { try { return fs.realpathSync(backup); } catch { return path.resolve(backup); } })();
|
|
1126
|
+
const insideBackup = (file) => {
|
|
1127
|
+
let real; try { real = fs.realpathSync(file); } catch { return true; }
|
|
1128
|
+
return real === realBackup || real.startsWith(`${realBackup}${path.sep}`);
|
|
1129
|
+
};
|
|
1130
|
+
const reference = (root, relative) => {
|
|
1131
|
+
let dir = root;
|
|
1132
|
+
for (const part of ['', ...relative.split(path.sep).slice(0, -1)]) {
|
|
1133
|
+
dir = part ? path.join(dir, part) : dir;
|
|
1134
|
+
const st = quiet(dir);
|
|
1135
|
+
if (!st || !st.isDirectory() || st.isSymbolicLink()) return null;
|
|
1136
|
+
}
|
|
1137
|
+
const file = path.join(root, relative); const st = quiet(file);
|
|
1138
|
+
return st && st.isFile() && !st.isSymbolicLink() && !insideBackup(file) ? { file, st } : null;
|
|
1139
|
+
};
|
|
1140
|
+
const archived = (relative, prior, size) => archives.some((root) => {
|
|
1141
|
+
const ref = reference(root, relative);
|
|
1142
|
+
return Boolean(ref && ref.st.size === size && sha256File(ref.file) === sha256File(prior));
|
|
1143
|
+
});
|
|
1144
|
+
const compare = (prior, current, relative = '') => {
|
|
1058
1145
|
const a = fs.lstatSync(prior);
|
|
1146
|
+
if (archives.length && !a.isSymbolicLink() && (a.isFile() || a.isDirectory())) {
|
|
1147
|
+
if (a.isDirectory()) {
|
|
1148
|
+
for (const name of fs.readdirSync(prior)) {
|
|
1149
|
+
if (/^(?:\._|\.DS_Store$)/.test(name)) continue;
|
|
1150
|
+
compare(path.join(prior, name), path.join(current, name), path.join(relative, name));
|
|
1151
|
+
}
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const ref = reference(live, relative);
|
|
1155
|
+
const same = ref && a.size === ref.st.size && sha256File(prior) === sha256File(ref.file);
|
|
1156
|
+
if (same || archived(relative, prior, a.size)) return;
|
|
1157
|
+
throw new Error(`different bytes: ${prior}`);
|
|
1158
|
+
}
|
|
1059
1159
|
const b = fs.lstatSync(current);
|
|
1060
1160
|
if (a.isSymbolicLink() || b.isSymbolicLink()) {
|
|
1061
1161
|
// Compare links themselves, never dereference them. Store symlinks are
|
|
@@ -1068,7 +1168,7 @@ function assertRedundantBackup(backup, live) {
|
|
|
1068
1168
|
// written by an exFAT/FAT round trip is not Brain data, so it is never what makes a copy unique.
|
|
1069
1169
|
for (const name of fs.readdirSync(prior)) {
|
|
1070
1170
|
if (/^(?:\._|\.DS_Store$)/.test(name)) continue;
|
|
1071
|
-
compare(path.join(prior, name), path.join(current, name));
|
|
1171
|
+
compare(path.join(prior, name), path.join(current, name), path.join(relative, name));
|
|
1072
1172
|
}
|
|
1073
1173
|
} else if (a.isFile() && b.isFile()) {
|
|
1074
1174
|
if (a.size !== b.size || sha256File(prior) !== sha256File(current)) {
|
|
@@ -1183,6 +1283,12 @@ export function reclaimBackups({
|
|
|
1183
1283
|
const safePreserved = new Map();
|
|
1184
1284
|
const retentionPolicy = rollbackRetentionPolicy(kbDir, env);
|
|
1185
1285
|
const liveInventory = storeInventory(kbDir);
|
|
1286
|
+
// #335: trees written by the explicit `npx ruvnet-brain --clean --archive` (never automatically).
|
|
1287
|
+
const realDir = (dir) => { try { const st = fs.lstatSync(dir); return st.isDirectory() && !st.isSymbolicLink(); } catch { return false; } };
|
|
1288
|
+
const listDirs = (dir) => { try { return fs.readdirSync(dir).sort().map((n) => path.join(dir, n)).filter(realDir); } catch { return []; } };
|
|
1289
|
+
const archiveDir = path.join(parent, `${base}.archive`);
|
|
1290
|
+
// A dot-named entry is an archive still being written, or one a killed sweep left half-written: never a reference.
|
|
1291
|
+
const archives = realDir(archiveDir) ? listDirs(archiveDir).flatMap(listDirs).filter((dir) => !path.basename(dir).startsWith('.')) : [];
|
|
1186
1292
|
|
|
1187
1293
|
// A store the live release's own COVERAGE.json marks ineligible is absent from live BY POLICY
|
|
1188
1294
|
// (excluded-no-corpus, fork, archived…), not lost by an update; its presence in a backup must not
|
|
@@ -1234,6 +1340,19 @@ export function reclaimBackups({
|
|
|
1234
1340
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`not a real directory: ${dir}`);
|
|
1235
1341
|
}
|
|
1236
1342
|
} catch (error) { kept.push([b, `unsafe reclaim target: ${error.message}`]); continue; }
|
|
1343
|
+
// #335: a copy whose every byte survives in live or in an archive tree is accounted for, whatever
|
|
1344
|
+
// its store inventory says; anything still unproven falls through to the unchanged checks below.
|
|
1345
|
+
let archivedCopy = false;
|
|
1346
|
+
if (archives.length && liveInventory.complete) {
|
|
1347
|
+
try { assertRedundantBackup(b, kbDir, archives); archivedCopy = true; } catch { /* not fully accounted for */ }
|
|
1348
|
+
}
|
|
1349
|
+
if (archivedCopy) {
|
|
1350
|
+
const size = dirSize(b);
|
|
1351
|
+
if (dryRun) { wouldRemove.push(b); freed += size; continue; }
|
|
1352
|
+
try { fs.rmSync(b, { recursive: true, force: true }); removed.push(b); freed += size; }
|
|
1353
|
+
catch (e) { kept.push([b, `could not remove: ${e.message}`]); }
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1237
1356
|
const backupInventory = storeInventory(b);
|
|
1238
1357
|
if (!liveInventory.complete || !backupInventory.complete) {
|
|
1239
1358
|
kept.push([b, `inventory is incomplete; refusing destructive reclaim (${backupInventory.reason || liveInventory.reason || 'unknown'})`]);
|
|
@@ -1423,14 +1542,26 @@ function archiveVerifierFromDigests(actualDigests, bytes) {
|
|
|
1423
1542
|
|
|
1424
1543
|
// Capture the closed transaction algorithm inventory synchronously. Never retain mutable bytes:
|
|
1425
1544
|
// the capability attests only to this creation-time snapshot, not a later caller-owned Buffer.
|
|
1426
|
-
|
|
1427
|
-
if (!Buffer.isBuffer(downloadedBuffer) || !Array.isArray(expectedDigests)) throw new TypeError('archive bytes and digest inventory required');
|
|
1545
|
+
function archiveAlgorithms(expectedDigests) {
|
|
1428
1546
|
const algorithms = new Set(['sha256']);
|
|
1429
1547
|
for (const expected of expectedDigests) {
|
|
1430
1548
|
if (expected == null) continue;
|
|
1431
1549
|
if (typeof expected !== 'string' || !expected) throw new TypeError('invalid archive digest');
|
|
1432
1550
|
algorithms.add(expected.includes(':') ? expected.split(':')[0] : 'sha256');
|
|
1433
1551
|
}
|
|
1552
|
+
return algorithms;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** The same capability, computed from the downloaded file in chunks (the bundle is never held in memory). */
|
|
1556
|
+
export function createDownloadedArchiveVerifierFromFile(file, expectedDigests = []) {
|
|
1557
|
+
if (typeof file !== 'string' || !Array.isArray(expectedDigests)) throw new TypeError('archive path and digest inventory required');
|
|
1558
|
+
const { bytes, digests } = digestFile(file, [...archiveAlgorithms(expectedDigests)]);
|
|
1559
|
+
return archiveVerifierFromDigests(Object.freeze(digests), bytes);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
export function createDownloadedArchiveVerifier(downloadedBuffer, expectedDigests = []) {
|
|
1563
|
+
if (!Buffer.isBuffer(downloadedBuffer) || !Array.isArray(expectedDigests)) throw new TypeError('archive bytes and digest inventory required');
|
|
1564
|
+
const algorithms = archiveAlgorithms(expectedDigests);
|
|
1434
1565
|
const actualDigests = Object.freeze(Object.fromEntries([...algorithms].map(algorithm =>
|
|
1435
1566
|
[algorithm, createHash(algorithm).update(downloadedBuffer).digest('hex')])));
|
|
1436
1567
|
return archiveVerifierFromDigests(actualDigests, downloadedBuffer.length);
|
|
@@ -1658,13 +1789,11 @@ async function main() {
|
|
|
1658
1789
|
if (verdict.verdict === 'REFUSED') {
|
|
1659
1790
|
console.log(`REFUSED — ${verdict.reason}`);
|
|
1660
1791
|
console.log('Nothing was downloaded; the live brain is untouched.');
|
|
1661
|
-
|
|
1662
|
-
? writeUpdateOutcome({ terminalVerdict: 'refused', reason: verdict.reason,
|
|
1792
|
+
if (APPLY) writeUpdateOutcome({ terminalVerdict: 'refused', reason: verdict.reason,
|
|
1663
1793
|
currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
|
|
1664
|
-
storeCount: targets.length })
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
if (refusedOutcome?.terminalVerdict === 'recovery-required') die(refusedOutcome.reason);
|
|
1794
|
+
storeCount: targets.length });
|
|
1795
|
+
else writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
|
|
1796
|
+
candidateKind: candidateIdentity.kind, candidateTag: candidateIdentity.tag, storeCount: targets.length });
|
|
1668
1797
|
process.exit(0);
|
|
1669
1798
|
}
|
|
1670
1799
|
|
|
@@ -1703,11 +1832,10 @@ async function main() {
|
|
|
1703
1832
|
if (!installed.valid) die(`already-current KB failed integrity: ${installed.failures.join('; ')}`);
|
|
1704
1833
|
// No transaction paths are created: the inventory still counts every retained managed copy.
|
|
1705
1834
|
const measuredDelta = storageDelta({ live: KB_DIR }, { prior: inventoryBefore.active, inventoryBefore });
|
|
1706
|
-
|
|
1835
|
+
writeUpdateOutcome({ terminalVerdict: 'noop', reason: 'already-current', storeCount: targets.length,
|
|
1707
1836
|
currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
|
|
1708
1837
|
storageDelta: measuredDelta,
|
|
1709
1838
|
phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'noop', storageDelta: measuredDelta }) });
|
|
1710
|
-
if (noopOutcome?.terminalVerdict === 'recovery-required') die(noopOutcome.reason);
|
|
1711
1839
|
console.log(`\nNothing to apply — already current.`); process.exit(0);
|
|
1712
1840
|
}
|
|
1713
1841
|
|
|
@@ -1741,27 +1869,26 @@ async function main() {
|
|
|
1741
1869
|
const originLabel = resolved.origin === 'latest-release-asset' ? `live release asset "${resolved.assetName}"`
|
|
1742
1870
|
: resolved.origin === 'live-manifest' ? 'live manifest' : 'PINNED FALLBACK (see warning above)';
|
|
1743
1871
|
console.log(`\n[${behindStores.length} store(s)] downloading ${resolved.url}\n (source: ${originLabel}) ...`);
|
|
1744
|
-
let buf = await fetchBuffer(resolved.url);
|
|
1745
|
-
const sigBuf = await fetchBuffer(`${resolved.url}.sig`, { failureCode: 3, kind: 'signature' });
|
|
1746
1872
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-update-release-'));
|
|
1873
|
+
downloadDir = tmp;
|
|
1747
1874
|
const zipPath = path.join(tmp, 'bundle.zip');
|
|
1748
1875
|
const sigPath = path.join(tmp, 'bundle.zip.sig');
|
|
1749
1876
|
const extractDir = path.join(tmp, 'extracted');
|
|
1750
|
-
|
|
1877
|
+
await fetchFile(resolved.url, zipPath);
|
|
1878
|
+
const sigBuf = await fetchBuffer(`${resolved.url}.sig`, { failureCode: 3, kind: 'signature' });
|
|
1751
1879
|
fs.writeFileSync(sigPath, sigBuf);
|
|
1752
1880
|
fs.mkdirSync(extractDir);
|
|
1753
|
-
console.log(` downloaded ${(
|
|
1881
|
+
console.log(` downloaded ${(fs.statSync(zipPath).size / 1e6).toFixed(1)} MB.`);
|
|
1754
1882
|
const signature = verifyDownloadedBundle(zipPath, sigPath);
|
|
1755
1883
|
if (!signature.ok) { fs.rmSync(tmp, { recursive: true, force: true }); die(`✗ SIGNATURE VERIFICATION FAILED: ${signature.reason}`, 4); }
|
|
1756
1884
|
console.log(` ✓ signature verified — ${signature.reason}`);
|
|
1757
1885
|
let archiveVerifier;
|
|
1758
|
-
try { archiveVerifier =
|
|
1886
|
+
try { archiveVerifier = createDownloadedArchiveVerifierFromFile(zipPath, resolvedTargets.map(({ resolved: target }) => target.digest)); }
|
|
1759
1887
|
catch (error) {
|
|
1760
1888
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1761
1889
|
die(`archive digest snapshot failed: ${error.message} — local files untouched.`, 4);
|
|
1762
1890
|
}
|
|
1763
1891
|
const bundleSha256 = archiveVerifier.digest();
|
|
1764
|
-
buf = null; // Snapshot owns only immutable digest strings; release the downloaded bytes before validation.
|
|
1765
1892
|
// DISK-SPACE PREFLIGHT, before a single byte is unpacked: the bundle unpacks in temp, then a whole
|
|
1766
1893
|
// candidate generation is built beside the live one: the bundle, plus what prepareCandidate carries into
|
|
1767
1894
|
// it from live — node_modules, and every private/local-ingest store file (restorePrivateFilesIntoCandidate
|
|
@@ -1916,12 +2043,11 @@ async function main() {
|
|
|
1916
2043
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1917
2044
|
console.log(` storage transaction: ${transaction.terminalVerdict}`);
|
|
1918
2045
|
if (transaction.terminalVerdict === 'cleanup-pending') {
|
|
1919
|
-
|
|
2046
|
+
writeUpdateOutcome({ terminalVerdict: 'cleanup-pending', storageDelta: transaction.storageDelta,
|
|
1920
2047
|
transactionReceipts: transaction.paths.receipts, bundleSha256,
|
|
1921
2048
|
phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'cleanup-pending', bundleSha256,
|
|
1922
2049
|
transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
|
|
1923
2050
|
storageDelta: transaction.storageDelta }) });
|
|
1924
|
-
if (cleanupOutcome?.terminalVerdict === 'recovery-required') die(cleanupOutcome.reason);
|
|
1925
2051
|
console.error('\nVerified live generation is active, but redundant rollback cleanup is pending.');
|
|
1926
2052
|
process.exitCode = 12;
|
|
1927
2053
|
return;
|
|
@@ -1986,7 +2112,7 @@ async function main() {
|
|
|
1986
2112
|
console.log(`\n${unchangedStores.length} of ${behindStores.length} store(s) were already at the canonical build and did not change: ${unchangedStores.join(', ')}`);
|
|
1987
2113
|
console.log(` (stores are forged independently — an unchanged store means its upstream repo did not move, not a failed update.)`);
|
|
1988
2114
|
}
|
|
1989
|
-
|
|
2115
|
+
writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length, retiredStores,
|
|
1990
2116
|
currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
|
|
1991
2117
|
storageDelta: transaction.storageDelta,
|
|
1992
2118
|
transactionReceipts: transaction.paths.receipts,
|
|
@@ -1996,7 +2122,6 @@ async function main() {
|
|
|
1996
2122
|
bundleSha256,
|
|
1997
2123
|
transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
|
|
1998
2124
|
storageDelta: transaction.storageDelta }) });
|
|
1999
|
-
if (finalOutcome?.terminalVerdict === 'recovery-required') die(finalOutcome.reason);
|
|
2000
2125
|
console.log(`\n(the above is read back from disk, verified — not a tag lookup)`);
|
|
2001
2126
|
process.exit(0);
|
|
2002
2127
|
}
|
|
@@ -2005,7 +2130,10 @@ async function main() {
|
|
|
2005
2130
|
// guard merely importing this file would start a live update — a network fetch, a directory swap, and
|
|
2006
2131
|
// a process.exit() inside whatever imported it. (Found exactly that way: the reclaim test's import
|
|
2007
2132
|
// began racing a real update against the test run.)
|
|
2008
|
-
|
|
2133
|
+
// Ships in the brain bundle, where only kb/ modules exist, so it cannot import
|
|
2134
|
+
// plugin/scripts/entrypoint.mjs; this is that helper's comparison, inline: real paths on both sides.
|
|
2135
|
+
const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
|
|
2136
|
+
if (invokedDirectly) for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => interruptedBySignal(signal));
|
|
2009
2137
|
if (invokedDirectly && STAGED_RELEASE_FILE) {
|
|
2010
2138
|
(async () => {
|
|
2011
2139
|
const input = JSON.parse(fs.readFileSync(STAGED_RELEASE_FILE, 'utf8'));
|
|
@@ -8,6 +8,15 @@ const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
|
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}$/;
|
|
11
|
+
// refresh-run.mjs atomicJson(): `<runId>.json.tmp-<pid>-<8 hex>`, written, fsynced, then renamed (#434).
|
|
12
|
+
const ATOMIC_TEMP = /^([A-Za-z0-9._-]+)\.json\.tmp-(\d+)-([a-f0-9]{8})$/;
|
|
13
|
+
// The write+rename takes milliseconds; a temp this old was abandoned by a crash or a failed write,
|
|
14
|
+
// whatever its PID (PIDs are reused, so the age is the evidence, not process liveness).
|
|
15
|
+
export const ABANDONED_TEMP_AGE_MS = 60 * 60 * 1000;
|
|
16
|
+
// Files an operating system or a file browser drops into any folder it shows (Finder, exFAT/SMB shadows,
|
|
17
|
+
// Explorer). They are never evidence and never ours to judge: ignored, and removed when a plain file.
|
|
18
|
+
const VOLUME_METADATA = /^(?:\.DS_Store|\._.*|Thumbs\.db|desktop\.ini)$/i;
|
|
19
|
+
export const isVolumeMetadata = (name) => VOLUME_METADATA.test(String(name));
|
|
11
20
|
|
|
12
21
|
export const LIFECYCLE_EVIDENCE_RETENTION_POLICY = Object.freeze({
|
|
13
22
|
schemaVersion: 1,
|
|
@@ -49,17 +58,85 @@ function stamp(value, label) {
|
|
|
49
58
|
return parsed;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
/**
|
|
62
|
+
* An abandoned atomic-write temporary (#434) is classified, not treated as foreign evidence. It is
|
|
63
|
+
* reclaimable only when it is an exclusive regular file older than ABANDONED_TEMP_AGE_MS and either
|
|
64
|
+
* empty or shadowed by a valid canonical receipt of the same run; a young one is an in-flight write
|
|
65
|
+
* (left alone), and a non-empty orphan with no canonical receipt is preserved as unsafe.
|
|
66
|
+
*/
|
|
67
|
+
function classifyAtomicTemp(root, file, match, nowMs, unsafe, temporaries) {
|
|
68
|
+
const stat = fs.lstatSync(file);
|
|
69
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('atomic-write temporary is not an exclusive regular file');
|
|
70
|
+
const identity = { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
71
|
+
const row = { path: file, runId: match[1], writerPid: Number(match[2]), bytes: stat.size, identity,
|
|
72
|
+
sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') };
|
|
73
|
+
if (!(nowMs - stat.mtimeMs >= ABANDONED_TEMP_AGE_MS)) { temporaries.push({ ...row, disposition: 'in-flight' }); return; }
|
|
74
|
+
let canonical = false;
|
|
75
|
+
try {
|
|
76
|
+
const { value } = regularJson(path.join(root, `${match[1]}.json`));
|
|
77
|
+
canonical = value?.kind === 'ruvnet-brain-refresh-run' && value.runId === match[1];
|
|
78
|
+
} catch { /* no valid canonical receipt */ }
|
|
79
|
+
if (stat.size !== 0 && !canonical) {
|
|
80
|
+
unsafe.push({ path: file, reason: 'abandoned atomic-write temporary has content and no canonical receipt; preserved' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
temporaries.push({ ...row, disposition: 'abandoned', canonicalReceipt: canonical });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A `.prune-<id>-<16 hex>` entry is the rename quarantineAndRemove() makes immediately before it deletes.
|
|
88
|
+
* Only an interruption between the two leaves one behind (a time-limit kill of the updater is the realistic
|
|
89
|
+
* cause, 4.7.21 RC2.3). It is reclaimed when it is older than ABANDONED_TEMP_AGE_MS (no prune is still
|
|
90
|
+
* between rename and delete), has the exact shape this module quarantines (a plain file under refresh-runs;
|
|
91
|
+
* a directory of phase files under the transactions root), and the run it came from is not live again
|
|
92
|
+
* (its canonical entry is absent or terminal). Anything else stays unsafe and is preserved, as before.
|
|
93
|
+
*/
|
|
94
|
+
function classifyQuarantine(root, entry, name, kind, nowMs, unsafe, quarantines) {
|
|
95
|
+
const preserve = (reason) => unsafe.push({ path: entry, reason });
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.lstatSync(entry);
|
|
98
|
+
const ageMs = nowMs - Math.max(stat.mtimeMs, stat.ctimeMs);
|
|
99
|
+
if (!(ageMs >= ABANDONED_TEMP_AGE_MS)) return preserve('recognized pruning quarantine remains');
|
|
100
|
+
const id = /^\.prune-(.+)-[a-f0-9]{16}$/.exec(name)[1];
|
|
101
|
+
if (kind === 'refresh') {
|
|
102
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return preserve('recognized pruning quarantine remains (not a quarantined receipt)');
|
|
103
|
+
try {
|
|
104
|
+
const { value } = regularJson(path.join(root, `${id}.json`));
|
|
105
|
+
if (!TERMINAL_REFRESH.has(value?.status)) return preserve('recognized pruning quarantine remains (its run is active)');
|
|
106
|
+
} catch (error) { if (error?.code !== 'ENOENT') return preserve('recognized pruning quarantine remains (its run receipt is unreadable)'); }
|
|
107
|
+
} else {
|
|
108
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return preserve('recognized pruning quarantine remains (not a quarantined transaction)');
|
|
109
|
+
const names = fs.readdirSync(entry).filter((file) => !isVolumeMetadata(file));
|
|
110
|
+
if (names.some((file) => !PHASE_FILE.test(file))) return preserve('recognized pruning quarantine remains (not a quarantined transaction)');
|
|
111
|
+
if (fs.existsSync(path.join(root, id))) return preserve('recognized pruning quarantine remains (its transaction is present)');
|
|
112
|
+
}
|
|
113
|
+
quarantines.push({ path: entry, kind, id, bytes: treeBytes(entry), identity: { dev: stat.dev, ino: stat.ino } });
|
|
114
|
+
} catch (error) { preserve(`recognized pruning quarantine remains (${error.message})`); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scanRefresh(root, unsafe, temporaries = [], nowMs = Date.now(), extras = { quarantines: [], foreign: [], volumeMetadata: [] }) {
|
|
53
118
|
if (!fs.existsSync(root)) return [];
|
|
54
119
|
const rows = [];
|
|
55
120
|
for (const name of fs.readdirSync(root).sort()) {
|
|
56
121
|
const file = path.join(root, name);
|
|
122
|
+
if (isVolumeMetadata(name)) { extras.volumeMetadata.push(file); continue; }
|
|
57
123
|
if (QUARANTINE.test(name)) {
|
|
58
|
-
|
|
124
|
+
classifyQuarantine(root, file, name, 'refresh', nowMs, unsafe, extras.quarantines);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const temp = ATOMIC_TEMP.exec(name);
|
|
128
|
+
if (temp) {
|
|
129
|
+
try { classifyAtomicTemp(root, file, temp, nowMs, unsafe, temporaries); } catch (error) { unsafe.push({ path: file, reason: error.message }); }
|
|
59
130
|
continue;
|
|
60
131
|
}
|
|
61
132
|
try {
|
|
62
|
-
if (!name.endsWith('.json'))
|
|
133
|
+
if (!name.endsWith('.json')) {
|
|
134
|
+
// Not evidence and not ours (a note, a copy someone made): reported and kept, never a reason to
|
|
135
|
+
// fail an update. Only a non-file (directory, link, device) stays unsafe.
|
|
136
|
+
const stat = fs.lstatSync(file);
|
|
137
|
+
if (stat.isFile() && !stat.isSymbolicLink()) { extras.foreign.push({ path: file, bytes: stat.size }); continue; }
|
|
138
|
+
throw new Error('unexpected refresh evidence entry');
|
|
139
|
+
}
|
|
63
140
|
const { value: receipt, bytes } = regularJson(file);
|
|
64
141
|
const runId = name.slice(0, -5);
|
|
65
142
|
if (!SAFE_ID.test(runId) || receipt?.schemaVersion !== 3 || receipt?.kind !== 'ruvnet-brain-refresh-run'
|
|
@@ -73,21 +150,25 @@ function scanRefresh(root, unsafe) {
|
|
|
73
150
|
return rows;
|
|
74
151
|
}
|
|
75
152
|
|
|
76
|
-
function scanTransactions(root, unsafe) {
|
|
153
|
+
function scanTransactions(root, unsafe, nowMs = Date.now(), extras = { quarantines: [], foreign: [], volumeMetadata: [] }) {
|
|
77
154
|
if (!fs.existsSync(root)) return [];
|
|
78
155
|
const rows = [];
|
|
79
156
|
for (const name of fs.readdirSync(root).sort()) {
|
|
80
157
|
const dir = path.join(root, name);
|
|
158
|
+
if (isVolumeMetadata(name)) { extras.volumeMetadata.push(dir); continue; }
|
|
81
159
|
if (QUARANTINE.test(name)) {
|
|
82
|
-
|
|
160
|
+
classifyQuarantine(root, dir, name, 'transaction', nowMs, unsafe, extras.quarantines);
|
|
83
161
|
continue;
|
|
84
162
|
}
|
|
85
163
|
try {
|
|
86
164
|
const stat = fs.lstatSync(dir);
|
|
165
|
+
if (stat.isFile() && !stat.isSymbolicLink()) { extras.foreign.push({ path: dir, bytes: stat.size }); continue; }
|
|
87
166
|
if (!SAFE_ID.test(name) || !stat.isDirectory() || stat.isSymbolicLink()) {
|
|
88
167
|
throw new Error('malformed transaction evidence entry');
|
|
89
168
|
}
|
|
90
|
-
const
|
|
169
|
+
const all = fs.readdirSync(dir).sort();
|
|
170
|
+
for (const file of all) if (isVolumeMetadata(file)) extras.volumeMetadata.push(path.join(dir, file));
|
|
171
|
+
const names = all.filter((file) => !isVolumeMetadata(file));
|
|
91
172
|
if (!names.length || names.some((file) => !PHASE_FILE.test(file))) throw new Error('transaction phase inventory is malformed');
|
|
92
173
|
const phases = names.map((file) => regularJson(path.join(dir, file)).value);
|
|
93
174
|
const latest = phases.at(-1);
|
|
@@ -137,8 +218,11 @@ function trustedEvidenceRoot(root, unsafe) {
|
|
|
137
218
|
function snapshot(options) {
|
|
138
219
|
const roots = rootsFor(options);
|
|
139
220
|
const unsafe = [];
|
|
140
|
-
const
|
|
141
|
-
const
|
|
221
|
+
const temporaries = [];
|
|
222
|
+
const extras = { quarantines: [], foreign: [], volumeMetadata: [] };
|
|
223
|
+
const nowMs = Date.parse((options.now || (() => new Date().toISOString()))());
|
|
224
|
+
const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe, temporaries, nowMs, extras) : [];
|
|
225
|
+
const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe, nowMs, extras) : [];
|
|
142
226
|
const preserveRefresh = new Set((options.preserveRefreshRunIds || []).map(String));
|
|
143
227
|
const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => physicalPath(entry)));
|
|
144
228
|
const protectedRefresh = new Map();
|
|
@@ -170,11 +254,11 @@ function snapshot(options) {
|
|
|
170
254
|
if (preserveTransactions.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'explicitly preserved transaction');
|
|
171
255
|
if (retainedTransactionPaths.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
|
|
172
256
|
}
|
|
173
|
-
const bytes = [...refresh, ...transactions].reduce((sum, row) => sum + row.bytes, 0)
|
|
257
|
+
const bytes = [...refresh, ...transactions, ...temporaries, ...extras.quarantines].reduce((sum, row) => sum + row.bytes, 0)
|
|
174
258
|
+ unsafe.filter(({ reason }) => /quarantine remains/.test(reason)).reduce((sum, { path: entry }) => {
|
|
175
259
|
try { return sum + treeBytes(entry); } catch { return sum; }
|
|
176
260
|
}, 0);
|
|
177
|
-
return { roots, unsafe, refresh, transactions, bytes, protectedRefresh, protectedTransactions };
|
|
261
|
+
return { roots, unsafe, refresh, transactions, temporaries, bytes, protectedRefresh, protectedTransactions, ...extras };
|
|
178
262
|
}
|
|
179
263
|
|
|
180
264
|
function counts(state) {
|
|
@@ -202,6 +286,10 @@ export function assessLifecycleEvidence(options = {}) {
|
|
|
202
286
|
&& !state.protectedTransactions.has(row.path)).sort((a, b) => a.timestamp - b.timestamp)
|
|
203
287
|
.map(({ path: entryPath }) => entryPath),
|
|
204
288
|
},
|
|
289
|
+
abandonedTemporaries: state.temporaries.filter((row) => row.disposition === 'abandoned')
|
|
290
|
+
.map(({ path: entryPath, runId, writerPid, bytes, sha256, canonicalReceipt }) => ({ path: entryPath, runId, writerPid, bytes, sha256, canonicalReceipt })),
|
|
291
|
+
abandonedQuarantines: state.quarantines.map(({ path: entryPath, kind, id, bytes }) => ({ path: entryPath, kind, id, bytes })),
|
|
292
|
+
foreign: state.foreign, volumeMetadata: state.volumeMetadata,
|
|
205
293
|
removed: { refresh: [], transactions: [] }, after: counts(state), unsafe: state.unsafe,
|
|
206
294
|
withinBudget: isWithin(state, policy) };
|
|
207
295
|
}
|
|
@@ -213,9 +301,11 @@ function quarantineAndRemove(entry, root, removeEntry, afterQuarantine) {
|
|
|
213
301
|
throw new Error(`refusing unsafe lifecycle evidence removal: ${entry}`);
|
|
214
302
|
}
|
|
215
303
|
const quarantine = path.join(root, `.prune-${name.replace(/\.json$/, '')}-${crypto.randomBytes(8).toString('hex')}`);
|
|
216
|
-
|
|
304
|
+
// Vanished since the snapshot (another sweep, the writer itself): already gone, not a failed prune.
|
|
305
|
+
try { fs.renameSync(resolved, quarantine); } catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
|
|
217
306
|
afterQuarantine?.({ entry: resolved, quarantine });
|
|
218
307
|
removeEntry(quarantine);
|
|
308
|
+
return true;
|
|
219
309
|
}
|
|
220
310
|
|
|
221
311
|
// Keep every JSON token (including numeric precision and escaped string spelling).
|
|
@@ -223,6 +313,7 @@ function quarantineAndRemove(entry, root, removeEntry, afterQuarantine) {
|
|
|
223
313
|
function compactTerminalPhases(row) {
|
|
224
314
|
const compacted = [];
|
|
225
315
|
for (const name of fs.readdirSync(row.path).sort()) {
|
|
316
|
+
if (isVolumeMetadata(name)) continue;
|
|
226
317
|
const file = path.join(row.path, name);
|
|
227
318
|
const stat = fs.lstatSync(file);
|
|
228
319
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('phase is not an exclusive regular file');
|
|
@@ -257,6 +348,7 @@ function compactTerminalPhases(row) {
|
|
|
257
348
|
return compacted;
|
|
258
349
|
}
|
|
259
350
|
|
|
351
|
+
export const MAX_VANISHED_RETRIES = 64;
|
|
260
352
|
export function pruneLifecycleEvidence(options = {}) {
|
|
261
353
|
const policy = options.policy || LIFECYCLE_EVIDENCE_RETENTION_POLICY;
|
|
262
354
|
const removeEntry = options.removeEntry || ((entry) => fs.rmSync(entry, { recursive: true, force: true }));
|
|
@@ -265,6 +357,40 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
265
357
|
// the exact entry it just quarantined. Recovery must not guess away private data.
|
|
266
358
|
const initial = assessLifecycleEvidence({ ...options, policy });
|
|
267
359
|
const removed = { refresh: [], transactions: [] };
|
|
360
|
+
// #434: reclaim abandoned atomic-write temporaries first (reported with size and sha256, never
|
|
361
|
+
// silent). The file identity is rechecked immediately before it is quarantined.
|
|
362
|
+
const reclaimedTemporaries = [];
|
|
363
|
+
for (const row of snapshot(options).temporaries.filter((entry) => entry.disposition === 'abandoned')) {
|
|
364
|
+
let current;
|
|
365
|
+
// A temp that vanished between the snapshot and this re-check is already gone; it must not throw the whole prune.
|
|
366
|
+
try { current = fs.lstatSync(row.path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
|
|
367
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || current.dev !== row.identity.dev
|
|
368
|
+
|| current.ino !== row.identity.ino || current.size !== row.identity.size || current.mtimeMs !== row.identity.mtimeMs) continue;
|
|
369
|
+
if (!quarantineAndRemove(row.path, path.dirname(row.path), removeEntry, options.afterQuarantine)) continue;
|
|
370
|
+
reclaimedTemporaries.push({ path: row.path, runId: row.runId, writerPid: row.writerPid, bytes: row.bytes,
|
|
371
|
+
sha256: row.sha256, canonicalReceipt: row.canonicalReceipt });
|
|
372
|
+
}
|
|
373
|
+
// Volume metadata (.DS_Store and friends) is removed when it is a plain file; anything else by that
|
|
374
|
+
// name is left alone (it was never counted as evidence either way).
|
|
375
|
+
const removedVolumeMetadata = [];
|
|
376
|
+
for (const entry of snapshot(options).volumeMetadata) {
|
|
377
|
+
try {
|
|
378
|
+
const stat = fs.lstatSync(entry);
|
|
379
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
380
|
+
fs.rmSync(entry, { force: true });
|
|
381
|
+
removedVolumeMetadata.push(entry);
|
|
382
|
+
} catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
383
|
+
}
|
|
384
|
+
// Abandoned quarantines of this module's own interrupted prunes (classifyQuarantine), identity re-checked
|
|
385
|
+
// immediately before removal and reported, never silent.
|
|
386
|
+
const reclaimedQuarantines = [];
|
|
387
|
+
for (const row of snapshot(options).quarantines) {
|
|
388
|
+
let current;
|
|
389
|
+
try { current = fs.lstatSync(row.path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
|
|
390
|
+
if (current.isSymbolicLink() || current.dev !== row.identity.dev || current.ino !== row.identity.ino) continue;
|
|
391
|
+
removeEntry(row.path);
|
|
392
|
+
reclaimedQuarantines.push({ path: row.path, kind: row.kind, id: row.id, bytes: row.bytes });
|
|
393
|
+
}
|
|
268
394
|
const compacted = [];
|
|
269
395
|
const compactState = snapshot(options);
|
|
270
396
|
if (!compactState.unsafe.length && compactState.bytes > policy.maxEvidenceBytes) {
|
|
@@ -272,6 +398,10 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
272
398
|
compacted.push(...compactTerminalPhases(row));
|
|
273
399
|
}
|
|
274
400
|
}
|
|
401
|
+
// Review r2 (4.7.21): an entry that "vanishes" on every rename (a filesystem that keeps listing it, a writer that
|
|
402
|
+
// keeps recreating it) must not spin this loop forever. Bounded; when the bound trips, pruning stops (fails closed:
|
|
403
|
+
// nothing more is removed) and the reason is returned.
|
|
404
|
+
let vanishedRetries = 0; let stopped = null;
|
|
275
405
|
for (;;) {
|
|
276
406
|
const state = snapshot(options);
|
|
277
407
|
if (isWithin(state, policy)) break;
|
|
@@ -285,10 +415,17 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
285
415
|
: [...refreshCandidates, ...transactionCandidates].sort((a, b) => a.timestamp - b.timestamp)[0];
|
|
286
416
|
if (!candidate) break;
|
|
287
417
|
const root = candidate.kind === 'refresh' ? state.roots.refresh : state.roots.transactions;
|
|
288
|
-
quarantineAndRemove(candidate.path, root, removeEntry, options.afterQuarantine)
|
|
418
|
+
if (!quarantineAndRemove(candidate.path, root, removeEntry, options.afterQuarantine)) { // gone: re-snapshot
|
|
419
|
+
vanishedRetries += 1;
|
|
420
|
+
if (vanishedRetries > MAX_VANISHED_RETRIES) {
|
|
421
|
+
stopped = `pruning stopped: lifecycle evidence vanished ${vanishedRetries} times while being removed (last: ${candidate.path}); nothing further was removed`;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
289
426
|
removed[candidate.kind === 'refresh' ? 'refresh' : 'transactions'].push(candidate.path);
|
|
290
427
|
}
|
|
291
428
|
const final = assessLifecycleEvidence({ ...options, policy });
|
|
292
|
-
return { ...final, before: initial.before, removed, compacted,
|
|
293
|
-
after: final.after, withinBudget: final.withinBudget };
|
|
429
|
+
return { ...final, before: initial.before, removed, reclaimedTemporaries, reclaimedQuarantines, removedVolumeMetadata, compacted,
|
|
430
|
+
after: final.after, withinBudget: final.withinBudget, ...(stopped ? { stopped } : {}) };
|
|
294
431
|
}
|