@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.
- package/dist/canonical-json.d.ts +1 -0
- package/dist/canonical-json.js +67 -0
- package/dist/captured-task.d.ts +140 -0
- package/dist/captured-task.js +122 -0
- package/dist/contribution-store.d.ts +97 -0
- package/dist/contribution-store.js +770 -0
- package/dist/corpus-read/acquire.d.ts +39 -0
- package/dist/corpus-read/acquire.js +212 -0
- package/dist/corpus-read/cache.d.ts +14 -0
- package/dist/corpus-read/cache.js +17 -0
- package/dist/corpus-read/capture-meta.d.ts +16 -0
- package/dist/corpus-read/capture-meta.js +18 -0
- package/dist/corpus-read/create-corpus.d.ts +2 -0
- package/dist/corpus-read/create-corpus.js +97 -0
- package/dist/corpus-read/fetch-artifact.d.ts +21 -0
- package/dist/corpus-read/fetch-artifact.js +32 -0
- package/dist/corpus-read/fetch.d.ts +12 -0
- package/dist/corpus-read/fetch.js +24 -0
- package/dist/corpus-read/http-discovery.d.ts +9 -0
- package/dist/corpus-read/http-discovery.js +128 -0
- package/dist/corpus-read/index.d.ts +10 -0
- package/dist/corpus-read/index.js +10 -0
- package/dist/corpus-read/ipfs.d.ts +5 -0
- package/dist/corpus-read/ipfs.js +72 -0
- package/dist/corpus-read/route-resolver.d.ts +16 -0
- package/dist/corpus-read/route-resolver.js +19 -0
- package/dist/corpus-read/types.d.ts +177 -0
- package/dist/corpus-read/types.js +42 -0
- package/dist/envelope.d.ts +134 -0
- package/dist/envelope.js +162 -0
- package/dist/evidence-adapter.d.ts +26 -0
- package/dist/evidence-adapter.js +321 -0
- package/dist/evidence-filesystem.d.ts +40 -0
- package/dist/evidence-filesystem.js +267 -0
- package/dist/evidence-index.d.ts +117 -0
- package/dist/evidence-index.js +1083 -0
- package/dist/evidence-store-lock.d.ts +2 -0
- package/dist/evidence-store-lock.js +208 -0
- package/dist/execution-envelope.d.ts +3142 -0
- package/dist/execution-envelope.js +203 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/manifest.d.ts +75 -0
- package/dist/manifest.js +184 -0
- package/dist/paired.d.ts +68 -0
- package/dist/paired.js +92 -0
- package/dist/scrub/build.d.ts +52 -0
- package/dist/scrub/build.js +84 -0
- package/dist/scrub/emit-scrub.d.ts +41 -0
- package/dist/scrub/emit-scrub.js +87 -0
- package/dist/scrub/index.d.ts +12 -0
- package/dist/scrub/index.js +12 -0
- package/dist/scrub/key-policy.d.ts +20 -0
- package/dist/scrub/key-policy.js +42 -0
- package/dist/scrub/layer2.d.ts +23 -0
- package/dist/scrub/layer2.js +32 -0
- package/dist/scrub/ml-pii-stage.d.ts +24 -0
- package/dist/scrub/ml-pii-stage.js +42 -0
- package/dist/scrub/openredaction-stage.d.ts +196 -0
- package/dist/scrub/openredaction-stage.js +270 -0
- package/dist/scrub/pii-build.d.ts +38 -0
- package/dist/scrub/pii-build.js +67 -0
- package/dist/scrub/pipeline.d.ts +38 -0
- package/dist/scrub/pipeline.js +83 -0
- package/dist/scrub/plain-patterns-stage.d.ts +31 -0
- package/dist/scrub/plain-patterns-stage.js +84 -0
- package/dist/scrub/secretlint-stage.d.ts +36 -0
- package/dist/scrub/secretlint-stage.js +249 -0
- package/dist/scrub/transformers-detector.d.ts +35 -0
- package/dist/scrub/transformers-detector.js +67 -0
- package/dist/scrub/types.d.ts +25 -0
- package/dist/scrub/types.js +1 -0
- package/dist/session-provenance.d.ts +75 -0
- package/dist/session-provenance.js +20 -0
- package/dist/skill-artifact.d.ts +442 -0
- package/dist/skill-artifact.js +136 -0
- package/dist/trajectory/hash-chain.d.ts +18 -0
- package/dist/trajectory/hash-chain.js +47 -0
- package/dist/trajectory/index.d.ts +4 -0
- package/dist/trajectory/index.js +4 -0
- package/dist/trajectory/schema.d.ts +606 -0
- package/dist/trajectory/schema.js +110 -0
- package/dist/trajectory/transcript-parsers/aider-history.d.ts +42 -0
- package/dist/trajectory/transcript-parsers/aider-history.js +181 -0
- package/dist/trajectory/transcript-parsers/claude-code-jsonl.d.ts +37 -0
- package/dist/trajectory/transcript-parsers/claude-code-jsonl.js +142 -0
- package/dist/trajectory/transcript-parsers/codex-session.d.ts +42 -0
- package/dist/trajectory/transcript-parsers/codex-session.js +235 -0
- package/dist/trajectory/transcript-parsers/continue-devdata.d.ts +41 -0
- package/dist/trajectory/transcript-parsers/continue-devdata.js +190 -0
- package/dist/trajectory/transcript-parsers/cursor-sqlite.d.ts +38 -0
- package/dist/trajectory/transcript-parsers/cursor-sqlite.js +118 -0
- package/dist/trajectory/transcript-parsers/gemini-session.d.ts +35 -0
- package/dist/trajectory/transcript-parsers/gemini-session.js +137 -0
- package/dist/trajectory/transcript-parsers/index.d.ts +7 -0
- package/dist/trajectory/transcript-parsers/index.js +7 -0
- package/dist/trajectory/transcript-parsers/types.d.ts +101 -0
- package/dist/trajectory/transcript-parsers/types.js +32 -0
- package/dist/trajectory/transcript-to-spans/attrs.d.ts +4 -0
- package/dist/trajectory/transcript-to-spans/attrs.js +30 -0
- package/dist/trajectory/transcript-to-spans/claude-code-stream-json.d.ts +8 -0
- package/dist/trajectory/transcript-to-spans/claude-code-stream-json.js +162 -0
- package/dist/trajectory/transcript-to-spans/codex-exec-json.d.ts +8 -0
- package/dist/trajectory/transcript-to-spans/codex-exec-json.js +253 -0
- package/dist/trajectory/transcript-to-spans/index.d.ts +4 -0
- package/dist/trajectory/transcript-to-spans/index.js +4 -0
- package/dist/trajectory/transcript-to-spans/types.d.ts +18 -0
- package/dist/trajectory/transcript-to-spans/types.js +1 -0
- package/dist/window.d.ts +12 -0
- package/dist/window.js +5 -0
- 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
|
+
}
|