mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-26 17:12:17 +00:00
.ruvector/intelligence.json is silently, permanently wiped under concurrent
ruvector processes — the normal state under Claude Code, where every hook is a
separate short-lived `ruvector hooks …` process sharing the store. Two defects
compound:
1. save() (and 6 standalone command sites) overwrote the store non-atomically, so
a concurrent reader could observe a torn, half-written file.
2. Intelligence.load() and the 9 standalone read sites wrapped the parse in
`try { … } catch {}` and fell through to empty defaults — the reader that hit
the torn file silently started from an empty store, and its own save() then
persisted the emptiness, replacing weeks of memories/trajectories with no error
(the hook still printed success:true).
Fix: a shared atomicWriteFileSync() (temp file + rename, so a partial write can
never appear at the real path) used by save() and all 6 write sites; and a shared
readIntelStoreSafe() plus the same logic in load() that quarantines a corrupt store
(renames it aside so it is preserved, not overwritten) and throws instead of
degrading to empty defaults. A missing store is still a legitimate fresh start.
With atomic writes, a concurrent reader can no longer produce a torn read, so the
quarantine path is only reached on genuine corruption.
bin/cli.js now exports its internals when required (not run as main) so the store
durability can be tested. test/smoke-intelligence-durability.mjs exercises the real
functions through the issue's exact truncation repro (4 checks).
Co-authored-by: Leo <noreply@khive.ai>
This commit is contained in:
parent
84e5f3d7dc
commit
08e5fe6eb0
2 changed files with 262 additions and 94 deletions
|
|
@ -3029,6 +3029,51 @@ function sanitizeDimension(value, fallback) {
|
|||
return (Number.isInteger(value) && value > 0 && value <= 65536) ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file atomically: serialize to a unique temp file in the same
|
||||
* directory, then rename() over the target. rename() is atomic on a POSIX
|
||||
* filesystem, so a concurrent reader never observes a torn/half-written file.
|
||||
* This is the root-cause fix for the intelligence.json wipe under Claude Code,
|
||||
* where every hook is a separate short-lived process sharing the store: a
|
||||
* plain writeFileSync lets one process read a half-written file mid-write.
|
||||
* The temp name carries pid + timestamp so concurrent writers never collide on
|
||||
* the temp file; the final rename is last-writer-wins.
|
||||
*/
|
||||
function atomicWriteFileSync(filePath, data) {
|
||||
const dir = path.dirname(filePath);
|
||||
const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}`);
|
||||
try {
|
||||
fs.writeFileSync(tmp, data);
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (err) {
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort temp cleanup */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSON intelligence store for a standalone command. Returns `{}` for a
|
||||
* missing store (legitimate fresh start), but on a corrupt/unparseable store
|
||||
* quarantines the file (renames it aside) and throws instead of returning `{}`.
|
||||
* The previous `try { parse } catch {}` → `{}` pattern let a corrupt read
|
||||
* degrade to an empty object that the command then wrote back, wiping the
|
||||
* store. Mirrors Intelligence.load()'s corrupt handling.
|
||||
*/
|
||||
function readIntelStoreSafe(dataPath) {
|
||||
if (!fs.existsSync(dataPath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
const quarantine = `${dataPath}.corrupt-${Date.now()}`;
|
||||
try { fs.renameSync(dataPath, quarantine); } catch { /* still fail loud below */ }
|
||||
throw new Error(
|
||||
`ruvector: intelligence store at ${dataPath} is corrupt and was quarantined to ` +
|
||||
`${quarantine} rather than overwritten with an empty store — ` +
|
||||
`restore it or delete it to start fresh. (${err.message})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Intelligence {
|
||||
constructor(options = {}) {
|
||||
this.intelPath = this.getIntelPath();
|
||||
|
|
@ -3156,39 +3201,56 @@ class Intelligence {
|
|||
edges: [],
|
||||
stats: { total_patterns: 0, total_memories: 0, total_trajectories: 0, total_errors: 0, session_count: 0, last_session: 0 }
|
||||
};
|
||||
// A missing store is a legitimate fresh start.
|
||||
if (!fs.existsSync(this.intelPath)) return defaults;
|
||||
|
||||
let data;
|
||||
try {
|
||||
if (fs.existsSync(this.intelPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(this.intelPath, 'utf-8'));
|
||||
// Merge with defaults to ensure all fields exist. The file is
|
||||
// untrusted on-disk input (ADR-210 security pass): shape-check each
|
||||
// field so a hand-edited/corrupted store cannot crash later code
|
||||
// that iterates arrays or spreads objects.
|
||||
const asArray = (v, dflt) => (Array.isArray(v) ? v : dflt);
|
||||
const asObject = (v, dflt) => (v && typeof v === 'object' && !Array.isArray(v) ? v : dflt);
|
||||
return {
|
||||
patterns: asObject(data.patterns, defaults.patterns),
|
||||
memories: asArray(data.memories, defaults.memories),
|
||||
trajectories: asArray(data.trajectories, defaults.trajectories),
|
||||
errors: asObject(data.errors, defaults.errors),
|
||||
file_sequences: asArray(data.file_sequences, defaults.file_sequences),
|
||||
agents: asObject(data.agents, defaults.agents),
|
||||
edges: asArray(data.edges, defaults.edges),
|
||||
stats: { ...defaults.stats, ...asObject(data.stats, {}) },
|
||||
// ADR-210 D0: embedding provenance of stored memory vectors
|
||||
// (null = legacy store, read-only for vector writes until reembed).
|
||||
// Malformed records are treated as absent (sanitized, never crash).
|
||||
embeddingProvenance: sanitizeProvenanceSafe(data.embeddingProvenance),
|
||||
// Preserve in-flight trajectories so trajectory-end (run in a later
|
||||
// process) can find what trajectory-begin recorded (#517)
|
||||
activeTrajectories: data.activeTrajectories || {},
|
||||
// Preserve auxiliary learned data if present
|
||||
coEditPatterns: data.coEditPatterns || undefined,
|
||||
sequences: data.sequences || undefined,
|
||||
learning: data.learning || undefined
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return defaults;
|
||||
data = JSON.parse(fs.readFileSync(this.intelPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
// A corrupt/unreadable store must NOT silently degrade to empty defaults:
|
||||
// the caller would then save() the emptiness back over the real data,
|
||||
// destroying weeks of accumulated memories with no signal. Quarantine the
|
||||
// file (rename it aside so it is preserved and the next save cannot clobber
|
||||
// it) and fail loud. With atomicWriteFileSync() below, a concurrent writer
|
||||
// can no longer produce a torn read, so reaching here means genuine
|
||||
// corruption — a fresh store restarts cleanly on the next run.
|
||||
const quarantine = `${this.intelPath}.corrupt-${Date.now()}`;
|
||||
try { fs.renameSync(this.intelPath, quarantine); } catch { /* still fail loud below */ }
|
||||
throw new Error(
|
||||
`ruvector: intelligence store at ${this.intelPath} is corrupt and was quarantined ` +
|
||||
`to ${quarantine} rather than overwritten with an empty store — ` +
|
||||
`restore it or delete it to start fresh. (${err.message})`
|
||||
);
|
||||
}
|
||||
|
||||
// Merge with defaults to ensure all fields exist. The file is
|
||||
// untrusted on-disk input (ADR-210 security pass): shape-check each
|
||||
// field so a hand-edited/corrupted store cannot crash later code
|
||||
// that iterates arrays or spreads objects.
|
||||
const asArray = (v, dflt) => (Array.isArray(v) ? v : dflt);
|
||||
const asObject = (v, dflt) => (v && typeof v === 'object' && !Array.isArray(v) ? v : dflt);
|
||||
return {
|
||||
patterns: asObject(data.patterns, defaults.patterns),
|
||||
memories: asArray(data.memories, defaults.memories),
|
||||
trajectories: asArray(data.trajectories, defaults.trajectories),
|
||||
errors: asObject(data.errors, defaults.errors),
|
||||
file_sequences: asArray(data.file_sequences, defaults.file_sequences),
|
||||
agents: asObject(data.agents, defaults.agents),
|
||||
edges: asArray(data.edges, defaults.edges),
|
||||
stats: { ...defaults.stats, ...asObject(data.stats, {}) },
|
||||
// ADR-210 D0: embedding provenance of stored memory vectors
|
||||
// (null = legacy store, read-only for vector writes until reembed).
|
||||
// Malformed records are treated as absent (sanitized, never crash).
|
||||
embeddingProvenance: sanitizeProvenanceSafe(data.embeddingProvenance),
|
||||
// Preserve in-flight trajectories so trajectory-end (run in a later
|
||||
// process) can find what trajectory-begin recorded (#517)
|
||||
activeTrajectories: data.activeTrajectories || {},
|
||||
// Preserve auxiliary learned data if present
|
||||
coEditPatterns: data.coEditPatterns || undefined,
|
||||
sequences: data.sequences || undefined,
|
||||
learning: data.learning || undefined
|
||||
};
|
||||
}
|
||||
|
||||
save() {
|
||||
|
|
@ -3217,7 +3279,7 @@ class Intelligence {
|
|||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.intelPath, JSON.stringify(this.data, null, 2));
|
||||
atomicWriteFileSync(this.intelPath, JSON.stringify(this.data, null, 2));
|
||||
}
|
||||
|
||||
now() { return Math.floor(Date.now() / 1000); }
|
||||
|
|
@ -5614,12 +5676,7 @@ hooksCmd.command('learning-config')
|
|||
|
||||
// Load existing intelligence data
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const engine = new LearningEngineClass();
|
||||
if (data.learning) {
|
||||
|
|
@ -5653,7 +5710,7 @@ hooksCmd.command('learning-config')
|
|||
// Save
|
||||
data.learning = engine.export();
|
||||
fs.mkdirSync(path.dirname(dataPath), { recursive: true });
|
||||
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
|
|
@ -5673,12 +5730,7 @@ hooksCmd.command('learning-stats')
|
|||
}
|
||||
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const engine = new LearningEngineClass();
|
||||
if (data.learning) {
|
||||
|
|
@ -5721,12 +5773,7 @@ hooksCmd.command('learning-update')
|
|||
}
|
||||
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const engine = new LearningEngineClass();
|
||||
if (data.learning) {
|
||||
|
|
@ -5746,7 +5793,7 @@ hooksCmd.command('learning-update')
|
|||
|
||||
// Save
|
||||
data.learning = engine.export();
|
||||
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
|
|
@ -5769,12 +5816,7 @@ hooksCmd.command('compress')
|
|||
}
|
||||
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const compress = new TensorCompressClass({
|
||||
autoCompress: false,
|
||||
|
|
@ -5809,7 +5851,7 @@ hooksCmd.command('compress')
|
|||
|
||||
// Save compressed data
|
||||
data.compressedPatterns = compress.export();
|
||||
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
|
|
@ -5828,12 +5870,7 @@ hooksCmd.command('compress-stats')
|
|||
}
|
||||
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const compress = new TensorCompressClass({ autoCompress: false });
|
||||
if (data.compressedPatterns) {
|
||||
|
|
@ -5882,12 +5919,7 @@ hooksCmd.command('compress-store')
|
|||
}
|
||||
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const compress = new TensorCompressClass({ autoCompress: false });
|
||||
if (data.compressedPatterns) {
|
||||
|
|
@ -5898,7 +5930,7 @@ hooksCmd.command('compress-store')
|
|||
|
||||
data.compressedPatterns = compress.export();
|
||||
fs.mkdirSync(path.dirname(dataPath), { recursive: true });
|
||||
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
|
||||
const stats = compress.getStats();
|
||||
console.log(JSON.stringify({
|
||||
|
|
@ -5921,12 +5953,7 @@ hooksCmd.command('compress-get')
|
|||
}
|
||||
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const compress = new TensorCompressClass({ autoCompress: false });
|
||||
if (data.compressedPatterns) {
|
||||
|
|
@ -5962,12 +5989,7 @@ hooksCmd.command('learn')
|
|||
}
|
||||
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const engine = new LearningEngineClass();
|
||||
if (data.learning) {
|
||||
|
|
@ -6001,7 +6023,7 @@ hooksCmd.command('learn')
|
|||
// Save
|
||||
data.learning = engine.export();
|
||||
fs.mkdirSync(path.dirname(dataPath), { recursive: true });
|
||||
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
});
|
||||
|
|
@ -6046,12 +6068,7 @@ hooksCmd.command('batch-learn')
|
|||
}
|
||||
|
||||
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(dataPath)) {
|
||||
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {}
|
||||
let data = readIntelStoreSafe(dataPath);
|
||||
|
||||
const engine = new LearningEngineClass();
|
||||
if (data.learning) {
|
||||
|
|
@ -6079,7 +6096,7 @@ hooksCmd.command('batch-learn')
|
|||
// Save
|
||||
data.learning = engine.export();
|
||||
fs.mkdirSync(path.dirname(dataPath), { recursive: true });
|
||||
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
|
||||
|
||||
const stats = engine.getStatsSummary();
|
||||
console.log(JSON.stringify({
|
||||
|
|
@ -10252,6 +10269,12 @@ harnessCmd
|
|||
// Bare `ruvector harness` defaults to status
|
||||
harnessCmd.action(() => printHarnessStatus({}));
|
||||
|
||||
program.parse();
|
||||
// Only drive the CLI when executed directly; when required (e.g. by tests)
|
||||
// expose the store-durability internals instead of parsing argv.
|
||||
if (require.main === module) {
|
||||
program.parse();
|
||||
} else {
|
||||
module.exports = { atomicWriteFileSync, readIntelStoreSafe, Intelligence };
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
145
npm/packages/ruvector/test/smoke-intelligence-durability.mjs
Normal file
145
npm/packages/ruvector/test/smoke-intelligence-durability.mjs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// Runtime smoke test for #634: the .ruvector/intelligence.json store must be
|
||||
// written atomically and must fail loud (not silently wipe) on a corrupt read.
|
||||
// Drives the REAL functions exported from bin/cli.js (commander/chalk are
|
||||
// stubbed only so the module can be required without the full CLI install).
|
||||
//
|
||||
// Run: node test/smoke-intelligence-durability.mjs
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const cliPath = path.join(here, '..', 'bin', 'cli.js');
|
||||
|
||||
// --- stub the two module-scope deps so cli.js can be required as a library ---
|
||||
const stubRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-stubs-'));
|
||||
function writeStub(name, indexJs) {
|
||||
const dir = path.join(stubRoot, 'node_modules', name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version: '0.0.0', main: 'index.js' }));
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), indexJs);
|
||||
}
|
||||
// commander: chainable no-op Command — any method returns a chainable, and
|
||||
// command()/createCommand() return fresh chainables.
|
||||
writeStub('commander', `
|
||||
function makeCmd() {
|
||||
const cmd = new Proxy(function () {}, {
|
||||
get(_t, prop) {
|
||||
if (prop === 'command' || prop === 'createCommand' || prop === 'addCommand') return () => makeCmd();
|
||||
if (prop === 'opts' || prop === 'optsWithGlobals') return () => ({});
|
||||
if (prop === 'args') return [];
|
||||
return () => cmd;
|
||||
},
|
||||
apply() { return makeCmd(); },
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
class Command { constructor() { return makeCmd(); } }
|
||||
module.exports = { Command, program: makeCmd() };
|
||||
`);
|
||||
// chalk: identity color functions
|
||||
writeStub('chalk', `
|
||||
const id = (s) => s;
|
||||
const handler = { get: () => new Proxy(id, handler), apply: (t, _th, a) => a[0] };
|
||||
const chalk = new Proxy(id, handler);
|
||||
module.exports = chalk; module.exports.default = chalk;
|
||||
`);
|
||||
|
||||
// Make the stubs resolvable from cli.js by pointing NODE_PATH — but require()
|
||||
// resolves relative to cli.js, so instead patch module resolution via a temp
|
||||
// symlinked node_modules next to the package.
|
||||
const pkgNodeModules = path.join(here, '..', 'node_modules');
|
||||
let createdSymlink = false;
|
||||
if (!fs.existsSync(pkgNodeModules)) {
|
||||
fs.symlinkSync(path.join(stubRoot, 'node_modules'), pkgNodeModules, 'dir');
|
||||
createdSymlink = true;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
const ok = (name) => { console.log(` ok - ${name}`); passed++; };
|
||||
|
||||
try {
|
||||
const { atomicWriteFileSync, readIntelStoreSafe, Intelligence } = require(cliPath);
|
||||
|
||||
// ---- helper: atomicWriteFileSync leaves no temp + produces the exact bytes
|
||||
{
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-aw-'));
|
||||
const f = path.join(dir, 'x.json');
|
||||
atomicWriteFileSync(f, '{"a":1}');
|
||||
assert.strictEqual(fs.readFileSync(f, 'utf-8'), '{"a":1}');
|
||||
assert.deepStrictEqual(fs.readdirSync(dir), ['x.json'], 'no .tmp file left behind');
|
||||
ok('#634 atomicWriteFileSync writes exact bytes, no temp leftover');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ---- helper: readIntelStoreSafe absent -> {}, valid -> parsed, corrupt -> throw+quarantine
|
||||
{
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-rd-'));
|
||||
const f = path.join(dir, 'intelligence.json');
|
||||
assert.deepStrictEqual(readIntelStoreSafe(f), {}, 'absent store -> {}');
|
||||
fs.writeFileSync(f, JSON.stringify({ memories: [1, 2, 3] }));
|
||||
assert.deepStrictEqual(readIntelStoreSafe(f).memories, [1, 2, 3], 'valid store parsed');
|
||||
fs.writeFileSync(f, '{ torn ');
|
||||
assert.throws(() => readIntelStoreSafe(f), /corrupt and was quarantined/, 'corrupt store throws');
|
||||
assert.ok(!fs.existsSync(f), 'corrupt store renamed away (not left to be overwritten)');
|
||||
assert.strictEqual(fs.readdirSync(dir).filter((x) => x.includes('.corrupt-')).length, 1, 'one quarantine file');
|
||||
ok('#634 readIntelStoreSafe: absent->{}, valid->parsed, corrupt->throw+quarantine');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ---- the reported wipe scenario, through the real Intelligence class ----
|
||||
{
|
||||
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-proj-'));
|
||||
const store = path.join(proj, '.ruvector', 'intelligence.json');
|
||||
fs.mkdirSync(path.dirname(store), { recursive: true });
|
||||
// A store with 3 memories that must survive.
|
||||
fs.writeFileSync(store, JSON.stringify({
|
||||
patterns: {}, memories: ['m1', 'm2', 'm3'], trajectories: [], errors: {},
|
||||
file_sequences: [], agents: {}, edges: [],
|
||||
stats: { total_patterns: 0, total_memories: 3, total_trajectories: 0, total_errors: 0, session_count: 1, last_session: 1 },
|
||||
}, null, 2));
|
||||
|
||||
const prevCwd = process.cwd();
|
||||
process.chdir(proj);
|
||||
try {
|
||||
// Sanity: a valid store loads its 3 memories.
|
||||
const okIntel = new Intelligence({ skipEngine: true });
|
||||
assert.strictEqual(okIntel.data.memories.length, 3, 'valid store loads 3 memories');
|
||||
|
||||
// Corrupt the store exactly as the issue's repro does (truncated file).
|
||||
const full = fs.readFileSync(store, 'utf-8');
|
||||
fs.writeFileSync(store, full.slice(0, Math.floor(full.length / 2)));
|
||||
|
||||
// OLD behavior: construct succeeds, load() returns empty defaults, next
|
||||
// save() persists the emptiness (wipe). NEW behavior: construct throws.
|
||||
assert.throws(() => new Intelligence({ skipEngine: true }), /corrupt and was quarantined/,
|
||||
'corrupt store -> throw, not silent empty-defaults');
|
||||
assert.ok(!fs.existsSync(store), 'corrupt store quarantined (not left in place to be overwritten)');
|
||||
const quarantined = fs.readdirSync(path.dirname(store)).filter((x) => x.includes('.corrupt-'));
|
||||
assert.strictEqual(quarantined.length, 1, 'store quarantined once (data preserved for recovery)');
|
||||
// The quarantined file still holds the original (now-truncated) bytes, not an empty store.
|
||||
ok('#634 corrupt store is quarantined + fails loud instead of silently wiping memories');
|
||||
|
||||
// Atomic save roundtrip: fresh store, save leaves no temp, reload matches.
|
||||
const fresh = new Intelligence({ skipEngine: true });
|
||||
fresh.data.memories.push('kept');
|
||||
fresh.save();
|
||||
assert.ok(!fs.readdirSync(path.dirname(store)).some((x) => x.includes('.tmp.')), 'save() leaves no temp file');
|
||||
const reloaded = new Intelligence({ skipEngine: true });
|
||||
assert.ok(reloaded.data.memories.includes('kept'), 'saved memory survives reload');
|
||||
ok('#634 save() is atomic (no temp leftover) and roundtrips');
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
fs.rmSync(proj, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\nAll ${passed} checks passed.`);
|
||||
} finally {
|
||||
if (createdSymlink) { try { fs.unlinkSync(pkgNodeModules); } catch { /* ignore */ } }
|
||||
fs.rmSync(stubRoot, { recursive: true, force: true });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue