@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/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:
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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.
|
package/src/rewind-host.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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 <
|
|
132
|
+
while (!failed && next < paths.length) {
|
|
91
133
|
const index = next
|
|
92
134
|
next += 1
|
|
93
|
-
const path =
|
|
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')
|