@anionex/dsh-turn-rewind 0.1.0 → 0.1.2
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/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/lib/client.js +3 -1
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +482 -118
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +303 -23
- package/lib/index.js +1 -1
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +119 -29
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1281 -106
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +27 -0
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +3 -1
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +70 -6
- package/lib/types/types.d.ts +44 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +3 -1
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +3 -0
- package/src/deadline.ts +18 -0
- package/src/engine.ts +542 -124
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +345 -23
- package/src/index.ts +1 -1
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +134 -30
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1403 -91
- package/src/types.ts +48 -1
- package/src/validate.ts +67 -7
package/lib/git.js
CHANGED
|
@@ -1,30 +1,38 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { link, lstat, mkdir, open, readFile, realpath, unlink } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
5
|
import { ChangeLedgerError, errorMessage } from './errors.js';
|
|
5
|
-
import { canonicalDirectory, isNodeError, validateRelativePath } from './path-utils.js';
|
|
6
|
+
import { canonicalDirectory, isNodeError, syncDirectory, validateRelativePath } from './path-utils.js';
|
|
6
7
|
const GIT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
7
8
|
/** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
|
|
8
9
|
export async function discoverRepository(cwd, signal) {
|
|
9
10
|
const root = await discoverRepositoryRoot(cwd, signal);
|
|
10
|
-
const sparse = (await
|
|
11
|
+
const sparse = (await runGitOptional(root, ['config', '--bool', '--get', 'core.sparseCheckout'], signal))?.trim() === 'true';
|
|
11
12
|
if (sparse) {
|
|
12
13
|
throw new ChangeLedgerError('SPARSE_CHECKOUT_UNSUPPORTED', 'sparse-checkout worktrees are not supported because absent paths are ambiguous');
|
|
13
14
|
}
|
|
14
|
-
const gitDirRaw = stripLineEnding(await
|
|
15
|
+
const gitDirRaw = stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal));
|
|
15
16
|
const gitDir = await realpath(resolve(root, gitDirRaw));
|
|
16
|
-
const commonDirRaw = stripLineEnding(await
|
|
17
|
+
const commonDirRaw = stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal));
|
|
17
18
|
const commonDir = await realpath(resolve(root, commonDirRaw));
|
|
18
|
-
const stageOutput = await
|
|
19
|
+
const stageOutput = await runGit(root, ['ls-files', '--stage', '-z'], signal);
|
|
19
20
|
const submodules = parseSubmodules(stageOutput);
|
|
20
21
|
if (submodules.length > 0) {
|
|
21
22
|
throw new ChangeLedgerError('SUBMODULE_UNSUPPORTED', `submodules require independent restore points; unsupported gitlinks: ${submodules.slice(0, 10).join(', ')}`);
|
|
22
23
|
}
|
|
23
|
-
const pathOutput = await
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
const pathOutput = await runGit(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], signal);
|
|
25
|
+
// `ls-files --others` refuses to descend into embedded repositories, such as a
|
|
26
|
+
// linked worktree nested inside this checkout; it reports the directory itself
|
|
27
|
+
// with a trailing slash (for example `nested/`). Those directories are separate
|
|
28
|
+
// checkouts, not files of this worktree, so exclude them from the snapshot
|
|
29
|
+
// inventory instead of rejecting the entry as a non-canonical path.
|
|
30
|
+
const paths = [...new Set(splitNul(pathOutput)
|
|
31
|
+
.filter((path) => !path.endsWith('/'))
|
|
32
|
+
.map(validateRelativePath))].sort(comparePaths);
|
|
33
|
+
const head = trimOptional(await runGitOptional(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal));
|
|
34
|
+
const branch = trimOptional(await runGitOptional(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal));
|
|
35
|
+
const stagedOutput = await runGit(root, ['diff', '--cached', '--name-only', '-z'], signal);
|
|
28
36
|
const stagedPaths = splitNul(stagedOutput).map(validateRelativePath).sort(comparePaths);
|
|
29
37
|
const operation = await gitOperation(gitDir);
|
|
30
38
|
return {
|
|
@@ -42,13 +50,55 @@ export async function discoverRepository(cwd, signal) {
|
|
|
42
50
|
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
43
51
|
export async function discoverRepositoryRoot(cwd, signal) {
|
|
44
52
|
const canonicalCwd = await canonicalDirectory(cwd);
|
|
45
|
-
const rootRaw = await
|
|
53
|
+
const rootRaw = await runGit(canonicalCwd, ['rev-parse', '--show-toplevel'], signal);
|
|
46
54
|
const root = await realpath(stripLineEnding(rootRaw));
|
|
47
55
|
if (!isAbsolute(root)) {
|
|
48
56
|
throw new ChangeLedgerError('GIT_ROOT_INVALID', `git returned a non-absolute worktree root: ${JSON.stringify(root)}`);
|
|
49
57
|
}
|
|
50
58
|
return root;
|
|
51
59
|
}
|
|
60
|
+
/** Create or read the per-worktree identity used by refs and cross-store locking. */
|
|
61
|
+
export async function ensureGitWorktreeIdentity(cwd, signal) {
|
|
62
|
+
const root = await discoverRepositoryRoot(cwd, signal);
|
|
63
|
+
const gitDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))));
|
|
64
|
+
const commonDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))));
|
|
65
|
+
const identityDir = join(gitDir, 'dsh-turn-rewind');
|
|
66
|
+
await mkdir(identityDir, { recursive: true, mode: 0o700 });
|
|
67
|
+
const worktreeId = await ensureWorktreeId(identityDir, commonDir, gitDir);
|
|
68
|
+
return {
|
|
69
|
+
root,
|
|
70
|
+
gitDir,
|
|
71
|
+
commonDir,
|
|
72
|
+
worktreeId,
|
|
73
|
+
lockPath: join(commonDir, 'dsh-turn-rewind', 'locks', `${worktreeId}.json`),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Resolve a moved linked worktree from its common directory and durable identity. */
|
|
77
|
+
export async function resolveGitWorktreeRoot(commonDir, worktreeId, fallback, signal) {
|
|
78
|
+
const candidates = [fallback];
|
|
79
|
+
try {
|
|
80
|
+
const output = await runGit(commonDir, [`--git-dir=${commonDir}`, 'worktree', 'list', '--porcelain', '-z'], signal);
|
|
81
|
+
for (const record of output.split('\0\0')) {
|
|
82
|
+
const first = record.split('\0', 1)[0];
|
|
83
|
+
if (first?.startsWith('worktree '))
|
|
84
|
+
candidates.push(first.slice('worktree '.length));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// The fallback below still gives deterministic failure details if the common directory is gone.
|
|
89
|
+
}
|
|
90
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
91
|
+
try {
|
|
92
|
+
const identity = await readGitWorktreeIdentity(candidate, signal);
|
|
93
|
+
if (identity.worktreeId === worktreeId && identity.commonDir === commonDir)
|
|
94
|
+
return identity.root;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Missing/pruned/unrelated worktrees are not candidates for this durable identity.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new ChangeLedgerError('GIT_WORKTREE_MISSING', `cannot locate Git worktree ${worktreeId} under ${JSON.stringify(commonDir)}`);
|
|
101
|
+
}
|
|
52
102
|
/** Return true when two repository fences refer to the same checkout state. */
|
|
53
103
|
export function sameRepositoryFence(left, right) {
|
|
54
104
|
return left.root === right.root
|
|
@@ -57,20 +107,21 @@ export function sameRepositoryFence(left, right) {
|
|
|
57
107
|
&& left.branch === right.branch
|
|
58
108
|
&& left.operation === right.operation;
|
|
59
109
|
}
|
|
60
|
-
|
|
110
|
+
/** Run Git with stable non-interactive defaults and return UTF-8 output. */
|
|
111
|
+
export async function runGit(cwd, args, signal, options = {}) {
|
|
61
112
|
return new Promise((resolvePromise, reject) => {
|
|
62
113
|
execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
|
|
63
114
|
encoding: 'utf8',
|
|
64
|
-
maxBuffer: GIT_MAX_BUFFER,
|
|
115
|
+
maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
|
|
65
116
|
windowsHide: true,
|
|
66
117
|
signal,
|
|
67
|
-
env:
|
|
68
|
-
...process.env,
|
|
69
|
-
GIT_OPTIONAL_LOCKS: '0',
|
|
70
|
-
GIT_TERMINAL_PROMPT: '0',
|
|
71
|
-
},
|
|
118
|
+
env: gitEnvironment(options.env),
|
|
72
119
|
}, (error, stdout, stderr) => {
|
|
73
120
|
if (error !== null) {
|
|
121
|
+
if (signal?.aborted === true) {
|
|
122
|
+
reject(signal.reason ?? error);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
74
125
|
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.trim() || errorMessage(error)}`, { cause: error }));
|
|
75
126
|
return;
|
|
76
127
|
}
|
|
@@ -78,9 +129,10 @@ async function git(cwd, args, signal) {
|
|
|
78
129
|
});
|
|
79
130
|
});
|
|
80
131
|
}
|
|
81
|
-
|
|
132
|
+
/** Run Git and return undefined for its ordinary "not found" exit status. */
|
|
133
|
+
export async function runGitOptional(cwd, args, signal, options = {}) {
|
|
82
134
|
try {
|
|
83
|
-
return await
|
|
135
|
+
return await runGit(cwd, args, signal, options);
|
|
84
136
|
}
|
|
85
137
|
catch (error) {
|
|
86
138
|
if (error instanceof ChangeLedgerError && error.code === 'GIT_COMMAND_FAILED' && gitExitCode(error.cause) === 1)
|
|
@@ -88,6 +140,105 @@ async function gitOptional(cwd, args, signal) {
|
|
|
88
140
|
throw error;
|
|
89
141
|
}
|
|
90
142
|
}
|
|
143
|
+
/** Run Git with binary stdin and return UTF-8 stdout. */
|
|
144
|
+
export async function runGitInput(cwd, args, input, signal, options = {}) {
|
|
145
|
+
return new Promise((resolvePromise, reject) => {
|
|
146
|
+
const child = spawn('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
signal,
|
|
149
|
+
env: gitEnvironment(options.env),
|
|
150
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
151
|
+
});
|
|
152
|
+
const stdout = [];
|
|
153
|
+
const stderr = [];
|
|
154
|
+
let stdoutBytes = 0;
|
|
155
|
+
child.stdout.on('data', (chunk) => {
|
|
156
|
+
stdoutBytes += chunk.length;
|
|
157
|
+
if (stdoutBytes <= GIT_MAX_BUFFER)
|
|
158
|
+
stdout.push(chunk);
|
|
159
|
+
});
|
|
160
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
161
|
+
let settled = false;
|
|
162
|
+
const fail = (error) => {
|
|
163
|
+
if (settled)
|
|
164
|
+
return;
|
|
165
|
+
settled = true;
|
|
166
|
+
if (signal?.aborted === true) {
|
|
167
|
+
reject(signal.reason ?? error);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${errorMessage(error)}`, { cause: error }));
|
|
171
|
+
};
|
|
172
|
+
child.on('error', fail);
|
|
173
|
+
child.stdin.on('error', fail);
|
|
174
|
+
child.on('close', (code) => {
|
|
175
|
+
if (settled)
|
|
176
|
+
return;
|
|
177
|
+
if (code !== 0) {
|
|
178
|
+
settled = true;
|
|
179
|
+
const message = Buffer.concat(stderr).toString('utf8').trim();
|
|
180
|
+
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${message || `exit ${String(code)}`}`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (stdoutBytes > GIT_MAX_BUFFER) {
|
|
184
|
+
settled = true;
|
|
185
|
+
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} output exceeded ${GIT_MAX_BUFFER} bytes`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
settled = true;
|
|
189
|
+
resolvePromise(Buffer.concat(stdout).toString('utf8'));
|
|
190
|
+
});
|
|
191
|
+
child.stdin.end(input);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** Run Git and return raw stdout bytes. */
|
|
195
|
+
export async function runGitBuffer(cwd, args, signal, options = {}) {
|
|
196
|
+
return new Promise((resolvePromise, reject) => {
|
|
197
|
+
execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
|
|
198
|
+
encoding: 'buffer',
|
|
199
|
+
maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
|
|
200
|
+
windowsHide: true,
|
|
201
|
+
signal,
|
|
202
|
+
env: gitEnvironment(options.env),
|
|
203
|
+
}, (error, stdout, stderr) => {
|
|
204
|
+
if (error !== null) {
|
|
205
|
+
if (signal?.aborted === true) {
|
|
206
|
+
reject(signal.reason ?? error);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.toString('utf8').trim() || errorMessage(error)}`, { cause: error }));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
resolvePromise(stdout);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function gitEnvironment(extra) {
|
|
217
|
+
const env = { ...process.env };
|
|
218
|
+
for (const key of [
|
|
219
|
+
'GIT_DIR',
|
|
220
|
+
'GIT_COMMON_DIR',
|
|
221
|
+
'GIT_WORK_TREE',
|
|
222
|
+
'GIT_INDEX_FILE',
|
|
223
|
+
'GIT_OBJECT_DIRECTORY',
|
|
224
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
225
|
+
'GIT_QUARANTINE_PATH',
|
|
226
|
+
'GIT_REPLACE_REF_BASE',
|
|
227
|
+
'GIT_CONFIG_PARAMETERS',
|
|
228
|
+
'GIT_CONFIG_COUNT',
|
|
229
|
+
])
|
|
230
|
+
delete env[key];
|
|
231
|
+
for (const key of Object.keys(env)) {
|
|
232
|
+
if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key))
|
|
233
|
+
delete env[key];
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
...env,
|
|
237
|
+
...extra,
|
|
238
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
239
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
91
242
|
function gitExitCode(error) {
|
|
92
243
|
if (error === null || typeof error !== 'object' || !('code' in error))
|
|
93
244
|
return undefined;
|
|
@@ -149,6 +300,135 @@ function stripLineEnding(value) {
|
|
|
149
300
|
function comparePaths(left, right) {
|
|
150
301
|
return Buffer.from(left).compare(Buffer.from(right));
|
|
151
302
|
}
|
|
303
|
+
function parseWorktreeId(value) {
|
|
304
|
+
const worktreeId = value.trim();
|
|
305
|
+
if (!/^[0-9a-f]{32}$/.test(worktreeId)) {
|
|
306
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity is malformed');
|
|
307
|
+
}
|
|
308
|
+
return worktreeId;
|
|
309
|
+
}
|
|
310
|
+
async function readGitWorktreeIdentity(cwd, signal) {
|
|
311
|
+
const root = await discoverRepositoryRoot(cwd, signal);
|
|
312
|
+
const gitDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))));
|
|
313
|
+
const commonDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))));
|
|
314
|
+
const worktreeId = await ensureWorktreeId(join(gitDir, 'dsh-turn-rewind'), commonDir, gitDir);
|
|
315
|
+
return {
|
|
316
|
+
root,
|
|
317
|
+
gitDir,
|
|
318
|
+
commonDir,
|
|
319
|
+
worktreeId,
|
|
320
|
+
lockPath: join(commonDir, 'dsh-turn-rewind', 'locks', `${worktreeId}.json`),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async function ensureWorktreeId(identityDir, commonDir, gitDir) {
|
|
324
|
+
const identityPath = join(identityDir, 'worktree-id');
|
|
325
|
+
const backupPath = join(identityDir, 'worktree-id.backup');
|
|
326
|
+
const initializedPath = join(identityDir, 'worktree-id.initialized');
|
|
327
|
+
const localPaths = [identityPath, backupPath, initializedPath];
|
|
328
|
+
const centralDir = join(commonDir, 'dsh-turn-rewind-worktree-identities');
|
|
329
|
+
const centralPath = join(centralDir, `${createHash('sha256').update(gitDir).digest('hex')}.id`);
|
|
330
|
+
await mkdir(identityDir, { recursive: true, mode: 0o700 });
|
|
331
|
+
await mkdir(centralDir, { recursive: true, mode: 0o700 });
|
|
332
|
+
const localCopies = await Promise.all(localPaths.map(readIdentityCopy));
|
|
333
|
+
const centralCopy = await readIdentityCopy(centralPath);
|
|
334
|
+
const valid = [...localCopies, centralCopy].flatMap(copy => copy.value === undefined ? [] : [copy.value]);
|
|
335
|
+
if (new Set(valid).size > 1) {
|
|
336
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity copies disagree');
|
|
337
|
+
}
|
|
338
|
+
const localValid = localCopies.flatMap(copy => copy.value === undefined ? [] : [copy.value]);
|
|
339
|
+
const localDurable = localValid[0];
|
|
340
|
+
const durable = localDurable ?? (gitDir === commonDir ? centralCopy.value : undefined);
|
|
341
|
+
if (durable !== undefined) {
|
|
342
|
+
for (const path of [...localPaths, centralPath]) {
|
|
343
|
+
if (await publishIdentityFile(path, durable, true) !== durable) {
|
|
344
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy disagrees at ${JSON.stringify(path)}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
await syncDirectory(identityDir);
|
|
348
|
+
await syncDirectory(centralDir);
|
|
349
|
+
return durable;
|
|
350
|
+
}
|
|
351
|
+
if (localCopies.some(copy => copy.malformed) || centralCopy.malformed) {
|
|
352
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity is malformed and no valid durable copy remains');
|
|
353
|
+
}
|
|
354
|
+
if (gitDir !== commonDir && centralCopy.value !== undefined) {
|
|
355
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'linked Git worktree identity is ambiguous because only a detached central copy remains');
|
|
356
|
+
}
|
|
357
|
+
const candidate = randomBytes(16).toString('hex');
|
|
358
|
+
const chosen = await publishIdentityFile(identityPath, candidate, false);
|
|
359
|
+
for (const path of [backupPath, initializedPath, centralPath]) {
|
|
360
|
+
if (await publishIdentityFile(path, chosen, true) !== chosen) {
|
|
361
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy disagrees at ${JSON.stringify(path)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
await syncDirectory(identityDir);
|
|
365
|
+
await syncDirectory(centralDir);
|
|
366
|
+
return chosen;
|
|
367
|
+
}
|
|
368
|
+
async function readIdentityCopy(path) {
|
|
369
|
+
try {
|
|
370
|
+
return { value: parseWorktreeId(await readFile(path, 'utf8')), malformed: false };
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
if (isNodeError(error, 'ENOENT'))
|
|
374
|
+
return { malformed: false };
|
|
375
|
+
if (error instanceof ChangeLedgerError && error.code === 'STATE_CORRUPT')
|
|
376
|
+
return { malformed: true };
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function publishIdentityFile(path, value, repairMalformed) {
|
|
381
|
+
const directory = dirname(path);
|
|
382
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
383
|
+
const temporary = join(directory, `.${randomBytes(16).toString('hex')}.tmp`);
|
|
384
|
+
try {
|
|
385
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
386
|
+
try {
|
|
387
|
+
await handle.writeFile(`${value}\n`);
|
|
388
|
+
await handle.sync();
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
await handle.close();
|
|
392
|
+
}
|
|
393
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
394
|
+
try {
|
|
395
|
+
await link(temporary, path);
|
|
396
|
+
await syncDirectory(directory);
|
|
397
|
+
return value;
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
if (!isNodeError(error, 'EEXIST'))
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
const existing = await readIdentityCopy(path);
|
|
404
|
+
if (existing.value !== undefined)
|
|
405
|
+
return existing.value;
|
|
406
|
+
if (!existing.malformed)
|
|
407
|
+
continue;
|
|
408
|
+
if (!repairMalformed) {
|
|
409
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy is malformed at ${JSON.stringify(path)}`);
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
await unlink(path);
|
|
413
|
+
await syncDirectory(directory);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `could not repair Git worktree identity copy at ${JSON.stringify(path)}`);
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
try {
|
|
424
|
+
await unlink(temporary);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
152
432
|
/** Return the Git metadata directory for diagnostics. */
|
|
153
433
|
export function gitMetadataParent(state) {
|
|
154
434
|
return dirname(state.commonDir);
|
package/lib/index.js
CHANGED
|
@@ -18,7 +18,7 @@ export class ChangeLedgerService {
|
|
|
18
18
|
});
|
|
19
19
|
void this.engine.initialize().then((reconciled) => {
|
|
20
20
|
if (reconciled > 0) {
|
|
21
|
-
ctx.logger.warn(`[change-ledger]
|
|
21
|
+
ctx.logger.warn(`[change-ledger] reconciled ${reconciled} interrupted durable operation(s)`);
|
|
22
22
|
}
|
|
23
23
|
else {
|
|
24
24
|
ctx.logger.info(`[change-ledger] ready; state=${this.engine.config.storageDir}`);
|
package/lib/path-utils.js
CHANGED
|
@@ -107,6 +107,13 @@ export async function pathExists(path) {
|
|
|
107
107
|
}
|
|
108
108
|
/** Ensure every existing parent below `root` is a real directory, never a symlink. */
|
|
109
109
|
export async function ensureSafeParents(root, target) {
|
|
110
|
+
await walkSafeParents(root, target, true);
|
|
111
|
+
}
|
|
112
|
+
/** Validate existing parents without creating missing directories. */
|
|
113
|
+
export async function assertSafeParents(root, target) {
|
|
114
|
+
await walkSafeParents(root, target, false);
|
|
115
|
+
}
|
|
116
|
+
async function walkSafeParents(root, target, createMissing) {
|
|
110
117
|
if (!isWithin(root, target) || target === root) {
|
|
111
118
|
throw new ChangeLedgerError('UNSAFE_TARGET', `restore target is outside the workspace: ${JSON.stringify(target)}`);
|
|
112
119
|
}
|
|
@@ -128,6 +135,8 @@ export async function ensureSafeParents(root, target) {
|
|
|
128
135
|
catch (error) {
|
|
129
136
|
if (!isNodeError(error, 'ENOENT'))
|
|
130
137
|
throw error;
|
|
138
|
+
if (!createMissing)
|
|
139
|
+
return;
|
|
131
140
|
await mkdir(current, { mode: 0o755 });
|
|
132
141
|
}
|
|
133
142
|
}
|
package/lib/rewind-host.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createDeadline } from './deadline.js';
|
|
2
3
|
import { ChangeLedgerError, errorMessage } from './errors.js';
|
|
3
4
|
import { discoverRepositoryRoot } from './git.js';
|
|
4
5
|
export const REWIND_HTTP_PATH = '/turn-rewind';
|
|
@@ -11,6 +12,7 @@ export class TurnCheckpointCoordinator {
|
|
|
11
12
|
captures = new Map();
|
|
12
13
|
pending = new Set();
|
|
13
14
|
failures = new Map();
|
|
15
|
+
skips = new Map();
|
|
14
16
|
workspaceTails = new Map();
|
|
15
17
|
constructor(engine) {
|
|
16
18
|
this.engine = engine;
|
|
@@ -28,55 +30,90 @@ export class TurnCheckpointCoordinator {
|
|
|
28
30
|
const key = checkpointKey(sessionId, turn);
|
|
29
31
|
if (this.pending.has(key))
|
|
30
32
|
return { status: 'pending' };
|
|
33
|
+
const reason = this.skips.get(key);
|
|
34
|
+
if (reason !== undefined)
|
|
35
|
+
return { status: 'skipped', reason };
|
|
31
36
|
const error = this.failures.get(key);
|
|
32
37
|
return error === undefined ? { status: 'missing' } : { status: 'failed', error };
|
|
33
38
|
}
|
|
34
39
|
async capture(ctx, agent, turn, signal) {
|
|
35
40
|
const key = checkpointKey(agent.id, turn);
|
|
36
|
-
const existing = this.captures.get(key);
|
|
37
|
-
if (existing !== undefined) {
|
|
38
|
-
await existing;
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
41
|
const cwd = agent.session.header.cwd;
|
|
42
42
|
if (cwd === undefined)
|
|
43
43
|
return;
|
|
44
44
|
const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn);
|
|
45
45
|
if (start === undefined) {
|
|
46
|
-
this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
|
|
46
|
+
await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
|
-
this.
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
49
|
+
const timeoutMs = this.engine.config.turnCheckpointTimeoutMs;
|
|
50
|
+
const outcomeDeadline = createDeadline(timeoutMs);
|
|
51
|
+
const outcomeSignal = AbortSignal.any([signal, outcomeDeadline.signal]);
|
|
52
|
+
const reserveMs = Math.min(250, Math.max(10, Math.ceil(timeoutMs / 5)));
|
|
53
|
+
const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs));
|
|
54
|
+
const captureSignal = AbortSignal.any([signal, captureDeadline.signal]);
|
|
55
|
+
try {
|
|
56
|
+
const existing = this.captures.get(key);
|
|
57
|
+
if (existing !== undefined) {
|
|
58
|
+
await waitWithSignal(existing, captureSignal).catch(() => undefined);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.pending.add(key);
|
|
62
|
+
this.failures.delete(key);
|
|
63
|
+
this.skips.delete(key);
|
|
64
|
+
const capture = (async () => {
|
|
65
|
+
try {
|
|
66
|
+
const workspace = await discoverRepositoryRoot(cwd, captureSignal);
|
|
67
|
+
await this.serializeWorkspace(workspace, captureSignal, async () => {
|
|
68
|
+
try {
|
|
69
|
+
await this.engine.createTurnCheckpoint({
|
|
70
|
+
cwd: workspace,
|
|
71
|
+
sessionId: agent.id,
|
|
72
|
+
turn,
|
|
73
|
+
turnStartSeq: start.seq,
|
|
74
|
+
signal: captureSignal,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
await this.recordFailure(ctx, agent.id, turn, checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal), { cwd: workspace, turnStartSeq: start.seq });
|
|
79
|
+
}
|
|
61
80
|
});
|
|
62
|
-
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await this.recordFailure(ctx, agent.id, turn, checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal), { cwd, turnStartSeq: start.seq });
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
this.pending.delete(key);
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
this.captures.set(key, capture);
|
|
90
|
+
try {
|
|
91
|
+
await waitWithSignal(capture, outcomeSignal);
|
|
63
92
|
}
|
|
64
93
|
catch (error) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
finally {
|
|
94
|
+
const bounded = checkpointTimeoutError(error, timeoutMs, outcomeDeadline.signal, signal);
|
|
95
|
+
const message = errorMessage(bounded);
|
|
68
96
|
this.pending.delete(key);
|
|
97
|
+
if (bounded instanceof ChangeLedgerError && isCheckpointSkip(bounded.code)) {
|
|
98
|
+
this.skips.set(key, message);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
this.failures.set(key, message);
|
|
102
|
+
}
|
|
69
103
|
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
captureDeadline.cancel();
|
|
107
|
+
outcomeDeadline.cancel();
|
|
108
|
+
}
|
|
73
109
|
}
|
|
74
110
|
async serializeWorkspace(workspace, signal, task) {
|
|
75
111
|
const previous = this.workspaceTails.get(workspace) ?? Promise.resolve();
|
|
76
|
-
const current =
|
|
112
|
+
const current = (async () => {
|
|
113
|
+
await waitWithSignal(previous.catch(() => undefined), signal);
|
|
77
114
|
signal.throwIfAborted();
|
|
78
115
|
await task();
|
|
79
|
-
});
|
|
116
|
+
})();
|
|
80
117
|
this.workspaceTails.set(workspace, current);
|
|
81
118
|
try {
|
|
82
119
|
await current;
|
|
@@ -86,12 +123,57 @@ export class TurnCheckpointCoordinator {
|
|
|
86
123
|
this.workspaceTails.delete(workspace);
|
|
87
124
|
}
|
|
88
125
|
}
|
|
89
|
-
recordFailure(ctx, sessionId, turn, error) {
|
|
126
|
+
async recordFailure(ctx, sessionId, turn, error, skipContext) {
|
|
90
127
|
const message = errorMessage(error);
|
|
128
|
+
if (error instanceof ChangeLedgerError && isCheckpointSkip(error.code)) {
|
|
129
|
+
this.skips.set(checkpointKey(sessionId, turn), message);
|
|
130
|
+
if (skipContext !== undefined) {
|
|
131
|
+
try {
|
|
132
|
+
await this.engine.recordTurnCheckpointSkip({
|
|
133
|
+
cwd: skipContext.cwd,
|
|
134
|
+
sessionId,
|
|
135
|
+
turn,
|
|
136
|
+
turnStartSeq: skipContext.turnStartSeq,
|
|
137
|
+
reason: message,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (persistError) {
|
|
141
|
+
ctx.logger.warn(`[turn-rewind] could not persist checkpoint skip for ${sessionId} turn ${String(turn)}: ${errorMessage(persistError)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
ctx.logger.warn(`[turn-rewind] checkpoint skipped for ${sessionId} turn ${String(turn)}: ${message}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
91
147
|
this.failures.set(checkpointKey(sessionId, turn), message);
|
|
92
148
|
ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`);
|
|
93
149
|
}
|
|
94
150
|
}
|
|
151
|
+
function isCheckpointSkip(code) {
|
|
152
|
+
return code === 'TURN_CHECKPOINT_DISABLED'
|
|
153
|
+
|| code === 'TURN_CHECKPOINT_TIMEOUT'
|
|
154
|
+
|| code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT'
|
|
155
|
+
|| code === 'GIT_OBJECT_STORE_UNSUPPORTED'
|
|
156
|
+
|| code === 'GIT_INDEX_UNSUPPORTED';
|
|
157
|
+
}
|
|
158
|
+
async function waitWithSignal(promise, signal) {
|
|
159
|
+
signal.throwIfAborted();
|
|
160
|
+
let rejectAbort;
|
|
161
|
+
const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
|
|
162
|
+
const onAbort = () => rejectAbort?.(signal.reason);
|
|
163
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
164
|
+
try {
|
|
165
|
+
return await Promise.race([promise, aborted]);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
signal.removeEventListener('abort', onAbort);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function checkpointTimeoutError(error, timeoutMs, deadline, callerSignal) {
|
|
172
|
+
if (!callerSignal.aborted && deadline.aborted) {
|
|
173
|
+
return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error });
|
|
174
|
+
}
|
|
175
|
+
return error;
|
|
176
|
+
}
|
|
95
177
|
/** Register the same-origin preview/apply endpoint consumed by the browser half. */
|
|
96
178
|
export function installRewindHttp(ctx, engine, coordinator) {
|
|
97
179
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -113,7 +195,15 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
|
|
|
113
195
|
const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT);
|
|
114
196
|
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
|
|
115
197
|
if (checkpoint === undefined) {
|
|
116
|
-
|
|
198
|
+
const durableSkip = await engine.findTurnCheckpointSkip({
|
|
199
|
+
cwd: target.cwd,
|
|
200
|
+
sessionId,
|
|
201
|
+
turn: target.turn,
|
|
202
|
+
turnStartSeq: target.turnStartSeq,
|
|
203
|
+
});
|
|
204
|
+
json(response, 200, durableSkip === undefined
|
|
205
|
+
? coordinator.state(sessionId, target.turn)
|
|
206
|
+
: { status: 'skipped', reason: durableSkip.reason });
|
|
117
207
|
return;
|
|
118
208
|
}
|
|
119
209
|
const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id });
|