@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/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(this.workspaceDir(workspace), 'manifests'))) {
360
+ for (const filename of await safeJsonNames(join(workspaceDir, 'manifests'))) {
340
361
  throwIfAborted(signal)
341
- const manifest = parseManifest(await readJson(join(this.workspaceDir(workspace), 'manifests', filename)))
362
+ const manifest = parseManifest(await readJson(join(workspaceDir, 'manifests', filename)))
342
363
  this.assertManifestOwned(manifest)
343
- await this.assertManifestWorkspaceIdentity(manifest, this.workspaceDir(workspace))
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(this.workspaceDir(workspace), 'manifests')
381
+ const directory = join(workspaceDir, 'manifests')
359
382
  try {
360
- await unlink(this.manifestPath(workspace, id))
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 filename of await safeJsonNames(join(workspaceDir, 'git-journals'))) {
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 ${filename} is stored under the wrong workspace key`)
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(this.workspaceDir(workspace), 'operations'))) {
489
- const operation = parseOperation(await readJson(join(this.workspaceDir(workspace), 'operations', filename)))
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.listManifests(workspace, signal)) {
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(this.workspaceDir(workspace), 'blobs')
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
+ }