@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.
- package/LICENSE +28 -0
- package/README.md +182 -0
- package/README.zh.md +180 -0
- package/SECURITY.md +53 -0
- package/cordis.patch.yml +4 -0
- package/docs/FORMAT.md +101 -0
- package/docs/IMPLEMENTATION_PLAN.md +56 -0
- package/docs/assets/turn-rewind-action.png +0 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +473 -0
- package/lib/client.js.map +1 -0
- package/lib/engine.js +616 -0
- package/lib/errors.js +20 -0
- package/lib/git.js +155 -0
- package/lib/index.js +71 -0
- package/lib/path-utils.js +262 -0
- package/lib/rewind-host.js +421 -0
- package/lib/snapshot.js +183 -0
- package/lib/store.js +391 -0
- package/lib/types/client/index.d.ts +81 -0
- package/lib/types/engine.d.ts +90 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/git.d.ts +14 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/path-utils.d.ts +34 -0
- package/lib/types/rewind-host.d.ts +137 -0
- package/lib/types/snapshot.d.ts +35 -0
- package/lib/types/store.d.ts +39 -0
- package/lib/types/types.d.ts +177 -0
- package/lib/types/validate.d.ts +11 -0
- package/lib/types.js +2 -0
- package/lib/validate.js +242 -0
- package/package.json +91 -0
- package/src/client/index.tsx +648 -0
- package/src/client/platform.d.ts +27 -0
- package/src/engine.ts +770 -0
- package/src/errors.ts +23 -0
- package/src/git.ts +178 -0
- package/src/index.ts +100 -0
- package/src/path-utils.ts +255 -0
- package/src/rewind-host.ts +584 -0
- package/src/snapshot.ts +229 -0
- package/src/store.ts +383 -0
- package/src/types.ts +195 -0
- package/src/validate.ts +245 -0
package/lib/store.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, mkdir, open, readFile, readdir, rmdir, stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ChangeLedgerError, errorMessage } from './errors.js';
|
|
5
|
+
import { isNodeError, openExclusive, processExists, readJson, syncDirectory, writeJsonAtomic } from './path-utils.js';
|
|
6
|
+
import { parseManifest, parseOperation, validateBlobHash, validateOperationId, validateRestorePointId } from './validate.js';
|
|
7
|
+
/** Durable content-addressed storage and per-workspace locking. */
|
|
8
|
+
export class LedgerStore {
|
|
9
|
+
config;
|
|
10
|
+
constructor(config) {
|
|
11
|
+
this.config = config;
|
|
12
|
+
}
|
|
13
|
+
/** Create the state root and reconcile crash-interrupted operations. */
|
|
14
|
+
async initialize() {
|
|
15
|
+
await mkdir(join(this.config.storageDir, 'workspaces'), { recursive: true, mode: 0o700 });
|
|
16
|
+
let reconciled = 0;
|
|
17
|
+
for (const workspaceKey of await safeDirectoryNames(join(this.config.storageDir, 'workspaces'))) {
|
|
18
|
+
const workspaceDir = join(this.config.storageDir, 'workspaces', workspaceKey);
|
|
19
|
+
if (await this.workspaceAppearsActive(workspaceDir))
|
|
20
|
+
continue;
|
|
21
|
+
const operationDir = join(workspaceDir, 'operations');
|
|
22
|
+
for (const filename of await safeJsonNames(operationDir)) {
|
|
23
|
+
const path = join(operationDir, filename);
|
|
24
|
+
const operation = parseOperation(await readJson(path));
|
|
25
|
+
if (filename !== `${operation.id}.json`) {
|
|
26
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`);
|
|
27
|
+
}
|
|
28
|
+
if (this.workspaceDir(operation.workspace) !== workspaceDir) {
|
|
29
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} is stored under the wrong workspace key`);
|
|
30
|
+
}
|
|
31
|
+
if (operation.state !== 'running' && operation.state !== 'rollback-running')
|
|
32
|
+
continue;
|
|
33
|
+
await this.writeOperation({
|
|
34
|
+
...operation,
|
|
35
|
+
state: 'interrupted',
|
|
36
|
+
error: operation.error ?? 'DSH stopped before the restore operation reached a terminal state',
|
|
37
|
+
});
|
|
38
|
+
reconciled += 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return reconciled;
|
|
42
|
+
}
|
|
43
|
+
/** Acquire the exclusive lock for one canonical workspace. */
|
|
44
|
+
async acquire(workspace) {
|
|
45
|
+
const lockPath = join(this.workspaceDir(workspace), 'lock.json');
|
|
46
|
+
await mkdir(this.workspaceDir(workspace), { recursive: true, mode: 0o700 });
|
|
47
|
+
const nonce = randomUUID();
|
|
48
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
49
|
+
let handle;
|
|
50
|
+
try {
|
|
51
|
+
handle = await openExclusive(lockPath);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (!isNodeError(error, 'EEXIST'))
|
|
55
|
+
throw error;
|
|
56
|
+
const reclaimed = await this.reclaimStaleLock(lockPath);
|
|
57
|
+
if (reclaimed)
|
|
58
|
+
continue;
|
|
59
|
+
throw new ChangeLedgerError('WORKSPACE_LOCKED', `another change-ledger operation owns ${JSON.stringify(workspace)}`);
|
|
60
|
+
}
|
|
61
|
+
const record = { pid: process.pid, createdAt: Date.now(), nonce };
|
|
62
|
+
try {
|
|
63
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8');
|
|
64
|
+
await handle.sync();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
await syncDirectory(this.workspaceDir(workspace));
|
|
70
|
+
let released = false;
|
|
71
|
+
return async () => {
|
|
72
|
+
if (released)
|
|
73
|
+
return;
|
|
74
|
+
released = true;
|
|
75
|
+
try {
|
|
76
|
+
const current = parseLock(await readJson(lockPath));
|
|
77
|
+
if (current.nonce !== nonce)
|
|
78
|
+
return;
|
|
79
|
+
await unlink(lockPath);
|
|
80
|
+
await syncDirectory(this.workspaceDir(workspace));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!isNodeError(error, 'ENOENT') && !isMissingStateRead(error))
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
throw new ChangeLedgerError('WORKSPACE_LOCKED', `could not acquire change-ledger lock for ${JSON.stringify(workspace)}`);
|
|
89
|
+
}
|
|
90
|
+
/** Persist a blob if it is not already present, and verify existing content. */
|
|
91
|
+
async putBlob(workspace, hash, content) {
|
|
92
|
+
validateBlobHash(hash);
|
|
93
|
+
const contentHash = createHash('sha256').update(content).digest('hex');
|
|
94
|
+
if (contentHash !== hash) {
|
|
95
|
+
throw new ChangeLedgerError('BLOB_HASH_MISMATCH', `refusing to store content whose SHA-256 does not match ${hash}`);
|
|
96
|
+
}
|
|
97
|
+
const path = this.blobPath(workspace, hash);
|
|
98
|
+
const directory = join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2));
|
|
99
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
100
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`);
|
|
101
|
+
try {
|
|
102
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
103
|
+
try {
|
|
104
|
+
await handle.writeFile(content);
|
|
105
|
+
await handle.sync();
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await handle.close();
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await link(temporary, path);
|
|
112
|
+
await syncDirectory(directory);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (!isNodeError(error, 'EEXIST'))
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
try {
|
|
122
|
+
await unlink(temporary);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const existing = await readFile(path);
|
|
130
|
+
const existingHash = createHash('sha256').update(existing).digest('hex');
|
|
131
|
+
if (existingHash !== hash || !existing.equals(content)) {
|
|
132
|
+
throw new ChangeLedgerError('BLOB_COLLISION', `stored blob ${hash} does not match its content hash`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Read and verify one content-addressed blob. */
|
|
136
|
+
async readBlob(workspace, hash) {
|
|
137
|
+
validateBlobHash(hash);
|
|
138
|
+
const content = await readFile(this.blobPath(workspace, hash));
|
|
139
|
+
const actual = createHash('sha256').update(content).digest('hex');
|
|
140
|
+
if (actual !== hash)
|
|
141
|
+
throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${hash} failed SHA-256 verification`);
|
|
142
|
+
return content;
|
|
143
|
+
}
|
|
144
|
+
/** Write one restore-point manifest atomically. */
|
|
145
|
+
async writeManifest(manifest) {
|
|
146
|
+
parseManifest(manifest);
|
|
147
|
+
await writeJsonAtomic(this.manifestPath(manifest.workspace, manifest.id), manifest);
|
|
148
|
+
}
|
|
149
|
+
/** Load and validate one restore-point manifest. */
|
|
150
|
+
async readManifest(workspace, id) {
|
|
151
|
+
validateRestorePointId(id);
|
|
152
|
+
let raw;
|
|
153
|
+
try {
|
|
154
|
+
raw = await readJson(this.manifestPath(workspace, id));
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (isMissingStateRead(error)) {
|
|
158
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `restore point ${id} does not exist`, { cause: error });
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
const manifest = parseManifest(raw);
|
|
163
|
+
if (manifest.id !== id) {
|
|
164
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `restore point file ${id}.json contains id ${manifest.id}`);
|
|
165
|
+
}
|
|
166
|
+
if (manifest.workspace !== workspace) {
|
|
167
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `restore point ${id} belongs to a different workspace`);
|
|
168
|
+
}
|
|
169
|
+
return manifest;
|
|
170
|
+
}
|
|
171
|
+
/** List all validated restore points for one workspace, newest first. */
|
|
172
|
+
async listManifests(workspace) {
|
|
173
|
+
const manifests = [];
|
|
174
|
+
for (const filename of await safeJsonNames(join(this.workspaceDir(workspace), 'manifests'))) {
|
|
175
|
+
const manifest = parseManifest(await readJson(join(this.workspaceDir(workspace), 'manifests', filename)));
|
|
176
|
+
if (filename !== `${manifest.id}.json`) {
|
|
177
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} does not match its persisted id ${manifest.id}`);
|
|
178
|
+
}
|
|
179
|
+
if (manifest.workspace !== workspace) {
|
|
180
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} belongs to a different workspace`);
|
|
181
|
+
}
|
|
182
|
+
manifests.push(manifest);
|
|
183
|
+
}
|
|
184
|
+
return manifests.sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id));
|
|
185
|
+
}
|
|
186
|
+
/** Delete one restore-point manifest. Blobs remain until garbage collection succeeds. */
|
|
187
|
+
async deleteManifest(workspace, id) {
|
|
188
|
+
validateRestorePointId(id);
|
|
189
|
+
const directory = join(this.workspaceDir(workspace), 'manifests');
|
|
190
|
+
try {
|
|
191
|
+
await unlink(this.manifestPath(workspace, id));
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (isNodeError(error, 'ENOENT')) {
|
|
195
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `restore point ${id} does not exist`);
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
await syncDirectory(directory);
|
|
200
|
+
}
|
|
201
|
+
/** Persist one restore-operation journal. */
|
|
202
|
+
async writeOperation(operation) {
|
|
203
|
+
parseOperation(operation);
|
|
204
|
+
await writeJsonAtomic(this.operationPath(operation.workspace, operation.id), operation);
|
|
205
|
+
}
|
|
206
|
+
/** List validated restore operations for one workspace. */
|
|
207
|
+
async listOperations(workspace) {
|
|
208
|
+
const operations = [];
|
|
209
|
+
for (const filename of await safeJsonNames(join(this.workspaceDir(workspace), 'operations'))) {
|
|
210
|
+
const operation = parseOperation(await readJson(join(this.workspaceDir(workspace), 'operations', filename)));
|
|
211
|
+
if (filename !== `${operation.id}.json`) {
|
|
212
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`);
|
|
213
|
+
}
|
|
214
|
+
if (operation.workspace !== workspace) {
|
|
215
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} belongs to a different workspace`);
|
|
216
|
+
}
|
|
217
|
+
operations.push(operation);
|
|
218
|
+
}
|
|
219
|
+
return operations.sort((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id));
|
|
220
|
+
}
|
|
221
|
+
/** Return whether an incomplete operation still references a restore point. */
|
|
222
|
+
async isReferencedByRecovery(workspace, restorePointId) {
|
|
223
|
+
return (await this.listOperations(workspace)).some((operation) => (operation.state === 'interrupted' || operation.state === 'recovery-required')
|
|
224
|
+
&& (operation.restorePointId === restorePointId || operation.rescuePointId === restorePointId));
|
|
225
|
+
}
|
|
226
|
+
/** Delete blobs not referenced by any remaining manifest. */
|
|
227
|
+
async collectGarbage(workspace, additionalReferenced = []) {
|
|
228
|
+
const referenced = new Set(additionalReferenced);
|
|
229
|
+
for (const hash of referenced)
|
|
230
|
+
validateBlobHash(hash);
|
|
231
|
+
for (const manifest of await this.listManifests(workspace)) {
|
|
232
|
+
for (const entry of Object.values(manifest.entries)) {
|
|
233
|
+
if (entry.kind === 'file')
|
|
234
|
+
referenced.add(entry.blob);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
let deletedBlobs = 0;
|
|
238
|
+
let retainedBlobs = 0;
|
|
239
|
+
const blobsRoot = join(this.workspaceDir(workspace), 'blobs');
|
|
240
|
+
for (const prefix of await safeDirectoryNames(blobsRoot)) {
|
|
241
|
+
const prefixPath = join(blobsRoot, prefix);
|
|
242
|
+
for (const filename of await safeFileNames(prefixPath)) {
|
|
243
|
+
if (filename.startsWith('.') && filename.endsWith('.tmp')) {
|
|
244
|
+
await unlink(join(prefixPath, filename));
|
|
245
|
+
deletedBlobs += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
validateBlobHash(filename);
|
|
249
|
+
if (filename.slice(0, 2) !== prefix) {
|
|
250
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `blob ${filename} is stored under the wrong prefix directory ${prefix}`);
|
|
251
|
+
}
|
|
252
|
+
if (referenced.has(filename)) {
|
|
253
|
+
retainedBlobs += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
await unlink(join(prefixPath, filename));
|
|
257
|
+
deletedBlobs += 1;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
await rmdir(prefixPath);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (!isNodeError(error, 'ENOTEMPTY') && !isNodeError(error, 'EEXIST') && !isNodeError(error, 'ENOENT'))
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { deletedBlobs, retainedBlobs };
|
|
268
|
+
}
|
|
269
|
+
workspaceDir(workspace) {
|
|
270
|
+
const key = createHash('sha256').update(workspace).digest('hex');
|
|
271
|
+
return join(this.config.storageDir, 'workspaces', key);
|
|
272
|
+
}
|
|
273
|
+
manifestPath(workspace, id) {
|
|
274
|
+
return join(this.workspaceDir(workspace), 'manifests', `${validateRestorePointId(id)}.json`);
|
|
275
|
+
}
|
|
276
|
+
operationPath(workspace, id) {
|
|
277
|
+
return join(this.workspaceDir(workspace), 'operations', `${validateOperationId(id)}.json`);
|
|
278
|
+
}
|
|
279
|
+
blobPath(workspace, hash) {
|
|
280
|
+
validateBlobHash(hash);
|
|
281
|
+
return join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2), hash);
|
|
282
|
+
}
|
|
283
|
+
async reclaimStaleLock(lockPath) {
|
|
284
|
+
let lock;
|
|
285
|
+
try {
|
|
286
|
+
lock = parseLock(await readJson(lockPath));
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
let age = 0;
|
|
290
|
+
try {
|
|
291
|
+
age = Date.now() - (await stat(lockPath)).mtimeMs;
|
|
292
|
+
}
|
|
293
|
+
catch (statError) {
|
|
294
|
+
if (isNodeError(statError, 'ENOENT'))
|
|
295
|
+
return true;
|
|
296
|
+
throw statError;
|
|
297
|
+
}
|
|
298
|
+
if (age < this.config.staleLockMs) {
|
|
299
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', `active lock is unreadable: ${errorMessage(error)}`);
|
|
300
|
+
}
|
|
301
|
+
await unlink(lockPath);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (processExists(lock.pid))
|
|
305
|
+
return false;
|
|
306
|
+
if (Date.now() - lock.createdAt < this.config.staleLockMs)
|
|
307
|
+
return false;
|
|
308
|
+
try {
|
|
309
|
+
const current = parseLock(await readJson(lockPath));
|
|
310
|
+
if (current.nonce !== lock.nonce)
|
|
311
|
+
return false;
|
|
312
|
+
await unlink(lockPath);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (isNodeError(error, 'ENOENT'))
|
|
317
|
+
return true;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async workspaceAppearsActive(workspaceDir) {
|
|
322
|
+
const lockPath = join(workspaceDir, 'lock.json');
|
|
323
|
+
try {
|
|
324
|
+
const lock = parseLock(await readJson(lockPath));
|
|
325
|
+
if (processExists(lock.pid))
|
|
326
|
+
return true;
|
|
327
|
+
return Date.now() - lock.createdAt < this.config.staleLockMs;
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (error instanceof ChangeLedgerError && error.code === 'STATE_READ_FAILED' && isMissingCause(error.cause))
|
|
331
|
+
return false;
|
|
332
|
+
if (isNodeError(error, 'ENOENT'))
|
|
333
|
+
return false;
|
|
334
|
+
try {
|
|
335
|
+
return Date.now() - (await stat(lockPath)).mtimeMs < this.config.staleLockMs;
|
|
336
|
+
}
|
|
337
|
+
catch (statError) {
|
|
338
|
+
if (isNodeError(statError, 'ENOENT'))
|
|
339
|
+
return false;
|
|
340
|
+
throw statError;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function parseLock(value) {
|
|
346
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
347
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock must be an object');
|
|
348
|
+
}
|
|
349
|
+
const record = value;
|
|
350
|
+
if (!Number.isSafeInteger(record.pid) || record.pid <= 0) {
|
|
351
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock pid is invalid');
|
|
352
|
+
}
|
|
353
|
+
if (!Number.isSafeInteger(record.createdAt) || record.createdAt < 0) {
|
|
354
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock createdAt is invalid');
|
|
355
|
+
}
|
|
356
|
+
if (typeof record.nonce !== 'string' || record.nonce === '') {
|
|
357
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock nonce is invalid');
|
|
358
|
+
}
|
|
359
|
+
return { pid: record.pid, createdAt: record.createdAt, nonce: record.nonce };
|
|
360
|
+
}
|
|
361
|
+
async function safeJsonNames(path) {
|
|
362
|
+
return (await safeFileNames(path)).filter((name) => name.endsWith('.json')).sort();
|
|
363
|
+
}
|
|
364
|
+
async function safeDirectoryNames(path) {
|
|
365
|
+
try {
|
|
366
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
367
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (isNodeError(error, 'ENOENT'))
|
|
371
|
+
return [];
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async function safeFileNames(path) {
|
|
376
|
+
try {
|
|
377
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
378
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (isNodeError(error, 'ENOENT'))
|
|
382
|
+
return [];
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function isMissingCause(error) {
|
|
387
|
+
return error instanceof Error && 'cause' in error && isNodeError(error.cause, 'ENOENT');
|
|
388
|
+
}
|
|
389
|
+
function isMissingStateRead(error) {
|
|
390
|
+
return error instanceof ChangeLedgerError && error.code === 'STATE_READ_FAILED' && isMissingCause(error);
|
|
391
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
interface ConversationNodeLike {
|
|
3
|
+
readonly kind: string;
|
|
4
|
+
readonly seq: number;
|
|
5
|
+
readonly content?: readonly {
|
|
6
|
+
readonly type: string;
|
|
7
|
+
readonly text?: string;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
interface ConversationChatNodeLike {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
readonly kind: string;
|
|
13
|
+
readonly data: ConversationNodeLike;
|
|
14
|
+
}
|
|
15
|
+
interface ConversationSnapshotLike {
|
|
16
|
+
readonly nodes: readonly ConversationNodeLike[];
|
|
17
|
+
readonly chat?: {
|
|
18
|
+
readonly nodes: {
|
|
19
|
+
values(): readonly ConversationChatNodeLike[];
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
type RewindNodeLike = ConversationNodeLike | ConversationChatNodeLike;
|
|
24
|
+
interface RewindMatch {
|
|
25
|
+
readonly messageSeq: number;
|
|
26
|
+
readonly promptText: string;
|
|
27
|
+
}
|
|
28
|
+
interface RewindMessageActionProps {
|
|
29
|
+
readonly matched: RewindMatch;
|
|
30
|
+
readonly sessionId: string;
|
|
31
|
+
readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
interface RewindPortalBridgeProps {
|
|
34
|
+
readonly sessionId: string;
|
|
35
|
+
readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>;
|
|
36
|
+
readonly useSession: <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T;
|
|
37
|
+
}
|
|
38
|
+
interface SlotsLike {
|
|
39
|
+
inject(name: string, install: () => unknown): void;
|
|
40
|
+
register(entry: {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly order: number;
|
|
44
|
+
readonly inject: () => {
|
|
45
|
+
readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
}, component: (props: RewindPortalBridgeProps) => ReactNode): () => void;
|
|
48
|
+
}
|
|
49
|
+
interface ClientContextLike {
|
|
50
|
+
readonly slots: SlotsLike;
|
|
51
|
+
readonly sessions: {
|
|
52
|
+
open(sessionId: string): void;
|
|
53
|
+
scope(sessionId: string): unknown | undefined;
|
|
54
|
+
};
|
|
55
|
+
readonly conversation: {
|
|
56
|
+
readonly input: {
|
|
57
|
+
for(scope: unknown): {
|
|
58
|
+
setDraft(text: string): void;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
effect(setup: () => (() => void), label?: string): unknown;
|
|
63
|
+
}
|
|
64
|
+
type ChangeKind = 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed';
|
|
65
|
+
/** Return the rewind anchor and editable text owned by one direct user message. */
|
|
66
|
+
export declare function selectRewindMessage(node: ConversationNodeLike): RewindMatch | null;
|
|
67
|
+
/** Browser plugin entry: bridge every direct user-message action row to the rewind UI. */
|
|
68
|
+
export declare const inject: string[];
|
|
69
|
+
export declare function apply(ctx: ClientContextLike): void;
|
|
70
|
+
/** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
|
|
71
|
+
export declare function RewindMessagePortals({ sessionId, openRestoredSession, useSession }: RewindPortalBridgeProps): ReactNode;
|
|
72
|
+
/** User-message action and its review-first file/conversation restore dialog. */
|
|
73
|
+
export declare function RewindMessageAction({ matched, sessionId, openRestoredSession }: RewindMessageActionProps): ReactNode;
|
|
74
|
+
/** Resolve one conversation node to its DOM row key and rewind match. */
|
|
75
|
+
export declare function selectRewindMessageTarget(value: RewindNodeLike): {
|
|
76
|
+
readonly matched: RewindMatch;
|
|
77
|
+
readonly rowKey: string;
|
|
78
|
+
} | null;
|
|
79
|
+
/** Describe the user-visible result of restoring one changed file. */
|
|
80
|
+
export declare function fileRecoveryLabel(kind: ChangeKind): string;
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { LedgerStore } from './store.js';
|
|
2
|
+
import { type ChangeLedgerConfig, type RecoverySummary, type ResolvedChangeLedgerConfig, type RestorePlan, type RestorePointInspection, type RestorePointManifest, type RestorePointSummary, type RestoreResult } from './types.js';
|
|
3
|
+
/** Persistent workspace change-set engine, independent of the DSH tool adapter. */
|
|
4
|
+
export declare class ChangeLedgerEngine {
|
|
5
|
+
readonly config: ResolvedChangeLedgerConfig;
|
|
6
|
+
readonly store: LedgerStore;
|
|
7
|
+
private readonly plans;
|
|
8
|
+
private readonly activePlans;
|
|
9
|
+
private readonly ready;
|
|
10
|
+
/** Build an engine and start crash-journal reconciliation. */
|
|
11
|
+
constructor(config?: ChangeLedgerConfig);
|
|
12
|
+
/** Wait for startup reconciliation and return the number of interrupted journals found. */
|
|
13
|
+
initialize(): Promise<number>;
|
|
14
|
+
/** Create a durable restore point for the current Git worktree. */
|
|
15
|
+
create(options: {
|
|
16
|
+
readonly cwd: string;
|
|
17
|
+
readonly sessionId?: string;
|
|
18
|
+
readonly label?: string;
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
}): Promise<RestorePointSummary>;
|
|
21
|
+
/** Capture project files before one DSH turn begins its first step. */
|
|
22
|
+
createTurnCheckpoint(options: {
|
|
23
|
+
readonly cwd: string;
|
|
24
|
+
readonly sessionId: string;
|
|
25
|
+
readonly turn: number;
|
|
26
|
+
readonly turnStartSeq: number;
|
|
27
|
+
readonly signal?: AbortSignal;
|
|
28
|
+
}): Promise<RestorePointSummary>;
|
|
29
|
+
/** Find the prompt-anchored checkpoint captured before one session turn. */
|
|
30
|
+
findTurnCheckpoint(options: {
|
|
31
|
+
readonly cwd: string;
|
|
32
|
+
readonly sessionId: string;
|
|
33
|
+
readonly turn: number;
|
|
34
|
+
readonly signal?: AbortSignal;
|
|
35
|
+
}): Promise<RestorePointSummary | undefined>;
|
|
36
|
+
/** List restore points for the current worktree. */
|
|
37
|
+
list(options: {
|
|
38
|
+
readonly cwd: string;
|
|
39
|
+
readonly includeRescue?: boolean;
|
|
40
|
+
readonly includeTurnCheckpoints?: boolean;
|
|
41
|
+
readonly signal?: AbortSignal;
|
|
42
|
+
}): Promise<RestorePointSummary[]>;
|
|
43
|
+
/** Compare one restore point with the current worktree. */
|
|
44
|
+
inspect(options: {
|
|
45
|
+
readonly cwd: string;
|
|
46
|
+
readonly restorePointId: string;
|
|
47
|
+
readonly signal?: AbortSignal;
|
|
48
|
+
}): Promise<RestorePointInspection>;
|
|
49
|
+
/** Produce an expiring, exact confirmation plan for a restore. */
|
|
50
|
+
planRestore(options: {
|
|
51
|
+
readonly cwd: string;
|
|
52
|
+
readonly restorePointId: string;
|
|
53
|
+
readonly sessionId?: string;
|
|
54
|
+
readonly paths?: readonly string[];
|
|
55
|
+
readonly allowHeadChange?: boolean;
|
|
56
|
+
readonly expectedCurrentTreeHash?: string;
|
|
57
|
+
readonly expectedRepository?: RestorePointManifest['repository'];
|
|
58
|
+
readonly signal?: AbortSignal;
|
|
59
|
+
}): Promise<RestorePlan>;
|
|
60
|
+
/** Apply one approved restore plan, creating a durable rescue point first. */
|
|
61
|
+
applyRestore(options: {
|
|
62
|
+
readonly planId: string;
|
|
63
|
+
readonly confirmation: string;
|
|
64
|
+
readonly sessionId?: string;
|
|
65
|
+
readonly signal?: AbortSignal;
|
|
66
|
+
}): Promise<RestoreResult>;
|
|
67
|
+
/** Delete one restore point and collect unreferenced blobs. */
|
|
68
|
+
delete(options: {
|
|
69
|
+
readonly cwd: string;
|
|
70
|
+
readonly restorePointId: string;
|
|
71
|
+
readonly confirmation: string;
|
|
72
|
+
readonly signal?: AbortSignal;
|
|
73
|
+
}): Promise<{
|
|
74
|
+
readonly restorePointId: string;
|
|
75
|
+
readonly deletedBlobs: number;
|
|
76
|
+
readonly retainedBlobs: number;
|
|
77
|
+
}>;
|
|
78
|
+
/** List restore operations that were interrupted or require manual recovery. */
|
|
79
|
+
listRecovery(options: {
|
|
80
|
+
readonly cwd: string;
|
|
81
|
+
readonly signal?: AbortSignal;
|
|
82
|
+
}): Promise<RecoverySummary[]>;
|
|
83
|
+
private createLocked;
|
|
84
|
+
private restorePaths;
|
|
85
|
+
private verifyPaths;
|
|
86
|
+
private assertStorageSeparated;
|
|
87
|
+
private expirePlans;
|
|
88
|
+
}
|
|
89
|
+
/** Resolve and validate every deployment-varying configuration value. */
|
|
90
|
+
export declare function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerConfig;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Stable error carrying a machine-readable change-ledger code. */
|
|
2
|
+
export declare class ChangeLedgerError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly name = "ChangeLedgerError";
|
|
5
|
+
/**
|
|
6
|
+
* @param code - Stable error category.
|
|
7
|
+
* @param message - Human-readable diagnostic.
|
|
8
|
+
* @param cause - Optional lower-level failure.
|
|
9
|
+
*/
|
|
10
|
+
constructor(code: string, message: string, options?: {
|
|
11
|
+
readonly cause?: unknown;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/** Convert an unknown thrown value into one bounded diagnostic line. */
|
|
15
|
+
export declare function errorMessage(error: unknown): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RepositoryState } from './types.js';
|
|
2
|
+
/** Repository discovery result plus the eligible path inventory. */
|
|
3
|
+
export interface RepositorySnapshotSource {
|
|
4
|
+
readonly state: RepositoryState;
|
|
5
|
+
readonly paths: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
/** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
|
|
8
|
+
export declare function discoverRepository(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource>;
|
|
9
|
+
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
10
|
+
export declare function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string>;
|
|
11
|
+
/** Return true when two repository fences refer to the same checkout state. */
|
|
12
|
+
export declare function sameRepositoryFence(left: RepositoryState, right: RepositoryState): boolean;
|
|
13
|
+
/** Return the Git metadata directory for diagnostics. */
|
|
14
|
+
export declare function gitMetadataParent(state: RepositoryState): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Turn Rewind, powered by persistent, inspectable, approval-gated Change Ledger restore points.
|
|
3
|
+
* @module @anionex/dsh-turn-rewind
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import { ChangeLedgerEngine } from './engine.js';
|
|
7
|
+
import type { ChangeLedgerConfig } from './types.js';
|
|
8
|
+
export * from './engine.js';
|
|
9
|
+
export * from './errors.js';
|
|
10
|
+
export * from './rewind-host.js';
|
|
11
|
+
export * from './types.js';
|
|
12
|
+
declare module '@deepseek-ai/cordis' {
|
|
13
|
+
interface Context {
|
|
14
|
+
changeLedger: ChangeLedgerService;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Cordis service exposed as `ctx.changeLedger` for other DSH plugins. */
|
|
18
|
+
export declare class ChangeLedgerService {
|
|
19
|
+
readonly engine: ChangeLedgerEngine;
|
|
20
|
+
/** Register the service and startup reconciliation. */
|
|
21
|
+
constructor(ctx: Context, config?: ChangeLedgerConfig);
|
|
22
|
+
/** Wait for startup reconciliation. */
|
|
23
|
+
initialize(): ReturnType<ChangeLedgerEngine['initialize']>;
|
|
24
|
+
/** Create a user restore point. */
|
|
25
|
+
create(options: Parameters<ChangeLedgerEngine['create']>[0]): ReturnType<ChangeLedgerEngine['create']>;
|
|
26
|
+
/** Capture project files before one turn enters its first Agent step. */
|
|
27
|
+
createTurnCheckpoint(options: Parameters<ChangeLedgerEngine['createTurnCheckpoint']>[0]): ReturnType<ChangeLedgerEngine['createTurnCheckpoint']>;
|
|
28
|
+
/** Find the prompt-anchored checkpoint for one session turn. */
|
|
29
|
+
findTurnCheckpoint(options: Parameters<ChangeLedgerEngine['findTurnCheckpoint']>[0]): ReturnType<ChangeLedgerEngine['findTurnCheckpoint']>;
|
|
30
|
+
/** List restore points. */
|
|
31
|
+
list(options: Parameters<ChangeLedgerEngine['list']>[0]): ReturnType<ChangeLedgerEngine['list']>;
|
|
32
|
+
/** Compare a restore point with the current worktree. */
|
|
33
|
+
inspect(options: Parameters<ChangeLedgerEngine['inspect']>[0]): ReturnType<ChangeLedgerEngine['inspect']>;
|
|
34
|
+
/** Create an expiring restore plan. */
|
|
35
|
+
planRestore(options: Parameters<ChangeLedgerEngine['planRestore']>[0]): ReturnType<ChangeLedgerEngine['planRestore']>;
|
|
36
|
+
/** Apply an exact restore plan after approval. */
|
|
37
|
+
applyRestore(options: Parameters<ChangeLedgerEngine['applyRestore']>[0]): ReturnType<ChangeLedgerEngine['applyRestore']>;
|
|
38
|
+
/** Delete one restore point and collect unused blobs. */
|
|
39
|
+
delete(options: Parameters<ChangeLedgerEngine['delete']>[0]): ReturnType<ChangeLedgerEngine['delete']>;
|
|
40
|
+
/** List interrupted restore operations and their rescue points. */
|
|
41
|
+
listRecovery(options: Parameters<ChangeLedgerEngine['listRecovery']>[0]): ReturnType<ChangeLedgerEngine['listRecovery']>;
|
|
42
|
+
}
|
|
43
|
+
export default ChangeLedgerService;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Return whether `candidate` is equal to or below `root`. */
|
|
2
|
+
export declare function isWithin(root: string, candidate: string): boolean;
|
|
3
|
+
/** Validate and normalize one repository-relative path without filesystem access. */
|
|
4
|
+
export declare function validateRelativePath(path: string): string;
|
|
5
|
+
/** Resolve a validated relative path below `root`. */
|
|
6
|
+
export declare function resolveWorkspacePath(root: string, path: string): string;
|
|
7
|
+
/** Expand a leading `~/` against the provided home directory. */
|
|
8
|
+
export declare function expandHome(path: string, home: string): string;
|
|
9
|
+
/** Canonicalize an existing directory and reject non-directories. */
|
|
10
|
+
export declare function canonicalDirectory(path: string): Promise<string>;
|
|
11
|
+
/** Atomically replace one JSON file with owner-only permissions. */
|
|
12
|
+
export declare function writeJsonAtomic(path: string, value: unknown): Promise<void>;
|
|
13
|
+
/** Read and parse one JSON file. */
|
|
14
|
+
export declare function readJson(path: string): Promise<unknown>;
|
|
15
|
+
/** Return whether a filesystem path exists without following its final symlink. */
|
|
16
|
+
export declare function pathExists(path: string): Promise<boolean>;
|
|
17
|
+
/** Ensure every existing parent below `root` is a real directory, never a symlink. */
|
|
18
|
+
export declare function ensureSafeParents(root: string, target: string): Promise<void>;
|
|
19
|
+
/** Replace a path with a regular file using a sibling temporary file. */
|
|
20
|
+
export declare function replaceRegularFile(path: string, content: Buffer, mode: number): Promise<void>;
|
|
21
|
+
/** Replace a path with a symbolic link using a sibling temporary name. */
|
|
22
|
+
export declare function replaceSymbolicLink(path: string, target: string): Promise<void>;
|
|
23
|
+
/** Remove one file/symlink or one empty directory, never a non-empty tree. */
|
|
24
|
+
export declare function removeRestoreTarget(path: string): Promise<void>;
|
|
25
|
+
/** Best-effort removal of empty parent directories up to but excluding `root`. */
|
|
26
|
+
export declare function pruneEmptyParents(root: string, start: string): Promise<void>;
|
|
27
|
+
/** Test whether a process id currently exists. */
|
|
28
|
+
export declare function processExists(pid: number): boolean;
|
|
29
|
+
/** Type guard for Node.js errors with a selected `code`. */
|
|
30
|
+
export declare function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException;
|
|
31
|
+
/** Create one owner-only file exclusively and return its handle. */
|
|
32
|
+
export declare function openExclusive(path: string): Promise<import("fs/promises").FileHandle>;
|
|
33
|
+
/** Flush directory-entry changes where the platform supports directory fsync. */
|
|
34
|
+
export declare function syncDirectory(path: string): Promise<void>;
|