@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/src/engine.ts
CHANGED
|
@@ -4,13 +4,7 @@ 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 {
|
|
8
|
-
discoverRepository,
|
|
9
|
-
discoverRepositoryRoot,
|
|
10
|
-
ensureGitWorktreeIdentity,
|
|
11
|
-
resolveGitWorktreeRoot,
|
|
12
|
-
sameRepositoryFence,
|
|
13
|
-
} from './git.js'
|
|
7
|
+
import { ensureGitWorktreeIdentity, resolveGitWorktreeRoot } from './git.js'
|
|
14
8
|
import {
|
|
15
9
|
captureGitTurnCheckpoint,
|
|
16
10
|
deleteGitCheckpoint,
|
|
@@ -34,6 +28,7 @@ import {
|
|
|
34
28
|
} from './path-utils.js'
|
|
35
29
|
import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js'
|
|
36
30
|
import { LedgerStore, type GitCheckpointJournal } from './store.js'
|
|
31
|
+
import { discoverWorkspace, discoverWorkspaceIdentity, sameWorkspaceFence } from './workspace.js'
|
|
37
32
|
import {
|
|
38
33
|
LEDGER_FORMAT_VERSION,
|
|
39
34
|
type ChangeLedgerConfig,
|
|
@@ -50,6 +45,7 @@ import {
|
|
|
50
45
|
type SnapshotEntry,
|
|
51
46
|
type WorkspaceChange,
|
|
52
47
|
type WorkspaceOverview,
|
|
48
|
+
type WorkspaceState,
|
|
53
49
|
type WorkspacePurgeReport,
|
|
54
50
|
} from './types.js'
|
|
55
51
|
|
|
@@ -109,12 +105,24 @@ export class ChangeLedgerEngine {
|
|
|
109
105
|
return restoredOperations + await this.reconcileGitCheckpointJournals()
|
|
110
106
|
}
|
|
111
107
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Acquire the exclusive lock for one workspace.
|
|
110
|
+
*
|
|
111
|
+
* A Git worktree also binds the shared per-worktree lock derived from its Git
|
|
112
|
+
* identity; an ordinary directory owns only the durable directory lock.
|
|
113
|
+
*/
|
|
114
|
+
private async acquireWorkspace(
|
|
115
|
+
workspace: Pick<WorkspaceState, 'type' | 'root'>,
|
|
116
|
+
signal?: AbortSignal,
|
|
117
|
+
): Promise<() => Promise<void>> {
|
|
118
|
+
if (workspace.type === 'directory') {
|
|
119
|
+
return this.store.acquire(workspace.root, undefined, undefined, signal)
|
|
120
|
+
}
|
|
121
|
+
const identity = await ensureGitWorktreeIdentity(workspace.root, signal)
|
|
122
|
+
if (identity.root !== workspace.root) {
|
|
123
|
+
throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace.root)}`)
|
|
116
124
|
}
|
|
117
|
-
return this.store.acquire(workspace, identity.lockPath, {
|
|
125
|
+
return this.store.acquire(workspace.root, identity.lockPath, {
|
|
118
126
|
commonDir: identity.commonDir,
|
|
119
127
|
gitDir: identity.gitDir,
|
|
120
128
|
worktreeId: identity.worktreeId,
|
|
@@ -126,7 +134,7 @@ export class ChangeLedgerEngine {
|
|
|
126
134
|
for (const journal of await this.store.listGitCheckpointJournals()) {
|
|
127
135
|
const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace)
|
|
128
136
|
const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace }
|
|
129
|
-
const release = await this.acquireWorkspace(workspace)
|
|
137
|
+
const release = await this.acquireWorkspace({ type: 'git', root: workspace })
|
|
130
138
|
try {
|
|
131
139
|
const manifest = await this.tryReadManifest(workspace, journal.restorePointId)
|
|
132
140
|
if (journal.action === 'publish') {
|
|
@@ -223,9 +231,9 @@ export class ChangeLedgerEngine {
|
|
|
223
231
|
readonly signal?: AbortSignal
|
|
224
232
|
}): Promise<RestorePointSummary> {
|
|
225
233
|
await this.ready
|
|
226
|
-
const source = await
|
|
234
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
227
235
|
await this.assertStorageSeparated(source.state.root)
|
|
228
|
-
const release = await this.acquireWorkspace(source.state
|
|
236
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
229
237
|
try {
|
|
230
238
|
const label = normalizeLabel(options.label)
|
|
231
239
|
const manifest = await this.createLocked({
|
|
@@ -267,9 +275,9 @@ export class ChangeLedgerEngine {
|
|
|
267
275
|
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer')
|
|
268
276
|
}
|
|
269
277
|
try {
|
|
270
|
-
const source = await
|
|
278
|
+
const source = await discoverWorkspace(options.cwd, this.config, signal)
|
|
271
279
|
await this.assertStorageSeparated(source.state.root)
|
|
272
|
-
const release = await this.acquireWorkspace(source.state
|
|
280
|
+
const release = await this.acquireWorkspace(source.state, signal)
|
|
273
281
|
try {
|
|
274
282
|
const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal)
|
|
275
283
|
const duplicate = existing.find(manifest => manifest.kind === 'turn'
|
|
@@ -289,7 +297,9 @@ export class ChangeLedgerEngine {
|
|
|
289
297
|
|
|
290
298
|
throwIfAborted(signal)
|
|
291
299
|
let manifest: RestorePointManifest
|
|
292
|
-
|
|
300
|
+
// Git-native checkpoints live in the repository object database, so an
|
|
301
|
+
// ordinary directory always uses the ledger's own content-addressed store.
|
|
302
|
+
if (this.config.turnCheckpointMode === 'legacy' || source.state.type === 'directory') {
|
|
293
303
|
manifest = await this.createLocked({
|
|
294
304
|
cwd: source.state.root,
|
|
295
305
|
kind: 'turn',
|
|
@@ -352,21 +362,21 @@ export class ChangeLedgerEngine {
|
|
|
352
362
|
readonly signal?: AbortSignal
|
|
353
363
|
}): Promise<void> {
|
|
354
364
|
await waitWithSignal(this.ready, options.signal)
|
|
355
|
-
const workspace = await
|
|
356
|
-
await this.assertStorageSeparated(workspace)
|
|
365
|
+
const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal)
|
|
366
|
+
await this.assertStorageSeparated(workspace.root)
|
|
357
367
|
const release = await this.acquireWorkspace(workspace, options.signal)
|
|
358
368
|
try {
|
|
359
369
|
throwIfAborted(options.signal)
|
|
360
|
-
const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest =>
|
|
370
|
+
const existing = (await this.store.listManifests(workspace.root, options.signal)).some(manifest =>
|
|
361
371
|
manifest.kind === 'turn'
|
|
362
372
|
&& manifest.sessionId === options.sessionId
|
|
363
373
|
&& manifest.turn === options.turn
|
|
364
374
|
&& manifest.turnStartSeq === options.turnStartSeq)
|
|
365
375
|
if (existing) {
|
|
366
|
-
await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq)
|
|
376
|
+
await this.store.deleteTurnCheckpointSkip(workspace.root, options.sessionId, options.turn, options.turnStartSeq)
|
|
367
377
|
return
|
|
368
378
|
}
|
|
369
|
-
await this.store.writeTurnCheckpointSkip(workspace, {
|
|
379
|
+
await this.store.writeTurnCheckpointSkip(workspace.root, {
|
|
370
380
|
version: 1,
|
|
371
381
|
sessionId: options.sessionId,
|
|
372
382
|
turn: options.turn,
|
|
@@ -388,12 +398,12 @@ export class ChangeLedgerEngine {
|
|
|
388
398
|
readonly signal?: AbortSignal
|
|
389
399
|
}): Promise<{ readonly reason: string } | undefined> {
|
|
390
400
|
await this.ready
|
|
391
|
-
const workspace = await
|
|
392
|
-
await this.assertStorageSeparated(workspace)
|
|
401
|
+
const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal)
|
|
402
|
+
await this.assertStorageSeparated(workspace.root)
|
|
393
403
|
const release = await this.acquireWorkspace(workspace, options.signal)
|
|
394
404
|
try {
|
|
395
405
|
const skip = await this.store.readTurnCheckpointSkip(
|
|
396
|
-
workspace,
|
|
406
|
+
workspace.root,
|
|
397
407
|
options.sessionId,
|
|
398
408
|
options.turn,
|
|
399
409
|
options.turnStartSeq,
|
|
@@ -412,9 +422,9 @@ export class ChangeLedgerEngine {
|
|
|
412
422
|
readonly signal?: AbortSignal
|
|
413
423
|
}): Promise<RestorePointSummary | undefined> {
|
|
414
424
|
await this.ready
|
|
415
|
-
const source = await
|
|
425
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
416
426
|
await this.assertStorageSeparated(source.state.root)
|
|
417
|
-
const release = await this.acquireWorkspace(source.state
|
|
427
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
418
428
|
try {
|
|
419
429
|
const manifest = (await this.store.listManifests(source.state.root)).find(point =>
|
|
420
430
|
point.kind === 'turn'
|
|
@@ -436,9 +446,9 @@ export class ChangeLedgerEngine {
|
|
|
436
446
|
readonly signal?: AbortSignal
|
|
437
447
|
}): Promise<RestorePointSummary[]> {
|
|
438
448
|
await this.ready
|
|
439
|
-
const source = await
|
|
449
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
440
450
|
await this.assertStorageSeparated(source.state.root)
|
|
441
|
-
const release = await this.acquireWorkspace(source.state
|
|
451
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
442
452
|
try {
|
|
443
453
|
const manifests = await this.store.listManifests(source.state.root)
|
|
444
454
|
return manifests
|
|
@@ -458,9 +468,9 @@ export class ChangeLedgerEngine {
|
|
|
458
468
|
readonly signal?: AbortSignal
|
|
459
469
|
}): Promise<RestorePointInspection> {
|
|
460
470
|
await this.ready
|
|
461
|
-
const source = await
|
|
471
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
462
472
|
await this.assertStorageSeparated(source.state.root)
|
|
463
|
-
const release = await this.acquireWorkspace(source.state
|
|
473
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
464
474
|
try {
|
|
465
475
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
466
476
|
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
@@ -472,15 +482,17 @@ export class ChangeLedgerEngine {
|
|
|
472
482
|
})
|
|
473
483
|
const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
|
|
474
484
|
if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
|
|
485
|
+
const currentState = current.source.state
|
|
486
|
+
assertWorkspaceIdentityCompatible(manifest.repository, currentState)
|
|
475
487
|
return {
|
|
476
488
|
restorePoint: summarize(manifest),
|
|
477
489
|
currentTreeHash: current.treeHash,
|
|
478
|
-
currentRepository:
|
|
479
|
-
...(
|
|
480
|
-
...(
|
|
481
|
-
...(
|
|
482
|
-
headChanged: repositoryHeadChanged(manifest.repository,
|
|
483
|
-
operationChanged: manifest.repository
|
|
490
|
+
currentRepository: currentState,
|
|
491
|
+
...(currentState.type === 'git' && currentState.head !== undefined ? { currentHead: currentState.head } : {}),
|
|
492
|
+
...(currentState.type === 'git' && currentState.branch !== undefined ? { currentBranch: currentState.branch } : {}),
|
|
493
|
+
...(currentState.type === 'git' && currentState.operation !== undefined ? { currentOperation: currentState.operation } : {}),
|
|
494
|
+
headChanged: repositoryHeadChanged(manifest.repository, currentState),
|
|
495
|
+
operationChanged: workspaceOperationChanged(manifest.repository, currentState),
|
|
484
496
|
changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
|
|
485
497
|
}
|
|
486
498
|
} finally {
|
|
@@ -501,9 +513,9 @@ export class ChangeLedgerEngine {
|
|
|
501
513
|
}): Promise<RestorePlan> {
|
|
502
514
|
await this.ready
|
|
503
515
|
this.expirePlans()
|
|
504
|
-
const source = await
|
|
516
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
505
517
|
await this.assertStorageSeparated(source.state.root)
|
|
506
|
-
const release = await this.acquireWorkspace(source.state
|
|
518
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
507
519
|
try {
|
|
508
520
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
509
521
|
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
@@ -518,8 +530,8 @@ export class ChangeLedgerEngine {
|
|
|
518
530
|
if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
|
|
519
531
|
throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again')
|
|
520
532
|
}
|
|
521
|
-
if (options.expectedRepository !== undefined && !
|
|
522
|
-
throw
|
|
533
|
+
if (options.expectedRepository !== undefined && !sameWorkspaceFence(options.expectedRepository, current.source.state)) {
|
|
534
|
+
throw workspaceStaleError(options.expectedRepository, current.source.state, 'inspection')
|
|
523
535
|
}
|
|
524
536
|
assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true)
|
|
525
537
|
const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries)
|
|
@@ -581,7 +593,7 @@ export class ChangeLedgerEngine {
|
|
|
581
593
|
|
|
582
594
|
try {
|
|
583
595
|
await this.assertStorageSeparated(plan.workspace)
|
|
584
|
-
const release = await this.acquireWorkspace(plan.
|
|
596
|
+
const release = await this.acquireWorkspace(plan.repository, options.signal)
|
|
585
597
|
try {
|
|
586
598
|
const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId)
|
|
587
599
|
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
@@ -721,9 +733,9 @@ export class ChangeLedgerEngine {
|
|
|
721
733
|
if (options.confirmation !== `DELETE ${options.restorePointId}`) {
|
|
722
734
|
throw new ChangeLedgerError('CONFIRMATION_MISMATCH', `confirmation must exactly equal "DELETE ${options.restorePointId}"`)
|
|
723
735
|
}
|
|
724
|
-
const source = await
|
|
736
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
725
737
|
await this.assertStorageSeparated(source.state.root)
|
|
726
|
-
const release = await this.acquireWorkspace(source.state
|
|
738
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
727
739
|
try {
|
|
728
740
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
729
741
|
if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
|
|
@@ -740,9 +752,9 @@ export class ChangeLedgerEngine {
|
|
|
740
752
|
/** List restore operations that were interrupted or require manual recovery. */
|
|
741
753
|
async listRecovery(options: { readonly cwd: string; readonly signal?: AbortSignal }): Promise<RecoverySummary[]> {
|
|
742
754
|
await this.ready
|
|
743
|
-
const source = await
|
|
755
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
744
756
|
await this.assertStorageSeparated(source.state.root)
|
|
745
|
-
const release = await this.acquireWorkspace(source.state
|
|
757
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
746
758
|
try {
|
|
747
759
|
return (await this.store.listOperations(source.state.root))
|
|
748
760
|
.filter((operation): operation is RestoreOperation & { state: 'interrupted' | 'recovery-required' } =>
|
|
@@ -842,10 +854,10 @@ export class ChangeLedgerEngine {
|
|
|
842
854
|
let liveRepository = false
|
|
843
855
|
let release: () => Promise<void>
|
|
844
856
|
try {
|
|
845
|
-
const
|
|
846
|
-
if (root === workspace) {
|
|
847
|
-
await this.assertStorageSeparated(root)
|
|
848
|
-
release = await this.acquireWorkspace(
|
|
857
|
+
const identity = await discoverWorkspaceIdentity(workspace, signal)
|
|
858
|
+
if (identity.root === workspace) {
|
|
859
|
+
await this.assertStorageSeparated(identity.root)
|
|
860
|
+
release = await this.acquireWorkspace(identity, signal)
|
|
849
861
|
liveRepository = true
|
|
850
862
|
} else {
|
|
851
863
|
release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
|
|
@@ -1129,11 +1141,13 @@ export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerC
|
|
|
1129
1141
|
}
|
|
1130
1142
|
|
|
1131
1143
|
function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
1144
|
+
const workspace = manifest.repository
|
|
1132
1145
|
return {
|
|
1133
1146
|
format: manifest.version,
|
|
1134
1147
|
id: manifest.id,
|
|
1135
1148
|
kind: manifest.kind,
|
|
1136
1149
|
workspace: manifest.workspace,
|
|
1150
|
+
workspaceType: workspace.type,
|
|
1137
1151
|
...(manifest.sessionId === undefined ? {} : { sessionId: manifest.sessionId }),
|
|
1138
1152
|
...(manifest.label === undefined ? {} : { label: manifest.label }),
|
|
1139
1153
|
...(manifest.parentRestorePoint === undefined ? {} : { parentRestorePoint: manifest.parentRestorePoint }),
|
|
@@ -1146,22 +1160,34 @@ function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
|
1146
1160
|
totalBytes: manifest.totalBytes,
|
|
1147
1161
|
restoreCount: manifest.restoreCount,
|
|
1148
1162
|
...(manifest.lastRestoredAt === undefined ? {} : { lastRestoredAt: manifest.lastRestoredAt }),
|
|
1149
|
-
...(
|
|
1150
|
-
...(
|
|
1151
|
-
...(
|
|
1152
|
-
stagedPathCount:
|
|
1163
|
+
...(workspace.type === 'git' && workspace.head !== undefined ? { head: workspace.head } : {}),
|
|
1164
|
+
...(workspace.type === 'git' && workspace.branch !== undefined ? { branch: workspace.branch } : {}),
|
|
1165
|
+
...(workspace.type === 'git' && workspace.operation !== undefined ? { operation: workspace.operation } : {}),
|
|
1166
|
+
stagedPathCount: workspace.type === 'git' ? workspace.stagedPaths.length : 0,
|
|
1153
1167
|
...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
|
|
1154
1168
|
}
|
|
1155
1169
|
}
|
|
1156
1170
|
|
|
1171
|
+
/** Reject a fence that now describes a different workspace or workspace mode. */
|
|
1172
|
+
function assertWorkspaceIdentityCompatible(before: WorkspaceState, after: WorkspaceState): void {
|
|
1173
|
+
if (before.type !== after.type) {
|
|
1174
|
+
throw new ChangeLedgerError('WORKSPACE_MODE_CHANGED', `workspace mode changed from ${before.type} to ${after.type}`)
|
|
1175
|
+
}
|
|
1176
|
+
if (before.root !== after.root) {
|
|
1177
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED', 'restore point no longer belongs to this workspace root')
|
|
1178
|
+
}
|
|
1179
|
+
if (before.type === 'git' && after.type === 'git' && before.commonDir !== after.commonDir) {
|
|
1180
|
+
throw new ChangeLedgerError('REPOSITORY_CHANGED', 'restore point no longer belongs to this Git worktree')
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1157
1184
|
function assertRepositoryCompatible(
|
|
1158
1185
|
manifest: RestorePointManifest,
|
|
1159
1186
|
current: RestorePointManifest['repository'],
|
|
1160
1187
|
allowHeadChange: boolean,
|
|
1161
1188
|
): void {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1189
|
+
assertWorkspaceIdentityCompatible(manifest.repository, current)
|
|
1190
|
+
if (manifest.repository.type !== 'git' || current.type !== 'git') return
|
|
1165
1191
|
if (manifest.repository.operation !== current.operation) {
|
|
1166
1192
|
throw new ChangeLedgerError(
|
|
1167
1193
|
'GIT_OPERATION_CHANGED',
|
|
@@ -1177,23 +1203,33 @@ function assertRepositoryCompatible(
|
|
|
1177
1203
|
}
|
|
1178
1204
|
|
|
1179
1205
|
function repositoryHeadChanged(
|
|
1180
|
-
before:
|
|
1181
|
-
after:
|
|
1206
|
+
before: WorkspaceState,
|
|
1207
|
+
after: WorkspaceState,
|
|
1182
1208
|
): boolean {
|
|
1209
|
+
if (before.type !== 'git' || after.type !== 'git') return false
|
|
1183
1210
|
return before.head !== after.head || before.branch !== after.branch
|
|
1184
1211
|
}
|
|
1185
1212
|
|
|
1213
|
+
function workspaceOperationChanged(before: WorkspaceState, after: WorkspaceState): boolean {
|
|
1214
|
+
if (before.type !== 'git' || after.type !== 'git') return false
|
|
1215
|
+
return before.operation !== after.operation
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1186
1218
|
function assertPlanRepositoryFresh(
|
|
1187
|
-
planned:
|
|
1188
|
-
current:
|
|
1219
|
+
planned: WorkspaceState,
|
|
1220
|
+
current: WorkspaceState,
|
|
1189
1221
|
): void {
|
|
1190
|
-
if (planned
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1222
|
+
if (!sameWorkspaceFence(planned, current)) {
|
|
1223
|
+
throw workspaceStaleError(planned, current, 'restore planning')
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** Stable stale-plan error for the workspace mode that actually drifted. */
|
|
1228
|
+
function workspaceStaleError(before: WorkspaceState, after: WorkspaceState, phase: string): ChangeLedgerError {
|
|
1229
|
+
if (before.type === 'git' && after.type === 'git') {
|
|
1230
|
+
return new ChangeLedgerError('PLAN_STALE_REPOSITORY', `Git repository state changed after ${phase}; inspect and plan again`)
|
|
1196
1231
|
}
|
|
1232
|
+
return new ChangeLedgerError('PLAN_STALE_WORKSPACE', `workspace state changed after ${phase}; inspect and plan again`)
|
|
1197
1233
|
}
|
|
1198
1234
|
|
|
1199
1235
|
function assertJournalMatchesManifest(journal: GitCheckpointJournal, manifest: RestorePointManifest): asserts manifest is RestorePointManifestV2 {
|
package/src/git.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { link, lstat, mkdir, open, readFile, realpath, unlink } from 'node:fs/pr
|
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
5
5
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
6
6
|
import { canonicalDirectory, isNodeError, syncDirectory, validateRelativePath } from './path-utils.js'
|
|
7
|
-
import type {
|
|
7
|
+
import type { GitWorkspaceState } from './types.js'
|
|
8
8
|
|
|
9
9
|
const GIT_MAX_BUFFER = 32 * 1024 * 1024
|
|
10
10
|
|
|
11
11
|
/** Repository discovery result plus the eligible path inventory. */
|
|
12
12
|
export interface RepositorySnapshotSource {
|
|
13
|
-
readonly state:
|
|
13
|
+
readonly state: GitWorkspaceState
|
|
14
14
|
readonly paths: readonly string[]
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -63,6 +63,7 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
|
|
|
63
63
|
|
|
64
64
|
return {
|
|
65
65
|
state: {
|
|
66
|
+
type: 'git',
|
|
66
67
|
root,
|
|
67
68
|
commonDir,
|
|
68
69
|
...(head === undefined ? {} : { head }),
|
|
@@ -74,6 +75,46 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Discover the owning Git worktree, or return undefined when `cwd` is not
|
|
80
|
+
* inside any repository. Every other discovery failure still throws, so a
|
|
81
|
+
* broken repository never silently degrades into an ordinary directory.
|
|
82
|
+
* @param cwd - candidate working directory.
|
|
83
|
+
* @param signal - optional caller cancellation.
|
|
84
|
+
* @returns the snapshot source, or undefined outside every repository.
|
|
85
|
+
*/
|
|
86
|
+
export async function discoverRepositoryOptional(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource | undefined> {
|
|
87
|
+
try {
|
|
88
|
+
return await discoverRepository(cwd, signal)
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (isNotRepositoryError(error)) return undefined
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolve the Git worktree root, or undefined when `cwd` is outside every repository. */
|
|
96
|
+
export async function discoverRepositoryRootOptional(cwd: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
97
|
+
try {
|
|
98
|
+
return await discoverRepositoryRoot(cwd, signal)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (isNotRepositoryError(error)) return undefined
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether one discovery failure is Git's plain "not a git repository" verdict.
|
|
107
|
+
*
|
|
108
|
+
* `gitEnvironment` pins the C locale, so the message text is stable and this
|
|
109
|
+
* three-way check (code, exit status, message) cannot swallow a real failure.
|
|
110
|
+
*/
|
|
111
|
+
function isNotRepositoryError(error: unknown): boolean {
|
|
112
|
+
return error instanceof ChangeLedgerError
|
|
113
|
+
&& error.code === 'GIT_COMMAND_FAILED'
|
|
114
|
+
&& gitExitCode(error.cause) === 128
|
|
115
|
+
&& error.message.includes('not a git repository')
|
|
116
|
+
}
|
|
117
|
+
|
|
77
118
|
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
78
119
|
export async function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
79
120
|
const canonicalCwd = await canonicalDirectory(cwd)
|
|
@@ -131,7 +172,7 @@ export async function resolveGitWorktreeRoot(
|
|
|
131
172
|
}
|
|
132
173
|
|
|
133
174
|
/** Return true when two repository fences refer to the same checkout state. */
|
|
134
|
-
export function sameRepositoryFence(left:
|
|
175
|
+
export function sameRepositoryFence(left: GitWorkspaceState, right: GitWorkspaceState): boolean {
|
|
135
176
|
return left.root === right.root
|
|
136
177
|
&& left.commonDir === right.commonDir
|
|
137
178
|
&& left.head === right.head
|
|
@@ -302,6 +343,10 @@ function gitEnvironment(extra: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv
|
|
|
302
343
|
...extra,
|
|
303
344
|
GIT_OPTIONAL_LOCKS: '0',
|
|
304
345
|
GIT_TERMINAL_PROMPT: '0',
|
|
346
|
+
// Git's diagnostics are matched for the "not a git repository" verdict that
|
|
347
|
+
// selects ordinary-directory mode; pin the C locale so that stays stable.
|
|
348
|
+
LC_ALL: 'C',
|
|
349
|
+
LANG: 'C',
|
|
305
350
|
}
|
|
306
351
|
}
|
|
307
352
|
|
|
@@ -495,6 +540,6 @@ async function publishIdentityFile(path: string, value: string, repairMalformed:
|
|
|
495
540
|
}
|
|
496
541
|
|
|
497
542
|
/** Return the Git metadata directory for diagnostics. */
|
|
498
|
-
export function gitMetadataParent(state:
|
|
543
|
+
export function gitMetadataParent(state: GitWorkspaceState): string {
|
|
499
544
|
return dirname(state.commonDir)
|
|
500
545
|
}
|
package/src/rewind-host.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
3
3
|
import { createDeadline } from './deadline.js'
|
|
4
4
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
5
5
|
import type { ChangeLedgerEngine } from './engine.js'
|
|
6
|
-
import {
|
|
6
|
+
import { discoverWorkspaceRoot } from './workspace.js'
|
|
7
7
|
|
|
8
8
|
interface SessionEventLike {
|
|
9
9
|
readonly type: string
|
|
@@ -14,12 +14,23 @@ interface SessionEventLike {
|
|
|
14
14
|
interface SessionHeaderLike {
|
|
15
15
|
readonly cwd?: string
|
|
16
16
|
readonly parentSession?: string
|
|
17
|
+
/**
|
|
18
|
+
* Fork-inherited prefix length on DSH releases before 0.1.5. 0.1.5 deleted the
|
|
19
|
+
* field — a stored header that still carries it is rejected outright — and moved
|
|
20
|
+
* the same quantity to {@link SessionLike.inheritedEventCount}.
|
|
21
|
+
*/
|
|
17
22
|
readonly seedLength?: number
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
interface SessionLike {
|
|
21
26
|
readonly id: string
|
|
22
27
|
readonly header: SessionHeaderLike
|
|
28
|
+
/**
|
|
29
|
+
* Exact number of leading events inherited from the fork parent, as reported by
|
|
30
|
+
* DSH 0.1.5+ on the live session and on the stored session-log snapshot. `0` —
|
|
31
|
+
* never `undefined` — for a session with no inherited prefix.
|
|
32
|
+
*/
|
|
33
|
+
readonly inheritedEventCount?: number
|
|
23
34
|
readonly events?: readonly SessionEventLike[]
|
|
24
35
|
snapshotEvents?(fromSeq?: number, toSeqExclusive?: number): readonly SessionEventLike[]
|
|
25
36
|
}
|
|
@@ -45,7 +56,11 @@ interface SessionsLike {
|
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
interface SessionQueryLike {
|
|
48
|
-
readSession(id: string): Promise<{
|
|
59
|
+
readSession(id: string): Promise<{
|
|
60
|
+
readonly session: SessionHeaderLike
|
|
61
|
+
readonly events: readonly SessionEventLike[]
|
|
62
|
+
readonly inheritedEventCount?: number
|
|
63
|
+
}>
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
interface HttpRequestLike {
|
|
@@ -223,7 +238,7 @@ export class TurnCheckpointCoordinator {
|
|
|
223
238
|
this.skips.delete(key)
|
|
224
239
|
const capture = (async () => {
|
|
225
240
|
try {
|
|
226
|
-
const workspace = await
|
|
241
|
+
const workspace = await discoverWorkspaceRoot(cwd, captureSignal)
|
|
227
242
|
await this.serializeWorkspace(workspace, captureSignal, async () => {
|
|
228
243
|
try {
|
|
229
244
|
await this.engine.createTurnCheckpoint({
|
|
@@ -531,7 +546,29 @@ async function readSession(
|
|
|
531
546
|
const live = ctx.sessions.get(sessionId)
|
|
532
547
|
if (live !== undefined) return live
|
|
533
548
|
const stored = await ctx.sessionQuery.readSession(sessionId)
|
|
534
|
-
return {
|
|
549
|
+
return {
|
|
550
|
+
id: sessionId,
|
|
551
|
+
header: stored.session,
|
|
552
|
+
events: stored.events,
|
|
553
|
+
...(stored.inheritedEventCount === undefined ? {} : { inheritedEventCount: stored.inheritedEventCount }),
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Fork-inherited prefix length reported by the host, or `undefined` when the host
|
|
559
|
+
* exposes no such datum.
|
|
560
|
+
*
|
|
561
|
+
* DSH releases before 0.1.5 carried it as `header.seedLength`; 0.1.5 deleted that
|
|
562
|
+
* field (a stored header still carrying it is rejected) and publishes the same
|
|
563
|
+
* quantity as `inheritedEventCount` on the live session and on its stored snapshot.
|
|
564
|
+
* @param session - live or stored session to read the lineage boundary from.
|
|
565
|
+
* @returns the inherited prefix length in events.
|
|
566
|
+
*/
|
|
567
|
+
function lineageBoundary(session: SessionLike): number | undefined {
|
|
568
|
+
const legacy = session.header.seedLength
|
|
569
|
+
if (typeof legacy === 'number') return legacy
|
|
570
|
+
const current = session.inheritedEventCount
|
|
571
|
+
return typeof current === 'number' ? current : undefined
|
|
535
572
|
}
|
|
536
573
|
|
|
537
574
|
interface MessageTarget {
|
|
@@ -565,12 +602,16 @@ async function resolveMessageCheckpoint(
|
|
|
565
602
|
const seen = new Set<string>([sessionId])
|
|
566
603
|
while (true) {
|
|
567
604
|
const parentId = current.header.parentSession
|
|
568
|
-
|
|
569
|
-
|
|
605
|
+
// A session is lineage-bound exactly when it declares a parent, and only then is
|
|
606
|
+
// the inherited-prefix length meaningful. 0.1.5 reports `0` — not `undefined` —
|
|
607
|
+
// for a session with no inherited prefix, so the datum must never be consulted
|
|
608
|
+
// for a root session or this guard inverts and rejects every unforked session.
|
|
609
|
+
const boundary = parentId === undefined ? undefined : lineageBoundary(current)
|
|
610
|
+
if ((parentId === undefined) !== (boundary === undefined)) {
|
|
570
611
|
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage has incomplete parent metadata')
|
|
571
612
|
}
|
|
572
|
-
if (parentId === undefined ||
|
|
573
|
-
|| target.messageSeq >=
|
|
613
|
+
if (parentId === undefined || boundary === undefined
|
|
614
|
+
|| target.messageSeq >= boundary || target.turnStartSeq >= boundary) {
|
|
574
615
|
return { target }
|
|
575
616
|
}
|
|
576
617
|
if (seen.has(parentId)) {
|
|
@@ -733,16 +774,16 @@ async function sharedWorkspaceSessions(
|
|
|
733
774
|
): Promise<readonly string[]> {
|
|
734
775
|
const listed = agents?.list() ?? []
|
|
735
776
|
if (listed.length === 0) return []
|
|
736
|
-
const root = await
|
|
777
|
+
const root = await discoverWorkspaceRoot(cwd)
|
|
737
778
|
const shared: string[] = []
|
|
738
779
|
for (const agent of listed) {
|
|
739
780
|
if (agent.status !== 'running') continue
|
|
740
781
|
const session = agent.session
|
|
741
782
|
if (session.header.cwd === undefined) continue
|
|
742
783
|
try {
|
|
743
|
-
if (await
|
|
784
|
+
if (await discoverWorkspaceRoot(session.header.cwd) === root) shared.push(session.id)
|
|
744
785
|
} catch {
|
|
745
|
-
// A live Session whose cwd
|
|
786
|
+
// A live Session whose cwd cannot be resolved cannot share this workspace.
|
|
746
787
|
}
|
|
747
788
|
}
|
|
748
789
|
return shared.sort()
|