@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.
- package/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +291 -22
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +622 -121
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +18 -2
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +250 -48
- package/lib/settings.js +58 -0
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1361 -119
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +48 -2
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +8 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +90 -6
- package/lib/types/types.d.ts +63 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +21 -3
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +536 -41
- package/src/deadline.ts +18 -0
- package/src/engine.ts +695 -126
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +21 -2
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +278 -50
- package/src/settings.ts +93 -0
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1502 -104
- package/src/types.ts +69 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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)
|