@evo-dev/core 0.0.1-alpha.11 → 0.0.1-alpha.13

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.
@@ -0,0 +1,952 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Stats } from "node:fs";
3
+ import { chmod, link, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
4
+ import type { FileHandle } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import {
7
+ createTrajectoryImportIndexDigest,
8
+ createTrajectoryImportSnapshotId,
9
+ stableTrajectoryImportJson,
10
+ } from "./diff.ts";
11
+ import {
12
+ type ResolveTrajectoryImportSourcePathsInput,
13
+ type TrajectoryImportPaths,
14
+ type TrajectoryImportSourcePaths,
15
+ resolveTrajectoryImportPaths,
16
+ resolveTrajectoryImportSourcePaths,
17
+ } from "./paths.ts";
18
+ import type {
19
+ CanonicalImportRecordType,
20
+ CanonicalImportSourceIdentityKind,
21
+ TrajectoryImportGenerationContractV1,
22
+ TrajectoryImportIndexRecordV1,
23
+ TrajectoryImportManifestV1,
24
+ TrajectoryImportMaterializationProgressV1,
25
+ TrajectoryImportPreviousSnapshotV1,
26
+ TrajectoryImportReceiptV1,
27
+ TrajectoryImportSource,
28
+ } from "./types.ts";
29
+
30
+ const PRIVATE_DIRECTORY_MODE = 0o700;
31
+ const PRIVATE_FILE_MODE = 0o600;
32
+ const DEFAULT_LOCK_WAIT_MS = 2_000;
33
+ const DEFAULT_LOCK_RETRY_MS = 10;
34
+ const DEFAULT_LOCK_STALE_MS = 120_000;
35
+ const DEFAULT_LOCK_HEARTBEAT_MS = 15_000;
36
+
37
+ const SOURCES = new Set<TrajectoryImportSource>([
38
+ "claude-code",
39
+ "codex",
40
+ "hermes",
41
+ "letta-code",
42
+ "openclaw",
43
+ "openhands",
44
+ ]);
45
+ const RECORD_TYPES = new Set<CanonicalImportRecordType>([
46
+ "meta",
47
+ "user",
48
+ "reasoning",
49
+ "assistant",
50
+ "assistant-tool-call",
51
+ "tool",
52
+ ]);
53
+ const IDENTITY_KINDS = new Set<CanonicalImportSourceIdentityKind>([
54
+ "native",
55
+ "location",
56
+ "content",
57
+ "synthetic",
58
+ ]);
59
+
60
+ export interface TrajectoryImportSourceLock {
61
+ ownerPath: string;
62
+ recoveryPath: string;
63
+ ownerToken: string;
64
+ handle: FileHandle;
65
+ timer: ReturnType<typeof setInterval>;
66
+ heartbeat: Promise<void> | null;
67
+ stopped: boolean;
68
+ lost: boolean;
69
+ }
70
+
71
+ export interface AcquireTrajectoryImportSourceLockOptions {
72
+ waitMs?: number;
73
+ retryMs?: number;
74
+ staleAfterMs?: number;
75
+ heartbeatMs?: number;
76
+ }
77
+
78
+ export async function ensurePrivateTrajectoryImportDirectory(path: string): Promise<void> {
79
+ await mkdir(path, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
80
+ const current = await lstat(path);
81
+ if (current.isSymbolicLink() || !current.isDirectory()) {
82
+ throw new Error("Trajectory import private state path must be a real directory.");
83
+ }
84
+ await chmod(path, PRIVATE_DIRECTORY_MODE);
85
+ }
86
+
87
+ export async function ensureTrajectoryImportStageDirectories(
88
+ paths: TrajectoryImportPaths,
89
+ ): Promise<void> {
90
+ for (const directory of [
91
+ paths.rootDir,
92
+ paths.projectDir,
93
+ paths.sourceDir,
94
+ paths.lockDir,
95
+ paths.generationsDir,
96
+ paths.generationDir,
97
+ paths.snapshotsDir,
98
+ paths.snapshotDir,
99
+ paths.receiptsDir,
100
+ paths.previewReceiptDir,
101
+ ]) {
102
+ await ensurePrivateTrajectoryImportDirectory(directory);
103
+ }
104
+ }
105
+
106
+ export async function acquireTrajectoryImportSourceLock(
107
+ paths: TrajectoryImportSourcePaths,
108
+ options: AcquireTrajectoryImportSourceLockOptions = {},
109
+ ): Promise<TrajectoryImportSourceLock> {
110
+ const waitMs = normalizeDuration("waitMs", options.waitMs, DEFAULT_LOCK_WAIT_MS, true);
111
+ const retryMs = normalizeDuration("retryMs", options.retryMs, DEFAULT_LOCK_RETRY_MS, false);
112
+ const staleAfterMs = normalizeDuration(
113
+ "staleAfterMs",
114
+ options.staleAfterMs,
115
+ DEFAULT_LOCK_STALE_MS,
116
+ false,
117
+ );
118
+ const heartbeatMs = normalizeDuration(
119
+ "heartbeatMs",
120
+ options.heartbeatMs,
121
+ DEFAULT_LOCK_HEARTBEAT_MS,
122
+ false,
123
+ );
124
+ if (heartbeatMs >= staleAfterMs) {
125
+ throw new Error("Trajectory import lock heartbeat must be shorter than its stale timeout.");
126
+ }
127
+
128
+ for (const directory of [paths.rootDir, paths.projectDir, paths.sourceDir, paths.lockDir]) {
129
+ await ensurePrivateTrajectoryImportDirectory(directory);
130
+ }
131
+ const deadline = Date.now() + waitMs;
132
+
133
+ while (true) {
134
+ if (await pathExists(paths.lockRecoveryPath)) {
135
+ await recoverStaleControlFile(paths.lockRecoveryPath, staleAfterMs);
136
+ if (await pathExists(paths.lockRecoveryPath)) {
137
+ await waitForTrajectoryImportLock(deadline, retryMs);
138
+ continue;
139
+ }
140
+ }
141
+
142
+ const ownerToken = randomUUID();
143
+ let handle: FileHandle;
144
+ try {
145
+ handle = await createPrivateExclusiveFile(
146
+ paths.lockOwnerPath,
147
+ `${stableTrajectoryImportJson({
148
+ schemaVersion: 1,
149
+ kind: "trajectory-import-source-lock",
150
+ ownerToken,
151
+ createdAt: new Date().toISOString(),
152
+ pid: process.pid,
153
+ })}\n`,
154
+ );
155
+ } catch (error) {
156
+ if (!hasErrorCode(error, "EEXIST")) throw error;
157
+ const recovered = await recoverStaleTrajectoryImportOwner(paths, staleAfterMs);
158
+ if (!recovered) await waitForTrajectoryImportLock(deadline, retryMs);
159
+ continue;
160
+ }
161
+
162
+ const lock: TrajectoryImportSourceLock = {
163
+ ownerPath: paths.lockOwnerPath,
164
+ recoveryPath: paths.lockRecoveryPath,
165
+ ownerToken,
166
+ handle,
167
+ timer: undefined as unknown as ReturnType<typeof setInterval>,
168
+ heartbeat: null,
169
+ stopped: false,
170
+ lost: false,
171
+ };
172
+ lock.timer = setInterval(() => {
173
+ if (lock.stopped || lock.heartbeat !== null) return;
174
+ lock.heartbeat = heartbeatTrajectoryImportSourceLock(lock).finally(() => {
175
+ lock.heartbeat = null;
176
+ });
177
+ }, heartbeatMs);
178
+ lock.timer.unref?.();
179
+ return lock;
180
+ }
181
+ }
182
+
183
+ export async function withTrajectoryImportSourceLock<T>(
184
+ paths: TrajectoryImportSourcePaths,
185
+ operation: (lock: TrajectoryImportSourceLock) => Promise<T>,
186
+ options: AcquireTrajectoryImportSourceLockOptions = {},
187
+ ): Promise<T> {
188
+ const lock = await acquireTrajectoryImportSourceLock(paths, options);
189
+ try {
190
+ const result = await operation(lock);
191
+ await assertTrajectoryImportSourceLockOwned(lock);
192
+ return result;
193
+ } finally {
194
+ await releaseTrajectoryImportSourceLock(lock);
195
+ }
196
+ }
197
+
198
+ export async function assertTrajectoryImportSourceLockOwned(
199
+ lock: TrajectoryImportSourceLock,
200
+ ): Promise<void> {
201
+ if (lock.lost || !(await trajectoryImportSourceLockIsOwned(lock))) {
202
+ lock.lost = true;
203
+ throw new Error("Trajectory import source lock ownership was lost.");
204
+ }
205
+ }
206
+
207
+ export async function releaseTrajectoryImportSourceLock(
208
+ lock: TrajectoryImportSourceLock,
209
+ ): Promise<void> {
210
+ lock.stopped = true;
211
+ clearInterval(lock.timer);
212
+ await lock.heartbeat?.catch(() => undefined);
213
+ await lock.handle.close().catch(() => undefined);
214
+ const recoveryToken = randomUUID();
215
+ let recoveryHandle: FileHandle;
216
+ try {
217
+ recoveryHandle = await createPrivateExclusiveFile(
218
+ lock.recoveryPath,
219
+ `${stableTrajectoryImportJson({
220
+ schemaVersion: 1,
221
+ kind: "trajectory-import-lock-release",
222
+ ownerToken: recoveryToken,
223
+ createdAt: new Date().toISOString(),
224
+ pid: process.pid,
225
+ })}\n`,
226
+ );
227
+ } catch (error) {
228
+ if (hasErrorCode(error, "EEXIST")) return;
229
+ throw error;
230
+ }
231
+ try {
232
+ if (await trajectoryImportSourceLockIsOwned(lock)) {
233
+ await rm(lock.ownerPath, { force: true });
234
+ }
235
+ } finally {
236
+ await recoveryHandle.close().catch(() => undefined);
237
+ await removeOwnedControlFile(lock.recoveryPath, recoveryToken);
238
+ }
239
+ }
240
+
241
+ export async function readTrajectoryImportManifest(
242
+ input: ResolveTrajectoryImportSourcePathsInput,
243
+ ): Promise<TrajectoryImportManifestV1 | null> {
244
+ const paths = resolveTrajectoryImportSourcePaths(input);
245
+ return readTrajectoryImportManifestAtPath(paths.manifestPath);
246
+ }
247
+
248
+ export async function readTrajectoryImportManifestAtPath(
249
+ path: string,
250
+ ): Promise<TrajectoryImportManifestV1 | null> {
251
+ return readOptionalJson(path, parseTrajectoryImportManifest);
252
+ }
253
+
254
+ export async function writeTrajectoryImportManifest(
255
+ paths: TrajectoryImportSourcePaths,
256
+ manifest: TrajectoryImportManifestV1,
257
+ ): Promise<void> {
258
+ const normalized = parseTrajectoryImportManifest(manifest);
259
+ await writePrivateAtomicFile(paths.manifestPath, `${stableTrajectoryImportJson(normalized)}\n`);
260
+ }
261
+
262
+ export async function readTrajectoryImportIndex(
263
+ path: string,
264
+ ): Promise<TrajectoryImportIndexRecordV1[]> {
265
+ await assertRegularStateFile(path);
266
+ const content = await readFile(path, "utf8");
267
+ const lines = content.endsWith("\n") ? content.slice(0, -1).split("\n") : content.split("\n");
268
+ if (lines.length === 1 && lines[0] === "") return [];
269
+ const records = lines.map((line, ordinal) => {
270
+ if (line.trim() === "") {
271
+ throw new Error("Trajectory import index contains an empty record.");
272
+ }
273
+ let value: unknown;
274
+ try {
275
+ value = JSON.parse(line);
276
+ } catch {
277
+ throw new Error("Trajectory import index contains invalid JSON.");
278
+ }
279
+ return parseTrajectoryImportIndexRecord(value, ordinal);
280
+ });
281
+ validateTrajectoryImportIndexSet(records);
282
+ return records;
283
+ }
284
+
285
+ export async function writeImmutableTrajectoryImportIndex(
286
+ paths: TrajectoryImportPaths,
287
+ records: TrajectoryImportIndexRecordV1[],
288
+ ): Promise<{ created: boolean; digest: string; records: TrajectoryImportIndexRecordV1[] }> {
289
+ const normalized = records.map((record, ordinal) =>
290
+ parseTrajectoryImportIndexRecord(record, ordinal),
291
+ );
292
+ validateTrajectoryImportIndexSet(normalized);
293
+ const content = `${normalized.map(stableTrajectoryImportJson).join("\n")}\n`;
294
+ const created = await writePrivateImmutableFile(paths.recordIndexPath, content);
295
+ return {
296
+ created,
297
+ digest: createTrajectoryImportIndexDigest(normalized),
298
+ records: normalized,
299
+ };
300
+ }
301
+
302
+ export async function readTrajectoryImportReceipt(
303
+ path: string,
304
+ ): Promise<TrajectoryImportReceiptV1 | null> {
305
+ return readOptionalJson(path, parseTrajectoryImportReceipt);
306
+ }
307
+
308
+ export async function writeImmutableTrajectoryImportReceipt(
309
+ path: string,
310
+ receipt: TrajectoryImportReceiptV1,
311
+ ): Promise<{ created: boolean; receipt: TrajectoryImportReceiptV1 }> {
312
+ const normalized = parseTrajectoryImportReceipt(receipt);
313
+ const created = await writePrivateImmutableFile(
314
+ path,
315
+ `${stableTrajectoryImportJson(normalized)}\n`,
316
+ );
317
+ return { created, receipt: normalized };
318
+ }
319
+
320
+ export async function readTrajectoryImportMaterializationProgress(
321
+ path: string,
322
+ ): Promise<TrajectoryImportMaterializationProgressV1 | null> {
323
+ return readOptionalJson(path, parseTrajectoryImportMaterializationProgress);
324
+ }
325
+
326
+ export async function writeTrajectoryImportMaterializationProgress(
327
+ paths: TrajectoryImportPaths,
328
+ progress: TrajectoryImportMaterializationProgressV1,
329
+ ): Promise<void> {
330
+ const normalized = parseTrajectoryImportMaterializationProgress(progress);
331
+ await writePrivateAtomicFile(
332
+ paths.materializationPath,
333
+ `${stableTrajectoryImportJson(normalized)}\n`,
334
+ );
335
+ }
336
+
337
+ export async function readTrajectoryImportPreviousSnapshot(
338
+ input: ResolveTrajectoryImportSourcePathsInput,
339
+ ): Promise<TrajectoryImportPreviousSnapshotV1 | null> {
340
+ const manifest = await readTrajectoryImportManifest(input);
341
+ if (manifest === null) return null;
342
+ if (manifest.projectKey !== input.projectKey || manifest.sourceKey !== input.sourceKey) {
343
+ throw new Error("Trajectory import manifest does not match its state path.");
344
+ }
345
+ return readTrajectoryImportSnapshot({
346
+ ...input,
347
+ generationId: manifest.currentGenerationId,
348
+ snapshotId: manifest.currentSnapshotId,
349
+ });
350
+ }
351
+
352
+ export async function readTrajectoryImportSnapshot(
353
+ input: ResolveTrajectoryImportSourcePathsInput & {
354
+ generationId: string;
355
+ snapshotId: string;
356
+ },
357
+ ): Promise<TrajectoryImportPreviousSnapshotV1> {
358
+ const manifest = await readTrajectoryImportManifest(input);
359
+ if (manifest === null) {
360
+ throw new Error("Trajectory import manifest was not found.");
361
+ }
362
+ if (manifest.projectKey !== input.projectKey || manifest.sourceKey !== input.sourceKey) {
363
+ throw new Error("Trajectory import manifest does not match its state path.");
364
+ }
365
+ if (manifest.currentGenerationId !== input.generationId) {
366
+ throw new Error("Trajectory import snapshot generation does not match its manifest.");
367
+ }
368
+ const paths = resolveTrajectoryImportPaths({
369
+ ...input,
370
+ previewId: manifest.currentPreviewId,
371
+ });
372
+ const records = await readTrajectoryImportIndex(paths.recordIndexPath);
373
+ const digest = createTrajectoryImportIndexDigest(records);
374
+ if (input.snapshotId === manifest.currentSnapshotId && digest !== manifest.currentIndexDigest) {
375
+ throw new Error("Trajectory import current index digest does not match its manifest.");
376
+ }
377
+ if (createTrajectoryImportSnapshotId(input.generationId, records) !== input.snapshotId) {
378
+ throw new Error("Trajectory import snapshot id does not match its index.");
379
+ }
380
+ return {
381
+ sourceKey: manifest.sourceKey,
382
+ generationId: input.generationId,
383
+ initialInputFingerprint: manifest.initialInputFingerprint,
384
+ generationContract: manifest.generationContract,
385
+ snapshotId: input.snapshotId,
386
+ records,
387
+ };
388
+ }
389
+
390
+ async function heartbeatTrajectoryImportSourceLock(
391
+ lock: TrajectoryImportSourceLock,
392
+ ): Promise<void> {
393
+ if (!(await trajectoryImportSourceLockIsOwned(lock))) {
394
+ lock.lost = true;
395
+ return;
396
+ }
397
+ try {
398
+ const now = new Date();
399
+ await lock.handle.utimes(now, now);
400
+ if (!(await trajectoryImportSourceLockIsOwned(lock))) lock.lost = true;
401
+ } catch {
402
+ lock.lost = true;
403
+ }
404
+ }
405
+
406
+ async function trajectoryImportSourceLockIsOwned(
407
+ lock: TrajectoryImportSourceLock,
408
+ ): Promise<boolean> {
409
+ try {
410
+ const value = JSON.parse(await readFile(lock.ownerPath, "utf8")) as {
411
+ ownerToken?: unknown;
412
+ };
413
+ return value.ownerToken === lock.ownerToken;
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
418
+
419
+ async function recoverStaleTrajectoryImportOwner(
420
+ paths: TrajectoryImportSourcePaths,
421
+ staleAfterMs: number,
422
+ ): Promise<boolean> {
423
+ const observed = await lstat(paths.lockOwnerPath).catch(() => null);
424
+ if (observed === null || !isStale(observed, staleAfterMs)) return observed === null;
425
+
426
+ const recoveryToken = randomUUID();
427
+ let recoveryHandle: FileHandle;
428
+ try {
429
+ recoveryHandle = await createPrivateExclusiveFile(
430
+ paths.lockRecoveryPath,
431
+ `${stableTrajectoryImportJson({
432
+ schemaVersion: 1,
433
+ kind: "trajectory-import-lock-recovery",
434
+ ownerToken: recoveryToken,
435
+ createdAt: new Date().toISOString(),
436
+ pid: process.pid,
437
+ })}\n`,
438
+ );
439
+ } catch (error) {
440
+ if (hasErrorCode(error, "EEXIST")) return false;
441
+ throw error;
442
+ }
443
+
444
+ try {
445
+ const current = await lstat(paths.lockOwnerPath).catch(() => null);
446
+ if (
447
+ current === null ||
448
+ !sameFileIdentity(observed, current) ||
449
+ !isStale(current, staleAfterMs)
450
+ ) {
451
+ return current === null;
452
+ }
453
+ const quarantinePath = join(
454
+ paths.lockDir,
455
+ `.owner.stale-${recoveryToken}-${randomUUID()}.json`,
456
+ );
457
+ try {
458
+ await rename(paths.lockOwnerPath, quarantinePath);
459
+ } catch (error) {
460
+ if (hasErrorCode(error, "ENOENT")) return true;
461
+ throw error;
462
+ }
463
+ const moved = await lstat(quarantinePath);
464
+ if (!sameFileIdentity(current, moved)) {
465
+ await restoreQuarantinedLock(quarantinePath, paths.lockOwnerPath);
466
+ return false;
467
+ }
468
+ await rm(quarantinePath, { force: true });
469
+ return true;
470
+ } finally {
471
+ await recoveryHandle.close().catch(() => undefined);
472
+ await removeOwnedControlFile(paths.lockRecoveryPath, recoveryToken);
473
+ }
474
+ }
475
+
476
+ async function recoverStaleControlFile(path: string, staleAfterMs: number): Promise<void> {
477
+ const observed = await lstat(path).catch(() => null);
478
+ if (observed === null || !isStale(observed, staleAfterMs)) return;
479
+ const quarantinePath = `${path}.stale-${randomUUID()}`;
480
+ try {
481
+ await rename(path, quarantinePath);
482
+ } catch (error) {
483
+ if (hasErrorCode(error, "ENOENT")) return;
484
+ throw error;
485
+ }
486
+ const moved = await lstat(quarantinePath);
487
+ if (sameFileIdentity(observed, moved)) {
488
+ await rm(quarantinePath, { force: true });
489
+ } else {
490
+ await restoreQuarantinedLock(quarantinePath, path);
491
+ }
492
+ }
493
+
494
+ async function restoreQuarantinedLock(quarantinePath: string, targetPath: string): Promise<void> {
495
+ try {
496
+ await link(quarantinePath, targetPath);
497
+ } catch (error) {
498
+ if (!hasErrorCode(error, "EEXIST")) throw error;
499
+ } finally {
500
+ await rm(quarantinePath, { force: true });
501
+ }
502
+ }
503
+
504
+ async function removeOwnedControlFile(path: string, ownerToken: string): Promise<void> {
505
+ try {
506
+ const value = JSON.parse(await readFile(path, "utf8")) as { ownerToken?: unknown };
507
+ if (value.ownerToken === ownerToken) await rm(path, { force: true });
508
+ } catch {
509
+ // A missing or replaced recovery claim is not ours to remove.
510
+ }
511
+ }
512
+
513
+ async function waitForTrajectoryImportLock(deadline: number, retryMs: number): Promise<void> {
514
+ if (Date.now() >= deadline) {
515
+ throw new Error("Trajectory import source lock is busy.");
516
+ }
517
+ await new Promise<void>((resolvePromise) => setTimeout(resolvePromise, retryMs));
518
+ }
519
+
520
+ async function writePrivateImmutableFile(path: string, content: string): Promise<boolean> {
521
+ await ensurePrivateTrajectoryImportDirectory(dirname(path));
522
+ const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
523
+ let handle: FileHandle | null = null;
524
+ try {
525
+ handle = await createPrivateExclusiveFile(temporaryPath, content);
526
+ await handle.close();
527
+ handle = null;
528
+ try {
529
+ await link(temporaryPath, path);
530
+ await chmod(path, PRIVATE_FILE_MODE);
531
+ return true;
532
+ } catch (error) {
533
+ if (!hasErrorCode(error, "EEXIST")) throw error;
534
+ await assertRegularStateFile(path);
535
+ const existing = await readFile(path, "utf8");
536
+ if (existing !== content) {
537
+ throw new Error("Trajectory import immutable state digest conflict.");
538
+ }
539
+ await chmod(path, PRIVATE_FILE_MODE);
540
+ return false;
541
+ }
542
+ } finally {
543
+ await handle?.close().catch(() => undefined);
544
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
545
+ }
546
+ }
547
+
548
+ async function writePrivateAtomicFile(path: string, content: string): Promise<void> {
549
+ await ensurePrivateTrajectoryImportDirectory(dirname(path));
550
+ const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
551
+ let handle: FileHandle | null = null;
552
+ try {
553
+ handle = await createPrivateExclusiveFile(temporaryPath, content);
554
+ await handle.close();
555
+ handle = null;
556
+ await rename(temporaryPath, path);
557
+ await chmod(path, PRIVATE_FILE_MODE);
558
+ } finally {
559
+ await handle?.close().catch(() => undefined);
560
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
561
+ }
562
+ }
563
+
564
+ async function createPrivateExclusiveFile(path: string, content: string): Promise<FileHandle> {
565
+ const handle = await open(path, "wx", PRIVATE_FILE_MODE);
566
+ try {
567
+ await handle.writeFile(content, "utf8");
568
+ await handle.sync();
569
+ await chmod(path, PRIVATE_FILE_MODE);
570
+ return handle;
571
+ } catch (error) {
572
+ await handle.close().catch(() => undefined);
573
+ await rm(path, { force: true }).catch(() => undefined);
574
+ throw error;
575
+ }
576
+ }
577
+
578
+ async function readOptionalJson<T>(path: string, parser: (value: unknown) => T): Promise<T | null> {
579
+ try {
580
+ await assertRegularStateFile(path);
581
+ const content = await readFile(path, "utf8");
582
+ return parser(JSON.parse(content));
583
+ } catch (error) {
584
+ if (hasErrorCode(error, "ENOENT")) return null;
585
+ if (error instanceof SyntaxError) {
586
+ throw new Error("Trajectory import state contains invalid JSON.");
587
+ }
588
+ throw error;
589
+ }
590
+ }
591
+
592
+ async function assertRegularStateFile(path: string): Promise<void> {
593
+ const current = await lstat(path);
594
+ if (current.isSymbolicLink() || !current.isFile()) {
595
+ throw new Error("Trajectory import state path must be a regular file.");
596
+ }
597
+ }
598
+
599
+ function parseTrajectoryImportManifest(value: unknown): TrajectoryImportManifestV1 {
600
+ const record = expectRecord(value, "manifest");
601
+ const source = expectString(record.source, "manifest.source");
602
+ if (!SOURCES.has(source as TrajectoryImportSource)) {
603
+ throw new Error("Invalid trajectory import manifest.source.");
604
+ }
605
+ const applyState = expectString(record.applyState, "manifest.applyState");
606
+ if (!["staged", "materializing", "completed"].includes(applyState)) {
607
+ throw new Error("Invalid trajectory import manifest.applyState.");
608
+ }
609
+ const scope = expectRecord(record.scope, "manifest.scope");
610
+ return {
611
+ schemaVersion: expectLiteral(record.schemaVersion, 1, "manifest.schemaVersion"),
612
+ kind: expectLiteral(record.kind, "trajectory-import-manifest", "manifest.kind"),
613
+ source: source as TrajectoryImportSource,
614
+ sourceKey: expectStateId(record.sourceKey, "src", "manifest.sourceKey"),
615
+ projectKey: expectScopeId(record.projectKey, "manifest.projectKey"),
616
+ scope: {
617
+ roleId: expectNullableScopeId(scope.roleId, "manifest.scope.roleId"),
618
+ },
619
+ currentGenerationId: expectStateId(
620
+ record.currentGenerationId,
621
+ "gen",
622
+ "manifest.currentGenerationId",
623
+ ),
624
+ initialInputFingerprint: expectNonEmptyString(
625
+ record.initialInputFingerprint,
626
+ "manifest.initialInputFingerprint",
627
+ ),
628
+ generationContract: parseGenerationContract(record.generationContract),
629
+ currentSnapshotId: expectStateId(
630
+ record.currentSnapshotId,
631
+ "snap",
632
+ "manifest.currentSnapshotId",
633
+ ),
634
+ previousSnapshotId:
635
+ record.previousSnapshotId === null || record.previousSnapshotId === undefined
636
+ ? null
637
+ : expectStateId(record.previousSnapshotId, "snap", "manifest.previousSnapshotId"),
638
+ currentPreviewId: expectStateId(
639
+ record.currentPreviewId,
640
+ "preview",
641
+ "manifest.currentPreviewId",
642
+ ),
643
+ currentInputFingerprint: expectNonEmptyString(
644
+ record.currentInputFingerprint,
645
+ "manifest.currentInputFingerprint",
646
+ ),
647
+ currentInputBytes: expectNonNegativeInteger(
648
+ record.currentInputBytes,
649
+ "manifest.currentInputBytes",
650
+ ),
651
+ currentIndexDigest: expectDigest(record.currentIndexDigest, "manifest.currentIndexDigest"),
652
+ stagedAt: expectIsoTimestamp(record.stagedAt, "manifest.stagedAt"),
653
+ applyState: applyState as TrajectoryImportManifestV1["applyState"],
654
+ latestMaterializedSnapshotId:
655
+ record.latestMaterializedSnapshotId === null
656
+ ? null
657
+ : expectStateId(
658
+ record.latestMaterializedSnapshotId,
659
+ "snap",
660
+ "manifest.latestMaterializedSnapshotId",
661
+ ),
662
+ rawContentStored: expectLiteral(record.rawContentStored, false, "manifest.rawContentStored"),
663
+ };
664
+ }
665
+
666
+ function parseTrajectoryImportReceipt(value: unknown): TrajectoryImportReceiptV1 {
667
+ const record = expectRecord(value, "receipt");
668
+ const operation = expectString(record.operation, "receipt.operation");
669
+ const outcome = expectString(record.outcome, "receipt.outcome");
670
+ if (!["stage", "materialize"].includes(operation)) {
671
+ throw new Error("Invalid trajectory import receipt.operation.");
672
+ }
673
+ if (!["staged", "completed"].includes(outcome)) {
674
+ throw new Error("Invalid trajectory import receipt.outcome.");
675
+ }
676
+ const counts = expectRecord(record.counts, "receipt.counts");
677
+ return {
678
+ schemaVersion: expectLiteral(record.schemaVersion, 1, "receipt.schemaVersion"),
679
+ kind: expectLiteral(record.kind, "trajectory-import-receipt", "receipt.kind"),
680
+ previewId: expectStateId(record.previewId, "preview", "receipt.previewId"),
681
+ sourceKey: expectStateId(record.sourceKey, "src", "receipt.sourceKey"),
682
+ generationId: expectStateId(record.generationId, "gen", "receipt.generationId"),
683
+ snapshotId: expectStateId(record.snapshotId, "snap", "receipt.snapshotId"),
684
+ operation: operation as TrajectoryImportReceiptV1["operation"],
685
+ outcome: outcome as TrajectoryImportReceiptV1["outcome"],
686
+ counts: {
687
+ total: expectNonNegativeInteger(counts.total, "receipt.counts.total"),
688
+ new: expectNonNegativeInteger(counts.new, "receipt.counts.new"),
689
+ unchanged: expectNonNegativeInteger(counts.unchanged, "receipt.counts.unchanged"),
690
+ changed: expectNonNegativeInteger(counts.changed, "receipt.counts.changed"),
691
+ missing: expectNonNegativeInteger(counts.missing, "receipt.counts.missing"),
692
+ },
693
+ createdAt: expectIsoTimestamp(record.createdAt, "receipt.createdAt"),
694
+ rawContentStored: expectLiteral(record.rawContentStored, false, "receipt.rawContentStored"),
695
+ };
696
+ }
697
+
698
+ function parseTrajectoryImportMaterializationProgress(
699
+ value: unknown,
700
+ ): TrajectoryImportMaterializationProgressV1 {
701
+ const record = expectRecord(value, "materializationProgress");
702
+ const progress: TrajectoryImportMaterializationProgressV1 = {
703
+ schemaVersion: expectLiteral(record.schemaVersion, 1, "materializationProgress.schemaVersion"),
704
+ kind: expectLiteral(
705
+ record.kind,
706
+ "trajectory-import-materialization-progress",
707
+ "materializationProgress.kind",
708
+ ),
709
+ previewId: expectStateId(record.previewId, "preview", "materializationProgress.previewId"),
710
+ sourceKey: expectStateId(record.sourceKey, "src", "materializationProgress.sourceKey"),
711
+ generationId: expectStateId(record.generationId, "gen", "materializationProgress.generationId"),
712
+ snapshotId: expectStateId(record.snapshotId, "snap", "materializationProgress.snapshotId"),
713
+ expectedSegmentIds: expectSafeIdArray(
714
+ record.expectedSegmentIds,
715
+ "materializationProgress.expectedSegmentIds",
716
+ ),
717
+ completedSegmentIds: expectSafeIdArray(
718
+ record.completedSegmentIds,
719
+ "materializationProgress.completedSegmentIds",
720
+ ),
721
+ expectedTriggerIds: expectSafeIdArray(
722
+ record.expectedTriggerIds,
723
+ "materializationProgress.expectedTriggerIds",
724
+ ),
725
+ completedTriggerIds: expectSafeIdArray(
726
+ record.completedTriggerIds,
727
+ "materializationProgress.completedTriggerIds",
728
+ ),
729
+ updatedAt: expectIsoTimestamp(record.updatedAt, "materializationProgress.updatedAt"),
730
+ rawContentStored: expectLiteral(
731
+ record.rawContentStored,
732
+ false,
733
+ "materializationProgress.rawContentStored",
734
+ ),
735
+ };
736
+ assertCompletedIdsExpected(
737
+ progress.completedSegmentIds,
738
+ progress.expectedSegmentIds,
739
+ "materializationProgress.completedSegmentIds",
740
+ );
741
+ assertCompletedIdsExpected(
742
+ progress.completedTriggerIds,
743
+ progress.expectedTriggerIds,
744
+ "materializationProgress.completedTriggerIds",
745
+ );
746
+ return progress;
747
+ }
748
+
749
+ function assertCompletedIdsExpected(completed: string[], expected: string[], field: string): void {
750
+ const expectedIds = new Set(expected);
751
+ if (completed.some((id) => !expectedIds.has(id))) {
752
+ throw new Error(`Invalid trajectory import ${field}: unexpected completed id.`);
753
+ }
754
+ }
755
+
756
+ function parseTrajectoryImportIndexRecord(
757
+ value: unknown,
758
+ ordinal: number,
759
+ ): TrajectoryImportIndexRecordV1 {
760
+ const record = expectRecord(value, `index[${ordinal}]`);
761
+ const sourceIdentityKind = expectString(
762
+ record.sourceIdentityKind,
763
+ `index[${ordinal}].sourceIdentityKind`,
764
+ );
765
+ if (!IDENTITY_KINDS.has(sourceIdentityKind as CanonicalImportSourceIdentityKind)) {
766
+ throw new Error(`Invalid trajectory import index[${ordinal}].sourceIdentityKind.`);
767
+ }
768
+ const recordType = expectString(record.recordType, `index[${ordinal}].recordType`);
769
+ if (!RECORD_TYPES.has(recordType as CanonicalImportRecordType)) {
770
+ throw new Error(`Invalid trajectory import index[${ordinal}].recordType.`);
771
+ }
772
+ const storedOrdinal = expectNonNegativeInteger(record.ordinal, `index[${ordinal}].ordinal`);
773
+ if (storedOrdinal !== ordinal) {
774
+ throw new Error("Trajectory import index ordinals must be contiguous.");
775
+ }
776
+ return {
777
+ schemaVersion: expectLiteral(record.schemaVersion, 1, `index[${ordinal}].schemaVersion`),
778
+ kind: expectLiteral(record.kind, "trajectory-import-index-record", `index[${ordinal}].kind`),
779
+ stableRecordKeyHash: expectDigest(
780
+ record.stableRecordKeyHash,
781
+ `index[${ordinal}].stableRecordKeyHash`,
782
+ ),
783
+ contentDigest: expectDigest(record.contentDigest, `index[${ordinal}].contentDigest`),
784
+ ordinal: storedOrdinal,
785
+ sourceIdentityKind: sourceIdentityKind as CanonicalImportSourceIdentityKind,
786
+ recordType: recordType as CanonicalImportRecordType,
787
+ };
788
+ }
789
+
790
+ function validateTrajectoryImportIndexSet(records: TrajectoryImportIndexRecordV1[]): void {
791
+ const keys = new Set<string>();
792
+ for (const record of records) {
793
+ if (keys.has(record.stableRecordKeyHash)) {
794
+ throw new Error("Trajectory import index contains a duplicate record identity.");
795
+ }
796
+ keys.add(record.stableRecordKeyHash);
797
+ }
798
+ }
799
+
800
+ function parseGenerationContract(value: unknown): TrajectoryImportGenerationContractV1 {
801
+ const record = expectRecord(value, "generationContract");
802
+ return {
803
+ normalizerVersion: expectNonEmptyString(
804
+ record.normalizerVersion,
805
+ "generationContract.normalizerVersion",
806
+ ),
807
+ canonicalSchemaVersion: expectPositiveInteger(
808
+ record.canonicalSchemaVersion,
809
+ "generationContract.canonicalSchemaVersion",
810
+ ),
811
+ canonicalConfigHash: expectNonEmptyString(
812
+ record.canonicalConfigHash,
813
+ "generationContract.canonicalConfigHash",
814
+ ),
815
+ };
816
+ }
817
+
818
+ function expectRecord(value: unknown, field: string): Record<string, unknown> {
819
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
820
+ throw new Error(`Invalid trajectory import ${field}.`);
821
+ }
822
+ return value as Record<string, unknown>;
823
+ }
824
+
825
+ function expectString(value: unknown, field: string): string {
826
+ if (typeof value !== "string") throw new Error(`Invalid trajectory import ${field}.`);
827
+ return value;
828
+ }
829
+
830
+ function expectNonEmptyString(value: unknown, field: string): string {
831
+ const text = expectString(value, field);
832
+ if (text.trim() === "") throw new Error(`Invalid trajectory import ${field}.`);
833
+ return text;
834
+ }
835
+
836
+ function expectScopeId(value: unknown, field: string): string {
837
+ const text = expectString(value, field);
838
+ if (
839
+ text.length > 120 ||
840
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u.test(text) ||
841
+ text === "." ||
842
+ text === ".."
843
+ ) {
844
+ throw new Error(`Invalid trajectory import ${field}.`);
845
+ }
846
+ return text;
847
+ }
848
+
849
+ function expectNullableScopeId(value: unknown, field: string): string | null {
850
+ return value === null ? null : expectScopeId(value, field);
851
+ }
852
+
853
+ function expectStateId(value: unknown, prefix: string, field: string): string {
854
+ const text = expectString(value, field);
855
+ if (!new RegExp(`^${prefix}-[a-f0-9]{24}$`, "u").test(text)) {
856
+ throw new Error(`Invalid trajectory import ${field}.`);
857
+ }
858
+ return text;
859
+ }
860
+
861
+ function expectDigest(value: unknown, field: string): string {
862
+ const text = expectString(value, field);
863
+ if (!/^[a-f0-9]{64}$/u.test(text)) {
864
+ throw new Error(`Invalid trajectory import ${field}.`);
865
+ }
866
+ return text;
867
+ }
868
+
869
+ function expectSafeIdArray(value: unknown, field: string): string[] {
870
+ if (!Array.isArray(value)) throw new Error(`Invalid trajectory import ${field}.`);
871
+ const ids = value.map((item, index) => {
872
+ const text = expectString(item, `${field}[${index}]`);
873
+ if (!/^[a-z][a-z0-9-]{1,119}$/u.test(text)) {
874
+ throw new Error(`Invalid trajectory import ${field}[${index}].`);
875
+ }
876
+ return text;
877
+ });
878
+ if (new Set(ids).size !== ids.length) {
879
+ throw new Error(`Invalid trajectory import ${field}: duplicate id.`);
880
+ }
881
+ return ids;
882
+ }
883
+
884
+ function expectNonNegativeInteger(value: unknown, field: string): number {
885
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
886
+ throw new Error(`Invalid trajectory import ${field}.`);
887
+ }
888
+ return value as number;
889
+ }
890
+
891
+ function expectPositiveInteger(value: unknown, field: string): number {
892
+ const parsed = expectNonNegativeInteger(value, field);
893
+ if (parsed < 1) throw new Error(`Invalid trajectory import ${field}.`);
894
+ return parsed;
895
+ }
896
+
897
+ function expectIsoTimestamp(value: unknown, field: string): string {
898
+ const text = expectString(value, field);
899
+ if (!Number.isFinite(Date.parse(text))) {
900
+ throw new Error(`Invalid trajectory import ${field}.`);
901
+ }
902
+ return text;
903
+ }
904
+
905
+ function expectLiteral<T extends string | number | boolean>(
906
+ value: unknown,
907
+ expected: T,
908
+ field: string,
909
+ ): T {
910
+ if (value !== expected) throw new Error(`Invalid trajectory import ${field}.`);
911
+ return expected;
912
+ }
913
+
914
+ function normalizeDuration(
915
+ field: string,
916
+ value: number | undefined,
917
+ fallback: number,
918
+ allowZero: boolean,
919
+ ): number {
920
+ const resolved = value ?? fallback;
921
+ if (!Number.isSafeInteger(resolved) || (allowZero ? resolved < 0 : resolved < 1)) {
922
+ throw new Error(`Invalid trajectory import lock ${field}.`);
923
+ }
924
+ return resolved;
925
+ }
926
+
927
+ function sameFileIdentity(left: Stats, right: Stats): boolean {
928
+ return left.dev === right.dev && left.ino === right.ino;
929
+ }
930
+
931
+ function isStale(stats: Stats, staleAfterMs: number): boolean {
932
+ return Date.now() - stats.mtimeMs > staleAfterMs;
933
+ }
934
+
935
+ async function pathExists(path: string): Promise<boolean> {
936
+ try {
937
+ await lstat(path);
938
+ return true;
939
+ } catch (error) {
940
+ if (hasErrorCode(error, "ENOENT")) return false;
941
+ throw error;
942
+ }
943
+ }
944
+
945
+ function hasErrorCode(error: unknown, code: string): boolean {
946
+ return (
947
+ typeof error === "object" &&
948
+ error !== null &&
949
+ "code" in error &&
950
+ (error as { code?: unknown }).code === code
951
+ );
952
+ }