@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 +25 -1
- package/lib/path-cache.js +137 -0
- package/lib/snapshot.js +33 -1
- package/lib/store.js +9 -0
- package/lib/types/engine.d.ts +8 -0
- package/lib/types/path-cache.d.ts +36 -0
- package/lib/types/snapshot.d.ts +5 -0
- package/lib/types/store.d.ts +4 -0
- package/package.json +1 -1
- package/src/engine.ts +26 -1
- package/src/path-cache.ts +144 -0
- package/src/snapshot.ts +36 -3
- package/src/store.ts +11 -0
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:
|
|
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');
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -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>;
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -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. */
|
package/lib/types/store.d.ts
CHANGED
|
@@ -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
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:
|
|
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')
|