ruvnet-brain 4.6.3 → 4.7.0

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.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +6 -50
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +2 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/commands/checkpoint.md +22 -40
  10. package/plugin/commands/rnb-brief.md +18 -15
  11. package/plugin/hooks/codex-hooks.json +1 -58
  12. package/plugin/hooks/hook-contracts.json +19 -220
  13. package/plugin/hooks/hooks.json +1 -70
  14. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  15. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  16. package/plugin/scripts/agentdb-recall.mjs +27 -21
  17. package/plugin/scripts/brain-footprint.mjs +1 -1
  18. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  19. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  20. package/plugin/scripts/continuation-gate.mjs +78 -1
  21. package/plugin/scripts/continuity-events.mjs +90 -82
  22. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  23. package/plugin/scripts/decision-gate.mjs +3 -3
  24. package/plugin/scripts/decision-recall-gate.mjs +141 -0
  25. package/plugin/scripts/decision-record.mjs +324 -0
  26. package/plugin/scripts/deferral-evidence.mjs +134 -0
  27. package/plugin/scripts/hook-failures.mjs +61 -0
  28. package/plugin/scripts/hook-shim.mjs +71 -6
  29. package/plugin/scripts/learn-capture.mjs +88 -37
  30. package/plugin/scripts/learning-store.mjs +70 -60
  31. package/plugin/scripts/node-sqlite.mjs +1 -1
  32. package/plugin/scripts/ruflo-bin.mjs +71 -1
  33. package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
  34. package/plugin/scripts/runtime-preferences.mjs +1 -1
  35. package/plugin/scripts/session-snapshot-hook.mjs +85 -332
  36. package/plugin/scripts/session-start-core.mjs +7 -9
  37. package/plugin/scripts/signal-watch.mjs +1 -1
  38. package/plugin/scripts/substitution-wall.mjs +222 -0
  39. package/plugin/scripts/turn-outcome-capture.mjs +74 -88
  40. package/scripts/codex-host-execution-proof.mjs +1 -1
  41. package/scripts/console-engine.mjs +0 -32
  42. package/scripts/deferral-replay.mjs +47 -0
  43. package/scripts/health-repair.mjs +1 -81
  44. package/scripts/hook-qualify-core.mjs +2 -2
  45. package/scripts/model-managed-workflow-service.mjs +9 -10
  46. package/scripts/model-routing-controller.mjs +9 -8
  47. package/scripts/no-silent-substitution.mjs +65 -1
  48. package/scripts/onboarding-console.mjs +8 -23
  49. package/scripts/product-integrity-contract.mjs +1 -1
  50. package/scripts/release-qualification-contract.mjs +9 -27
  51. package/scripts/remedy-registry.mjs +0 -18
  52. package/scripts/wired-check.mjs +1 -5
  53. package/plugin/scripts/continuity-brief.mjs +0 -357
  54. package/plugin/scripts/continuity-journal.mjs +0 -553
  55. package/plugin/scripts/learn-flush.mjs +0 -96
  56. package/plugin/scripts/learning-observation.mjs +0 -51
  57. package/plugin/scripts/learning-queue.mjs +0 -135
  58. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  59. package/plugin/scripts/project-capture-queue.mjs +0 -357
  60. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  61. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  62. package/plugin/scripts/project-progression-contract.mjs +0 -496
  63. package/plugin/scripts/project-progression-hook.mjs +0 -233
  64. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  65. package/plugin/scripts/project-progression-producer.mjs +0 -309
  66. package/plugin/scripts/project-progression-reader.mjs +0 -304
  67. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  68. package/plugin/scripts/project-progression-sources.mjs +0 -246
  69. package/plugin/scripts/project-progression-store.mjs +0 -723
  70. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  71. package/plugin/scripts/project-transition-hook.mjs +0 -246
  72. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  73. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  74. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  75. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -1,233 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { createProgressionSnapshot, digestCanonical } from './project-progression-contract.mjs';
5
- import { ProjectProgressionStore } from './project-progression-store.mjs';
6
- import { redactText } from './continuity-events.mjs';
7
- import os from 'node:os';
8
- import { resolveTurnDb } from './turn-outcome-capture.mjs';
9
- import { privateProgressionState, payloadReferencesExcludedResource } from './turn-capture-privacy.mjs';
10
-
11
- const HOSTS = new Set(['claude', 'codex']);
12
- const CAPTURE_TRIGGERS = new Set([
13
- 'SessionStart',
14
- 'UserPromptSubmit',
15
- 'PreToolUse',
16
- 'PostToolUse',
17
- 'Stop',
18
- 'SubagentStop',
19
- 'PreCompact',
20
- 'SessionEnd',
21
- // The EXPLICIT boundary: /ruvnet-brain:checkpoint. It routes through this same function on
22
- // purpose — a second writer for "the model wrote its own state" would be a second writer.
23
- 'checkpoint',
24
- ]);
25
- const PLUGIN_JSON = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.claude-plugin', 'plugin.json');
26
-
27
- function requireRecord(value, label) {
28
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
29
- }
30
-
31
- function requireString(value, label) {
32
- if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${label} must be a non-empty string`);
33
- return value.trim();
34
- }
35
-
36
- function own(value, key) {
37
- return Object.prototype.hasOwnProperty.call(value, key);
38
- }
39
-
40
- function aliased(value, camel, snake) {
41
- const hasCamel = own(value, camel);
42
- const hasSnake = own(value, snake);
43
- if (hasCamel && hasSnake) throw new Error(`ambiguous progression field: ${camel}/${snake}`);
44
- return hasCamel ? value[camel] : value[snake];
45
- }
46
-
47
- function progressionExtension(payload) {
48
- const hasCamel = own(payload, 'projectProgression');
49
- const hasSnake = own(payload, 'project_progression');
50
- if (hasCamel && hasSnake) throw new Error('ambiguous progression extension');
51
- if (!hasCamel && !hasSnake) return null;
52
- const value = hasCamel ? payload.projectProgression : payload.project_progression;
53
- requireRecord(value, 'project progression');
54
- return value;
55
- }
56
-
57
- export function hasProjectProgression(payload) {
58
- requireRecord(payload, 'host payload');
59
- return progressionExtension(payload) !== null;
60
- }
61
-
62
- export function readProgressionAdapterVersion() {
63
- let parsed;
64
- try { parsed = JSON.parse(fs.readFileSync(PLUGIN_JSON, 'utf8')); } catch {
65
- throw new Error(`progression adapter version is unreadable: ${PLUGIN_JSON}`);
66
- }
67
- return requireString(parsed?.version, 'progression adapter version');
68
- }
69
-
70
- function normalizeSourceIdentity(value, checkoutRoot, projectDir) {
71
- requireRecord(value, 'sourceIdentity');
72
- const checkoutPath = requireString(value.checkoutPath, 'sourceIdentity.checkoutPath');
73
- let canonicalCheckout;
74
- try { canonicalCheckout = fs.realpathSync.native(checkoutPath); } catch {
75
- throw new Error('source identity checkout path does not resolve to the active checkout');
76
- }
77
- if (canonicalCheckout !== checkoutRoot) {
78
- throw new Error('source identity checkout path does not match the active checkout path');
79
- }
80
- return { ...value, checkoutPath: canonicalCheckout, capturePath: fs.realpathSync.native(projectDir) };
81
- }
82
-
83
- const OBSERVATION_TEXT_LIMIT = 4_096;
84
-
85
- function boundedText(value) {
86
- if (typeof value !== 'string' || !value) return undefined;
87
- return value.length <= OBSERVATION_TEXT_LIMIT
88
- ? value
89
- : `${value.slice(0, OBSERVATION_TEXT_LIMIT)}...[truncated]`;
90
- }
91
-
92
- function toolAction(payload, { contentPathExcludes = [], projectDir } = {}) {
93
- const input = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
94
- // Classify original input before bounding it; the excluded reference can occur after 4096 chars.
95
- const privateSource = payloadReferencesExcludedResource(payload, contentPathExcludes, projectDir);
96
- const command = boundedText(input.command ?? input.cmd);
97
- const filePath = boundedText(input.file_path ?? input.notebook_path ?? input.path);
98
- const action = command || filePath;
99
- if (!action && !payload.tool_name) return null;
100
-
101
- const response = payload.tool_response;
102
- const responseRecord = response && typeof response === 'object' && !Array.isArray(response)
103
- ? response
104
- : null;
105
- const explicitCode = responseRecord && [responseRecord.exit_code, responseRecord.exitCode, responseRecord.status]
106
- .find((value) => Number.isSafeInteger(value));
107
- const responseText = typeof response === 'string' ? response : '';
108
- // Native host terminal envelopes may serialize an exact `Exit code: N` line. Do not
109
- // scan arbitrary prose: tool output often quotes logs or examples containing `status: 0`.
110
- const textualCode = responseText.match(/^\s*Exit code:\s*(-?\d+)\s*$/i);
111
- const exitCode = Number.isSafeInteger(explicitCode)
112
- ? explicitCode
113
- : textualCode ? Number(textualCode[1]) : undefined;
114
- const error = boundedText(typeof responseRecord?.error === 'string' ? redactText(responseRecord.error) : undefined);
115
- const signal = boundedText(typeof responseRecord?.signal === 'string' ? redactText(responseRecord.signal) : undefined);
116
- const interrupted = responseRecord?.interrupted === true || Boolean(signal);
117
- const explicitError = responseRecord?.isError === true || payload.is_error === true;
118
- const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(responseRecord?.outcome)
119
- ? responseRecord.outcome : null;
120
- const failed = explicitError || Boolean(error) || (Number.isSafeInteger(exitCode) && exitCode !== 0);
121
- const terminal = failed || Number.isSafeInteger(exitCode)
122
- || responseRecord?.success === true || responseRecord?.ok === true;
123
- const outcome = payload.hook_event_name === 'PostToolUse'
124
- ? (error ? 'failure' : interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
125
- : 'pending';
126
- const observation = {
127
- trigger: payload.hook_event_name,
128
- tool: boundedText(payload.tool_name) || 'unknown',
129
- ...(command ? { command } : {}),
130
- ...(filePath ? { filePath } : {}),
131
- outcome,
132
- ...(Number.isSafeInteger(exitCode) ? { exitCode } : responseRecord?.exit_code === null || responseRecord?.exitCode === null ? { exitCode: null } : {}),
133
- ...(error ? { error } : {}),
134
- ...(signal ? { signal } : {}),
135
- ...(interrupted ? { interrupted: true } : {}),
136
- ...(explicitError ? { isError: true } : {}),
137
- };
138
- if (responseRecord) {
139
- const stdout = boundedText(responseRecord.stdout);
140
- const stderr = boundedText(responseRecord.stderr);
141
- if (stdout) observation.stdout = stdout;
142
- if (stderr) observation.stderr = stderr;
143
- } else {
144
- const text = boundedText(response);
145
- if (text) observation.result = text;
146
- }
147
- if (privateSource) {
148
- if (command) observation.command = '[REDACTED:excluded-resource-command]'; if (filePath) observation.filePath = '[REDACTED:excluded-path]';
149
- for (const field of ['stdout', 'stderr', 'result', 'error', 'signal']) if (Object.hasOwn(observation, field)) observation[field] = '[REDACTED:excluded-resource-output]';
150
- }
151
- return observation;
152
- }
153
-
154
- export function enrichStateWithObservation(state, payload, privacy) {
155
- requireRecord(state, 'completeProjectState');
156
- const observation = toolAction(payload, privacy);
157
- if (!observation) return state;
158
- const commands = Array.isArray(state.commands) ? state.commands : [];
159
- const failures = Array.isArray(state.failures) ? state.failures : [];
160
- return {
161
- ...state,
162
- commands: [...commands, observation],
163
- ...(observation.outcome === 'failure' || observation.outcome === 'interrupted' ? { failures: [...failures, observation] } : {}),
164
- };
165
- }
166
-
167
- function verifyReceipt(snapshot, receipt) {
168
- if (!receipt || typeof receipt !== 'object'
169
- || receipt.eventKey !== snapshot.eventKey
170
- || receipt.payloadDigest !== snapshot.payloadDigest
171
- || receipt.readbackDigest !== snapshot.payloadDigest) {
172
- throw new Error('progression persistence returned an unverifiable exact-readback receipt');
173
- }
174
- }
175
-
176
- export function captureProjectTransition({
177
- host,
178
- payload,
179
- projectDir,
180
- adapterVersion = readProgressionAdapterVersion(),
181
- recoverFrozen = false,
182
- canCommit,
183
- env = process.env,
184
- storeFactory = (options) => new ProjectProgressionStore(options),
185
- } = {}) {
186
- const normalizedHost = requireString(host, 'host').toLowerCase();
187
- if (!HOSTS.has(normalizedHost)) throw new Error(`unsupported progression host: ${normalizedHost}`);
188
- requireRecord(payload, 'host payload');
189
- const sessionIdentity = requireString(payload.session_id, 'host payload session_id');
190
- const trigger = requireString(payload.hook_event_name, 'host payload hook_event_name');
191
- if (!CAPTURE_TRIGGERS.has(trigger)) throw new Error(`unsupported progression trigger: ${trigger}`);
192
- requireString(adapterVersion, 'adapterVersion');
193
- const progression = progressionExtension(payload);
194
- if (!progression) throw new Error('host payload is missing a complete progression extension');
195
-
196
- const requestedStorePath = requireString(
197
- aliased(progression, 'canonicalAgentDbPath', 'canonical_agent_db_path'),
198
- 'canonicalAgentDbPath',
199
- );
200
- if (!path.isAbsolute(requestedStorePath)) throw new Error('canonicalAgentDbPath must be absolute');
201
- const store = storeFactory({ projectDir, requestedStorePath });
202
- requireRecord(store?.resolution, 'progression store resolution');
203
- if (path.resolve(requestedStorePath) !== store.resolution.canonicalAgentDbPath) {
204
- throw new Error('foreign store root rejected');
205
- }
206
-
207
- const sourceIdentity = normalizeSourceIdentity(
208
- aliased(progression, 'sourceIdentity', 'source_identity'),
209
- store.resolution.checkoutRoot, projectDir,
210
- );
211
- const privacy = resolveTurnDb({ projectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
212
- if (privacy.skipped) throw new Error(`progression capture suspended: ${privacy.skipped}`);
213
- const observed = enrichStateWithObservation(aliased(progression, 'completeProjectState', 'complete_project_state'), payload, { contentPathExcludes: privacy.contentPathExcludes, projectDir });
214
- const protectedState = privateProgressionState(observed, privacy.contentPathExcludes, projectDir);
215
- if (recoverFrozen && digestCanonical(protectedState) !== digestCanonical(observed)) throw new Error('progression capture suspended: content exclusions changed; frozen snapshot retained');
216
- let snapshot = createProgressionSnapshot({
217
- projectIdentity: store.resolution.projectIdentity,
218
- sourceIdentity,
219
- hostIdentity: { host: normalizedHost, adapterVersion },
220
- sessionIdentity,
221
- sequence: progression.sequence,
222
- occurredAt: aliased(progression, 'occurredAt', 'occurred_at'),
223
- trigger,
224
- parentEventKeys: aliased(progression, 'parentEventKeys', 'parent_event_keys'),
225
- dedupId: aliased(progression, 'dedupId', 'dedup_id'),
226
- completeProjectState: protectedState,
227
- });
228
- let receipt;
229
- if (recoverFrozen) ({ snapshot, receipt } = store.captureFrozen(snapshot, { canCommit }));
230
- else receipt = store.capture(snapshot);
231
- verifyReceipt(snapshot, receipt);
232
- return { snapshot, receipt };
233
- }
@@ -1,225 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
-
4
- const OUTBOX_NAME = 'project-progression-outbox.jsonl';
5
- const READ_CHUNK_BYTES = 64 * 1024;
6
-
7
- // Decode only complete records, so a multibyte character crossing reads stays intact.
8
- function* readJsonl(file) {
9
- let fd;
10
- try { fd = fs.openSync(file, 'r'); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
11
- const chunk = Buffer.alloc(READ_CHUNK_BYTES);
12
- let parts = [];
13
- let line = 0;
14
- const parse = (bytes, terminated) => {
15
- if (!bytes.length) return undefined;
16
- const text = bytes.toString('utf8');
17
- if (!text.trim()) return undefined;
18
- try { return JSON.parse(text); } catch {
19
- if (!terminated) return undefined;
20
- throw new Error(`malformed outbox record at line ${line}`);
21
- }
22
- };
23
- try {
24
- let length;
25
- while ((length = fs.readSync(fd, chunk, 0, chunk.length, null))) {
26
- let start = 0;
27
- for (let end = 0; end < length; end += 1) {
28
- if (chunk[end] !== 10) continue;
29
- parts.push(Buffer.from(chunk.subarray(start, end)));
30
- line += 1;
31
- const record = parse(Buffer.concat(parts), true);
32
- if (record !== undefined) yield record;
33
- parts = []; start = end + 1;
34
- }
35
- if (start < length) parts.push(Buffer.from(chunk.subarray(start, length)));
36
- }
37
- if (parts.length) {
38
- line += 1;
39
- const record = parse(Buffer.concat(parts), false);
40
- if (record !== undefined) yield record;
41
- }
42
- } finally { fs.closeSync(fd); }
43
- }
44
-
45
- // Inspect only the final record; preceding history is never decoded or copied.
46
- function finalRecord(fd, size) {
47
- const parts = [];
48
- let position = size;
49
- while (position > 0) {
50
- const length = Math.min(READ_CHUNK_BYTES, position);
51
- position -= length;
52
- const chunk = Buffer.alloc(length);
53
- let read = 0;
54
- while (read < length) {
55
- const count = fs.readSync(fd, chunk, read, length - read, position + read);
56
- if (!count) throw new Error('outbox tail changed during inspection');
57
- read += count;
58
- }
59
- const delimiter = chunk.lastIndexOf(10);
60
- parts.unshift(delimiter < 0 ? chunk : chunk.subarray(delimiter + 1));
61
- if (delimiter >= 0) break;
62
- }
63
- return Buffer.concat(parts).toString('utf8');
64
- }
65
-
66
- function requireIdentity(value, label) {
67
- if (typeof value !== 'string' || !value) throw new TypeError(`${label} must be a non-empty string`);
68
- }
69
-
70
- function writeAll(fd, value) {
71
- const content = Buffer.from(value);
72
- let offset = 0;
73
- while (offset < content.length) offset += fs.writeSync(fd, content, offset, content.length - offset);
74
- }
75
-
76
- export class ProgressionOutbox {
77
- constructor({ projectRoot, fsync = fs.fsyncSync } = {}) {
78
- requireIdentity(projectRoot, 'projectRoot');
79
- this.path = path.join(projectRoot, '.swarm', OUTBOX_NAME);
80
- this.fsync = fsync;
81
- }
82
-
83
- appendRecord(record) {
84
- fs.mkdirSync(path.dirname(this.path), { recursive: true, mode: 0o700 });
85
- const noFollow = fs.constants.O_NOFOLLOW ?? 0;
86
- const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_RDWR | noFollow, 0o600);
87
- try {
88
- let separator = '';
89
- const size = fs.fstatSync(fd).size;
90
- if (size) {
91
- const last = Buffer.alloc(1);
92
- fs.readSync(fd, last, 0, 1, size - 1);
93
- if (last[0] !== 10) {
94
- try { JSON.parse(finalRecord(fd, size)); }
95
- catch { throw new Error('incomplete outbox final record; preserve and recover the torn tail before appending'); }
96
- separator = '\n';
97
- }
98
- }
99
- fs.fchmodSync(fd, 0o600);
100
- writeAll(fd, `${separator}${JSON.stringify(record)}\n`);
101
- this.fsync(fd);
102
- } finally {
103
- fs.closeSync(fd);
104
- }
105
- return record;
106
- }
107
-
108
- appendSnapshot(snapshot) {
109
- requireIdentity(snapshot?.eventKey, 'snapshot.eventKey');
110
- requireIdentity(snapshot?.payloadDigest, 'snapshot.payloadDigest');
111
- return this.appendRecord({
112
- type: 'snapshot',
113
- eventKey: snapshot.eventKey,
114
- payloadDigest: snapshot.payloadDigest,
115
- snapshot,
116
- });
117
- }
118
-
119
- markCommitted(receipt) {
120
- requireIdentity(receipt?.eventKey, 'receipt.eventKey');
121
- requireIdentity(receipt?.payloadDigest, 'receipt.payloadDigest');
122
- requireIdentity(receipt?.readbackDigest, 'receipt.readbackDigest');
123
- requireIdentity(receipt?.committedAt, 'receipt.committedAt');
124
- if (receipt.readbackDigest !== receipt.payloadDigest) throw new Error('readback digest mismatch');
125
- return this.appendRecord({ type: 'commit', ...receipt });
126
- }
127
-
128
- /** Disposition of one frozen payload, never a commit for its conflicting original key. */
129
- markRecovered(snapshot, receipt) {
130
- for (const [label, value] of Object.entries({ eventKey: snapshot?.eventKey,
131
- payloadDigest: snapshot?.payloadDigest, recoveryEventKey: receipt?.eventKey,
132
- recoveryPayloadDigest: receipt?.payloadDigest, readbackDigest: receipt?.readbackDigest,
133
- committedAt: receipt?.committedAt })) requireIdentity(value, label);
134
- if (receipt.eventKey === snapshot.eventKey || receipt.readbackDigest !== receipt.payloadDigest) {
135
- throw new Error('unverified recovery disposition');
136
- }
137
- return this.appendRecord({ type: 'recovery', eventKey: snapshot.eventKey,
138
- payloadDigest: snapshot.payloadDigest, recoveryEventKey: receipt.eventKey,
139
- recoveryPayloadDigest: receipt.payloadDigest, readbackDigest: receipt.readbackDigest,
140
- committedAt: receipt.committedAt });
141
- }
142
-
143
- records() {
144
- return [...readJsonl(this.path)];
145
- }
146
-
147
- /**
148
- * Snapshots fsynced but not committed. A key whose records DISAGREE (two snapshots, or a snapshot and
149
- * a commit, with different digests) is still never replayed — that is the fail-closed part — but it
150
- * is QUARANTINED, not thrown: on this repo's real outbox one such collision (2026-09-18, two Stop
151
- * boundaries of one session producing the same sequence and dedup id) made this method throw on
152
- * every call, every caller swallowed it, and no pending snapshot was replayed for 13 days while the
153
- * SessionStart notice read 0 pending. Quarantined keys are reported by quarantinedKeys().
154
- */
155
- pendingSnapshots() {
156
- const snapshots = new Map();
157
- const committed = new Map();
158
- const quarantined = new Map();
159
- const recoveries = [];
160
- let ordinal = 0;
161
- for (const record of readJsonl(this.path)) {
162
- ordinal += 1;
163
- requireIdentity(record?.eventKey, 'outbox eventKey');
164
- requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
165
- if (record.type === 'snapshot') {
166
- const prior = snapshots.get(record.eventKey);
167
- if (prior && prior.payloadDigest !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox event key collision');
168
- // Retain identity/recovery metadata, never every historical snapshot's full payload.
169
- // A second incremental scan materializes only the unresolved payloads selected here.
170
- else snapshots.set(record.eventKey, { eventKey: record.eventKey, payloadDigest: record.payloadDigest,
171
- ordinal, recoveryDiagnostics: record.snapshot?.recoveryDiagnostics && {
172
- originalEventKey: record.snapshot.recoveryDiagnostics.originalEventKey,
173
- frozenPayloadDigest: record.snapshot.recoveryDiagnostics.frozenPayloadDigest,
174
- }, snapshotDigest: record.snapshot?.payloadDigest });
175
- } else if (record.type === 'commit') {
176
- const prior = committed.get(record.eventKey);
177
- if (prior && prior !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox commit collision');
178
- committed.set(record.eventKey, record.payloadDigest);
179
- } else if (record.type === 'recovery') {
180
- requireIdentity(record.recoveryEventKey, 'recovery event key');
181
- requireIdentity(record.recoveryPayloadDigest, 'recovery payload digest');
182
- if (record.eventKey === record.recoveryEventKey || record.readbackDigest !== record.recoveryPayloadDigest) {
183
- throw new Error('unverified recovery disposition');
184
- }
185
- recoveries.push(record);
186
- } else {
187
- throw new Error('unsupported outbox record');
188
- }
189
- }
190
- for (const record of snapshots.values()) {
191
- const digest = committed.get(record.eventKey);
192
- if (digest && digest !== record.payloadDigest && !quarantined.has(record.eventKey)) {
193
- quarantined.set(record.eventKey, 'outbox commit digest mismatch');
194
- }
195
- }
196
- const recovered = new Set(recoveries.filter((record) => {
197
- const target = snapshots.get(record.recoveryEventKey);
198
- return !quarantined.has(record.recoveryEventKey)
199
- && committed.get(record.recoveryEventKey) === record.recoveryPayloadDigest
200
- && target?.snapshotDigest === record.recoveryPayloadDigest
201
- && target.recoveryDiagnostics?.originalEventKey === record.eventKey
202
- && target.recoveryDiagnostics?.frozenPayloadDigest === record.payloadDigest;
203
- }).map((record) => `${record.eventKey}:${record.payloadDigest}`));
204
- this.quarantine = [...quarantined].map(([eventKey, reason]) => ({ eventKey, reason }));
205
- const pending = new Map([...snapshots.values()]
206
- .filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
207
- && !recovered.has(`${record.eventKey}:${record.payloadDigest}`))
208
- .map((record) => [record.ordinal, record.eventKey]));
209
- const payloads = [];
210
- ordinal = 0;
211
- if (pending.size) {
212
- for (const record of readJsonl(this.path)) {
213
- ordinal += 1;
214
- if (pending.has(ordinal)) payloads.push({ eventKey: pending.get(ordinal), snapshot: record.snapshot });
215
- }
216
- }
217
- return payloads.sort((left, right) => left.eventKey.localeCompare(right.eventKey)).map((record) => record.snapshot);
218
- }
219
-
220
- /** Keys pendingSnapshots() refused to replay because their records disagree, with the reason. */
221
- quarantinedKeys() {
222
- this.pendingSnapshots();
223
- return this.quarantine;
224
- }
225
- }