@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/src/engine.ts CHANGED
@@ -58,7 +58,7 @@ const DEFAULTS = {
58
58
  planTtlMs: 15 * 60 * 1_000,
59
59
  staleLockMs: 30_000,
60
60
  turnCheckpointMode: 'legacy',
61
- turnCheckpointTimeoutMs: 5_000,
61
+ turnCheckpointTimeoutMs: 60_000,
62
62
  turnCheckpointMaxNewBytes: 32 * 1024 * 1024,
63
63
  turnCheckpointTrust: 'fast',
64
64
  } as const
@@ -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) {
package/src/git.ts CHANGED
@@ -4,7 +4,7 @@ import { link, lstat, mkdir, open, readFile, realpath, unlink } from 'node:fs/pr
4
4
  import { dirname, isAbsolute, join, resolve } from 'node:path'
5
5
  import { ChangeLedgerError, errorMessage } from './errors.js'
6
6
  import { canonicalDirectory, isNodeError, syncDirectory, validateRelativePath } from './path-utils.js'
7
- import type { GitWorkspaceState } from './types.js'
7
+ import type { CaptureSkip, CaptureTruncation, GitWorkspaceState } from './types.js'
8
8
 
9
9
  const GIT_MAX_BUFFER = 32 * 1024 * 1024
10
10
 
@@ -12,6 +12,10 @@ const GIT_MAX_BUFFER = 32 * 1024 * 1024
12
12
  export interface RepositorySnapshotSource {
13
13
  readonly state: GitWorkspaceState
14
14
  readonly paths: readonly string[]
15
+ /** Git discovery never skips a path; per-file limits are reported by capture. */
16
+ readonly skipped: readonly CaptureSkip[]
17
+ readonly skippedCount: number
18
+ readonly truncated?: CaptureTruncation
15
19
  }
16
20
 
17
21
  /** Durable identity and shared lock location for one concrete Git worktree. */
@@ -72,6 +76,8 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
72
76
  stagedPaths,
73
77
  },
74
78
  paths,
79
+ skipped: [],
80
+ skippedCount: 0,
75
81
  }
76
82
  }
77
83
 
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * DSH Turn Rewind, powered by persistent, inspectable, approval-gated Change Ledger restore points.
3
3
  * @module @anionex/dsh-turn-rewind
4
4
  */
5
+ import { readFileSync } from 'node:fs'
5
6
  import type { Context } from '@deepseek-ai/cordis'
6
7
  import { ChangeLedgerEngine } from './engine.js'
7
8
  import { installManageHttp, installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js'
@@ -14,6 +15,23 @@ export * from './rewind-host.js'
14
15
  export * from './settings.js'
15
16
  export * from './types.js'
16
17
 
18
+ /**
19
+ * Version of the running package.
20
+ *
21
+ * A Profile keeps the previous plugin in memory until DSH restarts, so the
22
+ * loaded version is logged at activation and can be compared with what is on
23
+ * disk in `.../profiles/<name>/node_modules/@anionex/dsh-turn-rewind`.
24
+ * @returns the package version, or `unknown` when the manifest is unreadable.
25
+ */
26
+ function pluginVersion(): string {
27
+ try {
28
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: unknown }
29
+ return typeof manifest.version === 'string' ? manifest.version : 'unknown'
30
+ } catch {
31
+ return 'unknown'
32
+ }
33
+ }
34
+
17
35
  declare module '@deepseek-ai/cordis' {
18
36
  interface Context {
19
37
  changeLedger: ChangeLedgerService
@@ -29,6 +47,7 @@ export class ChangeLedgerService {
29
47
  ctx.provide('changeLedger', this)
30
48
  this.engine = new ChangeLedgerEngine(config)
31
49
  const checkpoints = new TurnCheckpointCoordinator(this.engine)
50
+ ctx.logger.info(`[turn-rewind] v${pluginVersion()} active; workspace modes: git worktree, ordinary directory`)
32
51
  ctx.inject(['agents'], (scope: Context) => { checkpoints.install(scope) })
33
52
  // `apiProxy` is deliberately absent: DSH 0.1.2-alpha carriers (DSH Desktop 2.x)
34
53
  // provide `sessionController` instead, and the route must exist on both.
@@ -117,7 +117,7 @@ interface SessionControllerLike {
117
117
  }
118
118
 
119
119
  /** Services a conversation restart can be built on, in either carrier shape. */
120
- type ConversationRestartContext = Pick<Context, 'sessions' | 'sessionQuery'> & {
120
+ type ConversationRestartContext = Pick<Context, 'sessions' | 'sessionQuery' | 'get'> & {
121
121
  readonly apiProxy?: ApiProxyLike
122
122
  readonly sessionController?: SessionControllerLike
123
123
  }
@@ -432,6 +432,11 @@ export function createRewindHttpHandler(
432
432
  const changes = inspection.changes.slice(offset, offset + limit)
433
433
  const common = {
434
434
  status: 'ready', sessionId, messageSeq, turn: checkpoint.turn, checkpointId: checkpoint.id,
435
+ // A restore point that could not store every eligible path stays usable;
436
+ // the browser shows what was left out instead of blocking the rewind.
437
+ skippedCount: inspection.restorePoint.skippedCount,
438
+ ...(inspection.restorePoint.skipped === undefined ? {} : { skipped: inspection.restorePoint.skipped }),
439
+ ...(inspection.restorePoint.truncated === undefined ? {} : { captureTruncated: inspection.restorePoint.truncated }),
435
440
  turnStartSeq: checkpoint.turnStartSeq,
436
441
  totalChanges: inspection.changes.length,
437
442
  changes: changes.map(change => ({ path: change.path, kind: change.kind })),
@@ -667,14 +672,18 @@ async function createConversationRestart(
667
672
  || current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
668
673
  throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary')
669
674
  }
670
- const controller = ctx.sessionController
675
+ // Cordis throws when a context reads a service property it did not inject, and
676
+ // no single inject list covers both carriers: 0.1.1 provides `apiProxy` while
677
+ // 0.1.2-alpha provides `sessionController`. `ctx.get` is the optional accessor
678
+ // that returns undefined instead of failing the request.
679
+ const controller = ctx.get('sessionController') as SessionControllerLike | undefined
671
680
  if (controller !== undefined) {
672
681
  const created = checkpoint.previousTurnEndSeq === undefined
673
682
  ? await controller.create({ cwd: checkpoint.cwd })
674
683
  : await controller.fork({ sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq })
675
684
  return { sessionId: requiredText(created.sessionId, 'sessionController sessionId') }
676
685
  }
677
- const apiProxy = ctx.apiProxy
686
+ const apiProxy = ctx.get('apiProxy') as ApiProxyLike | undefined
678
687
  if (apiProxy === undefined) {
679
688
  throw new ChangeLedgerError(
680
689
  'CONVERSATION_REWIND_FAILED',
package/src/snapshot.ts CHANGED
@@ -6,6 +6,9 @@ import { isNodeError, resolveWorkspacePath } from './path-utils.js'
6
6
  import type { LedgerStore } from './store.js'
7
7
  import { discoverWorkspace, sameWorkspaceFence, type WorkspaceSnapshotSource } from './workspace.js'
8
8
  import type {
9
+ CaptureSkip,
10
+ CaptureSkipReason,
11
+ CaptureTruncation,
9
12
  ResolvedChangeLedgerConfig,
10
13
  SnapshotEntry,
11
14
  WorkspaceChange,
@@ -18,11 +21,20 @@ export interface CapturedTree {
18
21
  readonly source: WorkspaceSnapshotSource
19
22
  readonly entries: Readonly<Record<string, SnapshotEntry>>
20
23
  readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>
24
+ /** Eligible paths this capture could not store; a restore never touches them. */
25
+ readonly skipped: readonly CaptureSkip[]
26
+ readonly skippedCount: number
27
+ /** Set when a limit stopped the capture before every eligible path was read. */
28
+ readonly truncated?: CaptureTruncation
29
+ /** True when the recorded skip list is shorter than `skippedCount`. */
30
+ readonly skippedListTruncated: boolean
21
31
  readonly treeHash: string
22
32
  readonly fileCount: number
23
33
  readonly totalBytes: number
24
34
  }
25
35
 
36
+ const MAX_RECORDED_SKIPS = 50
37
+
26
38
  /** Capture the current tracked and non-ignored Git working tree. */
27
39
  export async function captureTree(options: {
28
40
  readonly cwd: string
@@ -33,12 +45,21 @@ export async function captureTree(options: {
33
45
  }): Promise<CapturedTree> {
34
46
  throwIfAborted(options.signal)
35
47
  const source = await discoverWorkspace(options.cwd, options.config, options.signal)
36
- if (source.paths.length > options.config.maxFiles) {
37
- throw new ChangeLedgerError(
38
- 'TOO_MANY_FILES',
39
- `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`,
40
- )
48
+ // Limits never discard an entire restore point: an over-limit path is left out
49
+ // and reported, and the remaining eligible paths are still captured. A restore
50
+ // only ever rewrites captured paths, so a partial point stays safe to apply.
51
+ const skipped: CaptureSkip[] = [...source.skipped]
52
+ let skippedCount = source.skippedCount
53
+ let truncated: CaptureTruncation | undefined = source.truncated
54
+ const recordSkip = (path: string, reason: CaptureSkipReason): void => {
55
+ skippedCount += 1
56
+ if (skipped.length < MAX_RECORDED_SKIPS) skipped.push({ path, reason })
57
+ else truncated ??= reason === 'snapshot-limit' ? 'snapshot-limit' : 'file-limit'
41
58
  }
59
+ const paths = source.paths.length > options.config.maxFiles
60
+ ? source.paths.slice(0, options.config.maxFiles)
61
+ : source.paths
62
+ if (source.paths.length > options.config.maxFiles) truncated ??= 'file-limit'
42
63
 
43
64
  const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
44
65
  // Git-object capture only applies to a Git worktree; an ordinary directory
@@ -51,18 +72,38 @@ export async function captureTree(options: {
51
72
  entries: Object.create(null) as Record<string, SnapshotEntry>,
52
73
  }
53
74
  let totalBytes = 0
75
+ let budgetSpent = false
54
76
  const capturePath = async (path: string): Promise<void> => {
55
77
  throwIfAborted(options.signal)
56
- const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal)
78
+ if (budgetSpent) {
79
+ // The aggregate budget is gone, but the path is still recorded so a
80
+ // restore can prove this point never observed it.
81
+ recordSkip(path, 'snapshot-limit')
82
+ return
83
+ }
84
+ let entry: Awaited<ReturnType<typeof captureEntry>>
85
+ try {
86
+ entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal)
87
+ } catch (error) {
88
+ if (error instanceof ChangeLedgerError && error.code === 'FILE_TOO_LARGE') {
89
+ recordSkip(path, 'file-too-large')
90
+ return
91
+ }
92
+ if (error instanceof ChangeLedgerError && error.code === 'UNSUPPORTED_FILE_TYPE') {
93
+ recordSkip(path, 'unsupported-file-type')
94
+ return
95
+ }
96
+ throw error
97
+ }
57
98
  if (entry === undefined) return
58
99
  if (entry.kind === 'file') {
59
- totalBytes += entry.content.length
60
- if (totalBytes > options.config.maxSnapshotBytes) {
61
- throw new ChangeLedgerError(
62
- 'SNAPSHOT_TOO_LARGE',
63
- `eligible files exceed configured aggregate limit of ${options.config.maxSnapshotBytes} bytes`,
64
- )
100
+ if (budgetSpent || totalBytes + entry.content.length > options.config.maxSnapshotBytes) {
101
+ budgetSpent = true
102
+ truncated ??= 'snapshot-limit'
103
+ recordSkip(path, 'snapshot-limit')
104
+ return
65
105
  }
106
+ totalBytes += entry.content.length
66
107
  if (options.store !== undefined) {
67
108
  await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content)
68
109
  }
@@ -75,22 +116,23 @@ export async function captureTree(options: {
75
116
  entries[path] = entry.snapshot
76
117
  if (gitCapture !== undefined) gitCapture.entries[path] = entry.snapshot
77
118
  }
78
- if (gitCapture === undefined) {
79
- for (const path of source.paths) await capturePath(path)
80
- } else {
119
+ {
120
+ // Reading one file at a time makes a real project directory take minutes, so
121
+ // every capture runs through a bounded worker pool. Per-file stability stats
122
+ // and the synchronous budget check keep the result identical to a serial run.
81
123
  let next = 0
82
124
  let failed = false
83
125
  let firstError: unknown = new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'parallel comparison capture failed')
84
126
  const concurrency = Math.min(
85
127
  32,
86
- source.paths.length,
128
+ paths.length,
87
129
  Math.max(1, Math.floor(COMPARISON_READ_BUDGET_BYTES / options.config.maxFileBytes)),
88
130
  )
89
131
  const workers = Array.from({ length: concurrency }, async () => {
90
- while (!failed && next < source.paths.length) {
132
+ while (!failed && next < paths.length) {
91
133
  const index = next
92
134
  next += 1
93
- const path = source.paths[index]
135
+ const path = paths[index]
94
136
  if (path === undefined) continue
95
137
  try {
96
138
  await capturePath(path)
@@ -110,6 +152,10 @@ export async function captureTree(options: {
110
152
  source,
111
153
  entries,
112
154
  ...(gitCapture === undefined ? {} : { gitEntries: gitCapture.entries }),
155
+ skipped,
156
+ skippedCount,
157
+ skippedListTruncated: skippedCount > skipped.length,
158
+ ...(truncated === undefined ? {} : { truncated }),
113
159
  treeHash: hashTree(entries),
114
160
  fileCount: Object.keys(entries).length,
115
161
  totalBytes,
package/src/store.ts CHANGED
@@ -270,6 +270,10 @@ export class LedgerStore {
270
270
  throw new ChangeLedgerError('BLOB_HASH_MISMATCH', `refusing to store content whose SHA-256 does not match ${hash}`)
271
271
  }
272
272
  const path = this.blobPath(workspace, hash)
273
+ // Content addressing makes an existing blob identical by construction, and
274
+ // re-writing it costs one fsync per file: a full-tree capture of a large
275
+ // workspace otherwise spends minutes proving it already stored the content.
276
+ if (await pathExists(path)) return
273
277
  const directory = join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2))
274
278
  await mkdir(directory, { recursive: true, mode: 0o700 })
275
279
  const temporary = join(directory, `.${randomUUID()}.tmp`)
package/src/types.ts CHANGED
@@ -33,6 +33,18 @@ export interface SymlinkSnapshotEntry {
33
33
  /** One path captured by a restore point. */
34
34
  export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry
35
35
 
36
+ /** Why one eligible path was left out of a restore point. */
37
+ export type CaptureSkipReason = 'file-too-large' | 'unsupported-file-type' | 'snapshot-limit'
38
+
39
+ /** One eligible path that a restore point could not store. */
40
+ export interface CaptureSkip {
41
+ readonly path: string
42
+ readonly reason: CaptureSkipReason
43
+ }
44
+
45
+ /** Why a capture stopped before it enumerated every eligible path. */
46
+ export type CaptureTruncation = 'file-limit' | 'snapshot-limit'
47
+
36
48
  /** Git facts that fence a restore point to the repository state it observed. */
37
49
  export interface GitWorkspaceState {
38
50
  readonly type: 'git'
@@ -80,6 +92,15 @@ export interface RestorePointManifestV1 {
80
92
  readonly fileCount: number
81
93
  readonly totalBytes: number
82
94
  readonly entries: Readonly<Record<string, SnapshotEntry>>
95
+ /**
96
+ * Eligible paths this restore point could not store, bounded for storage.
97
+ * A restore never touches them, so a partial point stays safe to apply.
98
+ */
99
+ readonly skipped?: readonly CaptureSkip[]
100
+ /** Total skipped paths, including any beyond the bounded list. */
101
+ readonly skippedCount?: number
102
+ /** Set when limits stopped the capture before every eligible path was read. */
103
+ readonly truncated?: CaptureTruncation
83
104
  readonly restoreCount: number
84
105
  readonly lastRestoredAt?: number
85
106
  }
@@ -152,6 +173,9 @@ export interface RestorePointSummary {
152
173
  readonly treeHash: string
153
174
  readonly fileCount: number
154
175
  readonly totalBytes: number
176
+ readonly skipped?: readonly CaptureSkip[]
177
+ readonly skippedCount: number
178
+ readonly truncated?: CaptureTruncation
155
179
  readonly restoreCount: number
156
180
  readonly lastRestoredAt?: number
157
181
  readonly head?: string
package/src/validate.ts CHANGED
@@ -5,6 +5,8 @@ import { hashTree } from './snapshot.js'
5
5
  import {
6
6
  GIT_CHECKPOINT_FORMAT_VERSION,
7
7
  LEDGER_FORMAT_VERSION,
8
+ type CaptureSkip,
9
+ type CaptureTruncation,
8
10
  type RepositoryState,
9
11
  type RestoreOperation,
10
12
  type RestorePointKind,
@@ -81,11 +83,24 @@ export function parseManifest(value: unknown): RestorePointManifest {
81
83
  corrupt('only turn restore points may carry turn metadata')
82
84
  }
83
85
  const lastRestoredAt = optionalNonNegativeInteger(record, 'lastRestoredAt')
86
+ const skipped = parseCaptureSkips(record.skipped)
87
+ const skippedCount = optionalNonNegativeInteger(record, 'skippedCount')
88
+ if (skippedCount !== undefined && skippedCount < (skipped?.length ?? 0)) {
89
+ corrupt('skippedCount cannot be smaller than the recorded skipped paths')
90
+ }
91
+ const truncatedValue = record.truncated
92
+ if (truncatedValue !== undefined && truncatedValue !== 'file-limit' && truncatedValue !== 'snapshot-limit') {
93
+ corrupt('truncated must be "file-limit" or "snapshot-limit"')
94
+ }
95
+ const truncated: CaptureTruncation | undefined = truncatedValue
84
96
  const common = {
85
97
  id,
86
98
  kind: restorePointKind,
87
99
  workspace,
88
100
  repository,
101
+ ...(skipped === undefined ? {} : { skipped }),
102
+ ...(skippedCount === undefined ? {} : { skippedCount }),
103
+ ...(truncated === undefined ? {} : { truncated }),
89
104
  ...(sessionId === undefined ? {} : { sessionId }),
90
105
  ...(label === undefined ? {} : { label }),
91
106
  ...(parentRestorePoint === undefined ? {} : { parentRestorePoint }),
@@ -168,6 +183,25 @@ export function validateBlobHash(value: string): string {
168
183
  * @param value - persisted `repository` field.
169
184
  * @returns the validated workspace state.
170
185
  */
186
+ /**
187
+ * Parse the optional bounded list of eligible paths a restore point could not store.
188
+ * @param value - persisted `skipped` field.
189
+ * @returns the validated list, or undefined when the manifest carries none.
190
+ */
191
+ function parseCaptureSkips(value: unknown): readonly CaptureSkip[] | undefined {
192
+ if (value === undefined) return undefined
193
+ if (!Array.isArray(value)) corrupt('skipped must be an array')
194
+ return value.map((entry) => {
195
+ const record = objectRecord(entry, 'skipped path')
196
+ const path = validateRelativePath(requireString(record.path, 'skipped path'))
197
+ const reason = record.reason
198
+ if (reason !== 'file-too-large' && reason !== 'unsupported-file-type') {
199
+ corrupt('skipped reason must be "file-too-large" or "unsupported-file-type"')
200
+ }
201
+ return { path, reason }
202
+ })
203
+ }
204
+
171
205
  function parseRepository(value: unknown): RepositoryState {
172
206
  const record = objectRecord(value, 'repository state')
173
207
  const root = absoluteString(record, 'root')