@jinn-network/core 0.1.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 (111) hide show
  1. package/dist/canonical-json.d.ts +1 -0
  2. package/dist/canonical-json.js +67 -0
  3. package/dist/captured-task.d.ts +140 -0
  4. package/dist/captured-task.js +122 -0
  5. package/dist/contribution-store.d.ts +97 -0
  6. package/dist/contribution-store.js +770 -0
  7. package/dist/corpus-read/acquire.d.ts +39 -0
  8. package/dist/corpus-read/acquire.js +212 -0
  9. package/dist/corpus-read/cache.d.ts +14 -0
  10. package/dist/corpus-read/cache.js +17 -0
  11. package/dist/corpus-read/capture-meta.d.ts +16 -0
  12. package/dist/corpus-read/capture-meta.js +18 -0
  13. package/dist/corpus-read/create-corpus.d.ts +2 -0
  14. package/dist/corpus-read/create-corpus.js +97 -0
  15. package/dist/corpus-read/fetch-artifact.d.ts +21 -0
  16. package/dist/corpus-read/fetch-artifact.js +32 -0
  17. package/dist/corpus-read/fetch.d.ts +12 -0
  18. package/dist/corpus-read/fetch.js +24 -0
  19. package/dist/corpus-read/http-discovery.d.ts +9 -0
  20. package/dist/corpus-read/http-discovery.js +128 -0
  21. package/dist/corpus-read/index.d.ts +10 -0
  22. package/dist/corpus-read/index.js +10 -0
  23. package/dist/corpus-read/ipfs.d.ts +5 -0
  24. package/dist/corpus-read/ipfs.js +72 -0
  25. package/dist/corpus-read/route-resolver.d.ts +16 -0
  26. package/dist/corpus-read/route-resolver.js +19 -0
  27. package/dist/corpus-read/types.d.ts +177 -0
  28. package/dist/corpus-read/types.js +42 -0
  29. package/dist/envelope.d.ts +134 -0
  30. package/dist/envelope.js +162 -0
  31. package/dist/evidence-adapter.d.ts +26 -0
  32. package/dist/evidence-adapter.js +321 -0
  33. package/dist/evidence-filesystem.d.ts +40 -0
  34. package/dist/evidence-filesystem.js +267 -0
  35. package/dist/evidence-index.d.ts +117 -0
  36. package/dist/evidence-index.js +1083 -0
  37. package/dist/evidence-store-lock.d.ts +2 -0
  38. package/dist/evidence-store-lock.js +208 -0
  39. package/dist/execution-envelope.d.ts +3142 -0
  40. package/dist/execution-envelope.js +203 -0
  41. package/dist/index.d.ts +12 -0
  42. package/dist/index.js +12 -0
  43. package/dist/manifest.d.ts +75 -0
  44. package/dist/manifest.js +184 -0
  45. package/dist/paired.d.ts +68 -0
  46. package/dist/paired.js +92 -0
  47. package/dist/scrub/build.d.ts +52 -0
  48. package/dist/scrub/build.js +84 -0
  49. package/dist/scrub/emit-scrub.d.ts +41 -0
  50. package/dist/scrub/emit-scrub.js +87 -0
  51. package/dist/scrub/index.d.ts +12 -0
  52. package/dist/scrub/index.js +12 -0
  53. package/dist/scrub/key-policy.d.ts +20 -0
  54. package/dist/scrub/key-policy.js +42 -0
  55. package/dist/scrub/layer2.d.ts +23 -0
  56. package/dist/scrub/layer2.js +32 -0
  57. package/dist/scrub/ml-pii-stage.d.ts +24 -0
  58. package/dist/scrub/ml-pii-stage.js +42 -0
  59. package/dist/scrub/openredaction-stage.d.ts +196 -0
  60. package/dist/scrub/openredaction-stage.js +270 -0
  61. package/dist/scrub/pii-build.d.ts +38 -0
  62. package/dist/scrub/pii-build.js +67 -0
  63. package/dist/scrub/pipeline.d.ts +38 -0
  64. package/dist/scrub/pipeline.js +83 -0
  65. package/dist/scrub/plain-patterns-stage.d.ts +31 -0
  66. package/dist/scrub/plain-patterns-stage.js +84 -0
  67. package/dist/scrub/secretlint-stage.d.ts +36 -0
  68. package/dist/scrub/secretlint-stage.js +249 -0
  69. package/dist/scrub/transformers-detector.d.ts +35 -0
  70. package/dist/scrub/transformers-detector.js +67 -0
  71. package/dist/scrub/types.d.ts +25 -0
  72. package/dist/scrub/types.js +1 -0
  73. package/dist/session-provenance.d.ts +75 -0
  74. package/dist/session-provenance.js +20 -0
  75. package/dist/skill-artifact.d.ts +442 -0
  76. package/dist/skill-artifact.js +136 -0
  77. package/dist/trajectory/hash-chain.d.ts +18 -0
  78. package/dist/trajectory/hash-chain.js +47 -0
  79. package/dist/trajectory/index.d.ts +4 -0
  80. package/dist/trajectory/index.js +4 -0
  81. package/dist/trajectory/schema.d.ts +606 -0
  82. package/dist/trajectory/schema.js +110 -0
  83. package/dist/trajectory/transcript-parsers/aider-history.d.ts +42 -0
  84. package/dist/trajectory/transcript-parsers/aider-history.js +181 -0
  85. package/dist/trajectory/transcript-parsers/claude-code-jsonl.d.ts +37 -0
  86. package/dist/trajectory/transcript-parsers/claude-code-jsonl.js +142 -0
  87. package/dist/trajectory/transcript-parsers/codex-session.d.ts +42 -0
  88. package/dist/trajectory/transcript-parsers/codex-session.js +235 -0
  89. package/dist/trajectory/transcript-parsers/continue-devdata.d.ts +41 -0
  90. package/dist/trajectory/transcript-parsers/continue-devdata.js +190 -0
  91. package/dist/trajectory/transcript-parsers/cursor-sqlite.d.ts +38 -0
  92. package/dist/trajectory/transcript-parsers/cursor-sqlite.js +118 -0
  93. package/dist/trajectory/transcript-parsers/gemini-session.d.ts +35 -0
  94. package/dist/trajectory/transcript-parsers/gemini-session.js +137 -0
  95. package/dist/trajectory/transcript-parsers/index.d.ts +7 -0
  96. package/dist/trajectory/transcript-parsers/index.js +7 -0
  97. package/dist/trajectory/transcript-parsers/types.d.ts +101 -0
  98. package/dist/trajectory/transcript-parsers/types.js +32 -0
  99. package/dist/trajectory/transcript-to-spans/attrs.d.ts +4 -0
  100. package/dist/trajectory/transcript-to-spans/attrs.js +30 -0
  101. package/dist/trajectory/transcript-to-spans/claude-code-stream-json.d.ts +8 -0
  102. package/dist/trajectory/transcript-to-spans/claude-code-stream-json.js +162 -0
  103. package/dist/trajectory/transcript-to-spans/codex-exec-json.d.ts +8 -0
  104. package/dist/trajectory/transcript-to-spans/codex-exec-json.js +253 -0
  105. package/dist/trajectory/transcript-to-spans/index.d.ts +4 -0
  106. package/dist/trajectory/transcript-to-spans/index.js +4 -0
  107. package/dist/trajectory/transcript-to-spans/types.d.ts +18 -0
  108. package/dist/trajectory/transcript-to-spans/types.js +1 -0
  109. package/dist/window.d.ts +12 -0
  110. package/dist/window.js +5 -0
  111. package/package.json +67 -0
@@ -0,0 +1,1083 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import Database from 'better-sqlite3';
5
+ import { EpisodeV1Schema } from '@jinn-network/plugin';
6
+ import { capturedTaskToEpisode, episodeFileName } from './evidence-adapter.js';
7
+ import { parseCapturedTask } from './captured-task.js';
8
+ import { assertRegularStat, assertSafeOwner, fsyncDirectory, identityFrom, inspectRegularPath, nodeErrorCode, openVerifiedRegular, openVerifiedRegularForUpdate, readVerifiedText, sameIdentity, secureRegularPath, validateDirectory, } from './evidence-filesystem.js';
9
+ import { withEvidenceStoreLockSync } from './evidence-store-lock.js';
10
+ export function defaultEvidenceIndexPath(episodesDir) {
11
+ return join(dirname(resolve(episodesDir)), 'evidence-index.sqlite');
12
+ }
13
+ const RETENTION = { policy: 'local-private', maxEpisodes: 200 };
14
+ const EVIDENCE_INDEX_APPLICATION_ID = 0x4a494e4e;
15
+ const EVIDENCE_INDEX_SCHEMA_VERSION = '1';
16
+ const LEGACY_SYNTHETIC_FIXTURE_RULE = 'legacy-short-session-placeholder-model-v1';
17
+ const LEGACY_KNOWN_SMOKE_PAYLOAD_RULE = 'legacy-known-smoke-payload-v1';
18
+ class EvidenceMutationCommittedError extends Error {
19
+ mutation;
20
+ cause;
21
+ constructor(message, mutation, cause) {
22
+ super(message);
23
+ this.mutation = mutation;
24
+ this.cause = cause;
25
+ this.name = 'EvidenceMutationCommittedError';
26
+ }
27
+ }
28
+ function asObject(value) {
29
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
30
+ ? value
31
+ : undefined;
32
+ }
33
+ /**
34
+ * The affected operator files carry null in exactly these four optional
35
+ * fields (#1811). Keep required nullable fields (notably parentSpanId) intact.
36
+ */
37
+ export function tolerateNullQuartet(value) {
38
+ const copy = structuredClone(value);
39
+ const root = asObject(copy);
40
+ if (!root)
41
+ return { value: copy, removed: 0 };
42
+ let removed = 0;
43
+ const outcome = asObject(root['outcome']);
44
+ if (outcome?.['summary'] === null) {
45
+ delete outcome['summary'];
46
+ removed += 1;
47
+ }
48
+ const cost = asObject(root['cost']);
49
+ for (const key of ['tokens', 'usdEstimate']) {
50
+ if (cost?.[key] === null) {
51
+ delete cost[key];
52
+ removed += 1;
53
+ }
54
+ }
55
+ if (root['lineage'] === null) {
56
+ delete root['lineage'];
57
+ removed += 1;
58
+ }
59
+ return { value: copy, removed };
60
+ }
61
+ function originFrom(raw) {
62
+ const origin = asObject(asObject(raw)?.['origin']);
63
+ const writer = typeof origin?.['writer'] === 'string' ? origin['writer'] : undefined;
64
+ const build = typeof origin?.['build'] === 'string' ? origin['build'] : undefined;
65
+ return writer && build
66
+ ? { originKind: 'stamped', originWriter: writer, originBuild: build }
67
+ : { originKind: 'legacy-unstamped' };
68
+ }
69
+ function classifyLegacySyntheticFixture(parsed) {
70
+ if (parsed.originKind !== 'legacy-unstamped')
71
+ return undefined;
72
+ const { episodeId, session, environment, trajectory, task } = parsed.episode;
73
+ if (!/^s(?:\d+|[A-Z]\d*)$/.test(session.sessionId))
74
+ return undefined;
75
+ if (!episodeId.startsWith(`${session.sessionId}-`)
76
+ || !/^\d{19}$/.test(episodeId.slice(session.sessionId.length + 1))) {
77
+ return undefined;
78
+ }
79
+ if (trajectory.length !== 2 || task.distributionTags.length !== 0)
80
+ return undefined;
81
+ if (['m', 'test-model'].includes(environment.model)) {
82
+ return {
83
+ rule: LEGACY_SYNTHETIC_FIXTURE_RULE,
84
+ reason: `legacy synthetic fixture excluded by ${LEGACY_SYNTHETIC_FIXTURE_RULE}: `
85
+ + 'short fixture session, generated episode id, placeholder model, '
86
+ + 'two-span trace, and no distribution tags',
87
+ };
88
+ }
89
+ const first = trajectory[0];
90
+ const second = trajectory[1];
91
+ const toolArgs = asObject(second.attributes['tool.args']);
92
+ if (task.summary === 'Search the web for X'
93
+ && first.kind === 'jinn.agent_turn'
94
+ && first.attributes['role'] === 'user'
95
+ && first.attributes['turn.text'] === task.summary
96
+ && second.kind === 'jinn.tool_call'
97
+ && second.name === 'tool:terminal'
98
+ && toolArgs?.['command'] === 'ls'
99
+ && second.attributes['tool.result'] === '{"output": "ok"}') {
100
+ return {
101
+ rule: LEGACY_KNOWN_SMOKE_PAYLOAD_RULE,
102
+ reason: `legacy synthetic fixture excluded by ${LEGACY_KNOWN_SMOKE_PAYLOAD_RULE}: `
103
+ + 'short fixture session and exact historical smoke-task payload',
104
+ };
105
+ }
106
+ return undefined;
107
+ }
108
+ function parseFile(raw, canonicalName) {
109
+ const tolerated = tolerateNullQuartet(raw);
110
+ const episodeResult = EpisodeV1Schema.safeParse(tolerated.value);
111
+ if (episodeResult.success) {
112
+ return {
113
+ episode: episodeResult.data,
114
+ normalizedRaw: tolerated.value,
115
+ nullCount: tolerated.removed,
116
+ sourceKind: canonicalName ? 'canonical-episode' : 'misnamed-episode',
117
+ ...originFrom(raw),
118
+ };
119
+ }
120
+ if (canonicalName)
121
+ throw episodeResult.error;
122
+ const task = parseCapturedTask(tolerated.value);
123
+ return {
124
+ episode: capturedTaskToEpisode(task, RETENTION),
125
+ normalizedRaw: tolerated.value,
126
+ nullCount: tolerated.removed,
127
+ sourceKind: 'legacy-capture',
128
+ ...originFrom(raw),
129
+ };
130
+ }
131
+ function readableReason(error) {
132
+ return error instanceof Error ? error.message : String(error);
133
+ }
134
+ function readVerifiedJson(path) {
135
+ const opened = openVerifiedRegular(path, 'evidence source');
136
+ try {
137
+ const text = readFileSync(opened.fd, 'utf8');
138
+ return {
139
+ raw: JSON.parse(text),
140
+ text,
141
+ identity: opened.identity,
142
+ };
143
+ }
144
+ finally {
145
+ closeSync(opened.fd);
146
+ }
147
+ }
148
+ function writeRepairJournal(directory, prefix, payload, transactionId = randomUUID()) {
149
+ const path = join(directory, `${prefix}${transactionId}.txn`);
150
+ let fd;
151
+ try {
152
+ fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
153
+ writeFileSync(fd, `${JSON.stringify(payload)}\n`, 'utf8');
154
+ if (process.platform !== 'win32')
155
+ fchmodSync(fd, 0o600);
156
+ fsyncSync(fd);
157
+ }
158
+ finally {
159
+ if (fd !== undefined)
160
+ closeSync(fd);
161
+ }
162
+ fsyncDirectory(directory);
163
+ return path;
164
+ }
165
+ function removeRepairJournal(path) {
166
+ rmSync(path, { force: true });
167
+ fsyncDirectory(dirname(path));
168
+ }
169
+ function writeUtf8AtStart(fd, value) {
170
+ const bytes = Buffer.from(value, 'utf8');
171
+ let offset = 0;
172
+ while (offset < bytes.length) {
173
+ const written = writeSync(fd, bytes, offset, bytes.length - offset, offset);
174
+ if (written <= 0)
175
+ throw new Error('evidence file write made no progress');
176
+ offset += written;
177
+ }
178
+ }
179
+ function repairJson(path, value, expected, originalText, mutation, onBeforeSourceMutation) {
180
+ const directory = dirname(path);
181
+ const normalizedText = `${JSON.stringify(value, null, 2)}\n`;
182
+ const journalPath = writeRepairJournal(directory, '.jinn-normalize-', {
183
+ version: 1,
184
+ sourceName: basename(path),
185
+ dev: expected.dev,
186
+ ino: expected.ino,
187
+ originalText,
188
+ normalizedText,
189
+ mutation: mutation,
190
+ });
191
+ let fd;
192
+ let writeStarted = false;
193
+ try {
194
+ const verified = openVerifiedRegularForUpdate(path, 'evidence source', expected);
195
+ fd = verified.fd;
196
+ onBeforeSourceMutation?.('normalize', path);
197
+ writeStarted = true;
198
+ ftruncateSync(fd, 0);
199
+ writeUtf8AtStart(fd, normalizedText);
200
+ if (process.platform !== 'win32')
201
+ fchmodSync(fd, 0o600);
202
+ fsyncSync(fd);
203
+ const written = fstatSync(fd);
204
+ assertRegularStat(written, path, 'normalized evidence source');
205
+ if (!sameIdentity(identityFrom(written), expected)) {
206
+ throw new Error(`evidence source descriptor changed during normalization: ${path}`);
207
+ }
208
+ const sourceAtCommit = lstatSync(path);
209
+ assertRegularStat(sourceAtCommit, path, 'normalized evidence source');
210
+ if (!sameIdentity(identityFrom(sourceAtCommit), expected)) {
211
+ throw new EvidenceMutationCommittedError(`evidence source changed while its pinned descriptor was normalized: ${path}`, { kind: 'normalization-recovery-retained', sourcePath: path, journalPath }, new Error(`pathname identity changed during normalization: ${path}`));
212
+ }
213
+ removeRepairJournal(journalPath);
214
+ return identityFrom(written);
215
+ }
216
+ catch (error) {
217
+ if (!writeStarted || error instanceof EvidenceMutationCommittedError)
218
+ throw error;
219
+ try {
220
+ if (fd === undefined)
221
+ throw new Error('normalization descriptor is unavailable');
222
+ ftruncateSync(fd, 0);
223
+ writeUtf8AtStart(fd, originalText);
224
+ fsyncSync(fd);
225
+ const restoredPath = lstatSync(path);
226
+ if (!sameIdentity(identityFrom(restoredPath), expected)) {
227
+ throw new Error(`evidence source path changed before normalization rollback: ${path}`);
228
+ }
229
+ removeRepairJournal(journalPath);
230
+ throw error;
231
+ }
232
+ catch (rollbackError) {
233
+ if (rollbackError === error)
234
+ throw error;
235
+ throw new EvidenceMutationCommittedError(`evidence normalization was interrupted and its recovery journal was retained: `
236
+ + `${journalPath}: ${readableReason(error)}; rollback failed: `
237
+ + readableReason(rollbackError), { kind: 'normalization-recovery-retained', sourcePath: path, journalPath }, error);
238
+ }
239
+ }
240
+ finally {
241
+ if (fd !== undefined)
242
+ closeSync(fd);
243
+ }
244
+ }
245
+ function publishNoClobber(sourcePath, targetPath, expected, mutation, onBeforeSourceMutation) {
246
+ const opened = openVerifiedRegular(sourcePath, 'evidence source', expected);
247
+ if (process.platform !== 'win32')
248
+ fchmodSync(opened.fd, 0o600);
249
+ const secured = opened.identity;
250
+ const directory = dirname(sourcePath);
251
+ const transactionId = randomUUID();
252
+ const quarantineName = `.jinn-rescue-source-${transactionId}.hold`;
253
+ const journalPath = writeRepairJournal(directory, '.jinn-rescue-', {
254
+ version: 1,
255
+ sourceName: basename(sourcePath),
256
+ targetName: basename(targetPath),
257
+ quarantineName,
258
+ dev: secured.dev,
259
+ ino: secured.ino,
260
+ }, transactionId);
261
+ let targetPublished = false;
262
+ try {
263
+ try {
264
+ linkSync(sourcePath, targetPath);
265
+ targetPublished = true;
266
+ }
267
+ catch (error) {
268
+ if (nodeErrorCode(error) === 'EEXIST') {
269
+ removeRepairJournal(journalPath);
270
+ throw new Error(`cannot rescue misnamed episode: target already exists: ${targetPath}`);
271
+ }
272
+ throw error;
273
+ }
274
+ const sourceAfterLink = lstatSync(sourcePath);
275
+ const targetAfterLink = lstatSync(targetPath);
276
+ const sourceIdentity = identityFrom(sourceAfterLink);
277
+ const targetIdentity = identityFrom(targetAfterLink);
278
+ if (!sourceAfterLink.isFile()
279
+ || sourceAfterLink.isSymbolicLink()
280
+ || !targetAfterLink.isFile()
281
+ || targetAfterLink.isSymbolicLink()
282
+ || !sameIdentity(sourceIdentity, secured)
283
+ || !sameIdentity(targetIdentity, secured)) {
284
+ throw new Error(`evidence source changed while rescue was being published: ${sourcePath}`);
285
+ }
286
+ assertSafeOwner(sourceAfterLink, sourcePath, 'evidence source');
287
+ assertSafeOwner(targetAfterLink, targetPath, 'rescue target');
288
+ fsyncDirectory(directory);
289
+ onBeforeSourceMutation?.('rescue-remove', sourcePath);
290
+ const quarantinePath = join(directory, quarantineName);
291
+ renameSync(sourcePath, quarantinePath);
292
+ const moved = lstatSync(quarantinePath);
293
+ if (!moved.isFile() || moved.isSymbolicLink()) {
294
+ throw new Error(`evidence source changed to an unsafe type before rescue: ${sourcePath}`);
295
+ }
296
+ assertSafeOwner(moved, quarantinePath, 'moved rescue source');
297
+ const movedIdentity = identityFrom(moved);
298
+ if (!sameIdentity(movedIdentity, secured)) {
299
+ // The source pathname was replaced after its descriptor was pinned.
300
+ // Restore that replacement with no-clobber link publication, then remove
301
+ // only the proven random quarantine alias. The canonical rescue target
302
+ // remains the original, descriptor-verified evidence.
303
+ try {
304
+ linkSync(quarantinePath, sourcePath);
305
+ const restored = lstatSync(sourcePath);
306
+ const quarantined = lstatSync(quarantinePath);
307
+ if (!sameIdentity(identityFrom(restored), movedIdentity)
308
+ || !sameIdentity(identityFrom(quarantined), movedIdentity)) {
309
+ throw new Error(`pathname replacement changed while being restored: ${sourcePath}`);
310
+ }
311
+ unlinkSync(quarantinePath);
312
+ fsyncDirectory(directory);
313
+ secureRegularPath(sourcePath, 'restored pathname replacement', movedIdentity);
314
+ removeRepairJournal(journalPath);
315
+ }
316
+ catch (restoreError) {
317
+ throw new EvidenceMutationCommittedError(`rescue target was published; a concurrent pathname replacement is retained at `
318
+ + `${quarantinePath}: ${readableReason(restoreError)}`, { kind: 'rescue-target-published', sourcePath, targetPath }, restoreError);
319
+ }
320
+ throw new EvidenceMutationCommittedError(`rescue target was published without deleting a concurrent pathname replacement: `
321
+ + sourcePath, { kind: 'rescue-target-published', sourcePath, targetPath }, new Error(`evidence source changed before rescue finalization: ${sourcePath}`));
322
+ }
323
+ unlinkSync(quarantinePath);
324
+ const targetAfterUnlink = lstatSync(targetPath);
325
+ assertRegularStat(targetAfterUnlink, targetPath, 'rescue target');
326
+ if (!sameIdentity(identityFrom(targetAfterUnlink), secured)) {
327
+ throw new Error(`rescue target changed after source finalization: ${targetPath}`);
328
+ }
329
+ fsyncDirectory(directory);
330
+ removeRepairJournal(journalPath);
331
+ return secured;
332
+ }
333
+ catch (error) {
334
+ if (!targetPublished) {
335
+ removeRepairJournal(journalPath);
336
+ throw error;
337
+ }
338
+ if (error instanceof EvidenceMutationCommittedError)
339
+ throw error;
340
+ throw new EvidenceMutationCommittedError(`rescue target was published and its recovery journal was retained: `
341
+ + `${journalPath}: ${readableReason(error)}`, { kind: 'rescue-target-published', sourcePath, targetPath }, error);
342
+ }
343
+ finally {
344
+ closeSync(opened.fd);
345
+ }
346
+ }
347
+ function safeJournalEntryName(value) {
348
+ return typeof value === 'string'
349
+ && value.length > 0
350
+ && value !== '.'
351
+ && value !== '..'
352
+ && basename(value) === value;
353
+ }
354
+ function readJournal(path) {
355
+ return JSON.parse(readVerifiedText(path, 'evidence repair journal').text);
356
+ }
357
+ function asNormalizeJournal(value) {
358
+ const object = asObject(value);
359
+ const mutation = asObject(object?.['mutation']);
360
+ if (object?.['version'] !== 1
361
+ || !safeJournalEntryName(object['sourceName'])
362
+ || typeof object['dev'] !== 'number'
363
+ || typeof object['ino'] !== 'number'
364
+ || typeof object['originalText'] !== 'string'
365
+ || typeof object['normalizedText'] !== 'string'
366
+ || mutation?.['kind'] !== 'normalized-json'
367
+ || typeof mutation['sourcePath'] !== 'string'
368
+ || typeof mutation['nullFieldsRemoved'] !== 'number') {
369
+ return undefined;
370
+ }
371
+ return object;
372
+ }
373
+ function asRescueJournal(value) {
374
+ const object = asObject(value);
375
+ if (object?.['version'] !== 1
376
+ || !safeJournalEntryName(object['sourceName'])
377
+ || !safeJournalEntryName(object['targetName'])
378
+ || !safeJournalEntryName(object['quarantineName'])
379
+ || typeof object['dev'] !== 'number'
380
+ || typeof object['ino'] !== 'number') {
381
+ return undefined;
382
+ }
383
+ return object;
384
+ }
385
+ function rawOwnedRegularIdentity(path, label) {
386
+ try {
387
+ const info = lstatSync(path);
388
+ if (!info.isFile() || info.isSymbolicLink()) {
389
+ throw new Error(`${label} must be a regular file: ${path}`);
390
+ }
391
+ assertSafeOwner(info, path, label);
392
+ return identityFrom(info);
393
+ }
394
+ catch (error) {
395
+ if (nodeErrorCode(error) === 'ENOENT')
396
+ return undefined;
397
+ throw error;
398
+ }
399
+ }
400
+ function recoverInterruptedRepairs(directory) {
401
+ const mutations = [];
402
+ const unreadable = [];
403
+ const names = readdirSync(directory).sort();
404
+ for (const name of names.filter((entry) => /^\.jinn-normalize-[0-9a-f-]{36}\.txn$/i.test(entry))) {
405
+ const journalPath = join(directory, name);
406
+ try {
407
+ const journal = asNormalizeJournal(readJournal(journalPath));
408
+ if (!journal)
409
+ throw new Error(`invalid evidence normalization journal: ${journalPath}`);
410
+ const sourcePath = join(directory, journal.sourceName);
411
+ if (resolve(journal.mutation.sourcePath) !== resolve(sourcePath)
412
+ || !Number.isSafeInteger(journal.dev)
413
+ || !Number.isSafeInteger(journal.ino)
414
+ || !Number.isSafeInteger(journal.mutation.nullFieldsRemoved)
415
+ || journal.mutation.nullFieldsRemoved < 1) {
416
+ throw new Error(`inconsistent evidence normalization journal: ${journalPath}`);
417
+ }
418
+ const expected = {
419
+ dev: journal.dev,
420
+ ino: journal.ino,
421
+ uid: process.platform === 'win32' ? 0 : process.getuid?.() ?? 0,
422
+ nlink: 1,
423
+ };
424
+ const current = inspectRegularPath(sourcePath, 'journaled evidence source');
425
+ if (!current || !sameIdentity(current, expected)) {
426
+ throw new Error(`journaled evidence source changed; recovery retained for inspection: ${sourcePath}`);
427
+ }
428
+ const opened = openVerifiedRegularForUpdate(sourcePath, 'journaled evidence source', expected);
429
+ try {
430
+ const text = readFileSync(opened.fd, 'utf8');
431
+ if (text === journal.normalizedText) {
432
+ mutations.push(journal.mutation);
433
+ }
434
+ else if (text !== journal.originalText) {
435
+ ftruncateSync(opened.fd, 0);
436
+ writeUtf8AtStart(opened.fd, journal.originalText);
437
+ fsyncSync(opened.fd);
438
+ }
439
+ }
440
+ finally {
441
+ closeSync(opened.fd);
442
+ }
443
+ secureRegularPath(sourcePath, 'recovered evidence source', expected);
444
+ removeRepairJournal(journalPath);
445
+ }
446
+ catch (error) {
447
+ unreadable.push({ sourcePath: journalPath, reason: readableReason(error) });
448
+ }
449
+ }
450
+ for (const name of names.filter((entry) => /^\.jinn-rescue-[0-9a-f-]{36}\.txn$/i.test(entry))) {
451
+ const journalPath = join(directory, name);
452
+ try {
453
+ const journal = asRescueJournal(readJournal(journalPath));
454
+ if (!journal)
455
+ throw new Error(`invalid evidence rescue journal: ${journalPath}`);
456
+ const transactionId = name.slice('.jinn-rescue-'.length, -'.txn'.length);
457
+ if (!journal.sourceName.endsWith('.json')
458
+ || journal.sourceName.endsWith('.episode.json')
459
+ || !journal.targetName.endsWith('.episode.json')
460
+ || journal.quarantineName !== `.jinn-rescue-source-${transactionId}.hold`
461
+ || !Number.isSafeInteger(journal.dev)
462
+ || !Number.isSafeInteger(journal.ino)) {
463
+ throw new Error(`inconsistent evidence rescue journal: ${journalPath}`);
464
+ }
465
+ const sourcePath = join(directory, journal.sourceName);
466
+ const targetPath = join(directory, journal.targetName);
467
+ const quarantinePath = join(directory, journal.quarantineName);
468
+ const expected = {
469
+ dev: journal.dev,
470
+ ino: journal.ino,
471
+ uid: process.platform === 'win32' ? 0 : process.getuid?.() ?? 0,
472
+ nlink: 1,
473
+ };
474
+ let source = rawOwnedRegularIdentity(sourcePath, 'journaled rescue source');
475
+ let target = rawOwnedRegularIdentity(targetPath, 'journaled rescue target');
476
+ let quarantine = rawOwnedRegularIdentity(quarantinePath, 'journaled rescue quarantine');
477
+ let sourceMatches = source !== undefined && sameIdentity(source, expected);
478
+ const targetMatches = target !== undefined && sameIdentity(target, expected);
479
+ if (!target && sourceMatches && source?.nlink === 1 && !quarantine) {
480
+ removeRepairJournal(journalPath);
481
+ continue;
482
+ }
483
+ if (!targetMatches) {
484
+ throw new Error(`journaled rescue target changed; recovery retained for inspection: ${targetPath}`);
485
+ }
486
+ if (quarantine) {
487
+ if (sameIdentity(quarantine, expected)) {
488
+ if (target?.nlink !== 2) {
489
+ throw new Error(`journaled rescue quarantine has an unsafe link count: ${targetPath}`);
490
+ }
491
+ unlinkSync(quarantinePath);
492
+ fsyncDirectory(directory);
493
+ }
494
+ else {
495
+ if (target?.nlink !== 1) {
496
+ throw new Error(`journaled pathname replacement has an unsafe target link count: ${targetPath}`);
497
+ }
498
+ if (!source) {
499
+ linkSync(quarantinePath, sourcePath);
500
+ source = rawOwnedRegularIdentity(sourcePath, 'restored pathname replacement');
501
+ }
502
+ if (!source
503
+ || !sameIdentity(source, quarantine)
504
+ || source.nlink !== 2
505
+ || quarantine.nlink !== 2) {
506
+ throw new Error(`journaled pathname replacement could not be safely restored: ${sourcePath}`);
507
+ }
508
+ unlinkSync(quarantinePath);
509
+ fsyncDirectory(directory);
510
+ secureRegularPath(sourcePath, 'restored pathname replacement', source);
511
+ }
512
+ source = rawOwnedRegularIdentity(sourcePath, 'journaled rescue source');
513
+ target = rawOwnedRegularIdentity(targetPath, 'journaled rescue target');
514
+ quarantine = rawOwnedRegularIdentity(quarantinePath, 'journaled rescue quarantine');
515
+ sourceMatches = source !== undefined && sameIdentity(source, expected);
516
+ if (quarantine) {
517
+ throw new Error(`journaled rescue quarantine was not removed: ${quarantinePath}`);
518
+ }
519
+ }
520
+ if (sourceMatches) {
521
+ if (source?.nlink !== 2 || target?.nlink !== 2) {
522
+ throw new Error(`journaled rescue link count is inconsistent: ${targetPath}`);
523
+ }
524
+ renameSync(sourcePath, quarantinePath);
525
+ const moved = rawOwnedRegularIdentity(quarantinePath, 'journaled rescue source');
526
+ if (!moved || !sameIdentity(moved, expected)) {
527
+ throw new Error(`journaled rescue source changed during recovery: ${sourcePath}`);
528
+ }
529
+ unlinkSync(quarantinePath);
530
+ fsyncDirectory(directory);
531
+ }
532
+ else if (target?.nlink !== 1) {
533
+ throw new Error(`journaled rescue target has an unsafe link count: ${targetPath}`);
534
+ }
535
+ secureRegularPath(targetPath, 'recovered rescue target', expected);
536
+ removeRepairJournal(journalPath);
537
+ mutations.push({
538
+ kind: 'rescued-misnamed-episode',
539
+ sourcePath,
540
+ targetPath,
541
+ });
542
+ }
543
+ catch (error) {
544
+ unreadable.push({ sourcePath: journalPath, reason: readableReason(error) });
545
+ }
546
+ }
547
+ return { mutations, unreadable };
548
+ }
549
+ function isInterruptedRepairArtifact(name) {
550
+ return /^\.jinn-(?:normalize|rescue)-[0-9a-f-]{36}\.txn$/i.test(name)
551
+ || /^\.jinn-rescue-source-[0-9a-f-]{36}\.hold$/i.test(name);
552
+ }
553
+ function prepareIndexDestination(dbPath, secureParent) {
554
+ const parent = dirname(dbPath);
555
+ let createdParent = false;
556
+ try {
557
+ validateDirectory(parent, 'evidence index parent', secureParent);
558
+ }
559
+ catch (error) {
560
+ if (nodeErrorCode(error) !== 'ENOENT')
561
+ throw error;
562
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
563
+ createdParent = true;
564
+ validateDirectory(parent, 'evidence index parent', true);
565
+ }
566
+ if (createdParent)
567
+ fsyncDirectory(dirname(parent));
568
+ const existing = inspectRegularPath(dbPath, 'evidence index destination');
569
+ const wal = inspectRegularPath(`${dbPath}-wal`, 'evidence index sidecar');
570
+ const shm = inspectRegularPath(`${dbPath}-shm`, 'evidence index sidecar');
571
+ if (!existing && (wal || shm)) {
572
+ throw new Error(`refusing orphaned evidence index sidecars without a database: ${dbPath}`);
573
+ }
574
+ if (existing)
575
+ return { created: false, identity: existing };
576
+ const fd = openSync(dbPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
577
+ try {
578
+ if (process.platform !== 'win32')
579
+ fchmodSync(fd, 0o600);
580
+ fsyncSync(fd);
581
+ }
582
+ finally {
583
+ closeSync(fd);
584
+ }
585
+ fsyncDirectory(parent);
586
+ const identity = inspectRegularPath(dbPath, 'evidence index destination');
587
+ if (!identity)
588
+ throw new Error(`evidence index destination disappeared: ${dbPath}`);
589
+ return { created: true, identity };
590
+ }
591
+ function secureIndexFiles(dbPath, expectedMain) {
592
+ for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
593
+ if (inspectRegularPath(path, 'evidence index file')) {
594
+ secureRegularPath(path, 'evidence index file', path === dbPath ? expectedMain : undefined);
595
+ }
596
+ }
597
+ }
598
+ function removeNewIndexAfterInitializationFailure(dbPath, expectedMain) {
599
+ const current = inspectRegularPath(dbPath, 'failed evidence index');
600
+ if (!current || !sameIdentity(current, expectedMain))
601
+ return;
602
+ for (const path of [`${dbPath}-wal`, `${dbPath}-shm`]) {
603
+ if (inspectRegularPath(path, 'failed evidence index sidecar'))
604
+ unlinkSync(path);
605
+ }
606
+ unlinkSync(dbPath);
607
+ fsyncDirectory(dirname(dbPath));
608
+ }
609
+ export class EvidenceIndex {
610
+ db;
611
+ dbPath;
612
+ dbIdentity;
613
+ constructor(options) {
614
+ const dbPath = resolve(options.dbPath);
615
+ this.dbPath = dbPath;
616
+ const prepared = prepareIndexDestination(dbPath, options.secureParent ?? false);
617
+ const db = new Database(dbPath);
618
+ this.db = db;
619
+ try {
620
+ const afterOpen = inspectRegularPath(dbPath, 'evidence index destination');
621
+ if (!afterOpen || !sameIdentity(afterOpen, prepared.identity)) {
622
+ throw new Error(`evidence index destination changed while opening: ${dbPath}`);
623
+ }
624
+ this.dbIdentity = afterOpen;
625
+ const applicationId = Number(db.pragma('application_id', { simple: true }));
626
+ if (prepared.created) {
627
+ if (applicationId !== 0) {
628
+ throw new Error(`new evidence index has an unexpected application id: ${applicationId}`);
629
+ }
630
+ db.pragma(`application_id = ${EVIDENCE_INDEX_APPLICATION_ID}`);
631
+ db.exec(`
632
+ CREATE TABLE evidence_index_meta (
633
+ key TEXT PRIMARY KEY,
634
+ value TEXT NOT NULL
635
+ );
636
+ CREATE TABLE episodes (
637
+ episode_id TEXT PRIMARY KEY,
638
+ session_id TEXT NOT NULL,
639
+ captured_at TEXT NOT NULL,
640
+ source_path TEXT NOT NULL,
641
+ source_kind TEXT NOT NULL,
642
+ origin_kind TEXT NOT NULL,
643
+ origin_writer TEXT,
644
+ origin_build TEXT,
645
+ outcome_status TEXT NOT NULL,
646
+ verification_strength TEXT NOT NULL,
647
+ duration_ms INTEGER NOT NULL,
648
+ activity_json TEXT,
649
+ episode_json TEXT NOT NULL,
650
+ content_sha256 TEXT NOT NULL
651
+ );
652
+ CREATE INDEX episodes_captured_at
653
+ ON episodes(captured_at DESC, episode_id ASC);
654
+ CREATE TABLE unreadable_records (
655
+ source_path TEXT PRIMARY KEY,
656
+ reason TEXT NOT NULL
657
+ );
658
+ CREATE TABLE excluded_records (
659
+ source_path TEXT PRIMARY KEY,
660
+ episode_id TEXT NOT NULL,
661
+ rule TEXT NOT NULL,
662
+ reason TEXT NOT NULL,
663
+ content_sha256 TEXT NOT NULL
664
+ );
665
+ INSERT INTO evidence_index_meta(key, value)
666
+ VALUES ('schema_version', '${EVIDENCE_INDEX_SCHEMA_VERSION}');
667
+ `);
668
+ }
669
+ else {
670
+ if (applicationId !== EVIDENCE_INDEX_APPLICATION_ID) {
671
+ throw new Error(`refusing database that is not a Jinn evidence index (application id missing): ${dbPath}`);
672
+ }
673
+ const tables = db.prepare(`
674
+ SELECT name FROM sqlite_master
675
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
676
+ ORDER BY name ASC
677
+ `).all();
678
+ const tableNames = tables.map((row) => row.name);
679
+ if (JSON.stringify(tableNames) !== JSON.stringify([
680
+ 'excluded_records',
681
+ 'episodes',
682
+ 'evidence_index_meta',
683
+ 'unreadable_records',
684
+ ].sort())) {
685
+ throw new Error(`refusing database with an unexpected Jinn evidence index schema: ${dbPath}`);
686
+ }
687
+ const version = db.prepare("SELECT value FROM evidence_index_meta WHERE key = 'schema_version'").get();
688
+ if (version?.value !== EVIDENCE_INDEX_SCHEMA_VERSION) {
689
+ throw new Error(`unsupported Jinn evidence index schema version: ${String(version?.value)}`);
690
+ }
691
+ }
692
+ db.pragma('journal_mode = WAL');
693
+ db.pragma('synchronous = FULL');
694
+ secureIndexFiles(dbPath, this.dbIdentity);
695
+ }
696
+ catch (error) {
697
+ try {
698
+ db.close();
699
+ }
700
+ catch {
701
+ // Preserve the initialization failure.
702
+ }
703
+ if (prepared.created) {
704
+ try {
705
+ removeNewIndexAfterInitializationFailure(dbPath, prepared.identity);
706
+ }
707
+ catch {
708
+ // Remove only fully verified files; otherwise preserve them for diagnosis.
709
+ }
710
+ }
711
+ throw error;
712
+ }
713
+ }
714
+ journalMode() {
715
+ const row = this.db.pragma('journal_mode', { simple: true });
716
+ return String(row).toLowerCase();
717
+ }
718
+ replace(rows, unreadable, excluded) {
719
+ const insertEpisode = this.db.prepare(`
720
+ INSERT INTO episodes (
721
+ episode_id, session_id, captured_at, source_path, source_kind,
722
+ origin_kind, origin_writer, origin_build, outcome_status,
723
+ verification_strength, duration_ms, activity_json, episode_json,
724
+ content_sha256
725
+ ) VALUES (
726
+ @episodeId, @sessionId, @capturedAt, @sourcePath, @sourceKind,
727
+ @originKind, @originWriter, @originBuild, @outcomeStatus,
728
+ @verificationStrength, @durationMs, @activityJson, @episodeJson,
729
+ @contentSha256
730
+ )
731
+ `);
732
+ const insertUnreadable = this.db.prepare(`
733
+ INSERT INTO unreadable_records(source_path, reason)
734
+ VALUES (@sourcePath, @reason)
735
+ `);
736
+ const insertExcluded = this.db.prepare(`
737
+ INSERT INTO excluded_records(
738
+ source_path, episode_id, rule, reason, content_sha256
739
+ ) VALUES (
740
+ @sourcePath, @episodeId, @rule, @reason, @contentSha256
741
+ )
742
+ `);
743
+ this.db.transaction(() => {
744
+ this.db.prepare('DELETE FROM episodes').run();
745
+ this.db.prepare('DELETE FROM unreadable_records').run();
746
+ this.db.prepare('DELETE FROM excluded_records').run();
747
+ for (const row of rows) {
748
+ insertEpisode.run({
749
+ ...row,
750
+ originWriter: row.originWriter ?? null,
751
+ originBuild: row.originBuild ?? null,
752
+ activityJson: row.activity === null ? null : JSON.stringify(row.activity),
753
+ episodeJson: JSON.stringify(row.episode),
754
+ });
755
+ }
756
+ for (const row of unreadable)
757
+ insertUnreadable.run(row);
758
+ for (const row of excluded)
759
+ insertExcluded.run(row);
760
+ })();
761
+ secureIndexFiles(this.dbPath, this.dbIdentity);
762
+ }
763
+ listEpisodes() {
764
+ const rows = this.db.prepare(`
765
+ SELECT
766
+ episode_id AS episodeId,
767
+ session_id AS sessionId,
768
+ captured_at AS capturedAt,
769
+ source_path AS sourcePath,
770
+ source_kind AS sourceKind,
771
+ origin_kind AS originKind,
772
+ origin_writer AS originWriter,
773
+ origin_build AS originBuild,
774
+ outcome_status AS outcomeStatus,
775
+ verification_strength AS verificationStrength,
776
+ duration_ms AS durationMs,
777
+ activity_json AS activityJson,
778
+ episode_json AS episodeJson,
779
+ content_sha256 AS contentSha256
780
+ FROM episodes
781
+ ORDER BY episode_id ASC
782
+ `).all();
783
+ return rows.map((row) => ({
784
+ episodeId: String(row['episodeId']),
785
+ sessionId: String(row['sessionId']),
786
+ capturedAt: String(row['capturedAt']),
787
+ sourcePath: String(row['sourcePath']),
788
+ sourceKind: row['sourceKind'],
789
+ originKind: row['originKind'],
790
+ ...(row['originWriter'] === null ? {} : { originWriter: String(row['originWriter']) }),
791
+ ...(row['originBuild'] === null ? {} : { originBuild: String(row['originBuild']) }),
792
+ outcomeStatus: row['outcomeStatus'],
793
+ verificationStrength: row['verificationStrength'],
794
+ durationMs: Number(row['durationMs']),
795
+ activity: row['activityJson'] === null
796
+ ? null
797
+ : JSON.parse(String(row['activityJson'])),
798
+ episode: EpisodeV1Schema.parse(JSON.parse(String(row['episodeJson']))),
799
+ contentSha256: String(row['contentSha256']),
800
+ }));
801
+ }
802
+ listUnreadable() {
803
+ return this.db.prepare(`
804
+ SELECT source_path AS sourcePath, reason
805
+ FROM unreadable_records
806
+ ORDER BY source_path ASC
807
+ `).all();
808
+ }
809
+ listExcluded() {
810
+ return this.db.prepare(`
811
+ SELECT
812
+ source_path AS sourcePath,
813
+ episode_id AS episodeId,
814
+ rule,
815
+ reason,
816
+ content_sha256 AS contentSha256
817
+ FROM excluded_records
818
+ ORDER BY source_path ASC
819
+ `).all();
820
+ }
821
+ close() {
822
+ let failure;
823
+ try {
824
+ secureIndexFiles(this.dbPath, this.dbIdentity);
825
+ }
826
+ catch (error) {
827
+ failure = error;
828
+ }
829
+ try {
830
+ this.db.close();
831
+ }
832
+ catch (error) {
833
+ failure ??= error;
834
+ }
835
+ try {
836
+ secureIndexFiles(this.dbPath, this.dbIdentity);
837
+ }
838
+ catch (error) {
839
+ failure ??= error;
840
+ }
841
+ if (failure !== undefined)
842
+ throw failure;
843
+ }
844
+ }
845
+ function scanEvidenceStore(episodesDirInput, repair, onBeforeSourceMutation) {
846
+ const episodesDir = resolve(episodesDirInput);
847
+ const unreadable = [];
848
+ const excluded = [];
849
+ const candidates = new Map();
850
+ const indexed = [];
851
+ const mutations = [];
852
+ let scannedFiles = 0;
853
+ let nullToleratedFiles = 0;
854
+ let nullFieldsRemoved = 0;
855
+ let misnamedEpisodes = 0;
856
+ let renamedFiles = 0;
857
+ let legacyUnstampedFiles = 0;
858
+ let files = [];
859
+ if (existsSync(episodesDir)) {
860
+ validateDirectory(episodesDir, 'evidence store', repair);
861
+ if (repair) {
862
+ const recovered = recoverInterruptedRepairs(episodesDir);
863
+ mutations.push(...recovered.mutations);
864
+ unreadable.push(...recovered.unreadable);
865
+ scannedFiles += recovered.unreadable.length;
866
+ for (const mutation of recovered.mutations) {
867
+ if (mutation.kind === 'normalized-json') {
868
+ nullToleratedFiles += 1;
869
+ nullFieldsRemoved += mutation.nullFieldsRemoved;
870
+ }
871
+ else if (mutation.kind === 'rescued-misnamed-episode') {
872
+ misnamedEpisodes += 1;
873
+ renamedFiles += 1;
874
+ }
875
+ }
876
+ }
877
+ const entries = readdirSync(episodesDir).sort();
878
+ const reported = new Set(unreadable.map((row) => resolve(row.sourcePath)));
879
+ for (const name of entries.filter(isInterruptedRepairArtifact)) {
880
+ const artifactPath = join(episodesDir, name);
881
+ if (reported.has(resolve(artifactPath)))
882
+ continue;
883
+ unreadable.push({
884
+ sourcePath: artifactPath,
885
+ reason: 'interrupted evidence repair state requires reindex --repair --json',
886
+ });
887
+ scannedFiles += 1;
888
+ }
889
+ files = entries.filter((name) => name.endsWith('.json'));
890
+ }
891
+ for (const name of files) {
892
+ scannedFiles += 1;
893
+ const sourcePath = join(episodesDir, name);
894
+ try {
895
+ if (repair)
896
+ secureRegularPath(sourcePath, 'evidence source');
897
+ const { raw, text, identity } = readVerifiedJson(sourcePath);
898
+ const parsed = parseFile(raw, name.endsWith('.episode.json'));
899
+ if (parsed.nullCount > 0)
900
+ nullToleratedFiles += 1;
901
+ if (parsed.sourceKind === 'misnamed-episode')
902
+ misnamedEpisodes += 1;
903
+ const episodeJson = JSON.stringify(parsed.episode);
904
+ const episodeCandidates = candidates.get(parsed.episode.episodeId) ?? [];
905
+ episodeCandidates.push({
906
+ sourcePath,
907
+ sourceText: text,
908
+ parsed,
909
+ identity,
910
+ contentSha256: createHash('sha256').update(episodeJson).digest('hex'),
911
+ });
912
+ candidates.set(parsed.episode.episodeId, episodeCandidates);
913
+ }
914
+ catch (error) {
915
+ unreadable.push({ sourcePath, reason: readableReason(error) });
916
+ }
917
+ }
918
+ for (const [episodeId, episodeCandidates] of [...candidates.entries()]
919
+ .sort(([left], [right]) => left.localeCompare(right))) {
920
+ if (episodeCandidates.length > 1) {
921
+ const conflicts = episodeCandidates
922
+ .map((candidate) => `${candidate.sourcePath} sha256=${candidate.contentSha256}`)
923
+ .join(', ');
924
+ for (const candidate of episodeCandidates) {
925
+ unreadable.push({
926
+ sourcePath: candidate.sourcePath,
927
+ reason: `duplicate episodeId: ${episodeId}; conflicting sources: ${conflicts}`,
928
+ });
929
+ }
930
+ continue;
931
+ }
932
+ const candidate = episodeCandidates[0];
933
+ const { sourcePath, sourceText, parsed } = candidate;
934
+ if (parsed.originKind === 'legacy-unstamped')
935
+ legacyUnstampedFiles += 1;
936
+ const syntheticFixture = classifyLegacySyntheticFixture(parsed);
937
+ if (syntheticFixture) {
938
+ excluded.push({
939
+ sourcePath,
940
+ episodeId,
941
+ ...syntheticFixture,
942
+ contentSha256: candidate.contentSha256,
943
+ });
944
+ continue;
945
+ }
946
+ let identity = candidate.identity;
947
+ let finalPath = sourcePath;
948
+ try {
949
+ if (repair && parsed.nullCount > 0) {
950
+ const mutation = {
951
+ kind: 'normalized-json',
952
+ sourcePath,
953
+ nullFieldsRemoved: parsed.nullCount,
954
+ };
955
+ identity = repairJson(sourcePath, parsed.normalizedRaw, identity, sourceText, mutation, onBeforeSourceMutation);
956
+ nullFieldsRemoved += parsed.nullCount;
957
+ mutations.push(mutation);
958
+ }
959
+ else if (repair) {
960
+ identity = secureRegularPath(sourcePath, 'evidence source', identity);
961
+ }
962
+ if (repair && parsed.sourceKind === 'misnamed-episode') {
963
+ const targetPath = join(episodesDir, episodeFileName(episodeId));
964
+ const mutation = {
965
+ kind: 'rescued-misnamed-episode',
966
+ sourcePath,
967
+ targetPath,
968
+ };
969
+ identity = publishNoClobber(sourcePath, targetPath, identity, mutation, onBeforeSourceMutation);
970
+ finalPath = targetPath;
971
+ renamedFiles += 1;
972
+ mutations.push(mutation);
973
+ }
974
+ indexed.push({
975
+ episodeId,
976
+ sessionId: parsed.episode.session.sessionId,
977
+ capturedAt: parsed.episode.session.capturedAt,
978
+ sourcePath: finalPath,
979
+ sourceKind: repair && parsed.sourceKind === 'misnamed-episode'
980
+ ? 'canonical-episode'
981
+ : parsed.sourceKind,
982
+ originKind: parsed.originKind,
983
+ ...(parsed.originWriter ? { originWriter: parsed.originWriter } : {}),
984
+ ...(parsed.originBuild ? { originBuild: parsed.originBuild } : {}),
985
+ outcomeStatus: parsed.episode.outcome.status,
986
+ verificationStrength: parsed.episode.outcome.verificationStrength,
987
+ durationMs: parsed.episode.cost.durationMs,
988
+ activity: parsed.episode.activity ?? null,
989
+ episode: parsed.episode,
990
+ contentSha256: candidate.contentSha256,
991
+ });
992
+ }
993
+ catch (error) {
994
+ if (error instanceof EvidenceMutationCommittedError) {
995
+ mutations.push(error.mutation);
996
+ if (error.mutation.kind === 'normalized-json') {
997
+ nullFieldsRemoved += error.mutation.nullFieldsRemoved;
998
+ }
999
+ else if (error.mutation.kind === 'rescued-misnamed-episode') {
1000
+ renamedFiles += 1;
1001
+ }
1002
+ }
1003
+ unreadable.push({ sourcePath, reason: readableReason(error) });
1004
+ }
1005
+ }
1006
+ unreadable.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
1007
+ excluded.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
1008
+ indexed.sort((left, right) => left.episodeId.localeCompare(right.episodeId));
1009
+ return {
1010
+ indexed,
1011
+ unreadable,
1012
+ excluded,
1013
+ report: {
1014
+ scannedFiles,
1015
+ indexedEpisodes: indexed.length,
1016
+ unreadableFiles: unreadable.length,
1017
+ unreadable: unreadable.map((row) => ({ path: row.sourcePath, reason: row.reason })),
1018
+ nullToleratedFiles,
1019
+ nullFieldsRemoved,
1020
+ misnamedEpisodes,
1021
+ renamedFiles,
1022
+ legacyUnstampedFiles,
1023
+ syntheticExcludedFiles: excluded.length,
1024
+ syntheticExcluded: excluded.map(({ sourcePath, episodeId, rule, reason }) => ({
1025
+ path: sourcePath,
1026
+ episodeId,
1027
+ rule,
1028
+ reason,
1029
+ })),
1030
+ mutations,
1031
+ indexUpdated: false,
1032
+ },
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Read-only store inspection for doctor/status surfaces. It deliberately
1037
+ * shares the reindex scanner while neither opening SQLite nor repairing files.
1038
+ */
1039
+ export function inspectEvidenceStore(options) {
1040
+ return scanEvidenceStore(options.episodesDir, false).report;
1041
+ }
1042
+ export function reindexEvidenceStore(options) {
1043
+ const episodesDir = resolve(options.episodesDir);
1044
+ const indexPath = resolve(options.indexPath);
1045
+ return withEvidenceStoreLockSync(episodesDir, () => {
1046
+ const index = new EvidenceIndex({
1047
+ dbPath: indexPath,
1048
+ secureParent: indexPath === resolve(defaultEvidenceIndexPath(episodesDir)),
1049
+ });
1050
+ let scan;
1051
+ try {
1052
+ scan = scanEvidenceStore(episodesDir, options.repair ?? false, options.onBeforeSourceMutation);
1053
+ options.onScanComplete?.();
1054
+ }
1055
+ catch (error) {
1056
+ try {
1057
+ index.close();
1058
+ }
1059
+ catch {
1060
+ // Preserve the source-validation error; no source mutation occurred.
1061
+ }
1062
+ throw error;
1063
+ }
1064
+ try {
1065
+ index.replace(scan.indexed, scan.unreadable, scan.excluded);
1066
+ scan.report.indexUpdated = true;
1067
+ }
1068
+ catch (error) {
1069
+ scan.report.indexUpdated = false;
1070
+ scan.report.indexError = readableReason(error);
1071
+ }
1072
+ try {
1073
+ index.close();
1074
+ }
1075
+ catch (error) {
1076
+ scan.report.indexUpdated = false;
1077
+ scan.report.indexError = scan.report.indexError
1078
+ ? `${scan.report.indexError}; close failed: ${readableReason(error)}`
1079
+ : `close failed: ${readableReason(error)}`;
1080
+ }
1081
+ return scan.report;
1082
+ });
1083
+ }