@anionex/dsh-turn-rewind 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +19 -6
  2. package/README.zh.md +19 -6
  3. package/SECURITY.md +3 -1
  4. package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
  5. package/docs/assets/turn-rewind-dialog.png +0 -0
  6. package/lib/client.js +291 -22
  7. package/lib/client.js.map +1 -1
  8. package/lib/deadline.js +13 -0
  9. package/lib/engine.js +622 -121
  10. package/lib/git-checkpoint.js +739 -0
  11. package/lib/git.js +295 -22
  12. package/lib/index.js +18 -2
  13. package/lib/path-utils.js +9 -0
  14. package/lib/rewind-host.js +250 -48
  15. package/lib/settings.js +58 -0
  16. package/lib/snapshot.js +113 -13
  17. package/lib/store.js +1361 -119
  18. package/lib/types/client/index.d.ts +74 -7
  19. package/lib/types/deadline.d.ts +6 -0
  20. package/lib/types/engine.d.ts +48 -2
  21. package/lib/types/git-checkpoint.d.ts +46 -0
  22. package/lib/types/git.d.ts +31 -0
  23. package/lib/types/index.d.ts +7 -0
  24. package/lib/types/path-utils.d.ts +2 -0
  25. package/lib/types/rewind-host.d.ts +8 -1
  26. package/lib/types/settings.d.ts +44 -0
  27. package/lib/types/snapshot.d.ts +6 -1
  28. package/lib/types/store.d.ts +90 -6
  29. package/lib/types/types.d.ts +63 -1
  30. package/lib/types.js +2 -0
  31. package/lib/validate.js +68 -9
  32. package/package.json +21 -3
  33. package/scripts/benchmark-git-checkpoint.mjs +149 -0
  34. package/scripts/wrap-client.mjs +18 -0
  35. package/src/client/index.tsx +536 -41
  36. package/src/deadline.ts +18 -0
  37. package/src/engine.ts +695 -126
  38. package/src/git-checkpoint.ts +853 -0
  39. package/src/git.ts +337 -22
  40. package/src/index.ts +21 -2
  41. package/src/path-utils.ts +10 -0
  42. package/src/rewind-host.ts +278 -50
  43. package/src/settings.ts +93 -0
  44. package/src/snapshot.ts +122 -12
  45. package/src/store.ts +1502 -104
  46. package/src/types.ts +69 -1
  47. package/src/validate.ts +67 -7
package/src/types.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  /** Durable format version. Readers reject every other version. */
2
2
  export const LEDGER_FORMAT_VERSION = 1 as const
3
3
 
4
+ /** Git-native checkpoint format used by bounded automatic turn capture. */
5
+ export const GIT_CHECKPOINT_FORMAT_VERSION = 2 as const
6
+
4
7
  /** A persisted restore-point identifier. */
5
8
  export type RestorePointId = string
6
9
 
@@ -16,6 +19,8 @@ export interface FileSnapshotEntry {
16
19
  readonly blob: string
17
20
  readonly size: number
18
21
  readonly mode: number
22
+ /** Absent for v1 Ledger blobs; `git` means `blob` is a Git object ID. */
23
+ readonly provider?: 'git'
19
24
  }
20
25
 
21
26
  /** One symbolic-link snapshot. */
@@ -42,7 +47,7 @@ export interface RepositoryState {
42
47
  export type RestorePointKind = 'user' | 'rescue' | 'turn'
43
48
 
44
49
  /** Durable workspace restore point. */
45
- export interface RestorePointManifest {
50
+ export interface RestorePointManifestV1 {
46
51
  readonly version: typeof LEDGER_FORMAT_VERSION
47
52
  readonly id: RestorePointId
48
53
  readonly kind: RestorePointKind
@@ -66,6 +71,34 @@ export interface RestorePointManifest {
66
71
  readonly lastRestoredAt?: number
67
72
  }
68
73
 
74
+ /** Git-native metadata pinned by a private ref. */
75
+ export interface GitCheckpointMetadata {
76
+ readonly objectFormat: 'sha1' | 'sha256'
77
+ readonly trust: 'metadata-fenced' | 'byte-verified'
78
+ /** Stable owner of refs created by one Change Ledger storage root. */
79
+ readonly storeId: string
80
+ /** Durable identity stored inside this specific Git worktree metadata directory. */
81
+ readonly worktreeId: string
82
+ readonly headTree: string
83
+ readonly indexTree: string
84
+ readonly worktreeTree: string
85
+ readonly envelopeTree: string
86
+ readonly commit: string
87
+ readonly ref: string
88
+ readonly newlyStoredBytes: number
89
+ }
90
+
91
+ /** Automatic turn checkpoint whose content is stored in the repository object database. */
92
+ export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash'> {
93
+ readonly version: typeof GIT_CHECKPOINT_FORMAT_VERSION
94
+ /** Deterministic sidecar identity; Git tree integrity is verified separately. */
95
+ readonly treeHash: string
96
+ readonly git: GitCheckpointMetadata
97
+ }
98
+
99
+ /** Durable workspace restore point in either supported format. */
100
+ export type RestorePointManifest = RestorePointManifestV1 | RestorePointManifestV2
101
+
69
102
  /** Observable difference between a restore point and the current workspace. */
70
103
  export interface WorkspaceChange {
71
104
  readonly path: string
@@ -89,6 +122,7 @@ export interface RestorePointInspection {
89
122
 
90
123
  /** Compact restore-point metadata for lists and tool results. */
91
124
  export interface RestorePointSummary {
125
+ readonly format: typeof LEDGER_FORMAT_VERSION | typeof GIT_CHECKPOINT_FORMAT_VERSION
92
126
  readonly id: RestorePointId
93
127
  readonly kind: RestorePointKind
94
128
  readonly workspace: string
@@ -108,6 +142,7 @@ export interface RestorePointSummary {
108
142
  readonly branch?: string
109
143
  readonly operation?: string
110
144
  readonly stagedPathCount: number
145
+ readonly trust?: GitCheckpointMetadata['trust']
111
146
  }
112
147
 
113
148
  /** An expiring restore plan whose confirmation must be echoed exactly. */
@@ -168,6 +203,14 @@ export interface ChangeLedgerConfig {
168
203
  readonly planTtlMs?: number
169
204
  /** Age after which a lock whose owner is gone may be reclaimed. */
170
205
  readonly staleLockMs?: number
206
+ /** Automatic turn-checkpoint implementation. Git-native is bounded and avoids rereading clean tracked content. */
207
+ readonly turnCheckpointMode?: 'off' | 'git-native' | 'legacy'
208
+ /** Maximum time one automatic checkpoint may block the first Agent step. */
209
+ readonly turnCheckpointTimeoutMs?: number
210
+ /** Maximum uncached worktree bytes read by one automatic Git-native checkpoint. */
211
+ readonly turnCheckpointMaxNewBytes?: number
212
+ /** Fast trusts fenced Git/stat metadata; strict rereads every eligible path. */
213
+ readonly turnCheckpointTrust?: 'fast' | 'strict'
171
214
  }
172
215
 
173
216
  /** Fully resolved plugin configuration. */
@@ -180,6 +223,10 @@ export interface ResolvedChangeLedgerConfig {
180
223
  readonly maxSnapshotBytes: number
181
224
  readonly planTtlMs: number
182
225
  readonly staleLockMs: number
226
+ readonly turnCheckpointMode: 'off' | 'git-native' | 'legacy'
227
+ readonly turnCheckpointTimeoutMs: number
228
+ readonly turnCheckpointMaxNewBytes: number
229
+ readonly turnCheckpointTrust: 'fast' | 'strict'
183
230
  }
184
231
 
185
232
  /** Current incomplete restore operation. */
@@ -193,3 +240,24 @@ export interface RecoverySummary {
193
240
  readonly error?: string
194
241
  readonly rollbackError?: string
195
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
+ }
package/src/validate.ts CHANGED
@@ -3,9 +3,11 @@ import { isAbsolute } from 'node:path'
3
3
  import { validateRelativePath } from './path-utils.js'
4
4
  import { hashTree } from './snapshot.js'
5
5
  import {
6
+ GIT_CHECKPOINT_FORMAT_VERSION,
6
7
  LEDGER_FORMAT_VERSION,
7
8
  type RepositoryState,
8
9
  type RestoreOperation,
10
+ type RestorePointKind,
9
11
  type RestorePointManifest,
10
12
  type SnapshotEntry,
11
13
  } from './types.js'
@@ -33,13 +35,17 @@ export function validateOperationId(value: string): string {
33
35
  /** Parse an untrusted durable restore-point manifest. */
34
36
  export function parseManifest(value: unknown): RestorePointManifest {
35
37
  const record = objectRecord(value, 'restore-point manifest')
36
- if (record.version !== LEDGER_FORMAT_VERSION) corrupt(`unsupported restore-point version ${String(record.version)}`)
38
+ const version = record.version
39
+ if (version !== LEDGER_FORMAT_VERSION && version !== GIT_CHECKPOINT_FORMAT_VERSION) {
40
+ corrupt(`unsupported restore-point version ${String(version)}`)
41
+ }
37
42
  const id = stringField(record, 'id')
38
43
  validateRestorePointId(id)
39
44
  const kind = record.kind
40
45
  if (kind !== 'user' && kind !== 'rescue' && kind !== 'turn') {
41
46
  corrupt('restore-point kind must be "user", "rescue", or "turn"')
42
47
  }
48
+ const restorePointKind: RestorePointKind = kind
43
49
  const workspace = absoluteString(record, 'workspace')
44
50
  const repository = parseRepository(record.repository)
45
51
  if (repository.root !== workspace) corrupt('repository.root must equal workspace')
@@ -48,7 +54,7 @@ export function parseManifest(value: unknown): RestorePointManifest {
48
54
  let totalBytes = 0
49
55
  for (const [path, entryValue] of Object.entries(entriesRecord)) {
50
56
  validateRelativePath(path)
51
- const entry = parseEntry(entryValue, path)
57
+ const entry = parseEntry(entryValue, path, version)
52
58
  entries[path] = entry
53
59
  if (entry.kind === 'file') totalBytes += entry.size
54
60
  }
@@ -75,10 +81,9 @@ export function parseManifest(value: unknown): RestorePointManifest {
75
81
  corrupt('only turn restore points may carry turn metadata')
76
82
  }
77
83
  const lastRestoredAt = optionalNonNegativeInteger(record, 'lastRestoredAt')
78
- return {
79
- version: LEDGER_FORMAT_VERSION,
84
+ const common = {
80
85
  id,
81
- kind,
86
+ kind: restorePointKind,
82
87
  workspace,
83
88
  repository,
84
89
  ...(sessionId === undefined ? {} : { sessionId }),
@@ -95,6 +100,9 @@ export function parseManifest(value: unknown): RestorePointManifest {
95
100
  restoreCount,
96
101
  ...(lastRestoredAt === undefined ? {} : { lastRestoredAt }),
97
102
  }
103
+ if (version === LEDGER_FORMAT_VERSION) return { version, ...common }
104
+ if (restorePointKind !== 'turn') corrupt('Git-native v2 restore points are automatic turn checkpoints')
105
+ return { version, ...common, git: parseGitCheckpoint(record.git, id) }
98
106
  }
99
107
 
100
108
  /** Parse an untrusted durable restore-operation journal. */
@@ -170,17 +178,25 @@ function parseRepository(value: unknown): RepositoryState {
170
178
  }
171
179
  }
172
180
 
173
- function parseEntry(value: unknown, path: string): SnapshotEntry {
181
+ function parseEntry(
182
+ value: unknown,
183
+ path: string,
184
+ version: typeof LEDGER_FORMAT_VERSION | typeof GIT_CHECKPOINT_FORMAT_VERSION,
185
+ ): SnapshotEntry {
174
186
  const record = objectRecord(value, `snapshot entry ${JSON.stringify(path)}`)
175
187
  const kind = record.kind
176
188
  const mode = nonNegativeInteger(record, 'mode')
177
189
  if (mode > 0o777) corrupt(`snapshot mode is out of range for ${JSON.stringify(path)}`)
178
190
  if (kind === 'file') {
191
+ const blob = stringField(record, 'blob')
192
+ if (version === LEDGER_FORMAT_VERSION) validateBlobHash(blob)
193
+ else validateGitObject(blob)
179
194
  return {
180
195
  kind,
181
- blob: validateBlobHash(stringField(record, 'blob')),
196
+ blob,
182
197
  size: nonNegativeInteger(record, 'size'),
183
198
  mode,
199
+ ...(version === GIT_CHECKPOINT_FORMAT_VERSION ? { provider: 'git' as const } : {}),
184
200
  }
185
201
  }
186
202
  if (kind === 'symlink') {
@@ -191,6 +207,50 @@ function parseEntry(value: unknown, path: string): SnapshotEntry {
191
207
  corrupt(`invalid snapshot entry kind for ${JSON.stringify(path)}`)
192
208
  }
193
209
 
210
+ function parseGitCheckpoint(value: unknown, restorePointId: string) {
211
+ const record = objectRecord(value, 'Git checkpoint metadata')
212
+ const objectFormat = record.objectFormat
213
+ if (objectFormat !== 'sha1' && objectFormat !== 'sha256') corrupt('Git checkpoint objectFormat must be sha1 or sha256')
214
+ const trust = record.trust
215
+ if (trust !== 'metadata-fenced' && trust !== 'byte-verified') corrupt('Git checkpoint trust is invalid')
216
+ const expectedLength = objectFormat === 'sha1' ? 40 : 64
217
+ const object = (key: string) => {
218
+ const oid = validateGitObject(stringField(record, key))
219
+ if (oid.length !== expectedLength) corrupt(`${key} length does not match Git object format`)
220
+ return oid
221
+ }
222
+ const storeId = hexIdentifier(record, 'storeId')
223
+ const worktreeId = hexIdentifier(record, 'worktreeId')
224
+ const ref = stringField(record, 'ref')
225
+ if (ref !== `refs/dsh-turn-rewind/v2/${storeId}/${worktreeId}/${restorePointId}`) {
226
+ corrupt('Git checkpoint ref does not match its durable store/worktree/restore-point identity')
227
+ }
228
+ return {
229
+ objectFormat,
230
+ trust,
231
+ storeId,
232
+ worktreeId,
233
+ headTree: object('headTree'),
234
+ indexTree: object('indexTree'),
235
+ worktreeTree: object('worktreeTree'),
236
+ envelopeTree: object('envelopeTree'),
237
+ commit: object('commit'),
238
+ ref,
239
+ newlyStoredBytes: nonNegativeInteger(record, 'newlyStoredBytes'),
240
+ } as const
241
+ }
242
+
243
+ function hexIdentifier(record: Record<string, unknown>, key: string): string {
244
+ const value = stringField(record, key)
245
+ if (!/^[0-9a-f]{32}$/.test(value)) corrupt(`${key} must be a 128-bit lowercase hexadecimal identifier`)
246
+ return value
247
+ }
248
+
249
+ function validateGitObject(value: string): string {
250
+ if (!GIT_OBJECT_PATTERN.test(value)) corrupt('invalid Git object id')
251
+ return value
252
+ }
253
+
194
254
  function objectRecord(value: unknown, label: string): Record<string, unknown> {
195
255
  if (value === null || typeof value !== 'object' || Array.isArray(value)) corrupt(`${label} must be an object`)
196
256
  const prototype = Object.getPrototypeOf(value)