@anionex/dsh-turn-rewind 0.3.2 → 0.3.4
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 +3 -0
- package/README.zh.md +3 -0
- package/lib/client.js +59 -1
- package/lib/client.js.map +1 -1
- package/lib/directory.js +27 -10
- package/lib/engine.js +60 -14
- package/lib/git.js +2 -0
- package/lib/index.js +23 -0
- package/lib/rewind-host.js +11 -2
- package/lib/snapshot.js +58 -15
- package/lib/store.js +5 -0
- package/lib/types/client/index.d.ts +54 -0
- package/lib/types/directory.d.ts +6 -1
- package/lib/types/git.d.ts +5 -1
- package/lib/types/index.d.ts +0 -4
- package/lib/types/rewind-host.d.ts +1 -1
- package/lib/types/snapshot.d.ts +8 -1
- package/lib/types/types.d.ts +21 -0
- package/lib/validate.js +33 -0
- package/package.json +1 -1
- package/src/client/index.tsx +64 -1
- package/src/directory.ts +45 -17
- package/src/engine.ts +61 -14
- package/src/git.ts +7 -1
- package/src/index.ts +19 -0
- package/src/rewind-host.ts +12 -3
- package/src/snapshot.ts +64 -18
- package/src/store.ts +4 -0
- package/src/types.ts +24 -0
- package/src/validate.ts +34 -0
package/lib/rewind-host.js
CHANGED
|
@@ -253,6 +253,11 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
|
|
|
253
253
|
const changes = inspection.changes.slice(offset, offset + limit);
|
|
254
254
|
const common = {
|
|
255
255
|
status: 'ready', sessionId, messageSeq, turn: checkpoint.turn, checkpointId: checkpoint.id,
|
|
256
|
+
// A restore point that could not store every eligible path stays usable;
|
|
257
|
+
// the browser shows what was left out instead of blocking the rewind.
|
|
258
|
+
skippedCount: inspection.restorePoint.skippedCount,
|
|
259
|
+
...(inspection.restorePoint.skipped === undefined ? {} : { skipped: inspection.restorePoint.skipped }),
|
|
260
|
+
...(inspection.restorePoint.truncated === undefined ? {} : { captureTruncated: inspection.restorePoint.truncated }),
|
|
256
261
|
turnStartSeq: checkpoint.turnStartSeq,
|
|
257
262
|
totalChanges: inspection.changes.length,
|
|
258
263
|
changes: changes.map(change => ({ path: change.path, kind: change.kind })),
|
|
@@ -452,14 +457,18 @@ async function createConversationRestart(ctx, sourceId, checkpoint) {
|
|
|
452
457
|
|| current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
|
|
453
458
|
throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary');
|
|
454
459
|
}
|
|
455
|
-
|
|
460
|
+
// Cordis throws when a context reads a service property it did not inject, and
|
|
461
|
+
// no single inject list covers both carriers: 0.1.1 provides `apiProxy` while
|
|
462
|
+
// 0.1.2-alpha provides `sessionController`. `ctx.get` is the optional accessor
|
|
463
|
+
// that returns undefined instead of failing the request.
|
|
464
|
+
const controller = ctx.get('sessionController');
|
|
456
465
|
if (controller !== undefined) {
|
|
457
466
|
const created = checkpoint.previousTurnEndSeq === undefined
|
|
458
467
|
? await controller.create({ cwd: checkpoint.cwd })
|
|
459
468
|
: await controller.fork({ sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq });
|
|
460
469
|
return { sessionId: requiredText(created.sessionId, 'sessionController sessionId') };
|
|
461
470
|
}
|
|
462
|
-
const apiProxy = ctx.apiProxy;
|
|
471
|
+
const apiProxy = ctx.get('apiProxy');
|
|
463
472
|
if (apiProxy === undefined) {
|
|
464
473
|
throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', 'this DSH host exposes neither sessionController nor apiProxy, so a new conversation cannot be created');
|
|
465
474
|
}
|
package/lib/snapshot.js
CHANGED
|
@@ -5,13 +5,29 @@ import { ChangeLedgerError } from './errors.js';
|
|
|
5
5
|
import { isNodeError, resolveWorkspacePath } from './path-utils.js';
|
|
6
6
|
import { discoverWorkspace, sameWorkspaceFence } from './workspace.js';
|
|
7
7
|
const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024;
|
|
8
|
+
const MAX_RECORDED_SKIPS = 50;
|
|
8
9
|
/** Capture the current tracked and non-ignored Git working tree. */
|
|
9
10
|
export async function captureTree(options) {
|
|
10
11
|
throwIfAborted(options.signal);
|
|
11
12
|
const source = await discoverWorkspace(options.cwd, options.config, options.signal);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// Limits never discard an entire restore point: an over-limit path is left out
|
|
14
|
+
// and reported, and the remaining eligible paths are still captured. A restore
|
|
15
|
+
// only ever rewrites captured paths, so a partial point stays safe to apply.
|
|
16
|
+
const skipped = [...source.skipped];
|
|
17
|
+
let skippedCount = source.skippedCount;
|
|
18
|
+
let truncated = source.truncated;
|
|
19
|
+
const recordSkip = (path, reason) => {
|
|
20
|
+
skippedCount += 1;
|
|
21
|
+
if (skipped.length < MAX_RECORDED_SKIPS)
|
|
22
|
+
skipped.push({ path, reason });
|
|
23
|
+
else
|
|
24
|
+
truncated ??= reason === 'snapshot-limit' ? 'snapshot-limit' : 'file-limit';
|
|
25
|
+
};
|
|
26
|
+
const paths = source.paths.length > options.config.maxFiles
|
|
27
|
+
? source.paths.slice(0, options.config.maxFiles)
|
|
28
|
+
: source.paths;
|
|
29
|
+
if (source.paths.length > options.config.maxFiles)
|
|
30
|
+
truncated ??= 'file-limit';
|
|
15
31
|
const entries = Object.create(null);
|
|
16
32
|
// Git-object capture only applies to a Git worktree; an ordinary directory
|
|
17
33
|
// always stores its own content-addressed blobs.
|
|
@@ -23,16 +39,40 @@ export async function captureTree(options) {
|
|
|
23
39
|
entries: Object.create(null),
|
|
24
40
|
};
|
|
25
41
|
let totalBytes = 0;
|
|
42
|
+
let budgetSpent = false;
|
|
26
43
|
const capturePath = async (path) => {
|
|
27
44
|
throwIfAborted(options.signal);
|
|
28
|
-
|
|
45
|
+
if (budgetSpent) {
|
|
46
|
+
// The aggregate budget is gone, but the path is still recorded so a
|
|
47
|
+
// restore can prove this point never observed it.
|
|
48
|
+
recordSkip(path, 'snapshot-limit');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let entry;
|
|
52
|
+
try {
|
|
53
|
+
entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof ChangeLedgerError && error.code === 'FILE_TOO_LARGE') {
|
|
57
|
+
recordSkip(path, 'file-too-large');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (error instanceof ChangeLedgerError && error.code === 'UNSUPPORTED_FILE_TYPE') {
|
|
61
|
+
recordSkip(path, 'unsupported-file-type');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
29
66
|
if (entry === undefined)
|
|
30
67
|
return;
|
|
31
68
|
if (entry.kind === 'file') {
|
|
32
|
-
totalBytes
|
|
33
|
-
|
|
34
|
-
|
|
69
|
+
if (budgetSpent || totalBytes + entry.content.length > options.config.maxSnapshotBytes) {
|
|
70
|
+
budgetSpent = true;
|
|
71
|
+
truncated ??= 'snapshot-limit';
|
|
72
|
+
recordSkip(path, 'snapshot-limit');
|
|
73
|
+
return;
|
|
35
74
|
}
|
|
75
|
+
totalBytes += entry.content.length;
|
|
36
76
|
if (options.store !== undefined) {
|
|
37
77
|
await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content);
|
|
38
78
|
}
|
|
@@ -46,20 +86,19 @@ export async function captureTree(options) {
|
|
|
46
86
|
if (gitCapture !== undefined)
|
|
47
87
|
gitCapture.entries[path] = entry.snapshot;
|
|
48
88
|
};
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
else {
|
|
89
|
+
{
|
|
90
|
+
// Reading one file at a time makes a real project directory take minutes, so
|
|
91
|
+
// every capture runs through a bounded worker pool. Per-file stability stats
|
|
92
|
+
// and the synchronous budget check keep the result identical to a serial run.
|
|
54
93
|
let next = 0;
|
|
55
94
|
let failed = false;
|
|
56
95
|
let firstError = new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'parallel comparison capture failed');
|
|
57
|
-
const concurrency = Math.min(32,
|
|
96
|
+
const concurrency = Math.min(32, paths.length, Math.max(1, Math.floor(COMPARISON_READ_BUDGET_BYTES / options.config.maxFileBytes)));
|
|
58
97
|
const workers = Array.from({ length: concurrency }, async () => {
|
|
59
|
-
while (!failed && next <
|
|
98
|
+
while (!failed && next < paths.length) {
|
|
60
99
|
const index = next;
|
|
61
100
|
next += 1;
|
|
62
|
-
const path =
|
|
101
|
+
const path = paths[index];
|
|
63
102
|
if (path === undefined)
|
|
64
103
|
continue;
|
|
65
104
|
try {
|
|
@@ -81,6 +120,10 @@ export async function captureTree(options) {
|
|
|
81
120
|
source,
|
|
82
121
|
entries,
|
|
83
122
|
...(gitCapture === undefined ? {} : { gitEntries: gitCapture.entries }),
|
|
123
|
+
skipped,
|
|
124
|
+
skippedCount,
|
|
125
|
+
skippedListTruncated: skippedCount > skipped.length,
|
|
126
|
+
...(truncated === undefined ? {} : { truncated }),
|
|
84
127
|
treeHash: hashTree(entries),
|
|
85
128
|
fileCount: Object.keys(entries).length,
|
|
86
129
|
totalBytes,
|
package/lib/store.js
CHANGED
|
@@ -180,6 +180,11 @@ export class LedgerStore {
|
|
|
180
180
|
throw new ChangeLedgerError('BLOB_HASH_MISMATCH', `refusing to store content whose SHA-256 does not match ${hash}`);
|
|
181
181
|
}
|
|
182
182
|
const path = this.blobPath(workspace, hash);
|
|
183
|
+
// Content addressing makes an existing blob identical by construction, and
|
|
184
|
+
// re-writing it costs one fsync per file: a full-tree capture of a large
|
|
185
|
+
// workspace otherwise spends minutes proving it already stored the content.
|
|
186
|
+
if (await pathExists(path))
|
|
187
|
+
return;
|
|
183
188
|
const directory = join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2));
|
|
184
189
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
185
190
|
const temporary = join(directory, `.${randomUUID()}.tmp`);
|
|
@@ -83,6 +83,41 @@ interface ClientContextLike {
|
|
|
83
83
|
effect(setup: () => (() => void), label?: string): unknown;
|
|
84
84
|
}
|
|
85
85
|
type ChangeKind = 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed';
|
|
86
|
+
interface ReadyPreview {
|
|
87
|
+
readonly status: 'ready';
|
|
88
|
+
readonly sessionId: string;
|
|
89
|
+
readonly messageSeq: number;
|
|
90
|
+
readonly turn: number;
|
|
91
|
+
readonly checkpointId: string;
|
|
92
|
+
readonly turnStartSeq: number;
|
|
93
|
+
readonly totalChanges: number;
|
|
94
|
+
readonly changes: readonly {
|
|
95
|
+
readonly path: string;
|
|
96
|
+
readonly kind: ChangeKind;
|
|
97
|
+
}[];
|
|
98
|
+
readonly offset: number;
|
|
99
|
+
readonly truncated: boolean;
|
|
100
|
+
/** Eligible paths the checkpoint could not store; a restore never touches them. */
|
|
101
|
+
readonly skippedCount: number;
|
|
102
|
+
readonly skipped: readonly {
|
|
103
|
+
readonly path: string;
|
|
104
|
+
readonly reason: string;
|
|
105
|
+
}[];
|
|
106
|
+
/** Set when limits stopped the capture before every eligible path was read. */
|
|
107
|
+
readonly captureTruncated?: 'file-limit' | 'snapshot-limit';
|
|
108
|
+
readonly headChanged: boolean;
|
|
109
|
+
readonly operationChanged: boolean;
|
|
110
|
+
readonly checkpointHead?: string;
|
|
111
|
+
readonly checkpointBranch?: string;
|
|
112
|
+
readonly checkpointOperation?: string;
|
|
113
|
+
readonly currentHead?: string;
|
|
114
|
+
readonly currentBranch?: string;
|
|
115
|
+
readonly currentOperation?: string;
|
|
116
|
+
readonly activeSessionIds: readonly string[];
|
|
117
|
+
readonly restoreBlocked: boolean;
|
|
118
|
+
readonly planId?: string;
|
|
119
|
+
readonly confirmation?: string;
|
|
120
|
+
}
|
|
86
121
|
/** Runtime-tunable Turn Rewind settings mirrored from the `turn-rewind` namespace. */
|
|
87
122
|
export interface TurnRewindSettingsValue {
|
|
88
123
|
readonly maxRestorePoints: number;
|
|
@@ -190,6 +225,25 @@ export declare function selectRewindMessageTarget(value: RewindNodeLike): {
|
|
|
190
225
|
export declare function responseJson(response: Response): Promise<unknown>;
|
|
191
226
|
/** Describe the user-visible result of restoring one changed file. */
|
|
192
227
|
export declare function fileRecoveryLabel(kind: ChangeKind): string;
|
|
228
|
+
/**
|
|
229
|
+
* Describe what a checkpoint could not store, in one user-facing sentence.
|
|
230
|
+
*
|
|
231
|
+
* Limits leave a restore point partial rather than failing it, so the dialog has
|
|
232
|
+
* to say which files stay untouched when the restore runs.
|
|
233
|
+
* @param ready - decoded ready preview, or null.
|
|
234
|
+
* @returns the warning text, or null when the checkpoint is complete.
|
|
235
|
+
*/
|
|
236
|
+
export declare function describeCaptureNotice(ready: ReadyPreview | null): string | null;
|
|
237
|
+
/**
|
|
238
|
+
* Explain one recorded checkpoint skip in user terms.
|
|
239
|
+
*
|
|
240
|
+
* A skip is not a failure of the message: the turn ran normally and only the
|
|
241
|
+
* file snapshot is missing, so the text names the actual cause and the two
|
|
242
|
+
* levers that fix it.
|
|
243
|
+
* @param reason - reason recorded by the Host, usually a `[CODE] detail` line.
|
|
244
|
+
* @returns one user-facing sentence.
|
|
245
|
+
*/
|
|
246
|
+
export declare function explainCheckpointSkip(reason: string): string;
|
|
193
247
|
/**
|
|
194
248
|
* Explain one recorded checkpoint failure in user terms.
|
|
195
249
|
*
|
package/lib/types/directory.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type { DirectoryWorkspaceState, ResolvedChangeLedgerConfig } from './types.js';
|
|
1
|
+
import type { CaptureSkip, CaptureTruncation, DirectoryWorkspaceState, ResolvedChangeLedgerConfig } from './types.js';
|
|
2
2
|
/** Ordinary-directory discovery result plus its eligible path inventory. */
|
|
3
3
|
export interface DirectorySnapshotSource {
|
|
4
4
|
readonly state: DirectoryWorkspaceState;
|
|
5
5
|
readonly paths: readonly string[];
|
|
6
|
+
/** Eligible paths that cannot be stored at all, such as sockets. */
|
|
7
|
+
readonly skipped: readonly CaptureSkip[];
|
|
8
|
+
readonly skippedCount: number;
|
|
9
|
+
/** Set when the file-count limit stopped the walk early. */
|
|
10
|
+
readonly truncated?: CaptureTruncation;
|
|
6
11
|
}
|
|
7
12
|
/** Resolve the canonical ordinary directory used as a non-Git workspace root. */
|
|
8
13
|
export declare function discoverDirectoryRoot(cwd: string): Promise<string>;
|
package/lib/types/git.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import type { GitWorkspaceState } from './types.js';
|
|
1
|
+
import type { CaptureSkip, CaptureTruncation, GitWorkspaceState } from './types.js';
|
|
2
2
|
/** Repository discovery result plus the eligible path inventory. */
|
|
3
3
|
export interface RepositorySnapshotSource {
|
|
4
4
|
readonly state: GitWorkspaceState;
|
|
5
5
|
readonly paths: readonly string[];
|
|
6
|
+
/** Git discovery never skips a path; per-file limits are reported by capture. */
|
|
7
|
+
readonly skipped: readonly CaptureSkip[];
|
|
8
|
+
readonly skippedCount: number;
|
|
9
|
+
readonly truncated?: CaptureTruncation;
|
|
6
10
|
}
|
|
7
11
|
/** Durable identity and shared lock location for one concrete Git worktree. */
|
|
8
12
|
export interface GitWorktreeIdentity {
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DSH Turn Rewind, powered by persistent, inspectable, approval-gated Change Ledger restore points.
|
|
3
|
-
* @module @anionex/dsh-turn-rewind
|
|
4
|
-
*/
|
|
5
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
6
2
|
import { ChangeLedgerEngine } from './engine.js';
|
|
7
3
|
import type { ChangeLedgerConfig } from './types.js';
|
|
@@ -130,7 +130,7 @@ interface SessionControllerLike {
|
|
|
130
130
|
}>;
|
|
131
131
|
}
|
|
132
132
|
/** Services a conversation restart can be built on, in either carrier shape. */
|
|
133
|
-
type ConversationRestartContext = Pick<Context, 'sessions' | 'sessionQuery'> & {
|
|
133
|
+
type ConversationRestartContext = Pick<Context, 'sessions' | 'sessionQuery' | 'get'> & {
|
|
134
134
|
readonly apiProxy?: ApiProxyLike;
|
|
135
135
|
readonly sessionController?: SessionControllerLike;
|
|
136
136
|
};
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { LedgerStore } from './store.js';
|
|
2
2
|
import { type WorkspaceSnapshotSource } from './workspace.js';
|
|
3
|
-
import type { ResolvedChangeLedgerConfig, SnapshotEntry, WorkspaceChange } from './types.js';
|
|
3
|
+
import type { CaptureSkip, CaptureTruncation, ResolvedChangeLedgerConfig, SnapshotEntry, WorkspaceChange } from './types.js';
|
|
4
4
|
/** One captured tree, optionally persisted into the blob store. */
|
|
5
5
|
export interface CapturedTree {
|
|
6
6
|
readonly source: WorkspaceSnapshotSource;
|
|
7
7
|
readonly entries: Readonly<Record<string, SnapshotEntry>>;
|
|
8
8
|
readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>;
|
|
9
|
+
/** Eligible paths this capture could not store; a restore never touches them. */
|
|
10
|
+
readonly skipped: readonly CaptureSkip[];
|
|
11
|
+
readonly skippedCount: number;
|
|
12
|
+
/** Set when a limit stopped the capture before every eligible path was read. */
|
|
13
|
+
readonly truncated?: CaptureTruncation;
|
|
14
|
+
/** True when the recorded skip list is shorter than `skippedCount`. */
|
|
15
|
+
readonly skippedListTruncated: boolean;
|
|
9
16
|
readonly treeHash: string;
|
|
10
17
|
readonly fileCount: number;
|
|
11
18
|
readonly totalBytes: number;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -25,6 +25,15 @@ export interface SymlinkSnapshotEntry {
|
|
|
25
25
|
}
|
|
26
26
|
/** One path captured by a restore point. */
|
|
27
27
|
export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry;
|
|
28
|
+
/** Why one eligible path was left out of a restore point. */
|
|
29
|
+
export type CaptureSkipReason = 'file-too-large' | 'unsupported-file-type' | 'snapshot-limit';
|
|
30
|
+
/** One eligible path that a restore point could not store. */
|
|
31
|
+
export interface CaptureSkip {
|
|
32
|
+
readonly path: string;
|
|
33
|
+
readonly reason: CaptureSkipReason;
|
|
34
|
+
}
|
|
35
|
+
/** Why a capture stopped before it enumerated every eligible path. */
|
|
36
|
+
export type CaptureTruncation = 'file-limit' | 'snapshot-limit';
|
|
28
37
|
/** Git facts that fence a restore point to the repository state it observed. */
|
|
29
38
|
export interface GitWorkspaceState {
|
|
30
39
|
readonly type: 'git';
|
|
@@ -67,6 +76,15 @@ export interface RestorePointManifestV1 {
|
|
|
67
76
|
readonly fileCount: number;
|
|
68
77
|
readonly totalBytes: number;
|
|
69
78
|
readonly entries: Readonly<Record<string, SnapshotEntry>>;
|
|
79
|
+
/**
|
|
80
|
+
* Eligible paths this restore point could not store, bounded for storage.
|
|
81
|
+
* A restore never touches them, so a partial point stays safe to apply.
|
|
82
|
+
*/
|
|
83
|
+
readonly skipped?: readonly CaptureSkip[];
|
|
84
|
+
/** Total skipped paths, including any beyond the bounded list. */
|
|
85
|
+
readonly skippedCount?: number;
|
|
86
|
+
/** Set when limits stopped the capture before every eligible path was read. */
|
|
87
|
+
readonly truncated?: CaptureTruncation;
|
|
70
88
|
readonly restoreCount: number;
|
|
71
89
|
readonly lastRestoredAt?: number;
|
|
72
90
|
}
|
|
@@ -133,6 +151,9 @@ export interface RestorePointSummary {
|
|
|
133
151
|
readonly treeHash: string;
|
|
134
152
|
readonly fileCount: number;
|
|
135
153
|
readonly totalBytes: number;
|
|
154
|
+
readonly skipped?: readonly CaptureSkip[];
|
|
155
|
+
readonly skippedCount: number;
|
|
156
|
+
readonly truncated?: CaptureTruncation;
|
|
136
157
|
readonly restoreCount: number;
|
|
137
158
|
readonly lastRestoredAt?: number;
|
|
138
159
|
readonly head?: string;
|
package/lib/validate.js
CHANGED
|
@@ -76,11 +76,24 @@ export function parseManifest(value) {
|
|
|
76
76
|
corrupt('only turn restore points may carry turn metadata');
|
|
77
77
|
}
|
|
78
78
|
const lastRestoredAt = optionalNonNegativeInteger(record, 'lastRestoredAt');
|
|
79
|
+
const skipped = parseCaptureSkips(record.skipped);
|
|
80
|
+
const skippedCount = optionalNonNegativeInteger(record, 'skippedCount');
|
|
81
|
+
if (skippedCount !== undefined && skippedCount < (skipped?.length ?? 0)) {
|
|
82
|
+
corrupt('skippedCount cannot be smaller than the recorded skipped paths');
|
|
83
|
+
}
|
|
84
|
+
const truncatedValue = record.truncated;
|
|
85
|
+
if (truncatedValue !== undefined && truncatedValue !== 'file-limit' && truncatedValue !== 'snapshot-limit') {
|
|
86
|
+
corrupt('truncated must be "file-limit" or "snapshot-limit"');
|
|
87
|
+
}
|
|
88
|
+
const truncated = truncatedValue;
|
|
79
89
|
const common = {
|
|
80
90
|
id,
|
|
81
91
|
kind: restorePointKind,
|
|
82
92
|
workspace,
|
|
83
93
|
repository,
|
|
94
|
+
...(skipped === undefined ? {} : { skipped }),
|
|
95
|
+
...(skippedCount === undefined ? {} : { skippedCount }),
|
|
96
|
+
...(truncated === undefined ? {} : { truncated }),
|
|
84
97
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
85
98
|
...(label === undefined ? {} : { label }),
|
|
86
99
|
...(parentRestorePoint === undefined ? {} : { parentRestorePoint }),
|
|
@@ -166,6 +179,26 @@ export function validateBlobHash(value) {
|
|
|
166
179
|
* @param value - persisted `repository` field.
|
|
167
180
|
* @returns the validated workspace state.
|
|
168
181
|
*/
|
|
182
|
+
/**
|
|
183
|
+
* Parse the optional bounded list of eligible paths a restore point could not store.
|
|
184
|
+
* @param value - persisted `skipped` field.
|
|
185
|
+
* @returns the validated list, or undefined when the manifest carries none.
|
|
186
|
+
*/
|
|
187
|
+
function parseCaptureSkips(value) {
|
|
188
|
+
if (value === undefined)
|
|
189
|
+
return undefined;
|
|
190
|
+
if (!Array.isArray(value))
|
|
191
|
+
corrupt('skipped must be an array');
|
|
192
|
+
return value.map((entry) => {
|
|
193
|
+
const record = objectRecord(entry, 'skipped path');
|
|
194
|
+
const path = validateRelativePath(requireString(record.path, 'skipped path'));
|
|
195
|
+
const reason = record.reason;
|
|
196
|
+
if (reason !== 'file-too-large' && reason !== 'unsupported-file-type') {
|
|
197
|
+
corrupt('skipped reason must be "file-too-large" or "unsupported-file-type"');
|
|
198
|
+
}
|
|
199
|
+
return { path, reason };
|
|
200
|
+
});
|
|
201
|
+
}
|
|
169
202
|
function parseRepository(value) {
|
|
170
203
|
const record = objectRecord(value, 'repository state');
|
|
171
204
|
const root = absoluteString(record, 'root');
|
package/package.json
CHANGED
package/src/client/index.tsx
CHANGED
|
@@ -116,6 +116,11 @@ interface ReadyPreview {
|
|
|
116
116
|
readonly changes: readonly { readonly path: string; readonly kind: ChangeKind }[]
|
|
117
117
|
readonly offset: number
|
|
118
118
|
readonly truncated: boolean
|
|
119
|
+
/** Eligible paths the checkpoint could not store; a restore never touches them. */
|
|
120
|
+
readonly skippedCount: number
|
|
121
|
+
readonly skipped: readonly { readonly path: string; readonly reason: string }[]
|
|
122
|
+
/** Set when limits stopped the capture before every eligible path was read. */
|
|
123
|
+
readonly captureTruncated?: 'file-limit' | 'snapshot-limit'
|
|
119
124
|
readonly headChanged: boolean
|
|
120
125
|
readonly operationChanged: boolean
|
|
121
126
|
readonly checkpointHead?: string
|
|
@@ -567,6 +572,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
|
|
|
567
572
|
}
|
|
568
573
|
}
|
|
569
574
|
|
|
575
|
+
const captureNotice = describeCaptureNotice(ready)
|
|
570
576
|
const actionLabel = mode === 'both' ? '恢复并从这里继续' : mode === 'code' ? '恢复文件' : '只回溯消息'
|
|
571
577
|
const radioName = `dcl-rewind-${sessionId}-${String(matched.messageSeq)}`
|
|
572
578
|
const branchChanged = ready !== null && ready.checkpointBranch !== ready.currentBranch
|
|
@@ -599,7 +605,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
|
|
|
599
605
|
{loading && <p className="dcl-rewind-status">正在检查可以恢复的项目文件…</p>}
|
|
600
606
|
{preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
|
|
601
607
|
{preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能、记录已超过保留期限,或已关闭自动检查点。仍可只回溯消息。</p>}
|
|
602
|
-
{preview?.status === 'skipped' && <p className="dcl-rewind-status"
|
|
608
|
+
{preview?.status === 'skipped' && <p className="dcl-rewind-status">{explainCheckpointSkip(preview.reason)}</p>}
|
|
603
609
|
{preview?.status === 'failed' && <p className="dcl-rewind-error">{explainCheckpointFailure(preview.error)}</p>}
|
|
604
610
|
{preview !== null && (
|
|
605
611
|
<div className="dcl-rewind-options">
|
|
@@ -619,6 +625,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
|
|
|
619
625
|
)}
|
|
620
626
|
{ready !== null && (
|
|
621
627
|
<>
|
|
628
|
+
{captureNotice !== null && <p className="dcl-rewind-warning">{captureNotice}</p>}
|
|
622
629
|
<div className="dcl-rewind-summary">
|
|
623
630
|
{mode === 'messages'
|
|
624
631
|
? <strong>项目文件保持不变</strong>
|
|
@@ -1045,6 +1052,16 @@ function decodePreview(value: unknown): Preview {
|
|
|
1045
1052
|
changes,
|
|
1046
1053
|
offset: requiredInteger(record.offset, 'offset'),
|
|
1047
1054
|
truncated: requiredBoolean(record.truncated, 'truncated'),
|
|
1055
|
+
skippedCount: typeof record.skippedCount === 'number' ? record.skippedCount : 0,
|
|
1056
|
+
skipped: Array.isArray(record.skipped)
|
|
1057
|
+
? record.skipped.map((entry) => {
|
|
1058
|
+
const skip = recordOf(entry)
|
|
1059
|
+
return { path: requiredString(skip.path, 'path'), reason: requiredString(skip.reason, 'reason') }
|
|
1060
|
+
})
|
|
1061
|
+
: [],
|
|
1062
|
+
...(record.captureTruncated === 'file-limit' || record.captureTruncated === 'snapshot-limit'
|
|
1063
|
+
? { captureTruncated: record.captureTruncated as 'file-limit' | 'snapshot-limit' }
|
|
1064
|
+
: {}),
|
|
1048
1065
|
headChanged: requiredBoolean(record.headChanged, 'headChanged'),
|
|
1049
1066
|
operationChanged: requiredBoolean(record.operationChanged, 'operationChanged'),
|
|
1050
1067
|
...optionalRecordString(record, 'checkpointHead'),
|
|
@@ -1269,6 +1286,52 @@ function friendlyError(error: unknown): string {
|
|
|
1269
1286
|
}
|
|
1270
1287
|
}
|
|
1271
1288
|
|
|
1289
|
+
/**
|
|
1290
|
+
* Describe what a checkpoint could not store, in one user-facing sentence.
|
|
1291
|
+
*
|
|
1292
|
+
* Limits leave a restore point partial rather than failing it, so the dialog has
|
|
1293
|
+
* to say which files stay untouched when the restore runs.
|
|
1294
|
+
* @param ready - decoded ready preview, or null.
|
|
1295
|
+
* @returns the warning text, or null when the checkpoint is complete.
|
|
1296
|
+
*/
|
|
1297
|
+
export function describeCaptureNotice(ready: ReadyPreview | null): string | null {
|
|
1298
|
+
if (ready === null) return null
|
|
1299
|
+
const example = (ready.skipped ?? [])[0]?.path
|
|
1300
|
+
if (ready.captureTruncated === 'snapshot-limit') {
|
|
1301
|
+
return '本轮要保存的文件总量超过上限,剩下的文件没有纳入检查点。恢复时不会改动它们。'
|
|
1302
|
+
}
|
|
1303
|
+
if (ready.captureTruncated === 'file-limit') {
|
|
1304
|
+
return '本轮文件数量超过上限,多出来的文件没有纳入检查点。恢复时不会改动它们。'
|
|
1305
|
+
}
|
|
1306
|
+
if (ready.skippedCount > 0) {
|
|
1307
|
+
return `有 ${String(ready.skippedCount)} 个文件因为超过单文件大小上限或类型不受支持,没有纳入检查点${example === undefined ? '' : `(例如 ${example})`}。恢复时不会改动它们。`
|
|
1308
|
+
}
|
|
1309
|
+
return null
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Explain one recorded checkpoint skip in user terms.
|
|
1314
|
+
*
|
|
1315
|
+
* A skip is not a failure of the message: the turn ran normally and only the
|
|
1316
|
+
* file snapshot is missing, so the text names the actual cause and the two
|
|
1317
|
+
* levers that fix it.
|
|
1318
|
+
* @param reason - reason recorded by the Host, usually a `[CODE] detail` line.
|
|
1319
|
+
* @returns one user-facing sentence.
|
|
1320
|
+
*/
|
|
1321
|
+
export function explainCheckpointSkip(reason: string): string {
|
|
1322
|
+
const code = /^\[([A-Z_]+)\]/.exec(reason)?.[1]
|
|
1323
|
+
if (code === 'TURN_CHECKPOINT_TIMEOUT') {
|
|
1324
|
+
return '这个项目目录太大,在检查点时间上限内没保存完文件快照,所以本轮没有回退点(消息本身没有受影响)。可以在插件设置里调大「检查点时间上限」,或在目录根用 .dsh-rewindignore 排除大目录(例如 node_modules、构建产物、数据集)。仍可只回溯消息。'
|
|
1325
|
+
}
|
|
1326
|
+
if (code === 'TURN_CHECKPOINT_DISABLED') {
|
|
1327
|
+
return '自动文件检查点已在设置里关闭,本轮没有回退点。仍可只回溯消息。'
|
|
1328
|
+
}
|
|
1329
|
+
if (code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT') {
|
|
1330
|
+
return '这一轮新增的内容超过检查点预算,没有保存文件快照。仍可只回溯消息。'
|
|
1331
|
+
}
|
|
1332
|
+
return `本轮没有保存文件检查点:${reason}仍可只回溯消息。`
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1272
1335
|
/**
|
|
1273
1336
|
* Explain one recorded checkpoint failure in user terms.
|
|
1274
1337
|
*
|
package/src/directory.ts
CHANGED
|
@@ -4,15 +4,34 @@ import { join, resolve } from 'node:path'
|
|
|
4
4
|
import ignore, { type Ignore } from 'ignore'
|
|
5
5
|
import { ChangeLedgerError } from './errors.js'
|
|
6
6
|
import { canonicalDirectory, isNodeError, isWithin } from './path-utils.js'
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
CaptureSkip,
|
|
9
|
+
CaptureTruncation,
|
|
10
|
+
DirectoryWorkspaceState,
|
|
11
|
+
ResolvedChangeLedgerConfig,
|
|
12
|
+
} from './types.js'
|
|
8
13
|
|
|
9
14
|
const IGNORE_FILE = '.dsh-rewindignore'
|
|
15
|
+
const MAX_RECORDED_SKIPS = 50
|
|
10
16
|
const DEFAULT_IGNORE_PATTERNS = ['.git', 'node_modules'] as const
|
|
11
17
|
|
|
12
18
|
/** Ordinary-directory discovery result plus its eligible path inventory. */
|
|
13
19
|
export interface DirectorySnapshotSource {
|
|
14
20
|
readonly state: DirectoryWorkspaceState
|
|
15
21
|
readonly paths: readonly string[]
|
|
22
|
+
/** Eligible paths that cannot be stored at all, such as sockets. */
|
|
23
|
+
readonly skipped: readonly CaptureSkip[]
|
|
24
|
+
readonly skippedCount: number
|
|
25
|
+
/** Set when the file-count limit stopped the walk early. */
|
|
26
|
+
readonly truncated?: CaptureTruncation
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Mutable walk accumulator shared by one directory enumeration. */
|
|
30
|
+
interface DirectoryWalk {
|
|
31
|
+
readonly paths: string[]
|
|
32
|
+
readonly skipped: CaptureSkip[]
|
|
33
|
+
skippedCount: number
|
|
34
|
+
truncated?: CaptureTruncation
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
/** Resolve the canonical ordinary directory used as a non-Git workspace root. */
|
|
@@ -28,10 +47,16 @@ export async function discoverDirectory(
|
|
|
28
47
|
): Promise<DirectorySnapshotSource> {
|
|
29
48
|
const root = await discoverDirectoryRoot(cwd)
|
|
30
49
|
const matcher = await loadIgnoreMatcher(root, config.maxFileBytes, signal)
|
|
31
|
-
const paths:
|
|
32
|
-
await walkDirectory(root, root, '', matcher,
|
|
33
|
-
paths.sort(comparePaths)
|
|
34
|
-
return {
|
|
50
|
+
const walk: DirectoryWalk = { paths: [], skipped: [], skippedCount: 0 }
|
|
51
|
+
await walkDirectory(root, root, '', matcher, walk, config.maxFiles, signal)
|
|
52
|
+
walk.paths.sort(comparePaths)
|
|
53
|
+
return {
|
|
54
|
+
state: { type: 'directory', root },
|
|
55
|
+
paths: walk.paths,
|
|
56
|
+
skipped: walk.skipped,
|
|
57
|
+
skippedCount: walk.skippedCount,
|
|
58
|
+
...(walk.truncated === undefined ? {} : { truncated: walk.truncated }),
|
|
59
|
+
}
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
/**
|
|
@@ -90,7 +115,7 @@ async function walkDirectory(
|
|
|
90
115
|
directory: string,
|
|
91
116
|
directoryPath: string,
|
|
92
117
|
matcher: Ignore,
|
|
93
|
-
|
|
118
|
+
walk: DirectoryWalk,
|
|
94
119
|
maxFiles: number,
|
|
95
120
|
signal?: AbortSignal,
|
|
96
121
|
): Promise<void> {
|
|
@@ -108,6 +133,7 @@ async function walkDirectory(
|
|
|
108
133
|
|
|
109
134
|
entries.sort((left, right) => comparePaths(left.name, right.name))
|
|
110
135
|
for (const entry of entries) {
|
|
136
|
+
if (walk.truncated !== undefined) return
|
|
111
137
|
throwIfAborted(signal)
|
|
112
138
|
await assertSafeDirectory(root, directory, after)
|
|
113
139
|
const path = directoryPath === '' ? entry.name : `${directoryPath}/${entry.name}`
|
|
@@ -121,26 +147,28 @@ async function walkDirectory(
|
|
|
121
147
|
}
|
|
122
148
|
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
123
149
|
if (matcher.ignores(`${path}/`)) continue
|
|
124
|
-
await walkDirectory(root, target, path, matcher,
|
|
150
|
+
await walkDirectory(root, target, path, matcher, walk, maxFiles, signal)
|
|
125
151
|
continue
|
|
126
152
|
}
|
|
127
153
|
if (path !== IGNORE_FILE && matcher.ignores(path)) continue
|
|
128
154
|
if (!info.isFile() && !info.isSymbolicLink()) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
`ordinary-directory path is not a regular file or symlink: ${JSON.stringify(path)}`,
|
|
132
|
-
)
|
|
155
|
+
recordSkip(walk, path, 'unsupported-file-type')
|
|
156
|
+
continue
|
|
133
157
|
}
|
|
134
|
-
paths.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
'TOO_MANY_FILES',
|
|
138
|
-
`workspace has more than ${maxFiles} eligible paths; latest path is ${JSON.stringify(path)}`,
|
|
139
|
-
)
|
|
158
|
+
if (walk.paths.length >= maxFiles) {
|
|
159
|
+
walk.truncated = 'file-limit'
|
|
160
|
+
return
|
|
140
161
|
}
|
|
162
|
+
walk.paths.push(path)
|
|
141
163
|
}
|
|
142
164
|
}
|
|
143
165
|
|
|
166
|
+
/** Record one skipped path, bounding the durable list while counting every entry. */
|
|
167
|
+
function recordSkip(walk: DirectoryWalk, path: string, reason: CaptureSkip['reason']): void {
|
|
168
|
+
walk.skippedCount += 1
|
|
169
|
+
if (walk.skipped.length < MAX_RECORDED_SKIPS) walk.skipped.push({ path, reason })
|
|
170
|
+
}
|
|
171
|
+
|
|
144
172
|
/**
|
|
145
173
|
* Re-prove that one traversed directory is still the same real directory inside
|
|
146
174
|
* the workspace root, so a swapped symlink or moved ancestor cannot redirect
|