@anionex/dsh-turn-rewind 0.1.2 → 0.2.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 +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +295 -25
- package/lib/client.js.map +1 -1
- package/lib/engine.js +140 -3
- package/lib/index.js +17 -1
- package/lib/rewind-host.js +104 -9
- package/lib/settings.js +58 -0
- package/lib/store.js +90 -23
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/engine.d.ts +21 -2
- package/lib/types/index.d.ts +7 -0
- package/lib/types/rewind-host.d.ts +16 -2
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/store.d.ts +20 -0
- package/lib/types/types.d.ts +19 -0
- package/package.json +19 -3
- package/src/client/index.tsx +540 -45
- package/src/engine.ts +154 -3
- package/src/index.ts +20 -1
- package/src/rewind-host.ts +122 -9
- package/src/settings.ts +93 -0
- package/src/store.ts +109 -23
- package/src/types.ts +21 -0
package/src/store.ts
CHANGED
|
@@ -203,6 +203,16 @@ export class LedgerStore {
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/** Acquire only the durable directory lock when the recorded worktree is no longer available. */
|
|
207
|
+
async acquireWorkspaceDir(
|
|
208
|
+
workspaceDir: string,
|
|
209
|
+
workspace: string,
|
|
210
|
+
signal?: AbortSignal,
|
|
211
|
+
): Promise<() => Promise<void>> {
|
|
212
|
+
const lease = await this.acquireOne(join(workspaceDir, 'lock.json'), workspace, false, true, signal)
|
|
213
|
+
return () => lease.release()
|
|
214
|
+
}
|
|
215
|
+
|
|
206
216
|
private async acquireOne(
|
|
207
217
|
initialLockPath: string,
|
|
208
218
|
workspace: string,
|
|
@@ -335,18 +345,26 @@ export class LedgerStore {
|
|
|
335
345
|
|
|
336
346
|
/** List all validated restore points for one workspace, newest first. */
|
|
337
347
|
async listManifests(workspace: string, signal?: AbortSignal): Promise<RestorePointManifest[]> {
|
|
348
|
+
const manifests = await this.listManifestsInDir(this.workspaceDir(workspace), signal)
|
|
349
|
+
for (const manifest of manifests) {
|
|
350
|
+
if (manifest.workspace !== workspace) {
|
|
351
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${manifest.id} belongs to a different workspace`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return manifests
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** List validated manifests directly from one storage directory, newest first. */
|
|
358
|
+
async listManifestsInDir(workspaceDir: string, signal?: AbortSignal): Promise<RestorePointManifest[]> {
|
|
338
359
|
const manifests: RestorePointManifest[] = []
|
|
339
|
-
for (const filename of await safeJsonNames(join(
|
|
360
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'manifests'))) {
|
|
340
361
|
throwIfAborted(signal)
|
|
341
|
-
const manifest = parseManifest(await readJson(join(
|
|
362
|
+
const manifest = parseManifest(await readJson(join(workspaceDir, 'manifests', filename)))
|
|
342
363
|
this.assertManifestOwned(manifest)
|
|
343
|
-
await this.assertManifestWorkspaceIdentity(manifest,
|
|
364
|
+
await this.assertManifestWorkspaceIdentity(manifest, workspaceDir)
|
|
344
365
|
if (filename !== `${manifest.id}.json`) {
|
|
345
366
|
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} does not match its persisted id ${manifest.id}`)
|
|
346
367
|
}
|
|
347
|
-
if (manifest.workspace !== workspace) {
|
|
348
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} belongs to a different workspace`)
|
|
349
|
-
}
|
|
350
368
|
manifests.push(manifest)
|
|
351
369
|
}
|
|
352
370
|
return manifests.sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id))
|
|
@@ -354,10 +372,15 @@ export class LedgerStore {
|
|
|
354
372
|
|
|
355
373
|
/** Delete one restore-point manifest. Blobs remain until garbage collection succeeds. */
|
|
356
374
|
async deleteManifest(workspace: string, id: string): Promise<void> {
|
|
375
|
+
await this.deleteManifestInDir(this.workspaceDir(workspace), id)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Delete one restore-point manifest directly from its storage directory. */
|
|
379
|
+
async deleteManifestInDir(workspaceDir: string, id: string): Promise<void> {
|
|
357
380
|
validateRestorePointId(id)
|
|
358
|
-
const directory = join(
|
|
381
|
+
const directory = join(workspaceDir, 'manifests')
|
|
359
382
|
try {
|
|
360
|
-
await unlink(
|
|
383
|
+
await unlink(join(directory, `${id}.json`))
|
|
361
384
|
} catch (error) {
|
|
362
385
|
if (isNodeError(error, 'ENOENT')) {
|
|
363
386
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `restore point ${id} does not exist`)
|
|
@@ -387,20 +410,25 @@ export class LedgerStore {
|
|
|
387
410
|
await writeJsonAtomic(this.gitCheckpointJournalPath(manifest.workspace, manifest.id), journal)
|
|
388
411
|
}
|
|
389
412
|
|
|
413
|
+
/** List every persisted workspace directory under this storage root. */
|
|
414
|
+
async listWorkspaceDirs(signal?: AbortSignal): Promise<string[]> {
|
|
415
|
+
const root = join(this.config.storageDir, 'workspaces')
|
|
416
|
+
const dirs: string[] = []
|
|
417
|
+
for (const key of await safeDirectoryNames(root)) {
|
|
418
|
+
throwIfAborted(signal)
|
|
419
|
+
dirs.push(join(root, key))
|
|
420
|
+
}
|
|
421
|
+
return dirs
|
|
422
|
+
}
|
|
423
|
+
|
|
390
424
|
/** List every pending Git checkpoint transition across this storage root. */
|
|
391
425
|
async listGitCheckpointJournals(): Promise<GitCheckpointJournal[]> {
|
|
392
426
|
const journals: GitCheckpointJournal[] = []
|
|
393
427
|
for (const workspaceKey of await safeDirectoryNames(join(this.config.storageDir, 'workspaces'))) {
|
|
394
428
|
const workspaceDir = join(this.config.storageDir, 'workspaces', workspaceKey)
|
|
395
|
-
for (const
|
|
396
|
-
const journal = parseGitCheckpointJournal(await readJson(join(workspaceDir, 'git-journals', filename)))
|
|
397
|
-
this.assertGitCheckpointJournalOwned(journal)
|
|
398
|
-
await this.assertGitCheckpointJournalWorkspaceIdentity(journal, workspaceDir)
|
|
399
|
-
if (filename !== `${journal.restorePointId}.json`) {
|
|
400
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${filename} does not match its restore-point id`)
|
|
401
|
-
}
|
|
429
|
+
for (const journal of await this.listGitCheckpointJournalsInDir(workspaceDir)) {
|
|
402
430
|
if (this.workspaceDir(journal.workspace) !== workspaceDir) {
|
|
403
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${
|
|
431
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${journal.restorePointId}.json is stored under the wrong workspace key`)
|
|
404
432
|
}
|
|
405
433
|
journals.push(journal)
|
|
406
434
|
}
|
|
@@ -408,6 +436,22 @@ export class LedgerStore {
|
|
|
408
436
|
return journals.sort((left, right) => left.createdAt - right.createdAt || left.restorePointId.localeCompare(right.restorePointId))
|
|
409
437
|
}
|
|
410
438
|
|
|
439
|
+
/** List pending Git checkpoint transitions directly from one storage directory. */
|
|
440
|
+
async listGitCheckpointJournalsInDir(workspaceDir: string, signal?: AbortSignal): Promise<GitCheckpointJournal[]> {
|
|
441
|
+
const journals: GitCheckpointJournal[] = []
|
|
442
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'git-journals'))) {
|
|
443
|
+
throwIfAborted(signal)
|
|
444
|
+
const journal = parseGitCheckpointJournal(await readJson(join(workspaceDir, 'git-journals', filename)))
|
|
445
|
+
this.assertGitCheckpointJournalOwned(journal)
|
|
446
|
+
await this.assertGitCheckpointJournalWorkspaceIdentity(journal, workspaceDir)
|
|
447
|
+
if (filename !== `${journal.restorePointId}.json`) {
|
|
448
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${filename} does not match its restore-point id`)
|
|
449
|
+
}
|
|
450
|
+
journals.push(journal)
|
|
451
|
+
}
|
|
452
|
+
return journals
|
|
453
|
+
}
|
|
454
|
+
|
|
411
455
|
/** Remove one completed Git checkpoint transition journal. */
|
|
412
456
|
async deleteGitCheckpointJournal(workspace: string, id: string): Promise<void> {
|
|
413
457
|
validateRestorePointId(id)
|
|
@@ -484,15 +528,24 @@ export class LedgerStore {
|
|
|
484
528
|
|
|
485
529
|
/** List validated restore operations for one workspace. */
|
|
486
530
|
async listOperations(workspace: string): Promise<RestoreOperation[]> {
|
|
531
|
+
const operations = await this.listOperationsInDir(this.workspaceDir(workspace))
|
|
532
|
+
for (const operation of operations) {
|
|
533
|
+
if (operation.workspace !== workspace) {
|
|
534
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${operation.id} belongs to a different workspace`)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return operations
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** List validated restore operations directly from one storage directory. */
|
|
541
|
+
async listOperationsInDir(workspaceDir: string, signal?: AbortSignal): Promise<RestoreOperation[]> {
|
|
487
542
|
const operations: RestoreOperation[] = []
|
|
488
|
-
for (const filename of await safeJsonNames(join(
|
|
489
|
-
|
|
543
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'operations'))) {
|
|
544
|
+
throwIfAborted(signal)
|
|
545
|
+
const operation = parseOperation(await readJson(join(workspaceDir, 'operations', filename)))
|
|
490
546
|
if (filename !== `${operation.id}.json`) {
|
|
491
547
|
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`)
|
|
492
548
|
}
|
|
493
|
-
if (operation.workspace !== workspace) {
|
|
494
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} belongs to a different workspace`)
|
|
495
|
-
}
|
|
496
549
|
operations.push(operation)
|
|
497
550
|
}
|
|
498
551
|
return operations.sort((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))
|
|
@@ -513,15 +566,48 @@ export class LedgerStore {
|
|
|
513
566
|
): Promise<{ deletedBlobs: number; retainedBlobs: number }> {
|
|
514
567
|
const referenced = new Set<string>(additionalReferenced)
|
|
515
568
|
for (const hash of referenced) validateBlobHash(hash)
|
|
516
|
-
for (const manifest of await this.
|
|
569
|
+
for (const manifest of await this.listManifestsInDir(this.workspaceDir(workspace), signal)) {
|
|
517
570
|
throwIfAborted(signal)
|
|
518
571
|
for (const entry of Object.values(manifest.entries)) {
|
|
519
572
|
if (entry.kind === 'file' && entry.provider !== 'git') referenced.add(entry.blob)
|
|
520
573
|
}
|
|
521
574
|
}
|
|
575
|
+
return this.sweepBlobs(this.workspaceDir(workspace), referenced, signal)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Delete blobs under one storage directory that no manifest in it references. */
|
|
579
|
+
async collectGarbageInDir(
|
|
580
|
+
workspaceDir: string,
|
|
581
|
+
signal?: AbortSignal,
|
|
582
|
+
): Promise<{ deletedBlobs: number; retainedBlobs: number }> {
|
|
583
|
+
const referenced = new Set<string>()
|
|
584
|
+
for (const manifest of await this.listManifestsInDir(workspaceDir, signal)) {
|
|
585
|
+
throwIfAborted(signal)
|
|
586
|
+
for (const entry of Object.values(manifest.entries)) {
|
|
587
|
+
if (entry.kind === 'file' && entry.provider !== 'git') referenced.add(entry.blob)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return this.sweepBlobs(workspaceDir, referenced, signal)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Remove every persisted automatic-checkpoint skip marker under one storage directory. */
|
|
594
|
+
async clearTurnOutcomesInDir(workspaceDir: string): Promise<number> {
|
|
595
|
+
let removed = 0
|
|
596
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'turn-outcomes'))) {
|
|
597
|
+
await unlink(join(workspaceDir, 'turn-outcomes', filename))
|
|
598
|
+
removed += 1
|
|
599
|
+
}
|
|
600
|
+
return removed
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private async sweepBlobs(
|
|
604
|
+
workspaceDir: string,
|
|
605
|
+
referenced: ReadonlySet<string>,
|
|
606
|
+
signal?: AbortSignal,
|
|
607
|
+
): Promise<{ deletedBlobs: number; retainedBlobs: number }> {
|
|
522
608
|
let deletedBlobs = 0
|
|
523
609
|
let retainedBlobs = 0
|
|
524
|
-
const blobsRoot = join(
|
|
610
|
+
const blobsRoot = join(workspaceDir, 'blobs')
|
|
525
611
|
for (const prefix of await safeDirectoryNames(blobsRoot)) {
|
|
526
612
|
throwIfAborted(signal)
|
|
527
613
|
const prefixPath = join(blobsRoot, prefix)
|
package/src/types.ts
CHANGED
|
@@ -240,3 +240,24 @@ export interface RecoverySummary {
|
|
|
240
240
|
readonly error?: string
|
|
241
241
|
readonly rollbackError?: string
|
|
242
242
|
}
|
|
243
|
+
|
|
244
|
+
/** Aggregated checkpoint inventory for one workspace, addressed by its recorded path. */
|
|
245
|
+
export interface WorkspaceOverview {
|
|
246
|
+
/** Canonical workspace path recorded in the persisted manifests. */
|
|
247
|
+
readonly workspace: string
|
|
248
|
+
/** Every restore point stored for the workspace, newest first. */
|
|
249
|
+
readonly restorePoints: readonly RestorePointSummary[]
|
|
250
|
+
/** Sum of every restore point's logical bytes. */
|
|
251
|
+
readonly totalBytes: number
|
|
252
|
+
/** Incomplete restore operations that still reference this workspace's points. */
|
|
253
|
+
readonly recoveryCount: number
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Aggregate cleanup report for one workspace purge. */
|
|
257
|
+
export interface WorkspacePurgeReport {
|
|
258
|
+
readonly workspace: string
|
|
259
|
+
readonly deletedRestorePoints: number
|
|
260
|
+
readonly retainedRestorePoints: number
|
|
261
|
+
readonly deletedBlobs: number
|
|
262
|
+
readonly retainedBlobs: number
|
|
263
|
+
}
|