@anionex/dsh-turn-rewind 0.2.3 → 0.3.1
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 +51 -7
- 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 +36 -10
- package/lib/settings.js +38 -5
- package/lib/snapshot.js +13 -5
- package/lib/types/client/index.d.ts +8 -1
- 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/rewind-host.d.ts +12 -0
- package/lib/types/settings.d.ts +14 -2
- 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 +14 -6
- package/src/client/index.tsx +52 -7
- package/src/directory.ts +194 -0
- package/src/engine.ts +104 -68
- package/src/git.ts +49 -4
- package/src/rewind-host.ts +52 -11
- package/src/settings.ts +73 -5
- 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({
|
|
@@ -360,7 +360,29 @@ async function readSession(ctx, sessionId) {
|
|
|
360
360
|
if (live !== undefined)
|
|
361
361
|
return live;
|
|
362
362
|
const stored = await ctx.sessionQuery.readSession(sessionId);
|
|
363
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
id: sessionId,
|
|
365
|
+
header: stored.session,
|
|
366
|
+
events: stored.events,
|
|
367
|
+
...(stored.inheritedEventCount === undefined ? {} : { inheritedEventCount: stored.inheritedEventCount }),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Fork-inherited prefix length reported by the host, or `undefined` when the host
|
|
372
|
+
* exposes no such datum.
|
|
373
|
+
*
|
|
374
|
+
* DSH releases before 0.1.5 carried it as `header.seedLength`; 0.1.5 deleted that
|
|
375
|
+
* field (a stored header still carrying it is rejected) and publishes the same
|
|
376
|
+
* quantity as `inheritedEventCount` on the live session and on its stored snapshot.
|
|
377
|
+
* @param session - live or stored session to read the lineage boundary from.
|
|
378
|
+
* @returns the inherited prefix length in events.
|
|
379
|
+
*/
|
|
380
|
+
function lineageBoundary(session) {
|
|
381
|
+
const legacy = session.header.seedLength;
|
|
382
|
+
if (typeof legacy === 'number')
|
|
383
|
+
return legacy;
|
|
384
|
+
const current = session.inheritedEventCount;
|
|
385
|
+
return typeof current === 'number' ? current : undefined;
|
|
364
386
|
}
|
|
365
387
|
async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
|
|
366
388
|
let current = await readSession(ctx, sessionId);
|
|
@@ -375,12 +397,16 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
|
|
|
375
397
|
const seen = new Set([sessionId]);
|
|
376
398
|
while (true) {
|
|
377
399
|
const parentId = current.header.parentSession;
|
|
378
|
-
|
|
379
|
-
|
|
400
|
+
// A session is lineage-bound exactly when it declares a parent, and only then is
|
|
401
|
+
// the inherited-prefix length meaningful. 0.1.5 reports `0` — not `undefined` —
|
|
402
|
+
// for a session with no inherited prefix, so the datum must never be consulted
|
|
403
|
+
// for a root session or this guard inverts and rejects every unforked session.
|
|
404
|
+
const boundary = parentId === undefined ? undefined : lineageBoundary(current);
|
|
405
|
+
if ((parentId === undefined) !== (boundary === undefined)) {
|
|
380
406
|
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage has incomplete parent metadata');
|
|
381
407
|
}
|
|
382
|
-
if (parentId === undefined ||
|
|
383
|
-
|| target.messageSeq >=
|
|
408
|
+
if (parentId === undefined || boundary === undefined
|
|
409
|
+
|| target.messageSeq >= boundary || target.turnStartSeq >= boundary) {
|
|
384
410
|
return { target };
|
|
385
411
|
}
|
|
386
412
|
if (seen.has(parentId)) {
|
|
@@ -521,7 +547,7 @@ async function sharedWorkspaceSessions(agents, cwd) {
|
|
|
521
547
|
const listed = agents?.list() ?? [];
|
|
522
548
|
if (listed.length === 0)
|
|
523
549
|
return [];
|
|
524
|
-
const root = await
|
|
550
|
+
const root = await discoverWorkspaceRoot(cwd);
|
|
525
551
|
const shared = [];
|
|
526
552
|
for (const agent of listed) {
|
|
527
553
|
if (agent.status !== 'running')
|
|
@@ -530,11 +556,11 @@ async function sharedWorkspaceSessions(agents, cwd) {
|
|
|
530
556
|
if (session.header.cwd === undefined)
|
|
531
557
|
continue;
|
|
532
558
|
try {
|
|
533
|
-
if (await
|
|
559
|
+
if (await discoverWorkspaceRoot(session.header.cwd) === root)
|
|
534
560
|
shared.push(session.id);
|
|
535
561
|
}
|
|
536
562
|
catch {
|
|
537
|
-
// A live Session whose cwd
|
|
563
|
+
// A live Session whose cwd cannot be resolved cannot share this workspace.
|
|
538
564
|
}
|
|
539
565
|
}
|
|
540
566
|
return shared.sort();
|
package/lib/settings.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
2
1
|
import z from '@deepseek-ai/schemastery';
|
|
3
2
|
import { resolveConfig } from './engine.js';
|
|
4
|
-
/**
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Namespace join key shared by the host section and the browser settings card.
|
|
5
|
+
*
|
|
6
|
+
* DSH 0.1.5 removed the `settingsNamespace` helper and made `SettingsNamespace`
|
|
7
|
+
* a compile-time-only brand, so a plain lowercase literal is the entire runtime
|
|
8
|
+
* value — the upstream convention (`const CHAT_SETTINGS_NAMESPACE = 'ui-chat'`).
|
|
9
|
+
* The literal satisfies 0.1.5's registration grammar `/^[a-z][a-z0-9-]*$/` and is
|
|
10
|
+
* the exact key the browser card binds through `ctx.settingsScope.bind`.
|
|
11
|
+
*/
|
|
12
|
+
export const TURN_REWIND_SETTINGS_NAMESPACE = 'turn-rewind';
|
|
6
13
|
/** Schemastery schema for the `turn-rewind` settings namespace. */
|
|
7
14
|
export const TurnRewindSettingsSchema = (() => {
|
|
8
15
|
const defaults = tunableSettings(resolveConfig({}));
|
|
@@ -41,10 +48,15 @@ function tunableSettings(resolved) {
|
|
|
41
48
|
* running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
|
|
42
49
|
* the user layer persists through the DSH settings provider. `storageDir` is never
|
|
43
50
|
* carried by the namespace: the storage root must not move while the engine runs.
|
|
51
|
+
*
|
|
52
|
+
* `settings` is an optional service, so the whole wiring stays behind
|
|
53
|
+
* `ctx.inject(['settings'], …)`: a host without a provider keeps running on the
|
|
54
|
+
* composition entry alone.
|
|
44
55
|
*/
|
|
45
56
|
export function installTurnRewindSettings(ctx, config, engine) {
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
const entry = tunableSettings(resolveConfig(config));
|
|
58
|
+
let source = () => entry;
|
|
59
|
+
const hooks = {
|
|
48
60
|
setSource: (current) => { source = current; },
|
|
49
61
|
onChange: () => {
|
|
50
62
|
try {
|
|
@@ -54,5 +66,26 @@ export function installTurnRewindSettings(ctx, config, engine) {
|
|
|
54
66
|
ctx.logger.warn(`[turn-rewind] could not apply settings update: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
67
|
}
|
|
56
68
|
},
|
|
69
|
+
};
|
|
70
|
+
ctx.inject(['settings'], (scope) => {
|
|
71
|
+
const provider = scope.settings;
|
|
72
|
+
// 0.1.5 owns the canonical wiring on the provider itself; it also suppresses the
|
|
73
|
+
// detach fallback while the consumer is unloading, which the deleted free
|
|
74
|
+
// function did not do.
|
|
75
|
+
if (typeof provider.installSection === 'function') {
|
|
76
|
+
provider.installSection(ctx, TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, entry, hooks);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Older hosts shipped that wiring only as the free `installSettingsSection`,
|
|
80
|
+
// which 0.1.5 removed, so reproduce its body against `register` + `scope.get()`
|
|
81
|
+
// + `scope.watch()` — present in every supported version.
|
|
82
|
+
const settingsScope = provider.register(TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, { base: entry });
|
|
83
|
+
hooks.setSource(() => settingsScope.get());
|
|
84
|
+
scope.effect(() => () => {
|
|
85
|
+
hooks.setSource(() => entry);
|
|
86
|
+
hooks.onChange();
|
|
87
|
+
});
|
|
88
|
+
hooks.onChange();
|
|
89
|
+
settingsScope.watch(() => { hooks.onChange(); });
|
|
57
90
|
});
|
|
58
91
|
}
|
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);
|
|
@@ -140,7 +140,14 @@ export interface ManageOverview {
|
|
|
140
140
|
}
|
|
141
141
|
/** Return the rewind anchor and editable text owned by one direct user message. */
|
|
142
142
|
export declare function selectRewindMessage(node: ConversationNodeLike): RewindMatch | null;
|
|
143
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Browser plugin entry: bridge every direct user-message action row to the rewind UI.
|
|
145
|
+
*
|
|
146
|
+
* Every service read on `ctx` must be declared here: Cordis throws while reading an
|
|
147
|
+
* undeclared service off the context proxy, before optional chaining can apply.
|
|
148
|
+
* `settingsScope` is provided by `@deepseek-ai/dsh-client-ui-settings` and may be
|
|
149
|
+
* absent, which is what `ctx.settingsScope?.bind(…)` below relies on.
|
|
150
|
+
*/
|
|
144
151
|
export declare const inject: string[];
|
|
145
152
|
export declare function apply(ctx: ClientContextLike): void;
|
|
146
153
|
/**
|
|
@@ -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;
|