@anionex/dsh-turn-rewind 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/README.zh.md +13 -4
- package/docs/FORMAT.md +5 -1
- package/lib/client.js +7 -0
- package/lib/client.js.map +1 -1
- package/lib/directory.js +162 -0
- package/lib/engine.js +94 -56
- package/lib/git.js +46 -0
- package/lib/rewind-host.js +16 -7
- package/lib/snapshot.js +13 -5
- package/lib/types/directory.d.ts +10 -0
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/git.d.ts +15 -4
- package/lib/types/snapshot.d.ts +2 -2
- package/lib/types/types.d.ts +15 -2
- package/lib/types/workspace.d.ts +37 -0
- package/lib/validate.js +23 -1
- package/lib/workspace.js +45 -0
- package/package.json +5 -2
- package/src/client/index.tsx +7 -0
- package/src/directory.ts +194 -0
- package/src/engine.ts +104 -68
- package/src/git.ts +49 -4
- package/src/rewind-host.ts +17 -8
- package/src/snapshot.ts +14 -6
- package/src/types.ts +18 -2
- package/src/validate.ts +21 -1
- package/src/workspace.ts +66 -0
package/lib/engine.js
CHANGED
|
@@ -4,11 +4,12 @@ import { join, resolve } from 'node:path';
|
|
|
4
4
|
import { lstat, realpath } from 'node:fs/promises';
|
|
5
5
|
import { createDeadline } from './deadline.js';
|
|
6
6
|
import { ChangeLedgerError, errorMessage } from './errors.js';
|
|
7
|
-
import {
|
|
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
10
|
import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js';
|
|
11
11
|
import { LedgerStore } from './store.js';
|
|
12
|
+
import { discoverWorkspace, discoverWorkspaceIdentity, sameWorkspaceFence } from './workspace.js';
|
|
12
13
|
import { LEDGER_FORMAT_VERSION, } from './types.js';
|
|
13
14
|
const DEFAULTS = {
|
|
14
15
|
maxRestorePoints: 50,
|
|
@@ -59,12 +60,21 @@ export class ChangeLedgerEngine {
|
|
|
59
60
|
const restoredOperations = await this.store.initialize();
|
|
60
61
|
return restoredOperations + await this.reconcileGitCheckpointJournals();
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Acquire the exclusive lock for one workspace.
|
|
65
|
+
*
|
|
66
|
+
* A Git worktree also binds the shared per-worktree lock derived from its Git
|
|
67
|
+
* identity; an ordinary directory owns only the durable directory lock.
|
|
68
|
+
*/
|
|
62
69
|
async acquireWorkspace(workspace, signal) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
if (workspace.type === 'directory') {
|
|
71
|
+
return this.store.acquire(workspace.root, undefined, undefined, signal);
|
|
72
|
+
}
|
|
73
|
+
const identity = await ensureGitWorktreeIdentity(workspace.root, signal);
|
|
74
|
+
if (identity.root !== workspace.root) {
|
|
75
|
+
throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace.root)}`);
|
|
66
76
|
}
|
|
67
|
-
return this.store.acquire(workspace, identity.lockPath, {
|
|
77
|
+
return this.store.acquire(workspace.root, identity.lockPath, {
|
|
68
78
|
commonDir: identity.commonDir,
|
|
69
79
|
gitDir: identity.gitDir,
|
|
70
80
|
worktreeId: identity.worktreeId,
|
|
@@ -75,7 +85,7 @@ export class ChangeLedgerEngine {
|
|
|
75
85
|
for (const journal of await this.store.listGitCheckpointJournals()) {
|
|
76
86
|
const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace);
|
|
77
87
|
const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace };
|
|
78
|
-
const release = await this.acquireWorkspace(workspace);
|
|
88
|
+
const release = await this.acquireWorkspace({ type: 'git', root: workspace });
|
|
79
89
|
try {
|
|
80
90
|
const manifest = await this.tryReadManifest(workspace, journal.restorePointId);
|
|
81
91
|
if (journal.action === 'publish') {
|
|
@@ -167,9 +177,9 @@ export class ChangeLedgerEngine {
|
|
|
167
177
|
/** Create a durable restore point for the current Git worktree. */
|
|
168
178
|
async create(options) {
|
|
169
179
|
await this.ready;
|
|
170
|
-
const source = await
|
|
180
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal);
|
|
171
181
|
await this.assertStorageSeparated(source.state.root);
|
|
172
|
-
const release = await this.acquireWorkspace(source.state
|
|
182
|
+
const release = await this.acquireWorkspace(source.state, options.signal);
|
|
173
183
|
try {
|
|
174
184
|
const label = normalizeLabel(options.label);
|
|
175
185
|
const manifest = await this.createLocked({
|
|
@@ -206,9 +216,9 @@ export class ChangeLedgerEngine {
|
|
|
206
216
|
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer');
|
|
207
217
|
}
|
|
208
218
|
try {
|
|
209
|
-
const source = await
|
|
219
|
+
const source = await discoverWorkspace(options.cwd, this.config, signal);
|
|
210
220
|
await this.assertStorageSeparated(source.state.root);
|
|
211
|
-
const release = await this.acquireWorkspace(source.state
|
|
221
|
+
const release = await this.acquireWorkspace(source.state, signal);
|
|
212
222
|
try {
|
|
213
223
|
const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal);
|
|
214
224
|
const duplicate = existing.find(manifest => manifest.kind === 'turn'
|
|
@@ -223,7 +233,9 @@ export class ChangeLedgerEngine {
|
|
|
223
233
|
}
|
|
224
234
|
throwIfAborted(signal);
|
|
225
235
|
let manifest;
|
|
226
|
-
|
|
236
|
+
// Git-native checkpoints live in the repository object database, so an
|
|
237
|
+
// ordinary directory always uses the ledger's own content-addressed store.
|
|
238
|
+
if (this.config.turnCheckpointMode === 'legacy' || source.state.type === 'directory') {
|
|
227
239
|
manifest = await this.createLocked({
|
|
228
240
|
cwd: source.state.root,
|
|
229
241
|
kind: 'turn',
|
|
@@ -278,20 +290,20 @@ export class ChangeLedgerEngine {
|
|
|
278
290
|
/** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
|
|
279
291
|
async recordTurnCheckpointSkip(options) {
|
|
280
292
|
await waitWithSignal(this.ready, options.signal);
|
|
281
|
-
const workspace = await
|
|
282
|
-
await this.assertStorageSeparated(workspace);
|
|
293
|
+
const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal);
|
|
294
|
+
await this.assertStorageSeparated(workspace.root);
|
|
283
295
|
const release = await this.acquireWorkspace(workspace, options.signal);
|
|
284
296
|
try {
|
|
285
297
|
throwIfAborted(options.signal);
|
|
286
|
-
const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest => manifest.kind === 'turn'
|
|
298
|
+
const existing = (await this.store.listManifests(workspace.root, options.signal)).some(manifest => manifest.kind === 'turn'
|
|
287
299
|
&& manifest.sessionId === options.sessionId
|
|
288
300
|
&& manifest.turn === options.turn
|
|
289
301
|
&& manifest.turnStartSeq === options.turnStartSeq);
|
|
290
302
|
if (existing) {
|
|
291
|
-
await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
|
|
303
|
+
await this.store.deleteTurnCheckpointSkip(workspace.root, options.sessionId, options.turn, options.turnStartSeq);
|
|
292
304
|
return;
|
|
293
305
|
}
|
|
294
|
-
await this.store.writeTurnCheckpointSkip(workspace, {
|
|
306
|
+
await this.store.writeTurnCheckpointSkip(workspace.root, {
|
|
295
307
|
version: 1,
|
|
296
308
|
sessionId: options.sessionId,
|
|
297
309
|
turn: options.turn,
|
|
@@ -307,11 +319,11 @@ export class ChangeLedgerEngine {
|
|
|
307
319
|
/** Read a durable skip marker for one exact prompt boundary. */
|
|
308
320
|
async findTurnCheckpointSkip(options) {
|
|
309
321
|
await this.ready;
|
|
310
|
-
const workspace = await
|
|
311
|
-
await this.assertStorageSeparated(workspace);
|
|
322
|
+
const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal);
|
|
323
|
+
await this.assertStorageSeparated(workspace.root);
|
|
312
324
|
const release = await this.acquireWorkspace(workspace, options.signal);
|
|
313
325
|
try {
|
|
314
|
-
const skip = await this.store.readTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
|
|
326
|
+
const skip = await this.store.readTurnCheckpointSkip(workspace.root, options.sessionId, options.turn, options.turnStartSeq);
|
|
315
327
|
return skip === undefined ? undefined : { reason: skip.reason };
|
|
316
328
|
}
|
|
317
329
|
finally {
|
|
@@ -321,9 +333,9 @@ export class ChangeLedgerEngine {
|
|
|
321
333
|
/** Find the prompt-anchored checkpoint captured before one session turn. */
|
|
322
334
|
async findTurnCheckpoint(options) {
|
|
323
335
|
await this.ready;
|
|
324
|
-
const source = await
|
|
336
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal);
|
|
325
337
|
await this.assertStorageSeparated(source.state.root);
|
|
326
|
-
const release = await this.acquireWorkspace(source.state
|
|
338
|
+
const release = await this.acquireWorkspace(source.state, options.signal);
|
|
327
339
|
try {
|
|
328
340
|
const manifest = (await this.store.listManifests(source.state.root)).find(point => point.kind === 'turn'
|
|
329
341
|
&& point.sessionId === options.sessionId
|
|
@@ -340,9 +352,9 @@ export class ChangeLedgerEngine {
|
|
|
340
352
|
/** List restore points for the current worktree. */
|
|
341
353
|
async list(options) {
|
|
342
354
|
await this.ready;
|
|
343
|
-
const source = await
|
|
355
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal);
|
|
344
356
|
await this.assertStorageSeparated(source.state.root);
|
|
345
|
-
const release = await this.acquireWorkspace(source.state
|
|
357
|
+
const release = await this.acquireWorkspace(source.state, options.signal);
|
|
346
358
|
try {
|
|
347
359
|
const manifests = await this.store.listManifests(source.state.root);
|
|
348
360
|
return manifests
|
|
@@ -358,9 +370,9 @@ export class ChangeLedgerEngine {
|
|
|
358
370
|
/** Compare one restore point with the current worktree. */
|
|
359
371
|
async inspect(options) {
|
|
360
372
|
await this.ready;
|
|
361
|
-
const source = await
|
|
373
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal);
|
|
362
374
|
await this.assertStorageSeparated(source.state.root);
|
|
363
|
-
const release = await this.acquireWorkspace(source.state
|
|
375
|
+
const release = await this.acquireWorkspace(source.state, options.signal);
|
|
364
376
|
try {
|
|
365
377
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
|
|
366
378
|
if (manifest.version === 2)
|
|
@@ -374,15 +386,17 @@ export class ChangeLedgerEngine {
|
|
|
374
386
|
const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries;
|
|
375
387
|
if (currentEntries === undefined)
|
|
376
388
|
throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable');
|
|
389
|
+
const currentState = current.source.state;
|
|
390
|
+
assertWorkspaceIdentityCompatible(manifest.repository, currentState);
|
|
377
391
|
return {
|
|
378
392
|
restorePoint: summarize(manifest),
|
|
379
393
|
currentTreeHash: current.treeHash,
|
|
380
|
-
currentRepository:
|
|
381
|
-
...(
|
|
382
|
-
...(
|
|
383
|
-
...(
|
|
384
|
-
headChanged: repositoryHeadChanged(manifest.repository,
|
|
385
|
-
operationChanged: manifest.repository
|
|
394
|
+
currentRepository: currentState,
|
|
395
|
+
...(currentState.type === 'git' && currentState.head !== undefined ? { currentHead: currentState.head } : {}),
|
|
396
|
+
...(currentState.type === 'git' && currentState.branch !== undefined ? { currentBranch: currentState.branch } : {}),
|
|
397
|
+
...(currentState.type === 'git' && currentState.operation !== undefined ? { currentOperation: currentState.operation } : {}),
|
|
398
|
+
headChanged: repositoryHeadChanged(manifest.repository, currentState),
|
|
399
|
+
operationChanged: workspaceOperationChanged(manifest.repository, currentState),
|
|
386
400
|
changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
|
|
387
401
|
};
|
|
388
402
|
}
|
|
@@ -394,9 +408,9 @@ export class ChangeLedgerEngine {
|
|
|
394
408
|
async planRestore(options) {
|
|
395
409
|
await this.ready;
|
|
396
410
|
this.expirePlans();
|
|
397
|
-
const source = await
|
|
411
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal);
|
|
398
412
|
await this.assertStorageSeparated(source.state.root);
|
|
399
|
-
const release = await this.acquireWorkspace(source.state
|
|
413
|
+
const release = await this.acquireWorkspace(source.state, options.signal);
|
|
400
414
|
try {
|
|
401
415
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
|
|
402
416
|
if (manifest.version === 2)
|
|
@@ -413,8 +427,8 @@ export class ChangeLedgerEngine {
|
|
|
413
427
|
if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
|
|
414
428
|
throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again');
|
|
415
429
|
}
|
|
416
|
-
if (options.expectedRepository !== undefined && !
|
|
417
|
-
throw
|
|
430
|
+
if (options.expectedRepository !== undefined && !sameWorkspaceFence(options.expectedRepository, current.source.state)) {
|
|
431
|
+
throw workspaceStaleError(options.expectedRepository, current.source.state, 'inspection');
|
|
418
432
|
}
|
|
419
433
|
assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true);
|
|
420
434
|
const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries);
|
|
@@ -467,7 +481,7 @@ export class ChangeLedgerEngine {
|
|
|
467
481
|
this.activePlans.add(plan.id);
|
|
468
482
|
try {
|
|
469
483
|
await this.assertStorageSeparated(plan.workspace);
|
|
470
|
-
const release = await this.acquireWorkspace(plan.
|
|
484
|
+
const release = await this.acquireWorkspace(plan.repository, options.signal);
|
|
471
485
|
try {
|
|
472
486
|
const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId);
|
|
473
487
|
if (manifest.version === 2)
|
|
@@ -590,9 +604,9 @@ export class ChangeLedgerEngine {
|
|
|
590
604
|
if (options.confirmation !== `DELETE ${options.restorePointId}`) {
|
|
591
605
|
throw new ChangeLedgerError('CONFIRMATION_MISMATCH', `confirmation must exactly equal "DELETE ${options.restorePointId}"`);
|
|
592
606
|
}
|
|
593
|
-
const source = await
|
|
607
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal);
|
|
594
608
|
await this.assertStorageSeparated(source.state.root);
|
|
595
|
-
const release = await this.acquireWorkspace(source.state
|
|
609
|
+
const release = await this.acquireWorkspace(source.state, options.signal);
|
|
596
610
|
try {
|
|
597
611
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
|
|
598
612
|
if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
|
|
@@ -609,9 +623,9 @@ export class ChangeLedgerEngine {
|
|
|
609
623
|
/** List restore operations that were interrupted or require manual recovery. */
|
|
610
624
|
async listRecovery(options) {
|
|
611
625
|
await this.ready;
|
|
612
|
-
const source = await
|
|
626
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal);
|
|
613
627
|
await this.assertStorageSeparated(source.state.root);
|
|
614
|
-
const release = await this.acquireWorkspace(source.state
|
|
628
|
+
const release = await this.acquireWorkspace(source.state, options.signal);
|
|
615
629
|
try {
|
|
616
630
|
return (await this.store.listOperations(source.state.root))
|
|
617
631
|
.filter((operation) => operation.state === 'interrupted' || operation.state === 'recovery-required')
|
|
@@ -698,10 +712,10 @@ export class ChangeLedgerEngine {
|
|
|
698
712
|
let liveRepository = false;
|
|
699
713
|
let release;
|
|
700
714
|
try {
|
|
701
|
-
const
|
|
702
|
-
if (root === workspace) {
|
|
703
|
-
await this.assertStorageSeparated(root);
|
|
704
|
-
release = await this.acquireWorkspace(
|
|
715
|
+
const identity = await discoverWorkspaceIdentity(workspace, signal);
|
|
716
|
+
if (identity.root === workspace) {
|
|
717
|
+
await this.assertStorageSeparated(identity.root);
|
|
718
|
+
release = await this.acquireWorkspace(identity, signal);
|
|
705
719
|
liveRepository = true;
|
|
706
720
|
}
|
|
707
721
|
else {
|
|
@@ -938,11 +952,13 @@ export function resolveConfig(config) {
|
|
|
938
952
|
};
|
|
939
953
|
}
|
|
940
954
|
function summarize(manifest) {
|
|
955
|
+
const workspace = manifest.repository;
|
|
941
956
|
return {
|
|
942
957
|
format: manifest.version,
|
|
943
958
|
id: manifest.id,
|
|
944
959
|
kind: manifest.kind,
|
|
945
960
|
workspace: manifest.workspace,
|
|
961
|
+
workspaceType: workspace.type,
|
|
946
962
|
...(manifest.sessionId === undefined ? {} : { sessionId: manifest.sessionId }),
|
|
947
963
|
...(manifest.label === undefined ? {} : { label: manifest.label }),
|
|
948
964
|
...(manifest.parentRestorePoint === undefined ? {} : { parentRestorePoint: manifest.parentRestorePoint }),
|
|
@@ -955,17 +971,29 @@ function summarize(manifest) {
|
|
|
955
971
|
totalBytes: manifest.totalBytes,
|
|
956
972
|
restoreCount: manifest.restoreCount,
|
|
957
973
|
...(manifest.lastRestoredAt === undefined ? {} : { lastRestoredAt: manifest.lastRestoredAt }),
|
|
958
|
-
...(
|
|
959
|
-
...(
|
|
960
|
-
...(
|
|
961
|
-
stagedPathCount:
|
|
974
|
+
...(workspace.type === 'git' && workspace.head !== undefined ? { head: workspace.head } : {}),
|
|
975
|
+
...(workspace.type === 'git' && workspace.branch !== undefined ? { branch: workspace.branch } : {}),
|
|
976
|
+
...(workspace.type === 'git' && workspace.operation !== undefined ? { operation: workspace.operation } : {}),
|
|
977
|
+
stagedPathCount: workspace.type === 'git' ? workspace.stagedPaths.length : 0,
|
|
962
978
|
...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
|
|
963
979
|
};
|
|
964
980
|
}
|
|
965
|
-
|
|
966
|
-
|
|
981
|
+
/** Reject a fence that now describes a different workspace or workspace mode. */
|
|
982
|
+
function assertWorkspaceIdentityCompatible(before, after) {
|
|
983
|
+
if (before.type !== after.type) {
|
|
984
|
+
throw new ChangeLedgerError('WORKSPACE_MODE_CHANGED', `workspace mode changed from ${before.type} to ${after.type}`);
|
|
985
|
+
}
|
|
986
|
+
if (before.root !== after.root) {
|
|
987
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED', 'restore point no longer belongs to this workspace root');
|
|
988
|
+
}
|
|
989
|
+
if (before.type === 'git' && after.type === 'git' && before.commonDir !== after.commonDir) {
|
|
967
990
|
throw new ChangeLedgerError('REPOSITORY_CHANGED', 'restore point no longer belongs to this Git worktree');
|
|
968
991
|
}
|
|
992
|
+
}
|
|
993
|
+
function assertRepositoryCompatible(manifest, current, allowHeadChange) {
|
|
994
|
+
assertWorkspaceIdentityCompatible(manifest.repository, current);
|
|
995
|
+
if (manifest.repository.type !== 'git' || current.type !== 'git')
|
|
996
|
+
return;
|
|
969
997
|
if (manifest.repository.operation !== current.operation) {
|
|
970
998
|
throw new ChangeLedgerError('GIT_OPERATION_CHANGED', `Git operation changed from ${manifest.repository.operation ?? 'none'} to ${current.operation ?? 'none'}`);
|
|
971
999
|
}
|
|
@@ -974,16 +1002,26 @@ function assertRepositoryCompatible(manifest, current, allowHeadChange) {
|
|
|
974
1002
|
}
|
|
975
1003
|
}
|
|
976
1004
|
function repositoryHeadChanged(before, after) {
|
|
1005
|
+
if (before.type !== 'git' || after.type !== 'git')
|
|
1006
|
+
return false;
|
|
977
1007
|
return before.head !== after.head || before.branch !== after.branch;
|
|
978
1008
|
}
|
|
1009
|
+
function workspaceOperationChanged(before, after) {
|
|
1010
|
+
if (before.type !== 'git' || after.type !== 'git')
|
|
1011
|
+
return false;
|
|
1012
|
+
return before.operation !== after.operation;
|
|
1013
|
+
}
|
|
979
1014
|
function assertPlanRepositoryFresh(planned, current) {
|
|
980
|
-
if (planned
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1015
|
+
if (!sameWorkspaceFence(planned, current)) {
|
|
1016
|
+
throw workspaceStaleError(planned, current, 'restore planning');
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/** Stable stale-plan error for the workspace mode that actually drifted. */
|
|
1020
|
+
function workspaceStaleError(before, after, phase) {
|
|
1021
|
+
if (before.type === 'git' && after.type === 'git') {
|
|
1022
|
+
return new ChangeLedgerError('PLAN_STALE_REPOSITORY', `Git repository state changed after ${phase}; inspect and plan again`);
|
|
986
1023
|
}
|
|
1024
|
+
return new ChangeLedgerError('PLAN_STALE_WORKSPACE', `workspace state changed after ${phase}; inspect and plan again`);
|
|
987
1025
|
}
|
|
988
1026
|
function assertJournalMatchesManifest(journal, manifest) {
|
|
989
1027
|
if (manifest.version !== 2
|
package/lib/git.js
CHANGED
|
@@ -37,6 +37,7 @@ export async function discoverRepository(cwd, signal) {
|
|
|
37
37
|
const operation = await gitOperation(gitDir);
|
|
38
38
|
return {
|
|
39
39
|
state: {
|
|
40
|
+
type: 'git',
|
|
40
41
|
root,
|
|
41
42
|
commonDir,
|
|
42
43
|
...(head === undefined ? {} : { head }),
|
|
@@ -47,6 +48,47 @@ export async function discoverRepository(cwd, signal) {
|
|
|
47
48
|
paths,
|
|
48
49
|
};
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Discover the owning Git worktree, or return undefined when `cwd` is not
|
|
53
|
+
* inside any repository. Every other discovery failure still throws, so a
|
|
54
|
+
* broken repository never silently degrades into an ordinary directory.
|
|
55
|
+
* @param cwd - candidate working directory.
|
|
56
|
+
* @param signal - optional caller cancellation.
|
|
57
|
+
* @returns the snapshot source, or undefined outside every repository.
|
|
58
|
+
*/
|
|
59
|
+
export async function discoverRepositoryOptional(cwd, signal) {
|
|
60
|
+
try {
|
|
61
|
+
return await discoverRepository(cwd, signal);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (isNotRepositoryError(error))
|
|
65
|
+
return undefined;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Resolve the Git worktree root, or undefined when `cwd` is outside every repository. */
|
|
70
|
+
export async function discoverRepositoryRootOptional(cwd, signal) {
|
|
71
|
+
try {
|
|
72
|
+
return await discoverRepositoryRoot(cwd, signal);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (isNotRepositoryError(error))
|
|
76
|
+
return undefined;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether one discovery failure is Git's plain "not a git repository" verdict.
|
|
82
|
+
*
|
|
83
|
+
* `gitEnvironment` pins the C locale, so the message text is stable and this
|
|
84
|
+
* three-way check (code, exit status, message) cannot swallow a real failure.
|
|
85
|
+
*/
|
|
86
|
+
function isNotRepositoryError(error) {
|
|
87
|
+
return error instanceof ChangeLedgerError
|
|
88
|
+
&& error.code === 'GIT_COMMAND_FAILED'
|
|
89
|
+
&& gitExitCode(error.cause) === 128
|
|
90
|
+
&& error.message.includes('not a git repository');
|
|
91
|
+
}
|
|
50
92
|
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
51
93
|
export async function discoverRepositoryRoot(cwd, signal) {
|
|
52
94
|
const canonicalCwd = await canonicalDirectory(cwd);
|
|
@@ -237,6 +279,10 @@ function gitEnvironment(extra) {
|
|
|
237
279
|
...extra,
|
|
238
280
|
GIT_OPTIONAL_LOCKS: '0',
|
|
239
281
|
GIT_TERMINAL_PROMPT: '0',
|
|
282
|
+
// Git's diagnostics are matched for the "not a git repository" verdict that
|
|
283
|
+
// selects ordinary-directory mode; pin the C locale so that stays stable.
|
|
284
|
+
LC_ALL: 'C',
|
|
285
|
+
LANG: 'C',
|
|
240
286
|
};
|
|
241
287
|
}
|
|
242
288
|
function gitExitCode(error) {
|
package/lib/rewind-host.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { createDeadline } from './deadline.js';
|
|
3
3
|
import { ChangeLedgerError, errorMessage } from './errors.js';
|
|
4
|
-
import {
|
|
4
|
+
import { discoverWorkspaceRoot } from './workspace.js';
|
|
5
5
|
function getSessionEvents(session) {
|
|
6
6
|
if (!session)
|
|
7
7
|
return [];
|
|
@@ -82,7 +82,7 @@ export class TurnCheckpointCoordinator {
|
|
|
82
82
|
this.skips.delete(key);
|
|
83
83
|
const capture = (async () => {
|
|
84
84
|
try {
|
|
85
|
-
const workspace = await
|
|
85
|
+
const workspace = await discoverWorkspaceRoot(cwd, captureSignal);
|
|
86
86
|
await this.serializeWorkspace(workspace, captureSignal, async () => {
|
|
87
87
|
try {
|
|
88
88
|
await this.engine.createTurnCheckpoint({
|
|
@@ -291,11 +291,20 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
|
|
|
291
291
|
if (request.method === 'POST') {
|
|
292
292
|
const body = objectBody(await readBody(request));
|
|
293
293
|
const mode = body.mode;
|
|
294
|
-
if (mode !== 'code' && mode !== 'both') {
|
|
295
|
-
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "
|
|
294
|
+
if (mode !== 'code' && mode !== 'both' && mode !== 'messages') {
|
|
295
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"');
|
|
296
296
|
}
|
|
297
297
|
const sessionId = requiredText(body.sessionId, 'sessionId');
|
|
298
298
|
const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq');
|
|
299
|
+
// Messages-only rewind never reads or writes project files, so it needs
|
|
300
|
+
// neither a checkpoint nor an exclusive workspace: it resolves the turn
|
|
301
|
+
// boundary straight from the Session log and forks there.
|
|
302
|
+
if (mode === 'messages') {
|
|
303
|
+
const target = messageTarget(await readSession(ctx, sessionId), messageSeq);
|
|
304
|
+
const fork = await createConversationRestart(ctx, sessionId, target);
|
|
305
|
+
json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
299
308
|
const checkpointId = requiredText(body.checkpointId, 'checkpointId');
|
|
300
309
|
const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId);
|
|
301
310
|
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd);
|
|
@@ -512,7 +521,7 @@ async function sharedWorkspaceSessions(agents, cwd) {
|
|
|
512
521
|
const listed = agents?.list() ?? [];
|
|
513
522
|
if (listed.length === 0)
|
|
514
523
|
return [];
|
|
515
|
-
const root = await
|
|
524
|
+
const root = await discoverWorkspaceRoot(cwd);
|
|
516
525
|
const shared = [];
|
|
517
526
|
for (const agent of listed) {
|
|
518
527
|
if (agent.status !== 'running')
|
|
@@ -521,11 +530,11 @@ async function sharedWorkspaceSessions(agents, cwd) {
|
|
|
521
530
|
if (session.header.cwd === undefined)
|
|
522
531
|
continue;
|
|
523
532
|
try {
|
|
524
|
-
if (await
|
|
533
|
+
if (await discoverWorkspaceRoot(session.header.cwd) === root)
|
|
525
534
|
shared.push(session.id);
|
|
526
535
|
}
|
|
527
536
|
catch {
|
|
528
|
-
// A live Session whose cwd
|
|
537
|
+
// A live Session whose cwd cannot be resolved cannot share this workspace.
|
|
529
538
|
}
|
|
530
539
|
}
|
|
531
540
|
return shared.sort();
|
package/lib/snapshot.js
CHANGED
|
@@ -2,18 +2,20 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { lstat, open, readlink } from 'node:fs/promises';
|
|
4
4
|
import { ChangeLedgerError } from './errors.js';
|
|
5
|
-
import { discoverRepository, sameRepositoryFence } from './git.js';
|
|
6
5
|
import { isNodeError, resolveWorkspacePath } from './path-utils.js';
|
|
6
|
+
import { discoverWorkspace, sameWorkspaceFence } from './workspace.js';
|
|
7
7
|
const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024;
|
|
8
8
|
/** Capture the current tracked and non-ignored Git working tree. */
|
|
9
9
|
export async function captureTree(options) {
|
|
10
10
|
throwIfAborted(options.signal);
|
|
11
|
-
const source = await
|
|
11
|
+
const source = await discoverWorkspace(options.cwd, options.config, options.signal);
|
|
12
12
|
if (source.paths.length > options.config.maxFiles) {
|
|
13
13
|
throw new ChangeLedgerError('TOO_MANY_FILES', `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`);
|
|
14
14
|
}
|
|
15
15
|
const entries = Object.create(null);
|
|
16
|
-
|
|
16
|
+
// Git-object capture only applies to a Git worktree; an ordinary directory
|
|
17
|
+
// always stores its own content-addressed blobs.
|
|
18
|
+
const gitObjectFormat = source.state.type === 'git' ? options.gitObjectFormat : undefined;
|
|
17
19
|
const gitCapture = gitObjectFormat === undefined
|
|
18
20
|
? undefined
|
|
19
21
|
: {
|
|
@@ -99,14 +101,20 @@ export async function captureStableTree(options) {
|
|
|
99
101
|
});
|
|
100
102
|
const second = await captureTree(options);
|
|
101
103
|
if (first.treeHash === second.treeHash
|
|
102
|
-
&&
|
|
103
|
-
&&
|
|
104
|
+
&& sameWorkspaceFence(first.source.state, second.source.state)
|
|
105
|
+
&& sameStagedPaths(first.source, second.source)
|
|
104
106
|
&& arraysEqual(first.source.paths, second.source.paths)) {
|
|
105
107
|
return second;
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures');
|
|
109
111
|
}
|
|
112
|
+
/** Whether two captures observed the same index state; ordinary directories have none. */
|
|
113
|
+
function sameStagedPaths(left, right) {
|
|
114
|
+
if (left.state.type !== 'git' || right.state.type !== 'git')
|
|
115
|
+
return true;
|
|
116
|
+
return arraysEqual(left.state.stagedPaths, right.state.stagedPaths);
|
|
117
|
+
}
|
|
110
118
|
/** Compute stable path-level differences between two captured trees. */
|
|
111
119
|
export function diffTrees(before, after, comparisonBefore = before, comparisonAfter = after) {
|
|
112
120
|
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DirectoryWorkspaceState, ResolvedChangeLedgerConfig } from './types.js';
|
|
2
|
+
/** Ordinary-directory discovery result plus its eligible path inventory. */
|
|
3
|
+
export interface DirectorySnapshotSource {
|
|
4
|
+
readonly state: DirectoryWorkspaceState;
|
|
5
|
+
readonly paths: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
/** Resolve the canonical ordinary directory used as a non-Git workspace root. */
|
|
8
|
+
export declare function discoverDirectoryRoot(cwd: string): Promise<string>;
|
|
9
|
+
/** Enumerate regular files and symlinks in an ordinary directory using rewind ignore rules. */
|
|
10
|
+
export declare function discoverDirectory(cwd: string, config: Pick<ResolvedChangeLedgerConfig, 'maxFiles' | 'maxFileBytes'>, signal?: AbortSignal): Promise<DirectorySnapshotSource>;
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export declare class ChangeLedgerEngine {
|
|
|
19
19
|
/** Wait for startup reconciliation and return the number of interrupted journals found. */
|
|
20
20
|
initialize(): Promise<number>;
|
|
21
21
|
private initializeStore;
|
|
22
|
+
/**
|
|
23
|
+
* Acquire the exclusive lock for one workspace.
|
|
24
|
+
*
|
|
25
|
+
* A Git worktree also binds the shared per-worktree lock derived from its Git
|
|
26
|
+
* identity; an ordinary directory owns only the durable directory lock.
|
|
27
|
+
*/
|
|
22
28
|
private acquireWorkspace;
|
|
23
29
|
private reconcileGitCheckpointJournals;
|
|
24
30
|
private publishGitManifest;
|
package/lib/types/git.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { GitWorkspaceState } from './types.js';
|
|
2
2
|
/** Repository discovery result plus the eligible path inventory. */
|
|
3
3
|
export interface RepositorySnapshotSource {
|
|
4
|
-
readonly state:
|
|
4
|
+
readonly state: GitWorkspaceState;
|
|
5
5
|
readonly paths: readonly string[];
|
|
6
6
|
}
|
|
7
7
|
/** Durable identity and shared lock location for one concrete Git worktree. */
|
|
@@ -14,6 +14,17 @@ export interface GitWorktreeIdentity {
|
|
|
14
14
|
}
|
|
15
15
|
/** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
|
|
16
16
|
export declare function discoverRepository(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource>;
|
|
17
|
+
/**
|
|
18
|
+
* Discover the owning Git worktree, or return undefined when `cwd` is not
|
|
19
|
+
* inside any repository. Every other discovery failure still throws, so a
|
|
20
|
+
* broken repository never silently degrades into an ordinary directory.
|
|
21
|
+
* @param cwd - candidate working directory.
|
|
22
|
+
* @param signal - optional caller cancellation.
|
|
23
|
+
* @returns the snapshot source, or undefined outside every repository.
|
|
24
|
+
*/
|
|
25
|
+
export declare function discoverRepositoryOptional(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource | undefined>;
|
|
26
|
+
/** Resolve the Git worktree root, or undefined when `cwd` is outside every repository. */
|
|
27
|
+
export declare function discoverRepositoryRootOptional(cwd: string, signal?: AbortSignal): Promise<string | undefined>;
|
|
17
28
|
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
18
29
|
export declare function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string>;
|
|
19
30
|
/** Create or read the per-worktree identity used by refs and cross-store locking. */
|
|
@@ -21,7 +32,7 @@ export declare function ensureGitWorktreeIdentity(cwd: string, signal?: AbortSig
|
|
|
21
32
|
/** Resolve a moved linked worktree from its common directory and durable identity. */
|
|
22
33
|
export declare function resolveGitWorktreeRoot(commonDir: string, worktreeId: string, fallback: string, signal?: AbortSignal): Promise<string>;
|
|
23
34
|
/** Return true when two repository fences refer to the same checkout state. */
|
|
24
|
-
export declare function sameRepositoryFence(left:
|
|
35
|
+
export declare function sameRepositoryFence(left: GitWorkspaceState, right: GitWorkspaceState): boolean;
|
|
25
36
|
/** Run Git with stable non-interactive defaults and return UTF-8 output. */
|
|
26
37
|
export declare function runGit(cwd: string, args: readonly string[], signal?: AbortSignal, options?: {
|
|
27
38
|
readonly env?: NodeJS.ProcessEnv;
|
|
@@ -42,4 +53,4 @@ export declare function runGitBuffer(cwd: string, args: readonly string[], signa
|
|
|
42
53
|
readonly maxBuffer?: number;
|
|
43
54
|
}): Promise<Buffer>;
|
|
44
55
|
/** Return the Git metadata directory for diagnostics. */
|
|
45
|
-
export declare function gitMetadataParent(state:
|
|
56
|
+
export declare function gitMetadataParent(state: GitWorkspaceState): string;
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { type RepositorySnapshotSource } from './git.js';
|
|
2
1
|
import type { LedgerStore } from './store.js';
|
|
2
|
+
import { type WorkspaceSnapshotSource } from './workspace.js';
|
|
3
3
|
import type { ResolvedChangeLedgerConfig, SnapshotEntry, WorkspaceChange } from './types.js';
|
|
4
4
|
/** One captured tree, optionally persisted into the blob store. */
|
|
5
5
|
export interface CapturedTree {
|
|
6
|
-
readonly source:
|
|
6
|
+
readonly source: WorkspaceSnapshotSource;
|
|
7
7
|
readonly entries: Readonly<Record<string, SnapshotEntry>>;
|
|
8
8
|
readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>;
|
|
9
9
|
readonly treeHash: string;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -26,7 +26,8 @@ export interface SymlinkSnapshotEntry {
|
|
|
26
26
|
/** One path captured by a restore point. */
|
|
27
27
|
export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry;
|
|
28
28
|
/** Git facts that fence a restore point to the repository state it observed. */
|
|
29
|
-
export interface
|
|
29
|
+
export interface GitWorkspaceState {
|
|
30
|
+
readonly type: 'git';
|
|
30
31
|
readonly root: string;
|
|
31
32
|
readonly commonDir: string;
|
|
32
33
|
readonly head?: string;
|
|
@@ -34,6 +35,15 @@ export interface RepositoryState {
|
|
|
34
35
|
readonly operation?: string;
|
|
35
36
|
readonly stagedPaths: readonly string[];
|
|
36
37
|
}
|
|
38
|
+
/** Identity of an ordinary directory workspace captured without Git. */
|
|
39
|
+
export interface DirectoryWorkspaceState {
|
|
40
|
+
readonly type: 'directory';
|
|
41
|
+
readonly root: string;
|
|
42
|
+
}
|
|
43
|
+
/** Workspace facts that fence a restore point to the state it observed. */
|
|
44
|
+
export type WorkspaceState = GitWorkspaceState | DirectoryWorkspaceState;
|
|
45
|
+
/** @deprecated Use `WorkspaceState`; the durable manifest field keeps its original name. */
|
|
46
|
+
export type RepositoryState = WorkspaceState;
|
|
37
47
|
/** Why a restore point exists. */
|
|
38
48
|
export type RestorePointKind = 'user' | 'rescue' | 'turn';
|
|
39
49
|
/** Durable workspace restore point. */
|
|
@@ -77,8 +87,10 @@ export interface GitCheckpointMetadata {
|
|
|
77
87
|
readonly newlyStoredBytes: number;
|
|
78
88
|
}
|
|
79
89
|
/** Automatic turn checkpoint whose content is stored in the repository object database. */
|
|
80
|
-
export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash'> {
|
|
90
|
+
export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash' | 'repository'> {
|
|
81
91
|
readonly version: typeof GIT_CHECKPOINT_FORMAT_VERSION;
|
|
92
|
+
/** Git-native checkpoints only exist for a Git worktree. */
|
|
93
|
+
readonly repository: GitWorkspaceState;
|
|
82
94
|
/** Deterministic sidecar identity; Git tree integrity is verified separately. */
|
|
83
95
|
readonly treeHash: string;
|
|
84
96
|
readonly git: GitCheckpointMetadata;
|
|
@@ -110,6 +122,7 @@ export interface RestorePointSummary {
|
|
|
110
122
|
readonly id: RestorePointId;
|
|
111
123
|
readonly kind: RestorePointKind;
|
|
112
124
|
readonly workspace: string;
|
|
125
|
+
readonly workspaceType: WorkspaceState['type'];
|
|
113
126
|
readonly sessionId?: string;
|
|
114
127
|
readonly label?: string;
|
|
115
128
|
readonly parentRestorePoint?: RestorePointId;
|