@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,321 @@
1
+ /**
2
+ * EvidencePort adapter (#1660) — a file-backed evidence store under one
3
+ * captures dir that (AC2) round-trips a schema-canonical `EpisodeV1` AND reads
4
+ * legacy `CapturedTask` captures, up-mapping them to `EpisodeV1`.
5
+ *
6
+ * Round-trip guarantee: `put` persists the schema-parsed form, so `get`
7
+ * returns the canonical `EpisodeV1` for what was put — not necessarily the
8
+ * caller's literal input. `EpisodeV1Schema` applies defaults
9
+ * (`distributionTags`/`redactedKeys` → `[]`, `provenance` → `'contributed'`),
10
+ * so a caller who omits defaulted fields reads back the canonicalized superset.
11
+ *
12
+ * Suffix discipline: written episodes are `<id>.episode.json`; legacy captures
13
+ * are any other `*.json`. The two never collide — the strict `CapturedTaskSchema`
14
+ * would reject the extra `EpisodeV1` fields, so `.episode.json` files are read
15
+ * back only as episodes and legacy `.json` files only as captures.
16
+ */
17
+ import { createHash, randomUUID } from 'node:crypto';
18
+ import { closeSync, fchmodSync, readFileSync, readdirSync, } from 'node:fs';
19
+ import { link, lstat, open, rm, } from 'node:fs/promises';
20
+ import { basename, join, resolve, sep } from 'node:path';
21
+ import { isDeepStrictEqual } from 'node:util';
22
+ import { EpisodeV1Schema, EpisodeV1WriteSchema } from '@jinn-network/plugin';
23
+ import { degraded, ok, unavailable } from '@jinn-network/plugin';
24
+ import { parseCapturedTask } from './captured-task.js';
25
+ import { assertSafeOwner, fsyncDirectory, identityFrom, inspectRegularPath, nodeErrorCode, openVerifiedRegular, prepareEvidenceDirectory, readVerifiedText, sameIdentity, secureRegularPath, validateDirectory, } from './evidence-filesystem.js';
26
+ import { withEvidenceStoreLock } from './evidence-store-lock.js';
27
+ const DEFAULT_RETENTION = { policy: 'local-private', maxEpisodes: 200 };
28
+ const EPISODE_SUFFIX = '.episode.json';
29
+ const MAX_PORTABLE_FILENAME_BYTES = 255;
30
+ const HASHED_EPISODE_STEM = /^episode-[a-f0-9]{64}$/;
31
+ export function episodeFileName(id) {
32
+ const literalStemIsSafe = /^[A-Za-z0-9._-]+$/.test(id)
33
+ && id !== '.'
34
+ && id !== '..'
35
+ && !HASHED_EPISODE_STEM.test(id)
36
+ && Buffer.byteLength(`${id}${EPISODE_SUFFIX}`, 'utf8') <= MAX_PORTABLE_FILENAME_BYTES;
37
+ const stem = literalStemIsSafe
38
+ ? id
39
+ : `episode-${createHash('sha256').update(id).digest('hex')}`;
40
+ return `${stem}${EPISODE_SUFFIX}`;
41
+ }
42
+ /**
43
+ * Up-map a legacy `CapturedTask` to a strict `EpisodeV1`. Fail-closed:
44
+ * `EpisodeV1Schema.parse` throws if the up-map is wrong.
45
+ *
46
+ * Field gaps `CapturedTask` leaves that this fills: each step's discriminated
47
+ * `kind`, `environment.skillsLoadout` (default `[]`), `retention.policy`
48
+ * (from the adapter's configured retention), and `episodeId` (the sessionId).
49
+ */
50
+ export function capturedTaskToEpisode(task, retention) {
51
+ const built = {
52
+ schemaVersion: 'jinn.episode.v1',
53
+ episodeId: task.session.sessionId,
54
+ session: task.session,
55
+ task: task.task,
56
+ trajectory: task.steps.map((step) => ({
57
+ spanId: step.spanId,
58
+ parentSpanId: step.parentSpanId,
59
+ kind: step.kind ?? stepKind(step),
60
+ name: step.name,
61
+ startTimeUnixNano: step.startTimeUnixNano,
62
+ endTimeUnixNano: step.endTimeUnixNano,
63
+ attributes: step.attributes,
64
+ redactedKeys: step.redactedKeys,
65
+ })),
66
+ environment: {
67
+ ...task.environment,
68
+ skillsLoadout: task.environment.skillsLoadout ?? [],
69
+ },
70
+ outcome: task.outcome,
71
+ cost: task.cost,
72
+ retention: { policy: retention.policy },
73
+ provenance: task.provenance,
74
+ ...(task.attemptGroup ? { attemptGroup: task.attemptGroup } : {}),
75
+ };
76
+ return EpisodeV1Schema.parse(built);
77
+ }
78
+ /**
79
+ * Classify a legacy step as an agent turn or a tool call. Real `CapturedTask`
80
+ * steps carry the capture-event convention — `jinn.capture.event.kind` is
81
+ * user-message/assistant-message/tool-call/tool-result/edit, with `name`
82
+ * falling back to `jinn.transcript.<kind>` — matching `distill-traces.ts`'s
83
+ * `eventKind`. User/assistant messages are `jinn.agent_turn`; everything else
84
+ * (tool calls/results, edits) is a `jinn.tool_call`.
85
+ */
86
+ function stepKind(step) {
87
+ const eventKind = typeof step.attributes['jinn.capture.event.kind'] === 'string'
88
+ ? step.attributes['jinn.capture.event.kind']
89
+ : step.name;
90
+ return /(^|\.)(user|assistant)-message$/.test(eventKind) ? 'jinn.agent_turn' : 'jinn.tool_call';
91
+ }
92
+ export function createEvidenceAdapter(deps) {
93
+ const capturesDir = deps.capturesDir;
94
+ const retention = deps.retention ?? DEFAULT_RETENTION;
95
+ function episodePath(id) {
96
+ // EpisodeV1 intentionally permits opaque host ids. Safe ids retain the
97
+ // original filename; path-shaped or platform-unsafe ids use a stable hash
98
+ // that the Python fallback mirrors exactly.
99
+ const path = join(capturesDir, episodeFileName(id));
100
+ const base = resolve(capturesDir);
101
+ if (!resolve(path).startsWith(base + sep)) {
102
+ throw new Error(`unsafe episodeId (escapes captures dir): ${JSON.stringify(id)}`);
103
+ }
104
+ return path;
105
+ }
106
+ async function classifyExisting(path, episode) {
107
+ try {
108
+ const identity = inspectRegularPath(path, 'evidence episode');
109
+ if (!identity)
110
+ return 'missing';
111
+ const opened = openVerifiedRegular(path, 'evidence episode', identity);
112
+ try {
113
+ if (process.platform !== 'win32')
114
+ fchmodSync(opened.fd, 0o600);
115
+ const existing = EpisodeV1Schema.parse(JSON.parse(readFileSync(opened.fd, 'utf8')));
116
+ return isDeepStrictEqual(existing, episode) ? 'identical' : 'collision';
117
+ }
118
+ finally {
119
+ closeSync(opened.fd);
120
+ }
121
+ }
122
+ catch (error) {
123
+ return nodeErrorCode(error) === 'ENOENT' ? 'missing' : 'collision';
124
+ }
125
+ }
126
+ async function prepareEvidenceDir() {
127
+ prepareEvidenceDirectory(capturesDir, 'evidence store', true);
128
+ }
129
+ /**
130
+ * Enforce the declared newest-`maxEpisodes` retention (#1772 rider — was
131
+ * declared via `retention()` but never enforced). Keeps the
132
+ * `maxEpisodes` episodes with the most recent `session.capturedAt`,
133
+ * deleting the rest. Runs after every successful write; a no-op once the
134
+ * dir is at or under the cap. Never throws into `put()` — pruning is
135
+ * hygiene, not the write contract; a failure to prune is warned and
136
+ * skipped, same posture as the malformed-file skip in `list()`.
137
+ */
138
+ async function pruneOldEpisodes() {
139
+ let files;
140
+ try {
141
+ files = readdirSync(capturesDir).filter((file) => file.endsWith(EPISODE_SUFFIX));
142
+ }
143
+ catch {
144
+ return;
145
+ }
146
+ if (files.length <= retention.maxEpisodes)
147
+ return;
148
+ const byRecency = files
149
+ .map((file) => {
150
+ const path = join(capturesDir, file);
151
+ let identity;
152
+ try {
153
+ const verified = readVerifiedText(path, 'evidence episode');
154
+ identity = verified.identity;
155
+ const episode = EpisodeV1Schema.parse(JSON.parse(verified.text));
156
+ return { file, capturedAt: episode.session.capturedAt, identity };
157
+ }
158
+ catch {
159
+ // An unparseable file sorts oldest — never preferentially retained
160
+ // over a valid, dated episode.
161
+ return { file, capturedAt: '', identity };
162
+ }
163
+ })
164
+ .sort((a, b) => b.capturedAt.localeCompare(a.capturedAt)
165
+ || a.file.localeCompare(b.file));
166
+ for (const { file, identity } of byRecency.slice(retention.maxEpisodes)) {
167
+ try {
168
+ if (!identity) {
169
+ throw new Error('refusing to prune a source that did not pass descriptor validation');
170
+ }
171
+ const path = join(capturesDir, file);
172
+ secureRegularPath(path, 'evidence episode', identity);
173
+ await rm(path, { force: true });
174
+ fsyncDirectory(capturesDir);
175
+ }
176
+ catch (err) {
177
+ console.warn(`[evidence] failed to prune old episode ${file}: ${String(err)}`);
178
+ }
179
+ }
180
+ }
181
+ async function persistEpisode(episode) {
182
+ const path = episodePath(episode.episodeId);
183
+ const existing = await classifyExisting(path, episode);
184
+ if (existing === 'identical') {
185
+ return ok({ episodeId: episode.episodeId });
186
+ }
187
+ if (existing === 'collision') {
188
+ return unavailable(`evidence episodeId collision: ${episode.episodeId}`);
189
+ }
190
+ const tmp = join(capturesDir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
191
+ let handle;
192
+ try {
193
+ handle = await open(tmp, 'wx', 0o600);
194
+ await handle.writeFile(JSON.stringify(episode), 'utf8');
195
+ if (process.platform !== 'win32')
196
+ await handle.chmod(0o600);
197
+ await handle.sync();
198
+ const expected = identityFrom(await handle.stat());
199
+ assertSafeOwner(await handle.stat(), tmp, 'evidence temp file');
200
+ try {
201
+ // A hard link atomically publishes the complete temp file without
202
+ // overwriting a concurrent winner at the canonical destination.
203
+ await link(tmp, path);
204
+ const [tempAfterLink, published] = await Promise.all([lstat(tmp), lstat(path)]);
205
+ if (!tempAfterLink.isFile()
206
+ || tempAfterLink.isSymbolicLink()
207
+ || !published.isFile()
208
+ || published.isSymbolicLink()
209
+ || !sameIdentity(identityFrom(tempAfterLink), expected)
210
+ || !sameIdentity(identityFrom(published), expected)) {
211
+ throw new Error(`evidence episode changed while it was being published: ${path}`);
212
+ }
213
+ assertSafeOwner(tempAfterLink, tmp, 'evidence temp file');
214
+ assertSafeOwner(published, path, 'evidence episode');
215
+ fsyncDirectory(capturesDir);
216
+ return ok({ episodeId: episode.episodeId });
217
+ }
218
+ catch (error) {
219
+ if (nodeErrorCode(error) !== 'EEXIST')
220
+ throw error;
221
+ const raced = await classifyExisting(path, episode);
222
+ if (raced === 'identical') {
223
+ return ok({ episodeId: episode.episodeId });
224
+ }
225
+ return unavailable(`evidence episodeId collision: ${episode.episodeId}`);
226
+ }
227
+ }
228
+ finally {
229
+ if (handle !== undefined)
230
+ await handle.close().catch(() => undefined);
231
+ await rm(tmp, { force: true });
232
+ fsyncDirectory(capturesDir);
233
+ }
234
+ }
235
+ return {
236
+ async put(episode) {
237
+ try {
238
+ const parsed = EpisodeV1WriteSchema.parse(episode);
239
+ const result = await withEvidenceStoreLock(capturesDir, async () => {
240
+ await prepareEvidenceDir();
241
+ const persisted = await persistEpisode(parsed);
242
+ if (persisted.status === 'ok')
243
+ await pruneOldEpisodes();
244
+ return persisted;
245
+ }, async (persisted) => {
246
+ if (persisted.status !== 'ok')
247
+ return;
248
+ try {
249
+ await deps.onStoreChanged?.();
250
+ }
251
+ catch (error) {
252
+ console.warn(`[evidence] derived index refresh failed: ${String(error)}`);
253
+ }
254
+ });
255
+ return result;
256
+ }
257
+ catch (e) {
258
+ return unavailable(`evidence store put failed: ${String(e)}`);
259
+ }
260
+ },
261
+ async get(episodeId) {
262
+ try {
263
+ const path = episodePath(episodeId);
264
+ if (!inspectRegularPath(path, 'evidence episode'))
265
+ return ok(null);
266
+ return ok(EpisodeV1Schema.parse(JSON.parse(readVerifiedText(path, 'evidence episode').text)));
267
+ }
268
+ catch (e) {
269
+ return unavailable(`evidence store get failed: ${String(e)}`);
270
+ }
271
+ },
272
+ async list(query = {}) {
273
+ try {
274
+ try {
275
+ validateDirectory(capturesDir, 'evidence store', false);
276
+ }
277
+ catch (error) {
278
+ if (nodeErrorCode(error) === 'ENOENT')
279
+ return ok([]);
280
+ throw error;
281
+ }
282
+ const episodes = [];
283
+ let unreadable = 0;
284
+ for (const file of readdirSync(capturesDir)) {
285
+ const path = join(capturesDir, file);
286
+ if (file.endsWith(EPISODE_SUFFIX)) {
287
+ try {
288
+ episodes.push(EpisodeV1Schema.parse(JSON.parse(readVerifiedText(path, 'evidence episode').text)));
289
+ }
290
+ catch (err) {
291
+ unreadable += 1;
292
+ console.warn(`[evidence] skipping malformed episode file ${file}: ${String(err)}`);
293
+ }
294
+ }
295
+ else if (file.endsWith('.json')) {
296
+ // A legacy CapturedTask capture — up-map it. A capture that fails
297
+ // to parse or up-map is skipped (warn), never throws.
298
+ try {
299
+ const task = parseCapturedTask(JSON.parse(readVerifiedText(path, 'legacy evidence capture').text));
300
+ episodes.push(capturedTaskToEpisode(task, retention));
301
+ }
302
+ catch (err) {
303
+ unreadable += 1;
304
+ console.warn(`[evidence] skipping malformed legacy capture ${file}: ${String(err)}`);
305
+ }
306
+ }
307
+ }
308
+ const value = query.limit ? episodes.slice(0, query.limit) : episodes;
309
+ return unreadable > 0
310
+ ? degraded(`evidence list skipped ${unreadable} unreadable record(s)`, value)
311
+ : ok(value);
312
+ }
313
+ catch (e) {
314
+ return unavailable(`evidence store list failed: ${String(e)}`);
315
+ }
316
+ },
317
+ async retention() {
318
+ return ok(retention);
319
+ },
320
+ };
321
+ }
@@ -0,0 +1,40 @@
1
+ import { type Stats } from 'node:fs';
2
+ export interface FileIdentity {
3
+ dev: number;
4
+ ino: number;
5
+ uid: number;
6
+ nlink: number;
7
+ }
8
+ export declare function nodeErrorCode(error: unknown): string | undefined;
9
+ export declare function identityFrom(stat: Stats): FileIdentity;
10
+ export declare function sameIdentity(left: FileIdentity, right: FileIdentity): boolean;
11
+ export declare function assertSafeOwner(stat: Stats, path: string, label: string): void;
12
+ export declare function assertRegularStat(stat: Stats, path: string, label: string): void;
13
+ export declare function inspectRegularPath(path: string, label: string): FileIdentity | undefined;
14
+ export declare function openVerifiedRegular(path: string, label: string, expected?: FileIdentity): {
15
+ fd: number;
16
+ identity: FileIdentity;
17
+ };
18
+ export declare function openVerifiedRegularForUpdate(path: string, label: string, expected?: FileIdentity): {
19
+ fd: number;
20
+ identity: FileIdentity;
21
+ };
22
+ export declare function secureRegularPath(path: string, label: string, expected?: FileIdentity): FileIdentity;
23
+ export declare function validateDirectory(path: string, label: string, secure: boolean): FileIdentity;
24
+ export declare function prepareEvidenceDirectory(path: string, label: string, secure: boolean): FileIdentity;
25
+ /**
26
+ * Complete the one recoverable interval in no-clobber episode publication.
27
+ *
28
+ * Both writers fsync a complete private temp file, hardlink it at the
29
+ * canonical name, fsync the directory, and finally remove the temp alias. A
30
+ * process or host crash after link(2) can therefore leave exactly two names
31
+ * for the same inode. Under the store lock, recognize only the two writer
32
+ * namespaces and remove the proven temp alias. Arbitrary hardlinks remain
33
+ * rejected by the normal reader path.
34
+ */
35
+ export declare function recoverWriterPublicationAliases(directory: string): void;
36
+ export declare function fsyncDirectory(path: string): void;
37
+ export declare function readVerifiedText(path: string, label: string, expected?: FileIdentity): {
38
+ text: string;
39
+ identity: FileIdentity;
40
+ };
@@ -0,0 +1,267 @@
1
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ export function nodeErrorCode(error) {
4
+ return error && typeof error === 'object' && 'code' in error
5
+ ? String(error.code)
6
+ : undefined;
7
+ }
8
+ export function identityFrom(stat) {
9
+ return {
10
+ dev: stat.dev,
11
+ ino: stat.ino,
12
+ uid: stat.uid,
13
+ nlink: stat.nlink,
14
+ };
15
+ }
16
+ export function sameIdentity(left, right) {
17
+ return left.dev === right.dev && left.ino === right.ino;
18
+ }
19
+ export function assertSafeOwner(stat, path, label) {
20
+ const uid = process.platform === 'win32' ? undefined : process.getuid?.();
21
+ if (uid !== undefined && stat.uid !== uid) {
22
+ throw new Error(`${label} must be owned by uid ${uid}: ${path}`);
23
+ }
24
+ }
25
+ export function assertRegularStat(stat, path, label) {
26
+ if (!stat.isFile() || stat.isSymbolicLink()) {
27
+ throw new Error(`${label} must be a regular file, not a symlink or other file type: ${path}`);
28
+ }
29
+ if (stat.nlink !== 1) {
30
+ throw new Error(`${label} must not be hardlinked: ${path}`);
31
+ }
32
+ assertSafeOwner(stat, path, label);
33
+ }
34
+ export function inspectRegularPath(path, label) {
35
+ try {
36
+ const stat = lstatSync(path);
37
+ assertRegularStat(stat, path, label);
38
+ return identityFrom(stat);
39
+ }
40
+ catch (error) {
41
+ if (nodeErrorCode(error) === 'ENOENT')
42
+ return undefined;
43
+ throw error;
44
+ }
45
+ }
46
+ function openVerifiedRegularWithFlags(path, label, flags, expected) {
47
+ const before = inspectRegularPath(path, label);
48
+ if (!before)
49
+ throw new Error(`${label} disappeared before it could be opened: ${path}`);
50
+ if (expected && !sameIdentity(before, expected)) {
51
+ throw new Error(`${label} changed while it was being processed: ${path}`);
52
+ }
53
+ const fd = openSync(path, flags | (constants.O_NOFOLLOW ?? 0));
54
+ try {
55
+ const opened = fstatSync(fd);
56
+ assertRegularStat(opened, path, label);
57
+ const identity = identityFrom(opened);
58
+ if (!sameIdentity(identity, before) || (expected && !sameIdentity(identity, expected))) {
59
+ throw new Error(`${label} changed while it was being opened: ${path}`);
60
+ }
61
+ return { fd, identity };
62
+ }
63
+ catch (error) {
64
+ closeSync(fd);
65
+ throw error;
66
+ }
67
+ }
68
+ export function openVerifiedRegular(path, label, expected) {
69
+ return openVerifiedRegularWithFlags(path, label, constants.O_RDONLY, expected);
70
+ }
71
+ export function openVerifiedRegularForUpdate(path, label, expected) {
72
+ return openVerifiedRegularWithFlags(path, label, constants.O_RDWR, expected);
73
+ }
74
+ export function secureRegularPath(path, label, expected) {
75
+ const opened = openVerifiedRegular(path, label, expected);
76
+ try {
77
+ if (process.platform !== 'win32')
78
+ fchmodSync(opened.fd, 0o600);
79
+ return opened.identity;
80
+ }
81
+ finally {
82
+ closeSync(opened.fd);
83
+ }
84
+ }
85
+ export function validateDirectory(path, label, secure) {
86
+ // Validate the terminal directory entry and its opened descriptor. Ancestor
87
+ // components remain outside this guarantee because Node does not expose
88
+ // portable openat-style traversal for pinning every component.
89
+ const before = lstatSync(path);
90
+ if (!before.isDirectory() || before.isSymbolicLink()) {
91
+ throw new Error(`${label} must be a regular directory, not a symlink: ${path}`);
92
+ }
93
+ assertSafeOwner(before, path, label);
94
+ const noFollow = constants.O_NOFOLLOW ?? 0;
95
+ const directoryOnly = constants.O_DIRECTORY ?? 0;
96
+ const fd = openSync(path, constants.O_RDONLY | noFollow | directoryOnly);
97
+ try {
98
+ const opened = fstatSync(fd);
99
+ if (!opened.isDirectory() || opened.isSymbolicLink()) {
100
+ throw new Error(`${label} changed while it was being opened: ${path}`);
101
+ }
102
+ assertSafeOwner(opened, path, label);
103
+ const beforeIdentity = identityFrom(before);
104
+ const openedIdentity = identityFrom(opened);
105
+ if (!sameIdentity(beforeIdentity, openedIdentity)) {
106
+ throw new Error(`${label} changed while it was being opened: ${path}`);
107
+ }
108
+ if (secure && process.platform !== 'win32')
109
+ fchmodSync(fd, 0o700);
110
+ return openedIdentity;
111
+ }
112
+ finally {
113
+ closeSync(fd);
114
+ }
115
+ }
116
+ export function prepareEvidenceDirectory(path, label, secure) {
117
+ try {
118
+ return validateDirectory(path, label, secure);
119
+ }
120
+ catch (error) {
121
+ if (nodeErrorCode(error) !== 'ENOENT')
122
+ throw error;
123
+ mkdirSync(path, { recursive: true, mode: 0o700 });
124
+ const identity = validateDirectory(path, label, secure);
125
+ fsyncDirectory(dirname(path));
126
+ return identity;
127
+ }
128
+ }
129
+ function writerTempCanonicalName(name) {
130
+ const coreWriter = /^\.(.+\.episode\.json)\.\d+\.([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.tmp$/i
131
+ .exec(name);
132
+ if (coreWriter)
133
+ return coreWriter[1];
134
+ const pythonWriter = /^\.(.+)\.([A-Za-z0-9_-]{8})\.tmp$/.exec(name);
135
+ return pythonWriter ? `${pythonWriter[1]}.episode.json` : undefined;
136
+ }
137
+ function isWriterTempAlias(name, canonicalName) {
138
+ return writerTempCanonicalName(name) === canonicalName;
139
+ }
140
+ function assertLinkedPublicationStat(stat, path, label) {
141
+ if (!stat.isFile() || stat.isSymbolicLink()) {
142
+ throw new Error(`${label} must be a regular file: ${path}`);
143
+ }
144
+ assertSafeOwner(stat, path, label);
145
+ }
146
+ /**
147
+ * Complete the one recoverable interval in no-clobber episode publication.
148
+ *
149
+ * Both writers fsync a complete private temp file, hardlink it at the
150
+ * canonical name, fsync the directory, and finally remove the temp alias. A
151
+ * process or host crash after link(2) can therefore leave exactly two names
152
+ * for the same inode. Under the store lock, recognize only the two writer
153
+ * namespaces and remove the proven temp alias. Arbitrary hardlinks remain
154
+ * rejected by the normal reader path.
155
+ */
156
+ export function recoverWriterPublicationAliases(directory) {
157
+ const names = readdirSync(directory).sort();
158
+ const canonicalNames = names.filter((name) => name.endsWith('.episode.json'));
159
+ for (const canonicalName of canonicalNames) {
160
+ const canonicalPath = join(directory, canonicalName);
161
+ const canonical = lstatSync(canonicalPath);
162
+ if (canonical.nlink === 1)
163
+ continue;
164
+ assertLinkedPublicationStat(canonical, canonicalPath, 'published evidence episode');
165
+ if (canonical.nlink !== 2)
166
+ continue;
167
+ const canonicalIdentity = identityFrom(canonical);
168
+ const aliases = names.filter((name) => {
169
+ if (!isWriterTempAlias(name, canonicalName))
170
+ return false;
171
+ const alias = lstatSync(join(directory, name));
172
+ return sameIdentity(identityFrom(alias), canonicalIdentity);
173
+ });
174
+ if (aliases.length !== 1)
175
+ continue;
176
+ const aliasPath = join(directory, aliases[0]);
177
+ const alias = lstatSync(aliasPath);
178
+ assertLinkedPublicationStat(alias, aliasPath, 'evidence publication temp alias');
179
+ if (alias.nlink !== 2 || !sameIdentity(identityFrom(alias), canonicalIdentity))
180
+ continue;
181
+ // Pin both names before cleanup. The evidence directory is owner-only and
182
+ // all Jinn writers hold the shared lock; deliberate mutation by another
183
+ // process running as the same uid is outside the local-store threat model.
184
+ const canonicalFd = openSync(canonicalPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
185
+ const aliasFd = openSync(aliasPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
186
+ try {
187
+ const openedCanonical = fstatSync(canonicalFd);
188
+ const openedAlias = fstatSync(aliasFd);
189
+ assertLinkedPublicationStat(openedCanonical, canonicalPath, 'published evidence episode');
190
+ assertLinkedPublicationStat(openedAlias, aliasPath, 'evidence publication temp alias');
191
+ if (openedCanonical.nlink !== 2
192
+ || openedAlias.nlink !== 2
193
+ || !sameIdentity(identityFrom(openedCanonical), canonicalIdentity)
194
+ || !sameIdentity(identityFrom(openedAlias), canonicalIdentity)) {
195
+ throw new Error(`evidence publication aliases changed during recovery: ${canonicalPath}`);
196
+ }
197
+ const aliasAtCommit = lstatSync(aliasPath);
198
+ const canonicalAtCommit = lstatSync(canonicalPath);
199
+ if (!sameIdentity(identityFrom(aliasAtCommit), canonicalIdentity)
200
+ || !sameIdentity(identityFrom(canonicalAtCommit), canonicalIdentity)) {
201
+ throw new Error(`evidence publication aliases changed before recovery: ${canonicalPath}`);
202
+ }
203
+ unlinkSync(aliasPath);
204
+ fsyncDirectory(directory);
205
+ }
206
+ finally {
207
+ closeSync(aliasFd);
208
+ closeSync(canonicalFd);
209
+ }
210
+ secureRegularPath(canonicalPath, 'recovered evidence episode', canonicalIdentity);
211
+ }
212
+ // A crash before link(2) leaves only the complete nlink=1 temp. Because
213
+ // temp creation also happens under this store lock, no cooperating writer
214
+ // can still be using it while recovery holds the lock.
215
+ for (const name of names) {
216
+ if (!writerTempCanonicalName(name))
217
+ continue;
218
+ const path = join(directory, name);
219
+ let identity;
220
+ try {
221
+ identity = inspectRegularPath(path, 'abandoned evidence publication temp');
222
+ }
223
+ catch (error) {
224
+ if (nodeErrorCode(error) === 'ENOENT')
225
+ continue;
226
+ throw error;
227
+ }
228
+ if (!identity)
229
+ continue;
230
+ const opened = openVerifiedRegular(path, 'abandoned evidence publication temp', identity);
231
+ try {
232
+ const atCommit = lstatSync(path);
233
+ assertRegularStat(atCommit, path, 'abandoned evidence publication temp');
234
+ if (!sameIdentity(identityFrom(atCommit), opened.identity)) {
235
+ throw new Error(`evidence publication temp changed before cleanup: ${path}`);
236
+ }
237
+ unlinkSync(path);
238
+ fsyncDirectory(directory);
239
+ }
240
+ finally {
241
+ closeSync(opened.fd);
242
+ }
243
+ }
244
+ }
245
+ export function fsyncDirectory(path) {
246
+ if (process.platform === 'win32')
247
+ return;
248
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_DIRECTORY ?? 0));
249
+ try {
250
+ fsyncSync(fd);
251
+ }
252
+ finally {
253
+ closeSync(fd);
254
+ }
255
+ }
256
+ export function readVerifiedText(path, label, expected) {
257
+ const opened = openVerifiedRegular(path, label, expected);
258
+ try {
259
+ return {
260
+ text: readFileSync(opened.fd, 'utf8'),
261
+ identity: opened.identity,
262
+ };
263
+ }
264
+ finally {
265
+ closeSync(opened.fd);
266
+ }
267
+ }