@anionex/dsh-turn-rewind 0.1.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 (45) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +182 -0
  3. package/README.zh.md +180 -0
  4. package/SECURITY.md +53 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/FORMAT.md +101 -0
  7. package/docs/IMPLEMENTATION_PLAN.md +56 -0
  8. package/docs/assets/turn-rewind-action.png +0 -0
  9. package/docs/assets/turn-rewind-dialog.png +0 -0
  10. package/lib/client.js +473 -0
  11. package/lib/client.js.map +1 -0
  12. package/lib/engine.js +616 -0
  13. package/lib/errors.js +20 -0
  14. package/lib/git.js +155 -0
  15. package/lib/index.js +71 -0
  16. package/lib/path-utils.js +262 -0
  17. package/lib/rewind-host.js +421 -0
  18. package/lib/snapshot.js +183 -0
  19. package/lib/store.js +391 -0
  20. package/lib/types/client/index.d.ts +81 -0
  21. package/lib/types/engine.d.ts +90 -0
  22. package/lib/types/errors.d.ts +15 -0
  23. package/lib/types/git.d.ts +14 -0
  24. package/lib/types/index.d.ts +43 -0
  25. package/lib/types/path-utils.d.ts +34 -0
  26. package/lib/types/rewind-host.d.ts +137 -0
  27. package/lib/types/snapshot.d.ts +35 -0
  28. package/lib/types/store.d.ts +39 -0
  29. package/lib/types/types.d.ts +177 -0
  30. package/lib/types/validate.d.ts +11 -0
  31. package/lib/types.js +2 -0
  32. package/lib/validate.js +242 -0
  33. package/package.json +91 -0
  34. package/src/client/index.tsx +648 -0
  35. package/src/client/platform.d.ts +27 -0
  36. package/src/engine.ts +770 -0
  37. package/src/errors.ts +23 -0
  38. package/src/git.ts +178 -0
  39. package/src/index.ts +100 -0
  40. package/src/path-utils.ts +255 -0
  41. package/src/rewind-host.ts +584 -0
  42. package/src/snapshot.ts +229 -0
  43. package/src/store.ts +383 -0
  44. package/src/types.ts +195 -0
  45. package/src/validate.ts +245 -0
package/src/types.ts ADDED
@@ -0,0 +1,195 @@
1
+ /** Durable format version. Readers reject every other version. */
2
+ export const LEDGER_FORMAT_VERSION = 1 as const
3
+
4
+ /** A persisted restore-point identifier. */
5
+ export type RestorePointId = string
6
+
7
+ /** A persisted restore-operation identifier. */
8
+ export type RestoreOperationId = string
9
+
10
+ /** An in-memory, expiring restore-plan identifier. */
11
+ export type RestorePlanId = string
12
+
13
+ /** One regular-file snapshot. */
14
+ export interface FileSnapshotEntry {
15
+ readonly kind: 'file'
16
+ readonly blob: string
17
+ readonly size: number
18
+ readonly mode: number
19
+ }
20
+
21
+ /** One symbolic-link snapshot. */
22
+ export interface SymlinkSnapshotEntry {
23
+ readonly kind: 'symlink'
24
+ readonly target: string
25
+ readonly mode: number
26
+ }
27
+
28
+ /** One path captured by a restore point. */
29
+ export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry
30
+
31
+ /** Git facts that fence a restore point to the repository state it observed. */
32
+ export interface RepositoryState {
33
+ readonly root: string
34
+ readonly commonDir: string
35
+ readonly head?: string
36
+ readonly branch?: string
37
+ readonly operation?: string
38
+ readonly stagedPaths: readonly string[]
39
+ }
40
+
41
+ /** Why a restore point exists. */
42
+ export type RestorePointKind = 'user' | 'rescue' | 'turn'
43
+
44
+ /** Durable workspace restore point. */
45
+ export interface RestorePointManifest {
46
+ readonly version: typeof LEDGER_FORMAT_VERSION
47
+ readonly id: RestorePointId
48
+ readonly kind: RestorePointKind
49
+ readonly workspace: string
50
+ readonly repository: RepositoryState
51
+ readonly sessionId?: string
52
+ readonly label?: string
53
+ readonly parentRestorePoint?: RestorePointId
54
+ /** DSH turn whose opening user request owns this automatic Web rewind checkpoint. */
55
+ readonly turn?: number
56
+ /** Inclusive `turn/start` event sequence; the snapshot was captured before the turn entered its first step. */
57
+ readonly turnStartSeq?: number
58
+ /** Legacy inclusive `turn/end` event sequence used by checkpoints created before prompt-anchored rewind. */
59
+ readonly turnEndSeq?: number
60
+ readonly createdAt: number
61
+ readonly treeHash: string
62
+ readonly fileCount: number
63
+ readonly totalBytes: number
64
+ readonly entries: Readonly<Record<string, SnapshotEntry>>
65
+ readonly restoreCount: number
66
+ readonly lastRestoredAt?: number
67
+ }
68
+
69
+ /** Observable difference between a restore point and the current workspace. */
70
+ export interface WorkspaceChange {
71
+ readonly path: string
72
+ readonly kind: 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed'
73
+ readonly before?: SnapshotEntry
74
+ readonly after?: SnapshotEntry
75
+ }
76
+
77
+ /** Result of comparing one restore point with its workspace. */
78
+ export interface RestorePointInspection {
79
+ readonly restorePoint: RestorePointSummary
80
+ readonly currentTreeHash: string
81
+ readonly currentRepository: RepositoryState
82
+ readonly currentHead?: string
83
+ readonly currentBranch?: string
84
+ readonly currentOperation?: string
85
+ readonly headChanged: boolean
86
+ readonly operationChanged: boolean
87
+ readonly changes: readonly WorkspaceChange[]
88
+ }
89
+
90
+ /** Compact restore-point metadata for lists and tool results. */
91
+ export interface RestorePointSummary {
92
+ readonly id: RestorePointId
93
+ readonly kind: RestorePointKind
94
+ readonly workspace: string
95
+ readonly sessionId?: string
96
+ readonly label?: string
97
+ readonly parentRestorePoint?: RestorePointId
98
+ readonly turn?: number
99
+ readonly turnStartSeq?: number
100
+ readonly turnEndSeq?: number
101
+ readonly createdAt: number
102
+ readonly treeHash: string
103
+ readonly fileCount: number
104
+ readonly totalBytes: number
105
+ readonly restoreCount: number
106
+ readonly lastRestoredAt?: number
107
+ readonly head?: string
108
+ readonly branch?: string
109
+ readonly operation?: string
110
+ readonly stagedPathCount: number
111
+ }
112
+
113
+ /** An expiring restore plan whose confirmation must be echoed exactly. */
114
+ export interface RestorePlan {
115
+ readonly id: RestorePlanId
116
+ readonly restorePointId: RestorePointId
117
+ readonly workspace: string
118
+ readonly repository: RepositoryState
119
+ readonly sessionId?: string
120
+ readonly createdAt: number
121
+ readonly expiresAt: number
122
+ readonly confirmation: string
123
+ readonly allowHeadChange: boolean
124
+ readonly paths: readonly string[]
125
+ readonly changes: readonly WorkspaceChange[]
126
+ readonly expected: Readonly<Record<string, SnapshotEntry | null>>
127
+ }
128
+
129
+ /** Durable journal for one attempted restore. */
130
+ export interface RestoreOperation {
131
+ readonly version: typeof LEDGER_FORMAT_VERSION
132
+ readonly id: RestoreOperationId
133
+ readonly workspace: string
134
+ readonly restorePointId: RestorePointId
135
+ readonly rescuePointId: RestorePointId
136
+ readonly sessionId?: string
137
+ readonly paths: readonly string[]
138
+ readonly startedAt: number
139
+ readonly finishedAt?: number
140
+ readonly state: 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'interrupted' | 'recovery-required'
141
+ readonly error?: string
142
+ readonly rollbackError?: string
143
+ }
144
+
145
+ /** A completed restore result. */
146
+ export interface RestoreResult {
147
+ readonly operationId: RestoreOperationId
148
+ readonly restorePointId: RestorePointId
149
+ readonly rescuePointId: RestorePointId
150
+ readonly restoredPaths: readonly string[]
151
+ }
152
+
153
+ /** Public plugin configuration. */
154
+ export interface ChangeLedgerConfig {
155
+ /** State directory. Must live outside every managed workspace. */
156
+ readonly storageDir?: string
157
+ /** Maximum user and rescue restore points retained per workspace. */
158
+ readonly maxRestorePoints?: number
159
+ /** Maximum automatic turn checkpoints retained per session. */
160
+ readonly maxTurnCheckpointsPerSession?: number
161
+ /** Maximum number of files in one restore point. */
162
+ readonly maxFiles?: number
163
+ /** Maximum bytes read from one regular file. */
164
+ readonly maxFileBytes?: number
165
+ /** Maximum aggregate regular-file bytes in one restore point. */
166
+ readonly maxSnapshotBytes?: number
167
+ /** Restore-plan lifetime in milliseconds. */
168
+ readonly planTtlMs?: number
169
+ /** Age after which a lock whose owner is gone may be reclaimed. */
170
+ readonly staleLockMs?: number
171
+ }
172
+
173
+ /** Fully resolved plugin configuration. */
174
+ export interface ResolvedChangeLedgerConfig {
175
+ readonly storageDir: string
176
+ readonly maxRestorePoints: number
177
+ readonly maxTurnCheckpointsPerSession: number
178
+ readonly maxFiles: number
179
+ readonly maxFileBytes: number
180
+ readonly maxSnapshotBytes: number
181
+ readonly planTtlMs: number
182
+ readonly staleLockMs: number
183
+ }
184
+
185
+ /** Current incomplete restore operation. */
186
+ export interface RecoverySummary {
187
+ readonly operationId: RestoreOperationId
188
+ readonly restorePointId: RestorePointId
189
+ readonly rescuePointId: RestorePointId
190
+ readonly state: 'interrupted' | 'recovery-required'
191
+ readonly paths: readonly string[]
192
+ readonly startedAt: number
193
+ readonly error?: string
194
+ readonly rollbackError?: string
195
+ }
@@ -0,0 +1,245 @@
1
+ import { ChangeLedgerError } from './errors.js'
2
+ import { isAbsolute } from 'node:path'
3
+ import { validateRelativePath } from './path-utils.js'
4
+ import { hashTree } from './snapshot.js'
5
+ import {
6
+ LEDGER_FORMAT_VERSION,
7
+ type RepositoryState,
8
+ type RestoreOperation,
9
+ type RestorePointManifest,
10
+ type SnapshotEntry,
11
+ } from './types.js'
12
+
13
+ const HASH_PATTERN = /^[0-9a-f]{64}$/
14
+ const GIT_OBJECT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
15
+ const ID_PATTERN = /^(?:rp|op)_[0-9a-z]+_[0-9a-f]{12}$/
16
+
17
+ /** Validate a restore-point id before using it in a filesystem path. */
18
+ export function validateRestorePointId(value: string): string {
19
+ if (!ID_PATTERN.test(value) || !value.startsWith('rp_')) {
20
+ throw new ChangeLedgerError('INVALID_RESTORE_POINT_ID', `invalid restore-point id ${JSON.stringify(value)}`)
21
+ }
22
+ return value
23
+ }
24
+
25
+ /** Validate a restore-operation id before using it in a filesystem path. */
26
+ export function validateOperationId(value: string): string {
27
+ if (!ID_PATTERN.test(value) || !value.startsWith('op_')) {
28
+ throw new ChangeLedgerError('INVALID_OPERATION_ID', `invalid restore-operation id ${JSON.stringify(value)}`)
29
+ }
30
+ return value
31
+ }
32
+
33
+ /** Parse an untrusted durable restore-point manifest. */
34
+ export function parseManifest(value: unknown): RestorePointManifest {
35
+ const record = objectRecord(value, 'restore-point manifest')
36
+ if (record.version !== LEDGER_FORMAT_VERSION) corrupt(`unsupported restore-point version ${String(record.version)}`)
37
+ const id = stringField(record, 'id')
38
+ validateRestorePointId(id)
39
+ const kind = record.kind
40
+ if (kind !== 'user' && kind !== 'rescue' && kind !== 'turn') {
41
+ corrupt('restore-point kind must be "user", "rescue", or "turn"')
42
+ }
43
+ const workspace = absoluteString(record, 'workspace')
44
+ const repository = parseRepository(record.repository)
45
+ if (repository.root !== workspace) corrupt('repository.root must equal workspace')
46
+ const entriesRecord = objectRecord(record.entries, 'restore-point entries')
47
+ const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
48
+ let totalBytes = 0
49
+ for (const [path, entryValue] of Object.entries(entriesRecord)) {
50
+ validateRelativePath(path)
51
+ const entry = parseEntry(entryValue, path)
52
+ entries[path] = entry
53
+ if (entry.kind === 'file') totalBytes += entry.size
54
+ }
55
+ const fileCount = nonNegativeInteger(record, 'fileCount')
56
+ if (fileCount !== Object.keys(entries).length) corrupt('restore-point fileCount does not match entries')
57
+ const storedTotal = nonNegativeInteger(record, 'totalBytes')
58
+ if (storedTotal !== totalBytes) corrupt('restore-point totalBytes does not match entries')
59
+ const treeHash = hashField(record, 'treeHash')
60
+ if (treeHash !== hashTree(entries)) corrupt('restore-point treeHash does not match entries')
61
+ const createdAt = nonNegativeInteger(record, 'createdAt')
62
+ const restoreCount = nonNegativeInteger(record, 'restoreCount')
63
+ const sessionId = optionalString(record, 'sessionId')
64
+ const label = optionalString(record, 'label')
65
+ const parentRestorePoint = optionalString(record, 'parentRestorePoint')
66
+ if (parentRestorePoint !== undefined) validateRestorePointId(parentRestorePoint)
67
+ const turn = optionalNonNegativeInteger(record, 'turn')
68
+ const turnStartSeq = optionalNonNegativeInteger(record, 'turnStartSeq')
69
+ const turnEndSeq = optionalNonNegativeInteger(record, 'turnEndSeq')
70
+ if (kind === 'turn') {
71
+ if (sessionId === undefined || turn === undefined || (turnStartSeq === undefined) === (turnEndSeq === undefined)) {
72
+ corrupt('turn restore points require sessionId, turn, and exactly one turn boundary')
73
+ }
74
+ } else if (turn !== undefined || turnStartSeq !== undefined || turnEndSeq !== undefined) {
75
+ corrupt('only turn restore points may carry turn metadata')
76
+ }
77
+ const lastRestoredAt = optionalNonNegativeInteger(record, 'lastRestoredAt')
78
+ return {
79
+ version: LEDGER_FORMAT_VERSION,
80
+ id,
81
+ kind,
82
+ workspace,
83
+ repository,
84
+ ...(sessionId === undefined ? {} : { sessionId }),
85
+ ...(label === undefined ? {} : { label }),
86
+ ...(parentRestorePoint === undefined ? {} : { parentRestorePoint }),
87
+ ...(turn === undefined ? {} : { turn }),
88
+ ...(turnStartSeq === undefined ? {} : { turnStartSeq }),
89
+ ...(turnEndSeq === undefined ? {} : { turnEndSeq }),
90
+ createdAt,
91
+ treeHash,
92
+ fileCount,
93
+ totalBytes,
94
+ entries,
95
+ restoreCount,
96
+ ...(lastRestoredAt === undefined ? {} : { lastRestoredAt }),
97
+ }
98
+ }
99
+
100
+ /** Parse an untrusted durable restore-operation journal. */
101
+ export function parseOperation(value: unknown): RestoreOperation {
102
+ const record = objectRecord(value, 'restore operation')
103
+ if (record.version !== LEDGER_FORMAT_VERSION) corrupt(`unsupported restore-operation version ${String(record.version)}`)
104
+ const id = stringField(record, 'id')
105
+ validateOperationId(id)
106
+ const restorePointId = stringField(record, 'restorePointId')
107
+ const rescuePointId = stringField(record, 'rescuePointId')
108
+ validateRestorePointId(restorePointId)
109
+ validateRestorePointId(rescuePointId)
110
+ const workspace = absoluteString(record, 'workspace')
111
+ const pathsValue = record.paths
112
+ if (!Array.isArray(pathsValue)) corrupt('restore-operation paths must be an array')
113
+ const paths = pathsValue.map((path) => validateRelativePath(requireString(path, 'restore-operation path')))
114
+ if (new Set(paths).size !== paths.length) corrupt('restore-operation paths contain duplicates')
115
+ const state = record.state
116
+ if (state !== 'running'
117
+ && state !== 'rollback-running'
118
+ && state !== 'completed'
119
+ && state !== 'rolled-back'
120
+ && state !== 'interrupted'
121
+ && state !== 'recovery-required') {
122
+ corrupt(`invalid restore-operation state ${JSON.stringify(state)}`)
123
+ }
124
+ const sessionId = optionalString(record, 'sessionId')
125
+ const startedAt = nonNegativeInteger(record, 'startedAt')
126
+ const finishedAt = optionalNonNegativeInteger(record, 'finishedAt')
127
+ const error = optionalString(record, 'error')
128
+ const rollbackError = optionalString(record, 'rollbackError')
129
+ return {
130
+ version: LEDGER_FORMAT_VERSION,
131
+ id,
132
+ workspace,
133
+ restorePointId,
134
+ rescuePointId,
135
+ ...(sessionId === undefined ? {} : { sessionId }),
136
+ paths,
137
+ startedAt,
138
+ ...(finishedAt === undefined ? {} : { finishedAt }),
139
+ state,
140
+ ...(error === undefined ? {} : { error }),
141
+ ...(rollbackError === undefined ? {} : { rollbackError }),
142
+ }
143
+ }
144
+
145
+ /** Validate one SHA-256 blob name. */
146
+ export function validateBlobHash(value: string): string {
147
+ if (!HASH_PATTERN.test(value)) {
148
+ throw new ChangeLedgerError('STATE_CORRUPT', `invalid blob hash ${JSON.stringify(value)}`)
149
+ }
150
+ return value
151
+ }
152
+
153
+ function parseRepository(value: unknown): RepositoryState {
154
+ const record = objectRecord(value, 'repository state')
155
+ const root = absoluteString(record, 'root')
156
+ const commonDir = absoluteString(record, 'commonDir')
157
+ const head = optionalHash(record, 'head')
158
+ const branch = optionalString(record, 'branch')
159
+ const operation = optionalString(record, 'operation')
160
+ const stagedValue = record.stagedPaths
161
+ if (!Array.isArray(stagedValue)) corrupt('repository stagedPaths must be an array')
162
+ const stagedPaths = stagedValue.map((path) => validateRelativePath(requireString(path, 'staged path')))
163
+ return {
164
+ root,
165
+ commonDir,
166
+ ...(head === undefined ? {} : { head }),
167
+ ...(branch === undefined ? {} : { branch }),
168
+ ...(operation === undefined ? {} : { operation }),
169
+ stagedPaths,
170
+ }
171
+ }
172
+
173
+ function parseEntry(value: unknown, path: string): SnapshotEntry {
174
+ const record = objectRecord(value, `snapshot entry ${JSON.stringify(path)}`)
175
+ const kind = record.kind
176
+ const mode = nonNegativeInteger(record, 'mode')
177
+ if (mode > 0o777) corrupt(`snapshot mode is out of range for ${JSON.stringify(path)}`)
178
+ if (kind === 'file') {
179
+ return {
180
+ kind,
181
+ blob: validateBlobHash(stringField(record, 'blob')),
182
+ size: nonNegativeInteger(record, 'size'),
183
+ mode,
184
+ }
185
+ }
186
+ if (kind === 'symlink') {
187
+ const target = stringField(record, 'target')
188
+ if (target.includes('\0')) corrupt(`snapshot symlink target contains a NUL byte for ${JSON.stringify(path)}`)
189
+ return { kind, target, mode }
190
+ }
191
+ corrupt(`invalid snapshot entry kind for ${JSON.stringify(path)}`)
192
+ }
193
+
194
+ function objectRecord(value: unknown, label: string): Record<string, unknown> {
195
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) corrupt(`${label} must be an object`)
196
+ const prototype = Object.getPrototypeOf(value)
197
+ if (prototype !== Object.prototype && prototype !== null) corrupt(`${label} must be a plain object`)
198
+ return value as Record<string, unknown>
199
+ }
200
+
201
+ function stringField(record: Record<string, unknown>, key: string): string {
202
+ return requireString(record[key], key)
203
+ }
204
+
205
+ function requireString(value: unknown, label: string): string {
206
+ if (typeof value !== 'string' || value === '') corrupt(`${label} must be a non-empty string`)
207
+ return value
208
+ }
209
+
210
+ function optionalString(record: Record<string, unknown>, key: string): string | undefined {
211
+ const value = record[key]
212
+ return value === undefined ? undefined : requireString(value, key)
213
+ }
214
+
215
+ function absoluteString(record: Record<string, unknown>, key: string): string {
216
+ const value = stringField(record, key)
217
+ if (!isAbsolute(value)) corrupt(`${key} must be an absolute path`)
218
+ return value
219
+ }
220
+
221
+ function hashField(record: Record<string, unknown>, key: string): string {
222
+ return validateBlobHash(stringField(record, key))
223
+ }
224
+
225
+ function optionalHash(record: Record<string, unknown>, key: string): string | undefined {
226
+ const value = record[key]
227
+ if (value === undefined) return undefined
228
+ const hash = requireString(value, key)
229
+ if (!GIT_OBJECT_PATTERN.test(hash)) corrupt(`${key} must be a Git object id`)
230
+ return hash
231
+ }
232
+
233
+ function nonNegativeInteger(record: Record<string, unknown>, key: string): number {
234
+ const value = record[key]
235
+ if (!Number.isSafeInteger(value) || (value as number) < 0) corrupt(`${key} must be a non-negative safe integer`)
236
+ return value as number
237
+ }
238
+
239
+ function optionalNonNegativeInteger(record: Record<string, unknown>, key: string): number | undefined {
240
+ return record[key] === undefined ? undefined : nonNegativeInteger(record, key)
241
+ }
242
+
243
+ function corrupt(message: string): never {
244
+ throw new ChangeLedgerError('STATE_CORRUPT', message)
245
+ }