@anionex/dsh-turn-rewind 0.3.2 → 0.3.3

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/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
- if (source.paths.length > options.config.maxFiles) {
13
- throw new ChangeLedgerError('TOO_MANY_FILES', `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`);
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
- const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal);
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 += entry.content.length;
33
- if (totalBytes > options.config.maxSnapshotBytes) {
34
- throw new ChangeLedgerError('SNAPSHOT_TOO_LARGE', `eligible files exceed configured aggregate limit of ${options.config.maxSnapshotBytes} bytes`);
69
+ if (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
  }
@@ -47,19 +87,19 @@ export async function captureTree(options) {
47
87
  gitCapture.entries[path] = entry.snapshot;
48
88
  };
49
89
  if (gitCapture === undefined) {
50
- for (const path of source.paths)
90
+ for (const path of paths)
51
91
  await capturePath(path);
52
92
  }
53
93
  else {
54
94
  let next = 0;
55
95
  let failed = false;
56
96
  let firstError = new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'parallel comparison capture failed');
57
- const concurrency = Math.min(32, source.paths.length, Math.max(1, Math.floor(COMPARISON_READ_BUDGET_BYTES / options.config.maxFileBytes)));
97
+ const concurrency = Math.min(32, paths.length, Math.max(1, Math.floor(COMPARISON_READ_BUDGET_BYTES / options.config.maxFileBytes)));
58
98
  const workers = Array.from({ length: concurrency }, async () => {
59
- while (!failed && next < source.paths.length) {
99
+ while (!failed && next < paths.length) {
60
100
  const index = next;
61
101
  next += 1;
62
- const path = source.paths[index];
102
+ const path = paths[index];
63
103
  if (path === undefined)
64
104
  continue;
65
105
  try {
@@ -81,6 +121,10 @@ export async function captureTree(options) {
81
121
  source,
82
122
  entries,
83
123
  ...(gitCapture === undefined ? {} : { gitEntries: gitCapture.entries }),
124
+ skipped,
125
+ skippedCount,
126
+ skippedListTruncated: skippedCount > skipped.length,
127
+ ...(truncated === undefined ? {} : { truncated }),
84
128
  treeHash: hashTree(entries),
85
129
  fileCount: Object.keys(entries).length,
86
130
  totalBytes,
@@ -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,15 @@ 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;
193
237
  /**
194
238
  * Explain one recorded checkpoint failure in user terms.
195
239
  *
@@ -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>;
@@ -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 {
@@ -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
  };
@@ -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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anionex/dsh-turn-rewind",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",
@@ -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
@@ -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,29 @@ 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
+
1272
1312
  /**
1273
1313
  * Explain one recorded checkpoint failure in user terms.
1274
1314
  *
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 { DirectoryWorkspaceState, ResolvedChangeLedgerConfig } from './types.js'
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: string[] = []
32
- await walkDirectory(root, root, '', matcher, paths, config.maxFiles, signal)
33
- paths.sort(comparePaths)
34
- return { state: { type: 'directory', root }, paths }
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
- paths: string[],
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, paths, maxFiles, signal)
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
- throw new ChangeLedgerError(
130
- 'UNSUPPORTED_FILE_TYPE',
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.push(path)
135
- if (paths.length > maxFiles) {
136
- throw new ChangeLedgerError(
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
package/src/engine.ts CHANGED
@@ -312,17 +312,36 @@ export class ChangeLedgerEngine {
312
312
  })
313
313
  } else {
314
314
  const id = makeId('rp')
315
- const captured = (await captureGitTurnCheckpoint({
316
- cwd: source.state.root,
317
- id,
318
- sessionId: options.sessionId,
319
- turn: options.turn,
320
- turnStartSeq: options.turnStartSeq,
321
- config: this.config,
322
- signal,
323
- })).manifest
324
- await this.publishGitManifest(captured, signal)
325
- manifest = captured
315
+ try {
316
+ const captured = (await captureGitTurnCheckpoint({
317
+ cwd: source.state.root,
318
+ id,
319
+ sessionId: options.sessionId,
320
+ turn: options.turn,
321
+ turnStartSeq: options.turnStartSeq,
322
+ config: this.config,
323
+ signal,
324
+ })).manifest
325
+ await this.publishGitManifest(captured, signal)
326
+ manifest = captured
327
+ } catch (error) {
328
+ // A Git-native turn tree cannot omit a path without changing the
329
+ // trust fences it is verified with, so a path that exceeds a limit
330
+ // falls back to the ledger's own store, which keeps the checkpoint
331
+ // and simply reports the paths it could not store.
332
+ if (!(error instanceof ChangeLedgerError)
333
+ || (error.code !== 'FILE_TOO_LARGE' && error.code !== 'UNSUPPORTED_FILE_TYPE')) throw error
334
+ manifest = await this.createLocked({
335
+ cwd: source.state.root,
336
+ kind: 'turn',
337
+ sessionId: options.sessionId,
338
+ label: `Before turn ${String(options.turn)} checkpoint`,
339
+ turn: options.turn,
340
+ turnStartSeq: options.turnStartSeq,
341
+ signal,
342
+ deferGarbageCollection: true,
343
+ })
344
+ }
326
345
  }
327
346
  await this.store.deleteTurnCheckpointSkip(
328
347
  source.state.root,
@@ -493,7 +512,7 @@ export class ChangeLedgerEngine {
493
512
  ...(currentState.type === 'git' && currentState.operation !== undefined ? { currentOperation: currentState.operation } : {}),
494
513
  headChanged: repositoryHeadChanged(manifest.repository, currentState),
495
514
  operationChanged: workspaceOperationChanged(manifest.repository, currentState),
496
- changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
515
+ changes: filterUnobservedChanges(manifest, diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries)),
497
516
  }
498
517
  } finally {
499
518
  await release()
@@ -534,7 +553,7 @@ export class ChangeLedgerEngine {
534
553
  throw workspaceStaleError(options.expectedRepository, current.source.state, 'inspection')
535
554
  }
536
555
  assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true)
537
- const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries)
556
+ const changes = filterUnobservedChanges(manifest, diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries))
538
557
  if (changes.length === 0) {
539
558
  throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`)
540
559
  }
@@ -952,6 +971,9 @@ export class ChangeLedgerEngine {
952
971
  fileCount: tree.fileCount,
953
972
  totalBytes: tree.totalBytes,
954
973
  entries: tree.entries,
974
+ ...(tree.skipped.length === 0 ? {} : { skipped: tree.skipped }),
975
+ ...(tree.skippedCount === 0 ? {} : { skippedCount: tree.skippedCount }),
976
+ ...(tree.truncated === undefined ? {} : { truncated: tree.truncated }),
955
977
  restoreCount: 0,
956
978
  }
957
979
  if (options.deferGarbageCollection !== true) {
@@ -1158,6 +1180,9 @@ function summarize(manifest: RestorePointManifest): RestorePointSummary {
1158
1180
  treeHash: manifest.treeHash,
1159
1181
  fileCount: manifest.fileCount,
1160
1182
  totalBytes: manifest.totalBytes,
1183
+ ...(manifest.skipped === undefined ? {} : { skipped: manifest.skipped }),
1184
+ skippedCount: manifest.skippedCount ?? 0,
1185
+ ...(manifest.truncated === undefined ? {} : { truncated: manifest.truncated }),
1161
1186
  restoreCount: manifest.restoreCount,
1162
1187
  ...(manifest.lastRestoredAt === undefined ? {} : { lastRestoredAt: manifest.lastRestoredAt }),
1163
1188
  ...(workspace.type === 'git' && workspace.head !== undefined ? { head: workspace.head } : {}),
@@ -1168,6 +1193,28 @@ function summarize(manifest: RestorePointManifest): RestorePointSummary {
1168
1193
  }
1169
1194
  }
1170
1195
 
1196
+ /**
1197
+ * Drop changes a restore point never observed.
1198
+ *
1199
+ * Limits leave a point partial by design, so it must never rewrite a path it
1200
+ * could not store, and while its skip list is incomplete it must not authorize
1201
+ * deleting a path it never saw either: a pre-existing file that merely exceeded
1202
+ * a limit must survive the rewind.
1203
+ * @param manifest - restore point being inspected or planned.
1204
+ * @param changes - raw path differences against the current workspace.
1205
+ * @returns the changes a restore may act on.
1206
+ */
1207
+ function filterUnobservedChanges(
1208
+ manifest: RestorePointManifest,
1209
+ changes: readonly WorkspaceChange[],
1210
+ ): readonly WorkspaceChange[] {
1211
+ const skipped = new Set((manifest.skipped ?? []).map(skip => skip.path))
1212
+ const incomplete = manifest.truncated !== undefined
1213
+ || (manifest.skippedCount ?? 0) > (manifest.skipped?.length ?? 0)
1214
+ if (skipped.size === 0 && !incomplete) return changes
1215
+ return changes.filter(change => !skipped.has(change.path) && !(incomplete && change.kind === 'added'))
1216
+ }
1217
+
1171
1218
  /** Reject a fence that now describes a different workspace or workspace mode. */
1172
1219
  function assertWorkspaceIdentityCompatible(before: WorkspaceState, after: WorkspaceState): void {
1173
1220
  if (before.type !== after.type) {