@anionex/dsh-turn-rewind 0.3.4 → 0.3.5

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/lib/engine.js CHANGED
@@ -7,6 +7,7 @@ import { ChangeLedgerError, errorMessage } from './errors.js';
7
7
  import { ensureGitWorktreeIdentity, resolveGitWorktreeRoot } from './git.js';
8
8
  import { captureGitTurnCheckpoint, deleteGitCheckpoint, deleteGitCheckpointRef, publishGitCheckpoint, readGitBlob, verifyGitCheckpoint, } from './git-checkpoint.js';
9
9
  import { assertSafeParents, ensureSafeParents, expandHome, isNodeError, isWithin, pruneEmptyParents, removeRestoreTarget, replaceRegularFile, replaceSymbolicLink, resolveWorkspacePath, validateRelativePath, } from './path-utils.js';
10
+ import { readPathCache, writePathCache } from './path-cache.js';
10
11
  import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js';
11
12
  import { LedgerStore } from './store.js';
12
13
  import { discoverWorkspace, discoverWorkspaceIdentity, sameWorkspaceFence } from './workspace.js';
@@ -15,7 +16,7 @@ const DEFAULTS = {
15
16
  maxRestorePoints: 50,
16
17
  maxTurnCheckpointsPerSession: 30,
17
18
  maxFiles: 20_000,
18
- maxFileBytes: 16 * 1024 * 1024,
19
+ maxFileBytes: 64 * 1024 * 1024,
19
20
  maxSnapshotBytes: 512 * 1024 * 1024,
20
21
  planTtlMs: 15 * 60 * 1_000,
21
22
  staleLockMs: 30_000,
@@ -66,6 +67,21 @@ export class ChangeLedgerEngine {
66
67
  * A Git worktree also binds the shared per-worktree lock derived from its Git
67
68
  * identity; an ordinary directory owns only the durable directory lock.
68
69
  */
70
+ /** Per-workspace path identity caches, loaded once per process generation. */
71
+ pathCaches = new Map();
72
+ /**
73
+ * Load (once) the identity cache that lets a capture reuse unchanged files.
74
+ * @param workspaceDir - durable directory of one workspace.
75
+ * @returns the mutable cache shared by every capture of this workspace.
76
+ */
77
+ async pathCacheFor(workspaceDir) {
78
+ const loaded = this.pathCaches.get(workspaceDir);
79
+ if (loaded !== undefined)
80
+ return loaded;
81
+ const cache = await readPathCache(workspaceDir);
82
+ this.pathCaches.set(workspaceDir, cache);
83
+ return cache;
84
+ }
69
85
  async acquireWorkspace(workspace, signal) {
70
86
  if (workspace.type === 'directory') {
71
87
  return this.store.acquire(workspace.root, undefined, undefined, signal);
@@ -401,6 +417,8 @@ export class ChangeLedgerEngine {
401
417
  const current = await captureStableTree({
402
418
  cwd: source.state.root,
403
419
  config: this.config,
420
+ store: this.store,
421
+ pathCache: await this.pathCacheFor(this.store.workspaceDirectory(source.state.root)),
404
422
  ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
405
423
  ...(options.signal === undefined ? {} : { signal: options.signal }),
406
424
  });
@@ -439,6 +457,8 @@ export class ChangeLedgerEngine {
439
457
  const current = await captureStableTree({
440
458
  cwd: source.state.root,
441
459
  config: this.config,
460
+ store: this.store,
461
+ pathCache: await this.pathCacheFor(this.store.workspaceDirectory(source.state.root)),
442
462
  ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
443
463
  ...(options.signal === undefined ? {} : { signal: options.signal }),
444
464
  });
@@ -799,12 +819,16 @@ export class ChangeLedgerEngine {
799
819
  await this.store.writeSnapshotCleanup(options.cwd);
800
820
  }
801
821
  try {
822
+ const workspaceDir = this.store.workspaceDirectory(options.cwd);
823
+ const pathCache = await this.pathCacheFor(workspaceDir);
802
824
  const tree = await captureStableTree({
803
825
  cwd: options.cwd,
804
826
  config: this.config,
805
827
  store: this.store,
828
+ pathCache,
806
829
  ...(options.signal === undefined ? {} : { signal: options.signal }),
807
830
  });
831
+ await writePathCache(workspaceDir, pathCache).catch(() => undefined);
808
832
  const manifest = {
809
833
  version: LEDGER_FORMAT_VERSION,
810
834
  id: makeId('rp'),
@@ -0,0 +1,137 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isNodeError, pathExists, writeJsonAtomic } from './path-utils.js';
4
+ const CACHE_FILE = 'path-cache.json';
5
+ const CACHE_VERSION = 1;
6
+ const MAX_ENTRIES = 200_000;
7
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
8
+ /** Stable identity string for one observed filesystem object. */
9
+ export function pathCacheKey(info) {
10
+ return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}:${info.mode.toString()}`;
11
+ }
12
+ /** Remember what one unknown does not already describe. */
13
+ export function toPathCacheEntry(info, entry) {
14
+ return {
15
+ dev: info.dev.toString(),
16
+ ino: info.ino.toString(),
17
+ size: info.size.toString(),
18
+ mtimeNs: info.mtimeNs.toString(),
19
+ ctimeNs: info.ctimeNs.toString(),
20
+ mode: Number(info.mode & 511n),
21
+ entry,
22
+ };
23
+ }
24
+ /** Whether a cached record still describes the object the filesystem reports now. */
25
+ export function matchesPathCache(record, info) {
26
+ return record.dev === info.dev.toString()
27
+ && record.ino === info.ino.toString()
28
+ && record.size === info.size.toString()
29
+ && record.mtimeNs === info.mtimeNs.toString()
30
+ && record.ctimeNs === info.ctimeNs.toString()
31
+ && record.mode === Number(info.mode & 511n);
32
+ }
33
+ /**
34
+ * Read the per-workspace path cache.
35
+ *
36
+ * The cache is a pure optimization: any malformed or unreadable record is
37
+ * discarded instead of trusted, and a discarded cache only costs a cold capture.
38
+ * @param workspaceDir - durable directory of one workspace.
39
+ * @returns the cached records, newest read wins.
40
+ */
41
+ export async function readPathCache(workspaceDir) {
42
+ const cache = new Map();
43
+ let raw;
44
+ try {
45
+ raw = await readFile(join(workspaceDir, CACHE_FILE), 'utf8');
46
+ }
47
+ catch (error) {
48
+ if (isNodeError(error, 'ENOENT'))
49
+ return cache;
50
+ return cache;
51
+ }
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
55
+ return cache;
56
+ const record = parsed;
57
+ if (record.version !== CACHE_VERSION || record.paths === null || typeof record.paths !== 'object')
58
+ return cache;
59
+ for (const [path, value] of Object.entries(record.paths)) {
60
+ const entry = parseCacheEntry(value);
61
+ if (entry === undefined)
62
+ continue;
63
+ cache.set(path, entry);
64
+ if (cache.size >= MAX_ENTRIES)
65
+ break;
66
+ }
67
+ }
68
+ catch {
69
+ return new Map();
70
+ }
71
+ return cache;
72
+ }
73
+ /** Persist the path cache atomically, replacing any previous generation. */
74
+ export async function writePathCache(workspaceDir, cache) {
75
+ const paths = Object.create(null);
76
+ let count = 0;
77
+ for (const [path, entry] of cache) {
78
+ if (count >= MAX_ENTRIES)
79
+ break;
80
+ paths[path] = serializeCacheEntry(entry);
81
+ count += 1;
82
+ }
83
+ await writeJsonAtomic(join(workspaceDir, CACHE_FILE), { version: CACHE_VERSION, paths });
84
+ }
85
+ /** Drop the cache when its file is unreadable or was never written. */
86
+ export async function pathCacheExists(workspaceDir) {
87
+ return pathExists(join(workspaceDir, CACHE_FILE));
88
+ }
89
+ function serializeCacheEntry(record) {
90
+ const entry = record.entry;
91
+ return {
92
+ dev: record.dev, ino: record.ino, size: record.size,
93
+ mtimeNs: record.mtimeNs, ctimeNs: record.ctimeNs, mode: record.mode,
94
+ kind: entry.kind,
95
+ ...(entry.kind === 'file' ? { blob: entry.blob, provider: entry.provider } : { target: entry.target }),
96
+ };
97
+ }
98
+ function parseCacheEntry(value) {
99
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
100
+ return undefined;
101
+ const record = value;
102
+ const text = (key) => (typeof record[key] === 'string' ? record[key] : undefined);
103
+ const dev = text('dev');
104
+ const ino = text('ino');
105
+ const size = text('size');
106
+ const mtimeNs = text('mtimeNs');
107
+ const ctimeNs = text('ctimeNs');
108
+ if (dev === undefined || ino === undefined || size === undefined || mtimeNs === undefined || ctimeNs === undefined)
109
+ return undefined;
110
+ if (typeof record.mode !== 'number' || !Number.isInteger(record.mode))
111
+ return undefined;
112
+ let entry;
113
+ if (record.kind === 'file') {
114
+ const blob = text('blob');
115
+ if (blob === undefined || !/^[0-9a-f]{64}$/.test(blob))
116
+ return undefined;
117
+ const provider = record.provider;
118
+ if (provider !== undefined && provider !== 'git')
119
+ return undefined;
120
+ entry = {
121
+ kind: 'file', blob, size: Number(size), mode: record.mode,
122
+ ...(provider === undefined ? {} : { provider }),
123
+ };
124
+ }
125
+ else if (record.kind === 'symlink') {
126
+ const target = text('target');
127
+ if (target === undefined || target.includes('\0'))
128
+ return undefined;
129
+ entry = { kind: 'symlink', target, mode: record.mode };
130
+ }
131
+ else {
132
+ return undefined;
133
+ }
134
+ if (BigInt(size) > BigInt(MAX_FILE_BYTES))
135
+ return undefined;
136
+ return { dev, ino, size, mtimeNs, ctimeNs, mode: record.mode, entry };
137
+ }
package/lib/snapshot.js CHANGED
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
3
3
  import { lstat, open, readlink } from 'node:fs/promises';
4
4
  import { ChangeLedgerError } from './errors.js';
5
5
  import { isNodeError, resolveWorkspacePath } from './path-utils.js';
6
+ import { matchesPathCache, toPathCacheEntry } from './path-cache.js';
6
7
  import { discoverWorkspace, sameWorkspaceFence } from './workspace.js';
7
8
  const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024;
8
9
  const MAX_RECORDED_SKIPS = 50;
@@ -42,6 +43,31 @@ export async function captureTree(options) {
42
43
  let budgetSpent = false;
43
44
  const capturePath = async (path) => {
44
45
  throwIfAborted(options.signal);
46
+ // Reuse is only sound when the identity is unchanged and the stored blob is
47
+ // still present; a garbage-collected blob falls back to a fresh read.
48
+ if (options.pathCache !== undefined && options.store !== undefined && gitCapture === undefined) {
49
+ const cached = options.pathCache.get(path);
50
+ if (cached !== undefined) {
51
+ let info;
52
+ try {
53
+ info = await lstat(resolveWorkspacePath(source.state.root, path), { bigint: true });
54
+ }
55
+ catch (error) {
56
+ if (!isNodeError(error, 'ENOENT'))
57
+ throw error;
58
+ }
59
+ if (info !== undefined && matchesPathCache(cached, info)) {
60
+ const reusable = cached.entry.kind === 'symlink'
61
+ || await options.store.hasBlob(source.state.root, cached.entry.blob);
62
+ if (reusable) {
63
+ entries[path] = cached.entry;
64
+ if (cached.entry.kind === 'file')
65
+ totalBytes += cached.entry.size;
66
+ return;
67
+ }
68
+ }
69
+ }
70
+ }
45
71
  if (budgetSpent) {
46
72
  // The aggregate budget is gone, but the path is still recorded so a
47
73
  // restore can prove this point never observed it.
@@ -85,6 +111,9 @@ export async function captureTree(options) {
85
111
  entries[path] = entry.snapshot;
86
112
  if (gitCapture !== undefined)
87
113
  gitCapture.entries[path] = entry.snapshot;
114
+ if (options.pathCache !== undefined && gitCapture === undefined) {
115
+ options.pathCache.set(path, toPathCacheEntry(entry.info, entry.snapshot));
116
+ }
88
117
  };
89
118
  {
90
119
  // Reading one file at a time makes a real project directory take minutes, so
@@ -139,6 +168,8 @@ export async function captureStableTree(options) {
139
168
  const first = await captureTree({
140
169
  cwd: options.cwd,
141
170
  config: options.config,
171
+ ...(options.store === undefined ? {} : { store: options.store }),
172
+ ...(options.pathCache === undefined ? {} : { pathCache: options.pathCache }),
142
173
  ...(options.gitObjectFormat === undefined ? {} : { gitObjectFormat: options.gitObjectFormat }),
143
174
  ...(options.signal === undefined ? {} : { signal: options.signal }),
144
175
  });
@@ -263,7 +294,7 @@ async function captureEntry(root, path, maxFileBytes, signal) {
263
294
  const after = await lstat(target, { bigint: true });
264
295
  if (!sameStat(before, after))
265
296
  continue;
266
- return { kind: 'symlink', snapshot: { kind: 'symlink', target: linkTarget, mode } };
297
+ return { kind: 'symlink', snapshot: { kind: 'symlink', target: linkTarget, mode }, info: after };
267
298
  }
268
299
  if (!before.isFile()) {
269
300
  throw new ChangeLedgerError('UNSUPPORTED_FILE_TYPE', `eligible path is not a regular file or symlink: ${JSON.stringify(path)}`);
@@ -299,6 +330,7 @@ async function captureEntry(root, path, maxFileBytes, signal) {
299
330
  kind: 'file',
300
331
  snapshot: { kind: 'file', blob, size: content.length, mode },
301
332
  content,
333
+ info: { ...before, size: BigInt(content.length) },
302
334
  };
303
335
  }
304
336
  throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`);
package/lib/store.js CHANGED
@@ -173,6 +173,15 @@ export class LedgerStore {
173
173
  throw new ChangeLedgerError('WORKSPACE_LOCKED', `could not acquire change-ledger lock for ${JSON.stringify(workspace)}`);
174
174
  }
175
175
  /** Persist a blob if it is not already present, and verify existing content. */
176
+ /** Whether one content-addressed blob is already stored for a workspace. */
177
+ async hasBlob(workspace, hash) {
178
+ validateBlobHash(hash);
179
+ return pathExists(this.blobPath(workspace, hash));
180
+ }
181
+ /** Durable directory holding one workspace's manifests, blobs, and caches. */
182
+ workspaceDirectory(workspace) {
183
+ return this.workspaceDir(workspace);
184
+ }
176
185
  async putBlob(workspace, hash, content) {
177
186
  validateBlobHash(hash);
178
187
  const contentHash = createHash('sha256').update(content).digest('hex');
@@ -25,6 +25,14 @@ export declare class ChangeLedgerEngine {
25
25
  * A Git worktree also binds the shared per-worktree lock derived from its Git
26
26
  * identity; an ordinary directory owns only the durable directory lock.
27
27
  */
28
+ /** Per-workspace path identity caches, loaded once per process generation. */
29
+ private readonly pathCaches;
30
+ /**
31
+ * Load (once) the identity cache that lets a capture reuse unchanged files.
32
+ * @param workspaceDir - durable directory of one workspace.
33
+ * @returns the mutable cache shared by every capture of this workspace.
34
+ */
35
+ private pathCacheFor;
28
36
  private acquireWorkspace;
29
37
  private reconcileGitCheckpointJournals;
30
38
  private publishGitManifest;
@@ -0,0 +1,36 @@
1
+ import type { BigIntStats } from 'node:fs';
2
+ import type { SnapshotEntry } from './types.js';
3
+ /**
4
+ * One observed path, keyed by the filesystem identity that proves it is unchanged.
5
+ *
6
+ * `ctimeNs` changes on every write even when size and mtime are restored, so a
7
+ * matching key means the stored content is still exactly what the path holds.
8
+ */
9
+ export interface PathCacheEntry {
10
+ readonly dev: string;
11
+ readonly ino: string;
12
+ readonly size: string;
13
+ readonly mtimeNs: string;
14
+ readonly ctimeNs: string;
15
+ readonly mode: number;
16
+ readonly entry: SnapshotEntry;
17
+ }
18
+ /** Stable identity string for one observed filesystem object. */
19
+ export declare function pathCacheKey(info: BigIntStats): string;
20
+ /** Remember what one unknown does not already describe. */
21
+ export declare function toPathCacheEntry(info: BigIntStats, entry: SnapshotEntry): PathCacheEntry;
22
+ /** Whether a cached record still describes the object the filesystem reports now. */
23
+ export declare function matchesPathCache(record: PathCacheEntry, info: BigIntStats): boolean;
24
+ /**
25
+ * Read the per-workspace path cache.
26
+ *
27
+ * The cache is a pure optimization: any malformed or unreadable record is
28
+ * discarded instead of trusted, and a discarded cache only costs a cold capture.
29
+ * @param workspaceDir - durable directory of one workspace.
30
+ * @returns the cached records, newest read wins.
31
+ */
32
+ export declare function readPathCache(workspaceDir: string): Promise<Map<string, PathCacheEntry>>;
33
+ /** Persist the path cache atomically, replacing any previous generation. */
34
+ export declare function writePathCache(workspaceDir: string, cache: ReadonlyMap<string, PathCacheEntry>): Promise<void>;
35
+ /** Drop the cache when its file is unreadable or was never written. */
36
+ export declare function pathCacheExists(workspaceDir: string): Promise<boolean>;
@@ -1,3 +1,4 @@
1
+ import { type PathCacheEntry } from './path-cache.js';
1
2
  import type { LedgerStore } from './store.js';
2
3
  import { type WorkspaceSnapshotSource } from './workspace.js';
3
4
  import type { CaptureSkip, CaptureTruncation, ResolvedChangeLedgerConfig, SnapshotEntry, WorkspaceChange } from './types.js';
@@ -23,6 +24,8 @@ export declare function captureTree(options: {
23
24
  readonly config: ResolvedChangeLedgerConfig;
24
25
  readonly store?: LedgerStore;
25
26
  readonly gitObjectFormat?: 'sha1' | 'sha256';
27
+ /** Per-path identity cache: an unchanged path is reused instead of re-read. */
28
+ readonly pathCache?: Map<string, PathCacheEntry>;
26
29
  readonly signal?: AbortSignal;
27
30
  }): Promise<CapturedTree>;
28
31
  /**
@@ -35,6 +38,8 @@ export declare function captureStableTree(options: {
35
38
  readonly config: ResolvedChangeLedgerConfig;
36
39
  readonly store?: LedgerStore;
37
40
  readonly gitObjectFormat?: 'sha1' | 'sha256';
41
+ /** Per-path identity cache: an unchanged path is reused instead of re-read. */
42
+ readonly pathCache?: Map<string, PathCacheEntry>;
38
43
  readonly signal?: AbortSignal;
39
44
  }): Promise<CapturedTree>;
40
45
  /** Compute stable path-level differences between two captured trees. */
@@ -39,6 +39,10 @@ export declare class LedgerStore {
39
39
  acquireWorkspaceDir(workspaceDir: string, workspace: string, signal?: AbortSignal): Promise<() => Promise<void>>;
40
40
  private acquireOne;
41
41
  /** Persist a blob if it is not already present, and verify existing content. */
42
+ /** Whether one content-addressed blob is already stored for a workspace. */
43
+ hasBlob(workspace: string, hash: string): Promise<boolean>;
44
+ /** Durable directory holding one workspace's manifests, blobs, and caches. */
45
+ workspaceDirectory(workspace: string): string;
42
46
  putBlob(workspace: string, hash: string, content: Buffer): Promise<void>;
43
47
  /** Read and verify one content-addressed blob. */
44
48
  readBlob(workspace: string, hash: string): Promise<Buffer>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anionex/dsh-turn-rewind",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Turn-level conversation and workspace rewind for DeepSeek Harness, powered by a persistent Change Ledger",
5
5
  "repository": {
6
6
  "type": "git",
package/src/engine.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  resolveWorkspacePath,
27
27
  validateRelativePath,
28
28
  } from './path-utils.js'
29
+ import { readPathCache, writePathCache, type PathCacheEntry } from './path-cache.js'
29
30
  import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js'
30
31
  import { LedgerStore, type GitCheckpointJournal } from './store.js'
31
32
  import { discoverWorkspace, discoverWorkspaceIdentity, sameWorkspaceFence } from './workspace.js'
@@ -53,7 +54,7 @@ const DEFAULTS = {
53
54
  maxRestorePoints: 50,
54
55
  maxTurnCheckpointsPerSession: 30,
55
56
  maxFiles: 20_000,
56
- maxFileBytes: 16 * 1024 * 1024,
57
+ maxFileBytes: 64 * 1024 * 1024,
57
58
  maxSnapshotBytes: 512 * 1024 * 1024,
58
59
  planTtlMs: 15 * 60 * 1_000,
59
60
  staleLockMs: 30_000,
@@ -111,6 +112,22 @@ export class ChangeLedgerEngine {
111
112
  * A Git worktree also binds the shared per-worktree lock derived from its Git
112
113
  * identity; an ordinary directory owns only the durable directory lock.
113
114
  */
115
+ /** Per-workspace path identity caches, loaded once per process generation. */
116
+ private readonly pathCaches = new Map<string, Map<string, PathCacheEntry>>()
117
+
118
+ /**
119
+ * Load (once) the identity cache that lets a capture reuse unchanged files.
120
+ * @param workspaceDir - durable directory of one workspace.
121
+ * @returns the mutable cache shared by every capture of this workspace.
122
+ */
123
+ private async pathCacheFor(workspaceDir: string): Promise<Map<string, PathCacheEntry>> {
124
+ const loaded = this.pathCaches.get(workspaceDir)
125
+ if (loaded !== undefined) return loaded
126
+ const cache = await readPathCache(workspaceDir)
127
+ this.pathCaches.set(workspaceDir, cache)
128
+ return cache
129
+ }
130
+
114
131
  private async acquireWorkspace(
115
132
  workspace: Pick<WorkspaceState, 'type' | 'root'>,
116
133
  signal?: AbortSignal,
@@ -496,6 +513,8 @@ export class ChangeLedgerEngine {
496
513
  const current = await captureStableTree({
497
514
  cwd: source.state.root,
498
515
  config: this.config,
516
+ store: this.store,
517
+ pathCache: await this.pathCacheFor(this.store.workspaceDirectory(source.state.root)),
499
518
  ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
500
519
  ...(options.signal === undefined ? {} : { signal: options.signal }),
501
520
  })
@@ -541,6 +560,8 @@ export class ChangeLedgerEngine {
541
560
  const current = await captureStableTree({
542
561
  cwd: source.state.root,
543
562
  config: this.config,
563
+ store: this.store,
564
+ pathCache: await this.pathCacheFor(this.store.workspaceDirectory(source.state.root)),
544
565
  ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
545
566
  ...(options.signal === undefined ? {} : { signal: options.signal }),
546
567
  })
@@ -948,12 +969,16 @@ export class ChangeLedgerEngine {
948
969
  await this.store.writeSnapshotCleanup(options.cwd)
949
970
  }
950
971
  try {
972
+ const workspaceDir = this.store.workspaceDirectory(options.cwd)
973
+ const pathCache = await this.pathCacheFor(workspaceDir)
951
974
  const tree = await captureStableTree({
952
975
  cwd: options.cwd,
953
976
  config: this.config,
954
977
  store: this.store,
978
+ pathCache,
955
979
  ...(options.signal === undefined ? {} : { signal: options.signal }),
956
980
  })
981
+ await writePathCache(workspaceDir, pathCache).catch(() => undefined)
957
982
  const manifest: RestorePointManifest = {
958
983
  version: LEDGER_FORMAT_VERSION,
959
984
  id: makeId('rp'),
@@ -0,0 +1,144 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { BigIntStats } from 'node:fs'
4
+ import { isNodeError, pathExists, writeJsonAtomic } from './path-utils.js'
5
+ import type { SnapshotEntry } from './types.js'
6
+
7
+ const CACHE_FILE = 'path-cache.json'
8
+ const CACHE_VERSION = 1
9
+ const MAX_ENTRIES = 200_000
10
+ const MAX_FILE_BYTES = 64 * 1024 * 1024
11
+
12
+ /**
13
+ * One observed path, keyed by the filesystem identity that proves it is unchanged.
14
+ *
15
+ * `ctimeNs` changes on every write even when size and mtime are restored, so a
16
+ * matching key means the stored content is still exactly what the path holds.
17
+ */
18
+ export interface PathCacheEntry {
19
+ readonly dev: string
20
+ readonly ino: string
21
+ readonly size: string
22
+ readonly mtimeNs: string
23
+ readonly ctimeNs: string
24
+ readonly mode: number
25
+ readonly entry: SnapshotEntry
26
+ }
27
+
28
+ /** Stable identity string for one observed filesystem object. */
29
+ export function pathCacheKey(info: BigIntStats): string {
30
+ return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}:${info.mode.toString()}`
31
+ }
32
+
33
+ /** Remember what one unknown does not already describe. */
34
+ export function toPathCacheEntry(info: BigIntStats, entry: SnapshotEntry): PathCacheEntry {
35
+ return {
36
+ dev: info.dev.toString(),
37
+ ino: info.ino.toString(),
38
+ size: info.size.toString(),
39
+ mtimeNs: info.mtimeNs.toString(),
40
+ ctimeNs: info.ctimeNs.toString(),
41
+ mode: Number(info.mode & 0o777n),
42
+ entry,
43
+ }
44
+ }
45
+
46
+ /** Whether a cached record still describes the object the filesystem reports now. */
47
+ export function matchesPathCache(record: PathCacheEntry, info: BigIntStats): boolean {
48
+ return record.dev === info.dev.toString()
49
+ && record.ino === info.ino.toString()
50
+ && record.size === info.size.toString()
51
+ && record.mtimeNs === info.mtimeNs.toString()
52
+ && record.ctimeNs === info.ctimeNs.toString()
53
+ && record.mode === Number(info.mode & 0o777n)
54
+ }
55
+
56
+ /**
57
+ * Read the per-workspace path cache.
58
+ *
59
+ * The cache is a pure optimization: any malformed or unreadable record is
60
+ * discarded instead of trusted, and a discarded cache only costs a cold capture.
61
+ * @param workspaceDir - durable directory of one workspace.
62
+ * @returns the cached records, newest read wins.
63
+ */
64
+ export async function readPathCache(workspaceDir: string): Promise<Map<string, PathCacheEntry>> {
65
+ const cache = new Map<string, PathCacheEntry>()
66
+ let raw: string
67
+ try {
68
+ raw = await readFile(join(workspaceDir, CACHE_FILE), 'utf8')
69
+ } catch (error) {
70
+ if (isNodeError(error, 'ENOENT')) return cache
71
+ return cache
72
+ }
73
+ try {
74
+ const parsed = JSON.parse(raw) as unknown
75
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return cache
76
+ const record = parsed as { version?: unknown; paths?: unknown }
77
+ if (record.version !== CACHE_VERSION || record.paths === null || typeof record.paths !== 'object') return cache
78
+ for (const [path, value] of Object.entries(record.paths as Record<string, unknown>)) {
79
+ const entry = parseCacheEntry(value)
80
+ if (entry === undefined) continue
81
+ cache.set(path, entry)
82
+ if (cache.size >= MAX_ENTRIES) break
83
+ }
84
+ } catch {
85
+ return new Map()
86
+ }
87
+ return cache
88
+ }
89
+
90
+ /** Persist the path cache atomically, replacing any previous generation. */
91
+ export async function writePathCache(workspaceDir: string, cache: ReadonlyMap<string, PathCacheEntry>): Promise<void> {
92
+ const paths: Record<string, unknown> = Object.create(null) as Record<string, unknown>
93
+ let count = 0
94
+ for (const [path, entry] of cache) {
95
+ if (count >= MAX_ENTRIES) break
96
+ paths[path] = serializeCacheEntry(entry)
97
+ count += 1
98
+ }
99
+ await writeJsonAtomic(join(workspaceDir, CACHE_FILE), { version: CACHE_VERSION, paths })
100
+ }
101
+
102
+ /** Drop the cache when its file is unreadable or was never written. */
103
+ export async function pathCacheExists(workspaceDir: string): Promise<boolean> {
104
+ return pathExists(join(workspaceDir, CACHE_FILE))
105
+ }
106
+
107
+ function serializeCacheEntry(record: PathCacheEntry): Record<string, unknown> {
108
+ const entry = record.entry
109
+ return {
110
+ dev: record.dev, ino: record.ino, size: record.size,
111
+ mtimeNs: record.mtimeNs, ctimeNs: record.ctimeNs, mode: record.mode,
112
+ kind: entry.kind,
113
+ ...(entry.kind === 'file' ? { blob: entry.blob, provider: entry.provider } : { target: entry.target }),
114
+ }
115
+ }
116
+
117
+ function parseCacheEntry(value: unknown): PathCacheEntry | undefined {
118
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
119
+ const record = value as Record<string, unknown>
120
+ const text = (key: string): string | undefined => (typeof record[key] === 'string' ? record[key] as string : undefined)
121
+ const dev = text('dev'); const ino = text('ino'); const size = text('size')
122
+ const mtimeNs = text('mtimeNs'); const ctimeNs = text('ctimeNs')
123
+ if (dev === undefined || ino === undefined || size === undefined || mtimeNs === undefined || ctimeNs === undefined) return undefined
124
+ if (typeof record.mode !== 'number' || !Number.isInteger(record.mode)) return undefined
125
+ let entry: SnapshotEntry
126
+ if (record.kind === 'file') {
127
+ const blob = text('blob')
128
+ if (blob === undefined || !/^[0-9a-f]{64}$/.test(blob)) return undefined
129
+ const provider = record.provider
130
+ if (provider !== undefined && provider !== 'git') return undefined
131
+ entry = {
132
+ kind: 'file', blob, size: Number(size), mode: record.mode,
133
+ ...(provider === undefined ? {} : { provider }),
134
+ }
135
+ } else if (record.kind === 'symlink') {
136
+ const target = text('target')
137
+ if (target === undefined || target.includes('\0')) return undefined
138
+ entry = { kind: 'symlink', target, mode: record.mode }
139
+ } else {
140
+ return undefined
141
+ }
142
+ if (BigInt(size) > BigInt(MAX_FILE_BYTES)) return undefined
143
+ return { dev, ino, size, mtimeNs, ctimeNs, mode: record.mode, entry }
144
+ }
package/src/snapshot.ts CHANGED
@@ -3,6 +3,7 @@ import { constants, type BigIntStats } from 'node:fs'
3
3
  import { lstat, open, readlink } from 'node:fs/promises'
4
4
  import { ChangeLedgerError } from './errors.js'
5
5
  import { isNodeError, resolveWorkspacePath } from './path-utils.js'
6
+ import { matchesPathCache, toPathCacheEntry, type PathCacheEntry } from './path-cache.js'
6
7
  import type { LedgerStore } from './store.js'
7
8
  import { discoverWorkspace, sameWorkspaceFence, type WorkspaceSnapshotSource } from './workspace.js'
8
9
  import type {
@@ -41,6 +42,8 @@ export async function captureTree(options: {
41
42
  readonly config: ResolvedChangeLedgerConfig
42
43
  readonly store?: LedgerStore
43
44
  readonly gitObjectFormat?: 'sha1' | 'sha256'
45
+ /** Per-path identity cache: an unchanged path is reused instead of re-read. */
46
+ readonly pathCache?: Map<string, PathCacheEntry>
44
47
  readonly signal?: AbortSignal
45
48
  }): Promise<CapturedTree> {
46
49
  throwIfAborted(options.signal)
@@ -75,6 +78,28 @@ export async function captureTree(options: {
75
78
  let budgetSpent = false
76
79
  const capturePath = async (path: string): Promise<void> => {
77
80
  throwIfAborted(options.signal)
81
+ // Reuse is only sound when the identity is unchanged and the stored blob is
82
+ // still present; a garbage-collected blob falls back to a fresh read.
83
+ if (options.pathCache !== undefined && options.store !== undefined && gitCapture === undefined) {
84
+ const cached = options.pathCache.get(path)
85
+ if (cached !== undefined) {
86
+ let info: BigIntStats | undefined
87
+ try {
88
+ info = await lstat(resolveWorkspacePath(source.state.root, path), { bigint: true })
89
+ } catch (error) {
90
+ if (!isNodeError(error, 'ENOENT')) throw error
91
+ }
92
+ if (info !== undefined && matchesPathCache(cached, info)) {
93
+ const reusable = cached.entry.kind === 'symlink'
94
+ || await options.store.hasBlob(source.state.root, cached.entry.blob)
95
+ if (reusable) {
96
+ entries[path] = cached.entry
97
+ if (cached.entry.kind === 'file') totalBytes += cached.entry.size
98
+ return
99
+ }
100
+ }
101
+ }
102
+ }
78
103
  if (budgetSpent) {
79
104
  // The aggregate budget is gone, but the path is still recorded so a
80
105
  // restore can prove this point never observed it.
@@ -115,6 +140,9 @@ export async function captureTree(options: {
115
140
  }
116
141
  entries[path] = entry.snapshot
117
142
  if (gitCapture !== undefined) gitCapture.entries[path] = entry.snapshot
143
+ if (options.pathCache !== undefined && gitCapture === undefined) {
144
+ options.pathCache.set(path, toPathCacheEntry(entry.info, entry.snapshot))
145
+ }
118
146
  }
119
147
  {
120
148
  // Reading one file at a time makes a real project directory take minutes, so
@@ -172,12 +200,16 @@ export async function captureStableTree(options: {
172
200
  readonly config: ResolvedChangeLedgerConfig
173
201
  readonly store?: LedgerStore
174
202
  readonly gitObjectFormat?: 'sha1' | 'sha256'
203
+ /** Per-path identity cache: an unchanged path is reused instead of re-read. */
204
+ readonly pathCache?: Map<string, PathCacheEntry>
175
205
  readonly signal?: AbortSignal
176
206
  }): Promise<CapturedTree> {
177
207
  for (let attempt = 0; attempt < 3; attempt += 1) {
178
208
  const first = await captureTree({
179
209
  cwd: options.cwd,
180
210
  config: options.config,
211
+ ...(options.store === undefined ? {} : { store: options.store }),
212
+ ...(options.pathCache === undefined ? {} : { pathCache: options.pathCache }),
181
213
  ...(options.gitObjectFormat === undefined ? {} : { gitObjectFormat: options.gitObjectFormat }),
182
214
  ...(options.signal === undefined ? {} : { signal: options.signal }),
183
215
  })
@@ -303,8 +335,8 @@ async function captureEntry(
303
335
  path: string,
304
336
  maxFileBytes: number,
305
337
  signal?: AbortSignal,
306
- ): Promise<{ readonly kind: 'file'; readonly snapshot: SnapshotEntry & { readonly kind: 'file' }; readonly content: Buffer }
307
- | { readonly kind: 'symlink'; readonly snapshot: SnapshotEntry & { readonly kind: 'symlink' } }
338
+ ): Promise<{ readonly kind: 'file'; readonly snapshot: SnapshotEntry & { readonly kind: 'file' }; readonly content: Buffer; readonly info: BigIntStats }
339
+ | { readonly kind: 'symlink'; readonly snapshot: SnapshotEntry & { readonly kind: 'symlink' }; readonly info: BigIntStats }
308
340
  | undefined> {
309
341
  const target = resolveWorkspacePath(root, path)
310
342
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -321,7 +353,7 @@ async function captureEntry(
321
353
  const linkTarget = await readlink(target)
322
354
  const after = await lstat(target, { bigint: true })
323
355
  if (!sameStat(before, after)) continue
324
- return { kind: 'symlink', snapshot: { kind: 'symlink', target: linkTarget, mode } }
356
+ return { kind: 'symlink', snapshot: { kind: 'symlink', target: linkTarget, mode }, info: after }
325
357
  }
326
358
  if (!before.isFile()) {
327
359
  throw new ChangeLedgerError('UNSUPPORTED_FILE_TYPE', `eligible path is not a regular file or symlink: ${JSON.stringify(path)}`)
@@ -355,6 +387,7 @@ async function captureEntry(
355
387
  kind: 'file',
356
388
  snapshot: { kind: 'file', blob, size: content.length, mode },
357
389
  content,
390
+ info: { ...before, size: BigInt(content.length) } as BigIntStats,
358
391
  }
359
392
  }
360
393
  throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`)
package/src/store.ts CHANGED
@@ -263,6 +263,17 @@ export class LedgerStore {
263
263
  }
264
264
 
265
265
  /** Persist a blob if it is not already present, and verify existing content. */
266
+ /** Whether one content-addressed blob is already stored for a workspace. */
267
+ async hasBlob(workspace: string, hash: string): Promise<boolean> {
268
+ validateBlobHash(hash)
269
+ return pathExists(this.blobPath(workspace, hash))
270
+ }
271
+
272
+ /** Durable directory holding one workspace's manifests, blobs, and caches. */
273
+ workspaceDirectory(workspace: string): string {
274
+ return this.workspaceDir(workspace)
275
+ }
276
+
266
277
  async putBlob(workspace: string, hash: string, content: Buffer): Promise<void> {
267
278
  validateBlobHash(hash)
268
279
  const contentHash = createHash('sha256').update(content).digest('hex')