@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/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +295 -25
- package/lib/client.js.map +1 -1
- package/lib/engine.js +140 -3
- package/lib/index.js +17 -1
- package/lib/rewind-host.js +104 -9
- package/lib/settings.js +58 -0
- package/lib/store.js +90 -23
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/engine.d.ts +21 -2
- package/lib/types/index.d.ts +7 -0
- package/lib/types/rewind-host.d.ts +16 -2
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/store.d.ts +20 -0
- package/lib/types/types.d.ts +19 -0
- package/package.json +19 -3
- package/src/client/index.tsx +540 -45
- package/src/engine.ts +154 -3
- package/src/index.ts +20 -1
- package/src/rewind-host.ts +122 -9
- package/src/settings.ts +93 -0
- package/src/store.ts +109 -23
- package/src/types.ts +21 -0
package/lib/store.js
CHANGED
|
@@ -117,6 +117,11 @@ export class LedgerStore {
|
|
|
117
117
|
throw error;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
/** Acquire only the durable directory lock when the recorded worktree is no longer available. */
|
|
121
|
+
async acquireWorkspaceDir(workspaceDir, workspace, signal) {
|
|
122
|
+
const lease = await this.acquireOne(join(workspaceDir, 'lock.json'), workspace, false, true, signal);
|
|
123
|
+
return () => lease.release();
|
|
124
|
+
}
|
|
120
125
|
async acquireOne(initialLockPath, workspace, shared, reclaim = true, signal) {
|
|
121
126
|
throwIfAborted(signal);
|
|
122
127
|
const ownerHostId = hostIdentity();
|
|
@@ -254,28 +259,39 @@ export class LedgerStore {
|
|
|
254
259
|
}
|
|
255
260
|
/** List all validated restore points for one workspace, newest first. */
|
|
256
261
|
async listManifests(workspace, signal) {
|
|
262
|
+
const manifests = await this.listManifestsInDir(this.workspaceDir(workspace), signal);
|
|
263
|
+
for (const manifest of manifests) {
|
|
264
|
+
if (manifest.workspace !== workspace) {
|
|
265
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${manifest.id} belongs to a different workspace`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return manifests;
|
|
269
|
+
}
|
|
270
|
+
/** List validated manifests directly from one storage directory, newest first. */
|
|
271
|
+
async listManifestsInDir(workspaceDir, signal) {
|
|
257
272
|
const manifests = [];
|
|
258
|
-
for (const filename of await safeJsonNames(join(
|
|
273
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'manifests'))) {
|
|
259
274
|
throwIfAborted(signal);
|
|
260
|
-
const manifest = parseManifest(await readJson(join(
|
|
275
|
+
const manifest = parseManifest(await readJson(join(workspaceDir, 'manifests', filename)));
|
|
261
276
|
this.assertManifestOwned(manifest);
|
|
262
|
-
await this.assertManifestWorkspaceIdentity(manifest,
|
|
277
|
+
await this.assertManifestWorkspaceIdentity(manifest, workspaceDir);
|
|
263
278
|
if (filename !== `${manifest.id}.json`) {
|
|
264
279
|
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} does not match its persisted id ${manifest.id}`);
|
|
265
280
|
}
|
|
266
|
-
if (manifest.workspace !== workspace) {
|
|
267
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} belongs to a different workspace`);
|
|
268
|
-
}
|
|
269
281
|
manifests.push(manifest);
|
|
270
282
|
}
|
|
271
283
|
return manifests.sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id));
|
|
272
284
|
}
|
|
273
285
|
/** Delete one restore-point manifest. Blobs remain until garbage collection succeeds. */
|
|
274
286
|
async deleteManifest(workspace, id) {
|
|
287
|
+
await this.deleteManifestInDir(this.workspaceDir(workspace), id);
|
|
288
|
+
}
|
|
289
|
+
/** Delete one restore-point manifest directly from its storage directory. */
|
|
290
|
+
async deleteManifestInDir(workspaceDir, id) {
|
|
275
291
|
validateRestorePointId(id);
|
|
276
|
-
const directory = join(
|
|
292
|
+
const directory = join(workspaceDir, 'manifests');
|
|
277
293
|
try {
|
|
278
|
-
await unlink(
|
|
294
|
+
await unlink(join(directory, `${id}.json`));
|
|
279
295
|
}
|
|
280
296
|
catch (error) {
|
|
281
297
|
if (isNodeError(error, 'ENOENT')) {
|
|
@@ -304,26 +320,45 @@ export class LedgerStore {
|
|
|
304
320
|
this.assertGitCheckpointJournalOwned(parseGitCheckpointJournal(journal));
|
|
305
321
|
await writeJsonAtomic(this.gitCheckpointJournalPath(manifest.workspace, manifest.id), journal);
|
|
306
322
|
}
|
|
323
|
+
/** List every persisted workspace directory under this storage root. */
|
|
324
|
+
async listWorkspaceDirs(signal) {
|
|
325
|
+
const root = join(this.config.storageDir, 'workspaces');
|
|
326
|
+
const dirs = [];
|
|
327
|
+
for (const key of await safeDirectoryNames(root)) {
|
|
328
|
+
throwIfAborted(signal);
|
|
329
|
+
dirs.push(join(root, key));
|
|
330
|
+
}
|
|
331
|
+
return dirs;
|
|
332
|
+
}
|
|
307
333
|
/** List every pending Git checkpoint transition across this storage root. */
|
|
308
334
|
async listGitCheckpointJournals() {
|
|
309
335
|
const journals = [];
|
|
310
336
|
for (const workspaceKey of await safeDirectoryNames(join(this.config.storageDir, 'workspaces'))) {
|
|
311
337
|
const workspaceDir = join(this.config.storageDir, 'workspaces', workspaceKey);
|
|
312
|
-
for (const
|
|
313
|
-
const journal = parseGitCheckpointJournal(await readJson(join(workspaceDir, 'git-journals', filename)));
|
|
314
|
-
this.assertGitCheckpointJournalOwned(journal);
|
|
315
|
-
await this.assertGitCheckpointJournalWorkspaceIdentity(journal, workspaceDir);
|
|
316
|
-
if (filename !== `${journal.restorePointId}.json`) {
|
|
317
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${filename} does not match its restore-point id`);
|
|
318
|
-
}
|
|
338
|
+
for (const journal of await this.listGitCheckpointJournalsInDir(workspaceDir)) {
|
|
319
339
|
if (this.workspaceDir(journal.workspace) !== workspaceDir) {
|
|
320
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${
|
|
340
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${journal.restorePointId}.json is stored under the wrong workspace key`);
|
|
321
341
|
}
|
|
322
342
|
journals.push(journal);
|
|
323
343
|
}
|
|
324
344
|
}
|
|
325
345
|
return journals.sort((left, right) => left.createdAt - right.createdAt || left.restorePointId.localeCompare(right.restorePointId));
|
|
326
346
|
}
|
|
347
|
+
/** List pending Git checkpoint transitions directly from one storage directory. */
|
|
348
|
+
async listGitCheckpointJournalsInDir(workspaceDir, signal) {
|
|
349
|
+
const journals = [];
|
|
350
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'git-journals'))) {
|
|
351
|
+
throwIfAborted(signal);
|
|
352
|
+
const journal = parseGitCheckpointJournal(await readJson(join(workspaceDir, 'git-journals', filename)));
|
|
353
|
+
this.assertGitCheckpointJournalOwned(journal);
|
|
354
|
+
await this.assertGitCheckpointJournalWorkspaceIdentity(journal, workspaceDir);
|
|
355
|
+
if (filename !== `${journal.restorePointId}.json`) {
|
|
356
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${filename} does not match its restore-point id`);
|
|
357
|
+
}
|
|
358
|
+
journals.push(journal);
|
|
359
|
+
}
|
|
360
|
+
return journals;
|
|
361
|
+
}
|
|
327
362
|
/** Remove one completed Git checkpoint transition journal. */
|
|
328
363
|
async deleteGitCheckpointJournal(workspace, id) {
|
|
329
364
|
validateRestorePointId(id);
|
|
@@ -396,15 +431,23 @@ export class LedgerStore {
|
|
|
396
431
|
}
|
|
397
432
|
/** List validated restore operations for one workspace. */
|
|
398
433
|
async listOperations(workspace) {
|
|
434
|
+
const operations = await this.listOperationsInDir(this.workspaceDir(workspace));
|
|
435
|
+
for (const operation of operations) {
|
|
436
|
+
if (operation.workspace !== workspace) {
|
|
437
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${operation.id} belongs to a different workspace`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return operations;
|
|
441
|
+
}
|
|
442
|
+
/** List validated restore operations directly from one storage directory. */
|
|
443
|
+
async listOperationsInDir(workspaceDir, signal) {
|
|
399
444
|
const operations = [];
|
|
400
|
-
for (const filename of await safeJsonNames(join(
|
|
401
|
-
|
|
445
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'operations'))) {
|
|
446
|
+
throwIfAborted(signal);
|
|
447
|
+
const operation = parseOperation(await readJson(join(workspaceDir, 'operations', filename)));
|
|
402
448
|
if (filename !== `${operation.id}.json`) {
|
|
403
449
|
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`);
|
|
404
450
|
}
|
|
405
|
-
if (operation.workspace !== workspace) {
|
|
406
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} belongs to a different workspace`);
|
|
407
|
-
}
|
|
408
451
|
operations.push(operation);
|
|
409
452
|
}
|
|
410
453
|
return operations.sort((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id));
|
|
@@ -419,16 +462,40 @@ export class LedgerStore {
|
|
|
419
462
|
const referenced = new Set(additionalReferenced);
|
|
420
463
|
for (const hash of referenced)
|
|
421
464
|
validateBlobHash(hash);
|
|
422
|
-
for (const manifest of await this.
|
|
465
|
+
for (const manifest of await this.listManifestsInDir(this.workspaceDir(workspace), signal)) {
|
|
466
|
+
throwIfAborted(signal);
|
|
467
|
+
for (const entry of Object.values(manifest.entries)) {
|
|
468
|
+
if (entry.kind === 'file' && entry.provider !== 'git')
|
|
469
|
+
referenced.add(entry.blob);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return this.sweepBlobs(this.workspaceDir(workspace), referenced, signal);
|
|
473
|
+
}
|
|
474
|
+
/** Delete blobs under one storage directory that no manifest in it references. */
|
|
475
|
+
async collectGarbageInDir(workspaceDir, signal) {
|
|
476
|
+
const referenced = new Set();
|
|
477
|
+
for (const manifest of await this.listManifestsInDir(workspaceDir, signal)) {
|
|
423
478
|
throwIfAborted(signal);
|
|
424
479
|
for (const entry of Object.values(manifest.entries)) {
|
|
425
480
|
if (entry.kind === 'file' && entry.provider !== 'git')
|
|
426
481
|
referenced.add(entry.blob);
|
|
427
482
|
}
|
|
428
483
|
}
|
|
484
|
+
return this.sweepBlobs(workspaceDir, referenced, signal);
|
|
485
|
+
}
|
|
486
|
+
/** Remove every persisted automatic-checkpoint skip marker under one storage directory. */
|
|
487
|
+
async clearTurnOutcomesInDir(workspaceDir) {
|
|
488
|
+
let removed = 0;
|
|
489
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'turn-outcomes'))) {
|
|
490
|
+
await unlink(join(workspaceDir, 'turn-outcomes', filename));
|
|
491
|
+
removed += 1;
|
|
492
|
+
}
|
|
493
|
+
return removed;
|
|
494
|
+
}
|
|
495
|
+
async sweepBlobs(workspaceDir, referenced, signal) {
|
|
429
496
|
let deletedBlobs = 0;
|
|
430
497
|
let retainedBlobs = 0;
|
|
431
|
-
const blobsRoot = join(
|
|
498
|
+
const blobsRoot = join(workspaceDir, 'blobs');
|
|
432
499
|
for (const prefix of await safeDirectoryNames(blobsRoot)) {
|
|
433
500
|
throwIfAborted(signal);
|
|
434
501
|
const prefixPath = join(blobsRoot, prefix);
|
|
@@ -37,14 +37,14 @@ interface RewindPortalBridgeProps {
|
|
|
37
37
|
}
|
|
38
38
|
interface SlotsLike {
|
|
39
39
|
inject(name: string, install: () => unknown): void;
|
|
40
|
-
register(entry: {
|
|
40
|
+
register<I, P>(entry: {
|
|
41
41
|
readonly name: string;
|
|
42
|
-
readonly id
|
|
43
|
-
readonly
|
|
44
|
-
readonly
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}, component: (props:
|
|
42
|
+
readonly id?: string;
|
|
43
|
+
readonly key?: string;
|
|
44
|
+
readonly order?: number;
|
|
45
|
+
readonly locale?: string;
|
|
46
|
+
readonly inject?: () => I;
|
|
47
|
+
}, component: (props: P) => ReactNode): () => void;
|
|
48
48
|
}
|
|
49
49
|
interface ClientContextLike {
|
|
50
50
|
readonly slots: SlotsLike;
|
|
@@ -59,9 +59,69 @@ interface ClientContextLike {
|
|
|
59
59
|
};
|
|
60
60
|
};
|
|
61
61
|
};
|
|
62
|
+
readonly settingsScope?: {
|
|
63
|
+
bind<T>(spec: {
|
|
64
|
+
readonly namespace: string;
|
|
65
|
+
}): SettingsScopeLike<T>;
|
|
66
|
+
};
|
|
62
67
|
effect(setup: () => (() => void), label?: string): unknown;
|
|
63
68
|
}
|
|
64
69
|
type ChangeKind = 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed';
|
|
70
|
+
/** Runtime-tunable Turn Rewind settings mirrored from the `turn-rewind` namespace. */
|
|
71
|
+
export interface TurnRewindSettingsValue {
|
|
72
|
+
readonly maxRestorePoints: number;
|
|
73
|
+
readonly maxTurnCheckpointsPerSession: number;
|
|
74
|
+
readonly maxFiles: number;
|
|
75
|
+
readonly maxFileBytes: number;
|
|
76
|
+
readonly maxSnapshotBytes: number;
|
|
77
|
+
readonly planTtlMs: number;
|
|
78
|
+
readonly staleLockMs: number;
|
|
79
|
+
readonly turnCheckpointMode: 'off' | 'git-native' | 'legacy';
|
|
80
|
+
readonly turnCheckpointTimeoutMs: number;
|
|
81
|
+
readonly turnCheckpointMaxNewBytes: number;
|
|
82
|
+
readonly turnCheckpointTrust: 'fast' | 'strict';
|
|
83
|
+
}
|
|
84
|
+
/** Browser mirror of one settings namespace, as bound by `ctx.settingsScope`. */
|
|
85
|
+
export interface SettingsScopeLike<T> {
|
|
86
|
+
getSnapshot(): SettingsScopeSnapshotLike<T>;
|
|
87
|
+
subscribe(listener: () => void): () => void;
|
|
88
|
+
set(field: string, value: unknown): Promise<void>;
|
|
89
|
+
unset(field: string): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
/** Sync snapshot shape shared by every settings scope. */
|
|
92
|
+
export interface SettingsScopeSnapshotLike<T> {
|
|
93
|
+
readonly status: 'loading' | 'ready' | 'unavailable';
|
|
94
|
+
readonly value: T | undefined;
|
|
95
|
+
readonly base: unknown;
|
|
96
|
+
readonly user: unknown;
|
|
97
|
+
readonly revision: number | undefined;
|
|
98
|
+
readonly writable: boolean;
|
|
99
|
+
readonly mode: 'host' | 'memory';
|
|
100
|
+
}
|
|
101
|
+
/** One checkpoint row in the storage-management overview. */
|
|
102
|
+
export interface ManageRestorePoint {
|
|
103
|
+
readonly id: string;
|
|
104
|
+
readonly kind: string;
|
|
105
|
+
readonly format: number;
|
|
106
|
+
readonly createdAt: number;
|
|
107
|
+
readonly totalBytes: number;
|
|
108
|
+
readonly fileCount: number;
|
|
109
|
+
readonly sessionId?: string;
|
|
110
|
+
readonly label?: string;
|
|
111
|
+
}
|
|
112
|
+
/** One workspace group in the storage-management overview. */
|
|
113
|
+
export interface ManageWorkspace {
|
|
114
|
+
readonly workspace: string;
|
|
115
|
+
readonly totalBytes: number;
|
|
116
|
+
readonly recoveryCount: number;
|
|
117
|
+
readonly restorePoints: readonly ManageRestorePoint[];
|
|
118
|
+
}
|
|
119
|
+
/** Storage-management overview served by `/turn-rewind/manage`. */
|
|
120
|
+
export interface ManageOverview {
|
|
121
|
+
readonly storageDir: string;
|
|
122
|
+
readonly totalBytes: number;
|
|
123
|
+
readonly workspaces: readonly ManageWorkspace[];
|
|
124
|
+
}
|
|
65
125
|
/** Return the rewind anchor and editable text owned by one direct user message. */
|
|
66
126
|
export declare function selectRewindMessage(node: ConversationNodeLike): RewindMatch | null;
|
|
67
127
|
/** Browser plugin entry: bridge every direct user-message action row to the rewind UI. */
|
|
@@ -71,6 +131,13 @@ export declare function apply(ctx: ClientContextLike): void;
|
|
|
71
131
|
export declare function RewindMessagePortals({ sessionId, openRestoredSession, useSession }: RewindPortalBridgeProps): ReactNode;
|
|
72
132
|
/** User-message action and its review-first file/conversation restore dialog. */
|
|
73
133
|
export declare function RewindMessageAction({ matched, sessionId, openRestoredSession }: RewindMessageActionProps): ReactNode;
|
|
134
|
+
interface TurnRewindSettingsCardProps {
|
|
135
|
+
readonly scope: SettingsScopeLike<TurnRewindSettingsValue> | undefined;
|
|
136
|
+
}
|
|
137
|
+
/** Settings card for the `turn-rewind` namespace: runtime options plus checkpoint management. */
|
|
138
|
+
export declare function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps): ReactNode;
|
|
139
|
+
/** Format one byte count with human-friendly units. */
|
|
140
|
+
export declare function formatBytes(bytes: number): string;
|
|
74
141
|
/** Resolve one conversation node to its DOM row key and rewind match. */
|
|
75
142
|
export declare function selectRewindMessageTarget(value: RewindNodeLike): {
|
|
76
143
|
readonly matched: RewindMatch;
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
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';
|
|
2
|
+
import { type ChangeLedgerConfig, type RecoverySummary, type ResolvedChangeLedgerConfig, type RestorePlan, type RestorePointInspection, type RestorePointManifest, type RestorePointSummary, type RestoreResult, type WorkspaceOverview, type WorkspacePurgeReport } from './types.js';
|
|
3
3
|
/** Persistent workspace change-set engine, independent of the DSH tool adapter. */
|
|
4
4
|
export declare class ChangeLedgerEngine {
|
|
5
|
-
readonly
|
|
5
|
+
private readonly currentConfig;
|
|
6
6
|
readonly store: LedgerStore;
|
|
7
7
|
private readonly plans;
|
|
8
8
|
private readonly activePlans;
|
|
9
9
|
private readonly ready;
|
|
10
10
|
/** Build an engine and start crash-journal reconciliation. */
|
|
11
11
|
constructor(config?: ChangeLedgerConfig);
|
|
12
|
+
/** Currently resolved configuration; runtime-tunable fields update in place. */
|
|
13
|
+
get config(): ResolvedChangeLedgerConfig;
|
|
14
|
+
/**
|
|
15
|
+
* Swap runtime-tunable configuration (limits, modes, timeouts, trust) in place.
|
|
16
|
+
* The storage root must never move once the engine owns locks and journals.
|
|
17
|
+
*/
|
|
18
|
+
updateConfig(config: ChangeLedgerConfig): void;
|
|
12
19
|
/** Wait for startup reconciliation and return the number of interrupted journals found. */
|
|
13
20
|
initialize(): Promise<number>;
|
|
14
21
|
private initializeStore;
|
|
@@ -105,6 +112,18 @@ export declare class ChangeLedgerEngine {
|
|
|
105
112
|
readonly cwd: string;
|
|
106
113
|
readonly signal?: AbortSignal;
|
|
107
114
|
}): Promise<RecoverySummary[]>;
|
|
115
|
+
/** Inventory every workspace this storage root has ever persisted state for. */
|
|
116
|
+
listWorkspaces(options?: {
|
|
117
|
+
readonly signal?: AbortSignal;
|
|
118
|
+
}): Promise<WorkspaceOverview[]>;
|
|
119
|
+
/** Delete unprotected restore points recorded for one workspace and collect unused blobs. */
|
|
120
|
+
purgeWorkspace(options: {
|
|
121
|
+
readonly workspace: string;
|
|
122
|
+
/** Restrict the purge to these restore points; protection rules still apply. */
|
|
123
|
+
readonly restorePointIds?: readonly string[];
|
|
124
|
+
readonly signal?: AbortSignal;
|
|
125
|
+
}): Promise<WorkspacePurgeReport>;
|
|
126
|
+
private purgeWorkspaceDir;
|
|
108
127
|
private createLocked;
|
|
109
128
|
private restorePaths;
|
|
110
129
|
private assertPathFresh;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ChangeLedgerConfig } from './types.js';
|
|
|
8
8
|
export * from './engine.js';
|
|
9
9
|
export * from './errors.js';
|
|
10
10
|
export * from './rewind-host.js';
|
|
11
|
+
export * from './settings.js';
|
|
11
12
|
export * from './types.js';
|
|
12
13
|
declare module '@deepseek-ai/cordis' {
|
|
13
14
|
interface Context {
|
|
@@ -39,5 +40,11 @@ export declare class ChangeLedgerService {
|
|
|
39
40
|
delete(options: Parameters<ChangeLedgerEngine['delete']>[0]): ReturnType<ChangeLedgerEngine['delete']>;
|
|
40
41
|
/** List interrupted restore operations and their rescue points. */
|
|
41
42
|
listRecovery(options: Parameters<ChangeLedgerEngine['listRecovery']>[0]): ReturnType<ChangeLedgerEngine['listRecovery']>;
|
|
43
|
+
/** Inventory every workspace this storage root has persisted state for. */
|
|
44
|
+
listWorkspaces(options?: Parameters<ChangeLedgerEngine['listWorkspaces']>[0]): ReturnType<ChangeLedgerEngine['listWorkspaces']>;
|
|
45
|
+
/** Delete unprotected restore points recorded for one workspace. */
|
|
46
|
+
purgeWorkspace(options: Parameters<ChangeLedgerEngine['purgeWorkspace']>[0]): ReturnType<ChangeLedgerEngine['purgeWorkspace']>;
|
|
47
|
+
/** Swap runtime-tunable configuration; the storage root must stay fixed. */
|
|
48
|
+
updateConfig(config: ChangeLedgerConfig): void;
|
|
42
49
|
}
|
|
43
50
|
export default ChangeLedgerService;
|
|
@@ -20,6 +20,11 @@ interface AgentLike {
|
|
|
20
20
|
readonly status: 'idle' | 'running';
|
|
21
21
|
readonly session: SessionLike;
|
|
22
22
|
}
|
|
23
|
+
interface ToolExecutionLike {
|
|
24
|
+
readonly agent?: AgentLike;
|
|
25
|
+
readonly parent?: symbol;
|
|
26
|
+
readonly signal: AbortSignal;
|
|
27
|
+
}
|
|
23
28
|
interface AgentsLike {
|
|
24
29
|
list(): AgentLike[];
|
|
25
30
|
}
|
|
@@ -106,10 +111,11 @@ declare module '@deepseek-ai/cordis' {
|
|
|
106
111
|
readonly step: number;
|
|
107
112
|
readonly signal: AbortSignal;
|
|
108
113
|
}, next: () => Promise<unknown>): Promise<unknown>;
|
|
114
|
+
'tools/execute'(exec: ToolExecutionLike, next: () => Promise<unknown>): Promise<unknown>;
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
117
|
export declare const REWIND_HTTP_PATH = "/turn-rewind";
|
|
112
|
-
/** Capture each turn
|
|
118
|
+
/** Capture each turn beside model work and gate root tool side effects on its bounded outcome. */
|
|
113
119
|
export declare class TurnCheckpointCoordinator {
|
|
114
120
|
private readonly engine;
|
|
115
121
|
private readonly captures;
|
|
@@ -118,7 +124,7 @@ export declare class TurnCheckpointCoordinator {
|
|
|
118
124
|
private readonly skips;
|
|
119
125
|
private readonly workspaceTails;
|
|
120
126
|
constructor(engine: ChangeLedgerEngine);
|
|
121
|
-
/**
|
|
127
|
+
/** Keep sidecar checkpoint work out of the Agent response waterfall. */
|
|
122
128
|
install(ctx: Context): void;
|
|
123
129
|
/** Current capture state for a session turn when no durable checkpoint exists yet. */
|
|
124
130
|
state(sessionId: string, turn: number): {
|
|
@@ -126,7 +132,10 @@ export declare class TurnCheckpointCoordinator {
|
|
|
126
132
|
readonly error?: string;
|
|
127
133
|
readonly reason?: string;
|
|
128
134
|
};
|
|
135
|
+
private startCapture;
|
|
129
136
|
private capture;
|
|
137
|
+
/** Wait only for the bounded checkpoint outcome of the Agent's open turn. */
|
|
138
|
+
private waitForOpenTurn;
|
|
130
139
|
private serializeWorkspace;
|
|
131
140
|
private recordFailure;
|
|
132
141
|
}
|
|
@@ -136,4 +145,9 @@ export declare function installRewindHttp(ctx: Context, engine: ChangeLedgerEngi
|
|
|
136
145
|
export declare function createRewindHttpHandler(ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & {
|
|
137
146
|
readonly agents?: AgentsLike;
|
|
138
147
|
}, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
|
|
148
|
+
export declare const MANAGE_HTTP_PATH = "/turn-rewind/manage";
|
|
149
|
+
/** Register the same-origin storage-management endpoint consumed by the settings card. */
|
|
150
|
+
export declare function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void;
|
|
151
|
+
/** Build the storage-management route as a testable unit. */
|
|
152
|
+
export declare function createManageHttpHandler(engine: ChangeLedgerEngine): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
|
|
139
153
|
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime settings surface for Turn Rewind on the DSH web settings page.
|
|
3
|
+
* @module @anionex/dsh-turn-rewind
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import z from '@deepseek-ai/schemastery';
|
|
7
|
+
import { type ChangeLedgerEngine } from './engine.js';
|
|
8
|
+
import type { ChangeLedgerConfig } from './types.js';
|
|
9
|
+
/** Namespace join key shared by the host section and the browser settings card. */
|
|
10
|
+
export declare const TURN_REWIND_SETTINGS_NAMESPACE: Branded<"SettingsNamespace">;
|
|
11
|
+
/** Every runtime-tunable field of {@link ChangeLedgerConfig}; `storageDir` stays config-layer only. */
|
|
12
|
+
export interface TurnRewindSettings {
|
|
13
|
+
/** Maximum user and rescue restore points retained per workspace. */
|
|
14
|
+
maxRestorePoints: number;
|
|
15
|
+
/** Maximum automatic turn checkpoints retained per session. */
|
|
16
|
+
maxTurnCheckpointsPerSession: number;
|
|
17
|
+
/** Maximum number of files in one restore point. */
|
|
18
|
+
maxFiles: number;
|
|
19
|
+
/** Maximum bytes read from one regular file. */
|
|
20
|
+
maxFileBytes: number;
|
|
21
|
+
/** Maximum aggregate regular-file bytes in one restore point. */
|
|
22
|
+
maxSnapshotBytes: number;
|
|
23
|
+
/** Restore-plan lifetime in milliseconds. */
|
|
24
|
+
planTtlMs: number;
|
|
25
|
+
/** Age after which a lock whose owner is gone may be reclaimed. */
|
|
26
|
+
staleLockMs: number;
|
|
27
|
+
/** Automatic turn-checkpoint implementation; `off` records durable skips instead. */
|
|
28
|
+
turnCheckpointMode: 'off' | 'git-native' | 'legacy';
|
|
29
|
+
/** Maximum time one automatic checkpoint may block the first Agent step. */
|
|
30
|
+
turnCheckpointTimeoutMs: number;
|
|
31
|
+
/** Maximum uncached worktree bytes read by one automatic Git-native checkpoint. */
|
|
32
|
+
turnCheckpointMaxNewBytes: number;
|
|
33
|
+
/** Fast trusts fenced Git/stat metadata; strict rereads every eligible path. */
|
|
34
|
+
turnCheckpointTrust: 'fast' | 'strict';
|
|
35
|
+
}
|
|
36
|
+
/** Schemastery schema for the `turn-rewind` settings namespace. */
|
|
37
|
+
export declare const TurnRewindSettingsSchema: z<TurnRewindSettings>;
|
|
38
|
+
/**
|
|
39
|
+
* Register the `turn-rewind` settings namespace and apply its resolved value to the
|
|
40
|
+
* running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
|
|
41
|
+
* the user layer persists through the DSH settings provider. `storageDir` is never
|
|
42
|
+
* carried by the namespace: the storage root must not move while the engine runs.
|
|
43
|
+
*/
|
|
44
|
+
export declare function installTurnRewindSettings(ctx: Context, config: ChangeLedgerConfig, engine: ChangeLedgerEngine): void;
|
package/lib/types/store.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export declare class LedgerStore {
|
|
|
35
35
|
readonly gitDir: string;
|
|
36
36
|
readonly worktreeId: string;
|
|
37
37
|
}, signal?: AbortSignal): Promise<() => Promise<void>>;
|
|
38
|
+
/** Acquire only the durable directory lock when the recorded worktree is no longer available. */
|
|
39
|
+
acquireWorkspaceDir(workspaceDir: string, workspace: string, signal?: AbortSignal): Promise<() => Promise<void>>;
|
|
38
40
|
private acquireOne;
|
|
39
41
|
/** Persist a blob if it is not already present, and verify existing content. */
|
|
40
42
|
putBlob(workspace: string, hash: string, content: Buffer): Promise<void>;
|
|
@@ -46,12 +48,20 @@ export declare class LedgerStore {
|
|
|
46
48
|
readManifest(workspace: string, id: string): Promise<RestorePointManifest>;
|
|
47
49
|
/** List all validated restore points for one workspace, newest first. */
|
|
48
50
|
listManifests(workspace: string, signal?: AbortSignal): Promise<RestorePointManifest[]>;
|
|
51
|
+
/** List validated manifests directly from one storage directory, newest first. */
|
|
52
|
+
listManifestsInDir(workspaceDir: string, signal?: AbortSignal): Promise<RestorePointManifest[]>;
|
|
49
53
|
/** Delete one restore-point manifest. Blobs remain until garbage collection succeeds. */
|
|
50
54
|
deleteManifest(workspace: string, id: string): Promise<void>;
|
|
55
|
+
/** Delete one restore-point manifest directly from its storage directory. */
|
|
56
|
+
deleteManifestInDir(workspaceDir: string, id: string): Promise<void>;
|
|
51
57
|
/** Persist the intent needed to finish or roll back a Git ref/manifest transition after a crash. */
|
|
52
58
|
writeGitCheckpointJournal(action: GitCheckpointJournal['action'], manifest: RestorePointManifestV2): Promise<void>;
|
|
59
|
+
/** List every persisted workspace directory under this storage root. */
|
|
60
|
+
listWorkspaceDirs(signal?: AbortSignal): Promise<string[]>;
|
|
53
61
|
/** List every pending Git checkpoint transition across this storage root. */
|
|
54
62
|
listGitCheckpointJournals(): Promise<GitCheckpointJournal[]>;
|
|
63
|
+
/** List pending Git checkpoint transitions directly from one storage directory. */
|
|
64
|
+
listGitCheckpointJournalsInDir(workspaceDir: string, signal?: AbortSignal): Promise<GitCheckpointJournal[]>;
|
|
55
65
|
/** Remove one completed Git checkpoint transition journal. */
|
|
56
66
|
deleteGitCheckpointJournal(workspace: string, id: string): Promise<void>;
|
|
57
67
|
/** Persist one explicit automatic-checkpoint skip so the UI survives restarts. */
|
|
@@ -68,6 +78,8 @@ export declare class LedgerStore {
|
|
|
68
78
|
writeOperation(operation: RestoreOperation): Promise<void>;
|
|
69
79
|
/** List validated restore operations for one workspace. */
|
|
70
80
|
listOperations(workspace: string): Promise<RestoreOperation[]>;
|
|
81
|
+
/** List validated restore operations directly from one storage directory. */
|
|
82
|
+
listOperationsInDir(workspaceDir: string, signal?: AbortSignal): Promise<RestoreOperation[]>;
|
|
71
83
|
/** Return whether an incomplete operation still references a restore point. */
|
|
72
84
|
isReferencedByRecovery(workspace: string, restorePointId: string): Promise<boolean>;
|
|
73
85
|
/** Delete blobs not referenced by any remaining manifest. */
|
|
@@ -75,6 +87,14 @@ export declare class LedgerStore {
|
|
|
75
87
|
deletedBlobs: number;
|
|
76
88
|
retainedBlobs: number;
|
|
77
89
|
}>;
|
|
90
|
+
/** Delete blobs under one storage directory that no manifest in it references. */
|
|
91
|
+
collectGarbageInDir(workspaceDir: string, signal?: AbortSignal): Promise<{
|
|
92
|
+
deletedBlobs: number;
|
|
93
|
+
retainedBlobs: number;
|
|
94
|
+
}>;
|
|
95
|
+
/** Remove every persisted automatic-checkpoint skip marker under one storage directory. */
|
|
96
|
+
clearTurnOutcomesInDir(workspaceDir: string): Promise<number>;
|
|
97
|
+
private sweepBlobs;
|
|
78
98
|
/** Finish a prior bounded legacy capture's orphan cleanup, if one is pending. */
|
|
79
99
|
reconcileSnapshotCleanup(workspace: string, signal?: AbortSignal): Promise<boolean>;
|
|
80
100
|
private workspaceDir;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -218,3 +218,22 @@ export interface RecoverySummary {
|
|
|
218
218
|
readonly error?: string;
|
|
219
219
|
readonly rollbackError?: string;
|
|
220
220
|
}
|
|
221
|
+
/** Aggregated checkpoint inventory for one workspace, addressed by its recorded path. */
|
|
222
|
+
export interface WorkspaceOverview {
|
|
223
|
+
/** Canonical workspace path recorded in the persisted manifests. */
|
|
224
|
+
readonly workspace: string;
|
|
225
|
+
/** Every restore point stored for the workspace, newest first. */
|
|
226
|
+
readonly restorePoints: readonly RestorePointSummary[];
|
|
227
|
+
/** Sum of every restore point's logical bytes. */
|
|
228
|
+
readonly totalBytes: number;
|
|
229
|
+
/** Incomplete restore operations that still reference this workspace's points. */
|
|
230
|
+
readonly recoveryCount: number;
|
|
231
|
+
}
|
|
232
|
+
/** Aggregate cleanup report for one workspace purge. */
|
|
233
|
+
export interface WorkspacePurgeReport {
|
|
234
|
+
readonly workspace: string;
|
|
235
|
+
readonly deletedRestorePoints: number;
|
|
236
|
+
readonly retainedRestorePoints: number;
|
|
237
|
+
readonly deletedBlobs: number;
|
|
238
|
+
readonly retainedBlobs: number;
|
|
239
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anionex/dsh-turn-rewind",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Turn-level conversation and workspace rewind for DeepSeek Harness, powered by a persistent Change Ledger",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -55,7 +55,19 @@
|
|
|
55
55
|
"platform": "web",
|
|
56
56
|
"inject": [
|
|
57
57
|
"@deepseek-ai/dsh-client-runtime",
|
|
58
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"compatibility": {
|
|
63
|
+
"dsh": ">=0.1.0-rc.8 <0.2.0",
|
|
64
|
+
"dshReleases": {
|
|
65
|
+
"0.1.0-rc.8": "compatible",
|
|
66
|
+
"0.1.1-rc.1": "compatible",
|
|
67
|
+
"0.1.1-rc.2": "compatible"
|
|
68
|
+
},
|
|
69
|
+
"profiles": [
|
|
70
|
+
"web"
|
|
59
71
|
]
|
|
60
72
|
}
|
|
61
73
|
},
|
|
@@ -77,10 +89,14 @@
|
|
|
77
89
|
],
|
|
78
90
|
"license": "BSD-3-Clause",
|
|
79
91
|
"peerDependencies": {
|
|
80
|
-
"@deepseek-ai/cordis": "^4.0.1"
|
|
92
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
93
|
+
"@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0",
|
|
94
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
81
95
|
},
|
|
82
96
|
"devDependencies": {
|
|
83
97
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
98
|
+
"@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0",
|
|
99
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
84
100
|
"@types/node": "^24.0.0",
|
|
85
101
|
"@types/react": "18.3.28",
|
|
86
102
|
"react": "18.3.1",
|