ruvnet-brain 4.5.3 → 4.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +4 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +40 -3
- package/plugin/hooks/hook-contracts.json +218 -19
- package/plugin/hooks/hooks.json +51 -2
- package/plugin/scripts/agentdb-recall.mjs +101 -30
- package/plugin/scripts/continuity-hook-policy.mjs +9 -0
- package/plugin/scripts/continuity-journal.mjs +33 -33
- package/plugin/scripts/ground-ruvnet.sh +5 -5
- package/plugin/scripts/hook-shim.mjs +4 -30
- package/plugin/scripts/project-capture-queue.mjs +333 -0
- package/plugin/scripts/project-progression-contract.mjs +1 -1
- package/plugin/scripts/project-progression-hook.mjs +3 -3
- package/plugin/scripts/project-progression-producer.mjs +53 -28
- package/plugin/scripts/project-progression-session-start.mjs +44 -4
- package/plugin/scripts/project-progression-store.mjs +14 -0
- package/plugin/scripts/project-transition-hook.mjs +204 -0
- package/plugin/scripts/session-snapshot-hook.mjs +44 -272
- package/plugin/scripts/session-start-budget.mjs +2 -2
- package/plugin/scripts/turn-outcome-capture.mjs +125 -47
- package/plugin/scripts/turn-transport-journal.mjs +106 -0
- package/scripts/release-qualification-contract.mjs +13 -0
|
@@ -31,9 +31,8 @@
|
|
|
31
31
|
*
|
|
32
32
|
* LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
|
|
33
33
|
* (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
|
|
34
|
-
* distill in order; the hook itself only reads, fingerprints and spawns. A
|
|
35
|
-
*
|
|
36
|
-
* write is visible rather than silent. Advisory always: nothing here throws to the caller.
|
|
34
|
+
* distill in order; the hook itself only reads, fingerprints and spawns. A project-local journal containing redacted content and canonical binding is fsynced BEFORE
|
|
35
|
+
* spawning; fresh sessions replay stable keys without upsert and require exact readback receipts. Advisory always: nothing here throws to the caller.
|
|
37
36
|
*/
|
|
38
37
|
import fs from 'node:fs';
|
|
39
38
|
import os from 'node:os';
|
|
@@ -44,6 +43,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
44
43
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
45
44
|
import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
|
|
46
45
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
46
|
+
import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
|
|
47
47
|
|
|
48
48
|
export const TURN_NAMESPACE = 'turns';
|
|
49
49
|
export const MIN_OUTCOME_CHARS = 200;
|
|
@@ -154,8 +154,9 @@ const consentMap = (value) => value !== null && typeof value === 'object'
|
|
|
154
154
|
&& !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
155
155
|
&& Object.values(value).every((setting) => setting === 'on' || setting === 'off');
|
|
156
156
|
|
|
157
|
-
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000 } = {}) {
|
|
157
|
+
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false } = {}) {
|
|
158
158
|
const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs });
|
|
159
|
+
const capturePath = fs.realpathSync.native(projectDir);
|
|
159
160
|
let policy = {};
|
|
160
161
|
const file = brainHome && turnCapturePolicyFile(brainHome);
|
|
161
162
|
if (file && fs.existsSync(file)) {
|
|
@@ -166,15 +167,19 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
|
|
|
166
167
|
} catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
|
|
167
168
|
}
|
|
168
169
|
// A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
|
|
169
|
-
const setting = policy.paths?.[
|
|
170
|
+
const setting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
|
|
170
171
|
if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
|
|
171
172
|
const db = resolved.canonicalAgentDbPath;
|
|
173
|
+
if (unknownOriginalPath && Object.entries(policy.paths || {}).some(([origin, choice]) => choice === 'off'
|
|
174
|
+
&& (origin === resolved.checkoutRoot || origin.startsWith(`${resolved.checkoutRoot}${path.sep}`)))) {
|
|
175
|
+
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'capture origin cannot be verified under path opt-out' };
|
|
176
|
+
}
|
|
172
177
|
assertTurnStoreFiles(db);
|
|
173
|
-
if (setting === 'off') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
|
|
178
|
+
if (setting === 'off') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
|
|
174
179
|
let exists = false;
|
|
175
180
|
try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
|
|
176
|
-
if (!exists && setting !== 'on') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
|
|
177
|
-
return { db, scope: 'project', projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
|
|
181
|
+
if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
|
|
182
|
+
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
// Revalidation narrows the queue-to-launch window; it does not make SQLite's later open atomic.
|
|
@@ -197,9 +202,9 @@ function assertTurnStoreFiles(db) {
|
|
|
197
202
|
const projectName = (projectDir) => redactText(path.basename(path.resolve(projectDir))).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
|
|
198
203
|
|
|
199
204
|
/** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
|
|
200
|
-
export function launchDetached(steps, { receipts }) {
|
|
201
|
-
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
|
|
202
|
-
cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...
|
|
205
|
+
export function launchDetached(steps, { receipts, brainHome, projectDir, env = process.env }) {
|
|
206
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps: steps.map((step) => step.kind === 'store' && step.journalFile ? { kind: 'journal', journalFile: step.journalFile, db: step.args[step.args.indexOf('--path') + 1] } : step), receipts })], {
|
|
207
|
+
cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, ...RUFLO_ENV, ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(projectDir ? { RUVNET_TURN_PROJECT_DIR: projectDir } : {}) },
|
|
203
208
|
});
|
|
204
209
|
child.unref();
|
|
205
210
|
return { launched: true, pid: child.pid };
|
|
@@ -257,7 +262,8 @@ export function captureTurnOutcome({
|
|
|
257
262
|
Object.assign(report, { db, scope });
|
|
258
263
|
if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
|
|
259
264
|
const steps = [];
|
|
260
|
-
const
|
|
265
|
+
const rootStat = fs.statSync(projectRoot);
|
|
266
|
+
const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome, rootIdentity: `${rootStat.dev}:${rootStat.ino}` };
|
|
261
267
|
let dedupe;
|
|
262
268
|
const project = projectName(projectRoot);
|
|
263
269
|
const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
|
|
@@ -292,11 +298,17 @@ export function captureTurnOutcome({
|
|
|
292
298
|
if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
|
|
293
299
|
else {
|
|
294
300
|
const at = now();
|
|
295
|
-
const key = `turn-${project}-${
|
|
301
|
+
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
296
302
|
dedupe = { stateFile, identity, fingerprint, key };
|
|
297
|
-
|
|
303
|
+
let value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
|
|
304
|
+
// Retry uses the original durable content (including its timestamp), never a new value.
|
|
305
|
+
const pendingFile = path.join(path.dirname(db), 'turn-outbox', `${digest(key)}.json`);
|
|
306
|
+
if (fs.existsSync(pendingFile)) {
|
|
307
|
+
try { const pending = readJournal(pendingFile, db); value = pending.value; }
|
|
308
|
+
catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
|
|
309
|
+
}
|
|
298
310
|
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
|
|
299
|
-
'--path', db, '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
311
|
+
'--path', db, '--no-upsert', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
300
312
|
// This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
|
|
301
313
|
Object.assign(report, { queued: true, key, value });
|
|
302
314
|
}
|
|
@@ -316,10 +328,12 @@ export function captureTurnOutcome({
|
|
|
316
328
|
// Only explicit persisted consent permits creating a project store directory.
|
|
317
329
|
if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
|
|
318
330
|
if (report.queued) {
|
|
331
|
+
const step = steps.find((item) => item.kind === 'store');
|
|
332
|
+
step.journalFile = journalTurn(step, db, report.key, { onDurability: (evidence) => { report.durability = evidence; } });
|
|
319
333
|
fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
|
|
320
334
|
`${JSON.stringify({ ts: Date.now(), key: report.key, hash: crypto.createHash('sha256').update(report.value).digest('hex'), len: report.value.length })}\n`, { mode: 0o600 });
|
|
321
335
|
}
|
|
322
|
-
report.launch = launch(steps, { receipts });
|
|
336
|
+
report.launch = launch(steps, { receipts, brainHome, projectDir: binding.projectDir, env });
|
|
323
337
|
if (dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
|
|
324
338
|
} catch (error) {
|
|
325
339
|
return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: `launch failed: ${redactText(error.message)}` }, skipped: `launch failed: ${redactText(error.message)}` };
|
|
@@ -335,48 +349,106 @@ function readBack({ ruflo, db, key, run, options }) {
|
|
|
335
349
|
}
|
|
336
350
|
|
|
337
351
|
/** The detached worker: bounded steps, safe error evidence, exact content readback. */
|
|
338
|
-
export function runSteps({ steps = [], receipts } = {}, { run = spawnSync, read = readBack,
|
|
352
|
+
export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spawnSync, read = readBack,
|
|
353
|
+
env = process.env, deadlineMs, home = os.homedir(),
|
|
354
|
+
projectDir = env.RUVNET_TURN_PROJECT_DIR || process.cwd(),
|
|
355
|
+
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') } = {}) {
|
|
356
|
+
const run = (binary, args, options) => {
|
|
357
|
+
if (deadlineMs === undefined) return suppliedRun(binary, args, options);
|
|
358
|
+
const remaining = deadlineMs - Date.now();
|
|
359
|
+
if (remaining <= 0) return { status: 1, error: new Error('turn replay deadline exhausted') };
|
|
360
|
+
return suppliedRun(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
|
|
361
|
+
};
|
|
339
362
|
const results = [];
|
|
340
|
-
for (const
|
|
341
|
-
let status =
|
|
342
|
-
const
|
|
343
|
-
const db = args[args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
|
|
344
|
-
const key = step.kind === 'store' ? args[args.indexOf('-k') + 1] : undefined;
|
|
345
|
-
const valueIndex = args.indexOf('--value') + 1;
|
|
346
|
-
if (step.kind === 'store') args[valueIndex] = redactText(args[valueIndex]);
|
|
363
|
+
for (const queued of steps) {
|
|
364
|
+
let status = 1; let error = null; let verified = false; let db = ''; let key;
|
|
365
|
+
const kind = queued.kind === 'journal' ? 'store' : queued.kind;
|
|
347
366
|
try {
|
|
348
|
-
if (!step.projectRoot || !step.projectDir || !step.brainHome) throw new Error('queued step has no canonical project binding');
|
|
349
|
-
const target = resolveTurnDb({ projectDir: step.projectDir, brainHome: step.brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
350
|
-
if (target.skipped) throw new Error(target.skipped);
|
|
351
|
-
if (target.projectRoot !== step.projectRoot || target.db !== db) throw new Error('queued canonical project/store identity changed');
|
|
352
367
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
368
|
+
if (kind === 'store' && queued.kind !== 'journal') { const data = storeData(queued); key = data.key; db = data.db; }
|
|
369
|
+
if (queued.kind !== 'journal' && (!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
|
|
370
|
+
const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 });
|
|
371
|
+
if (target.skipped) throw new Error(target.skipped);
|
|
372
|
+
db = target.db;
|
|
373
|
+
let args; let value; let binding;
|
|
374
|
+
if (kind === 'store') {
|
|
375
|
+
const data = queued.kind === 'journal' ? readJournal(queued.journalFile, db) : storeData(queued);
|
|
376
|
+
if (queued.kind === 'journal' && queued.db !== db) throw new Error('foreign turn queue store rejected');
|
|
377
|
+
if (queued.kind !== 'journal' && data.db !== db) throw new Error('foreign turn store rejected');
|
|
378
|
+
if (data.legacyBrainHome && data.legacyBrainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
379
|
+
if (queued.kind !== 'journal' && queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
380
|
+
binding = queued.kind === 'journal' ? data.binding : queued;
|
|
381
|
+
key = data.key; value = redactText(data.value);
|
|
382
|
+
args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
|
|
383
|
+
} else if (kind === 'distill') {
|
|
384
|
+
if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
385
|
+
const recipe = queued.args;
|
|
386
|
+
if (!Array.isArray(recipe) || recipe.slice(0, 3).join(' ') !== 'memory distill run') throw new Error('invalid turn distill operation');
|
|
387
|
+
const seen = new Set();
|
|
388
|
+
for (let i = 3; i < recipe.length; i += 2) {
|
|
389
|
+
const flag = recipe[i]; const value = recipe[i + 1];
|
|
390
|
+
if (seen.has(flag) || !['--db', '--namespace', '--max-entries'].includes(flag) || typeof value !== 'string') throw new Error('invalid turn distill flags');
|
|
391
|
+
seen.add(flag);
|
|
392
|
+
if (flag === '--db' && value !== db || flag === '--namespace' && value !== TURN_NAMESPACE || flag === '--max-entries' && value !== '500') throw new Error('invalid turn distill scope');
|
|
393
|
+
}
|
|
394
|
+
if (!seen.has('--db')) throw new Error('missing turn distill database');
|
|
395
|
+
binding = queued;
|
|
396
|
+
args = ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'];
|
|
397
|
+
} else throw new Error('invalid turn worker operation');
|
|
398
|
+
if (!binding.projectRoot || !binding.projectDir) throw new Error('queued step has no canonical project binding');
|
|
399
|
+
const rootStat = fs.statSync(target.projectRoot);
|
|
400
|
+
if (binding.rootIdentity && binding.rootIdentity !== `${rootStat.dev}:${rootStat.ino}`) throw new Error('queued canonical project identity changed');
|
|
401
|
+
if (binding.projectRoot !== target.projectRoot) throw new Error('queued canonical project/store identity changed');
|
|
402
|
+
const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
403
|
+
if (origin.skipped) throw new Error(origin.skipped);
|
|
404
|
+
if (origin.projectRoot !== target.projectRoot || origin.db !== db) throw new Error('queued canonical capture origin changed');
|
|
405
|
+
const ruflo = resolveRuflo({ env, home });
|
|
406
|
+
if (!ruflo) throw new Error('ruflo not found for durable replay');
|
|
407
|
+
const invocation = rufloInvocation(ruflo, args);
|
|
408
|
+
const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
|
|
409
|
+
if (remaining <= 0) throw new Error('turn replay deadline exhausted');
|
|
410
|
+
const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), cwd: path.dirname(db), windowsHide: true,
|
|
411
|
+
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: path.dirname(db) } };
|
|
412
|
+
const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
|
|
413
|
+
if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
|
|
414
|
+
verified = kind === 'store' && existing === value;
|
|
415
|
+
const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
|
|
359
416
|
status = Number.isInteger(r.status) ? r.status : 1;
|
|
360
|
-
if (
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
417
|
+
if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
|
|
418
|
+
if (verified) status = 0;
|
|
419
|
+
if (!verified && (r.error || status !== 0)) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
|
|
420
|
+
else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
365
421
|
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
366
|
-
const row = { at: new Date().toISOString(), kind
|
|
367
|
-
|
|
368
|
-
...(step.kind === 'store' ? { verified } : {}) };
|
|
422
|
+
const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
|
|
423
|
+
...(kind === 'store' ? { verified } : {}) };
|
|
369
424
|
results.push(row);
|
|
370
425
|
if (receipts) {
|
|
371
|
-
try {
|
|
372
|
-
|
|
373
|
-
fs.appendFileSync(receipts, `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
374
|
-
} catch { /* receipts are best effort */ }
|
|
426
|
+
try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
|
|
427
|
+
catch { /* failed receipt persistence retains the journal for exact-key retry */ }
|
|
375
428
|
}
|
|
376
429
|
}
|
|
377
430
|
return results;
|
|
378
431
|
}
|
|
379
432
|
|
|
433
|
+
/** Replay only bounded canonical project entries; consent suspends rather than deletes them. */
|
|
434
|
+
export function replayTurnQueue({ projectDir = process.cwd(), env = process.env, home = os.homedir(),
|
|
435
|
+
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'), limit = 10,
|
|
436
|
+
launch = launchDetached, synchronous = false, runner, deadlineMs } = {}) {
|
|
437
|
+
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
|
|
438
|
+
try {
|
|
439
|
+
const target = resolveTurnDb({ projectDir, brainHome });
|
|
440
|
+
if (target.skipped) return { queued: false, skipped: target.skipped };
|
|
441
|
+
const steps = pendingTurnFiles(target.db, limit).map((journalFile) => ({ kind: 'journal', journalFile, db: target.db }));
|
|
442
|
+
if (!steps.length) return { queued: false, count: 0 };
|
|
443
|
+
if (synchronous) {
|
|
444
|
+
const results = runSteps({ steps, receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl') }, { env, home, projectDir, brainHome, ...(runner ? { run: runner } : {}), deadlineMs });
|
|
445
|
+
const pending = pendingTurnFiles(target.db, 25).length;
|
|
446
|
+
return { queued: false, count: steps.length, pending, failed: results.filter((row) => row.status !== 0).length, verified: results.filter((row) => row.verified === true).length, results };
|
|
447
|
+
}
|
|
448
|
+
return { queued: true, count: steps.length, pending: steps.length, launch: launch(steps, { receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl'), projectDir, brainHome, env }) };
|
|
449
|
+
} catch (error) { return { queued: false, skipped: redactText(error.message) }; }
|
|
450
|
+
}
|
|
451
|
+
|
|
380
452
|
/** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
|
|
381
453
|
export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now() } = {}) {
|
|
382
454
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
@@ -389,8 +461,14 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
|
|
|
389
461
|
try { return [JSON.parse(line)]; } catch { return []; }
|
|
390
462
|
}).filter((r) => r.kind === 'store' && (r.storeIdentity === crypto.createHash('sha256').update(target.db).digest('hex') || r.db === target.db) && now - Date.parse(r.at) < 7 * 86_400_000).slice(-20);
|
|
391
463
|
} catch { /* first run */ }
|
|
392
|
-
|
|
464
|
+
rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
|
|
465
|
+
let pending = 0;
|
|
466
|
+
try { pending = pendingTurnFiles(target.db, 25).length; } catch { return { state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }; }
|
|
467
|
+
const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
|
|
468
|
+
const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
|
|
393
469
|
if (failed.length) return { state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` };
|
|
470
|
+
if (historical.length) return { state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` };
|
|
471
|
+
if (pending) return { state: 'unknown', line: `turn recording pending durable replay (${pending} entries); not yet proven` };
|
|
394
472
|
return rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
|
|
395
473
|
: { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' };
|
|
396
474
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
6
|
+
export const turnQueueDirectory = (db) => path.join(path.dirname(db), 'turn-outbox');
|
|
7
|
+
function regular(file, directory = false) {
|
|
8
|
+
const stat = fs.lstatSync(file);
|
|
9
|
+
if (stat.isSymbolicLink() || (!directory && (!stat.isFile() || stat.nlink !== 1))
|
|
10
|
+
|| (directory && (!stat.isDirectory() || fs.realpathSync.native(file) !== file))) throw new Error('unsafe turn journal path');
|
|
11
|
+
}
|
|
12
|
+
// Node's Windows directory handles do not consistently support fsync. Never weaken
|
|
13
|
+
// file fsync or hide unexpected I/O errors; a missing namespace flush is reported.
|
|
14
|
+
export function syncTurnDirectory(dir, { platform = process.platform, io = fs } = {}) {
|
|
15
|
+
let fd;
|
|
16
|
+
try {
|
|
17
|
+
fd = io.openSync(dir, 'r'); io.fsyncSync(fd);
|
|
18
|
+
return { platform, directoryFsync: 'completed' };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (platform !== 'win32' || !['EISDIR', 'EPERM', 'EACCES', 'EINVAL', 'ENOTSUP', 'EBADF'].includes(error.code)) throw error;
|
|
21
|
+
return { platform, directoryFsync: 'unavailable', reason: error.code,
|
|
22
|
+
limitation: 'file fsync remains required; directory-entry persistence across power loss is unproven' };
|
|
23
|
+
} finally { if (fd !== undefined) io.closeSync(fd); }
|
|
24
|
+
}
|
|
25
|
+
// Legacy recipes are data extraction inputs only, never executable commands.
|
|
26
|
+
export function storeData(step) {
|
|
27
|
+
if (step?.kind !== 'store' || !Array.isArray(step.args) || step.args[0] !== 'memory' || step.args[1] !== 'store') throw new Error('invalid turn store operation');
|
|
28
|
+
const fields = {}; const aliases = { '-k': 'key', '--key': 'key', '--value': 'value', '-n': 'namespace', '--namespace': 'namespace', '--path': 'db', '--tags': 'tags', '--provenance': 'provenance' };
|
|
29
|
+
let strict = false;
|
|
30
|
+
for (let i = 2; i < step.args.length; i++) {
|
|
31
|
+
const flag = step.args[i];
|
|
32
|
+
if (flag === '--no-upsert') { if (strict) throw new Error('duplicate turn store flag'); strict = true; continue; }
|
|
33
|
+
const field = aliases[flag];
|
|
34
|
+
if (!field || Object.hasOwn(fields, field) || typeof step.args[i + 1] !== 'string') throw new Error('unknown or duplicate turn store flag');
|
|
35
|
+
fields[field] = step.args[++i];
|
|
36
|
+
}
|
|
37
|
+
if (!fields.key || typeof fields.value !== 'string' || fields.namespace !== 'turns' || !path.isAbsolute(fields.db || '')) throw new Error('invalid turn store namespace or data');
|
|
38
|
+
return { key: fields.key, value: fields.value, db: fields.db };
|
|
39
|
+
}
|
|
40
|
+
const bindingOf = (step) => ({ projectRoot: step.projectRoot, projectDir: step.projectDir, rootIdentity: step.rootIdentity });
|
|
41
|
+
function validateBinding(binding) {
|
|
42
|
+
if (!binding || Object.keys(binding).some((key) => !['projectRoot', 'projectDir', 'rootIdentity'].includes(key))
|
|
43
|
+
|| !path.isAbsolute(binding.projectRoot || '') || !path.isAbsolute(binding.projectDir || '')
|
|
44
|
+
|| typeof binding.rootIdentity !== 'string' || !/^\d+:\d+$/.test(binding.rootIdentity)) throw new Error('invalid turn canonical binding');
|
|
45
|
+
}
|
|
46
|
+
export function journalTurn(step, db, key, { platform = process.platform, io = fs, onDurability } = {}) {
|
|
47
|
+
const dir = turnQueueDirectory(db);
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); regular(dir, true);
|
|
49
|
+
const file = path.join(dir, `${digest(key)}.json`);
|
|
50
|
+
const data = storeData(step);
|
|
51
|
+
if (data.db !== db || data.key !== key) throw new Error('turn journal identity mismatch');
|
|
52
|
+
const binding = bindingOf(step); validateBinding(binding);
|
|
53
|
+
const record = { schemaVersion: 2, key, value: data.value, contentDigest: digest(data.value), consentScope: 'canonical-project-and-path', binding };
|
|
54
|
+
let created = false;
|
|
55
|
+
try {
|
|
56
|
+
const fd = io.openSync(file, 'wx', 0o600);
|
|
57
|
+
created = true;
|
|
58
|
+
try { io.writeFileSync(fd, JSON.stringify(record)); io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error.code !== 'EEXIST') throw error;
|
|
61
|
+
const previous = readJournal(file, db);
|
|
62
|
+
if (previous.key !== key || previous.contentDigest !== record.contentDigest) throw new Error('turn journal identity collision');
|
|
63
|
+
// Reflush an existing complete entry: its creator may have died before fsync.
|
|
64
|
+
const fd = io.openSync(file, 'r+');
|
|
65
|
+
try { io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
66
|
+
}
|
|
67
|
+
const evidence = { fileFsync: 'completed', entry: created ? 'created-exclusively' : 'existing-exact-match-reflushed', ...syncTurnDirectory(dir, { platform, io }) };
|
|
68
|
+
if (onDurability) onDurability(evidence);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
export function readJournal(file, db) {
|
|
72
|
+
const dir = turnQueueDirectory(db); regular(dir, true);
|
|
73
|
+
if (path.dirname(file) !== dir) throw new Error('foreign turn journal rejected');
|
|
74
|
+
regular(file);
|
|
75
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
76
|
+
let normalized = record;
|
|
77
|
+
if (record.schemaVersion === 1) {
|
|
78
|
+
const data = storeData(record.step);
|
|
79
|
+
if (!record.step.args.includes('--no-upsert')) throw new Error('legacy turn journal requires strict insert');
|
|
80
|
+
if (data.db !== db || data.key !== record.key) throw new Error('legacy turn journal identity mismatch');
|
|
81
|
+
// Legacy job-selected consent settings are never consumed; caller policy governs replay.
|
|
82
|
+
normalized = { schemaVersion: 2, key: data.key, value: data.value, contentDigest: record.contentDigest,
|
|
83
|
+
consentScope: record.consentScope, binding: bindingOf(record.step), legacyBrainHome: record.step.brainHome };
|
|
84
|
+
} else if (Object.keys(record).some((key) => !['schemaVersion', 'key', 'value', 'contentDigest', 'consentScope', 'binding'].includes(key))) throw new Error('unknown turn journal field');
|
|
85
|
+
validateBinding(normalized.binding);
|
|
86
|
+
if (normalized.schemaVersion !== 2 || normalized.consentScope !== 'canonical-project-and-path'
|
|
87
|
+
|| typeof normalized.key !== 'string' || !normalized.key.startsWith('turn-') || normalized.key.length > 250
|
|
88
|
+
|| typeof normalized.value !== 'string' || normalized.value.length > 4000
|
|
89
|
+
|| digest(normalized.value) !== normalized.contentDigest
|
|
90
|
+
|| path.basename(file) !== `${digest(normalized.key)}.json`) throw new Error('invalid turn journal');
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
export function pendingTurnFiles(db, limit = 10) {
|
|
94
|
+
const dir = turnQueueDirectory(db);
|
|
95
|
+
try { regular(dir, true); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
96
|
+
return fs.readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, Math.min(25, Math.max(0, limit))).map((name) => path.join(dir, name));
|
|
97
|
+
}
|
|
98
|
+
export function acknowledgeJournal(file, db, options = {}) {
|
|
99
|
+
readJournal(file, db); fs.unlinkSync(file); return syncTurnDirectory(path.dirname(file), options);
|
|
100
|
+
}
|
|
101
|
+
export function appendReceipt(file, row) {
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
103
|
+
if (fs.existsSync(file)) regular(file);
|
|
104
|
+
const fd = fs.openSync(file, 'a', 0o600);
|
|
105
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(row)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
106
|
+
}
|
|
@@ -68,6 +68,7 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
68
68
|
"reason": "Canonical project isolation, persisted consent and truthful capture status",
|
|
69
69
|
"files": [
|
|
70
70
|
"tests/unit/turn-outcome-capture.test.mjs",
|
|
71
|
+
"tests/unit/turn-journal-platform.test.mjs",
|
|
71
72
|
"tests/unit/project-store-resolver.test.mjs"
|
|
72
73
|
]
|
|
73
74
|
},
|
|
@@ -160,6 +161,18 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
160
161
|
"tests/unit/managed-memory-boundary.test.mjs"
|
|
161
162
|
]
|
|
162
163
|
},
|
|
164
|
+
{
|
|
165
|
+
"id": "automatic-memory-transition-durability",
|
|
166
|
+
"reason": "Reviewed POSIX automatic boundary capture, complete history, consent suspension, first-start recovery and data-only turn replay; real global Ruflo and filesystem prerequisites are required",
|
|
167
|
+
"files": [
|
|
168
|
+
"tests/unit/project-transition-hook.test.mjs",
|
|
169
|
+
"tests/unit/turn-transport-security.test.mjs",
|
|
170
|
+
"tests/unit/turn-durable-transport.test.mjs",
|
|
171
|
+
"tests/integration/automatic-progression-continuation.test.mjs",
|
|
172
|
+
"tests/integration/capture-consent-boundary.test.mjs",
|
|
173
|
+
"tests/integration/session-start-turn-replay.test.mjs"
|
|
174
|
+
]
|
|
175
|
+
},
|
|
163
176
|
{
|
|
164
177
|
"id": "pending-continuity-capacity",
|
|
165
178
|
"reason": "POSIX non-root permission refusal, capacity-pressure reporting and recovery preserve all accepted pending continuity events",
|