@anionex/dsh-turn-rewind 0.1.1 → 0.1.2

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.
@@ -0,0 +1,149 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFile } from 'node:child_process'
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { performance } from 'node:perf_hooks'
7
+ import { promisify } from 'node:util'
8
+ import { ChangeLedgerEngine } from '../lib/index.js'
9
+
10
+ const execFileAsync = promisify(execFile)
11
+ const options = parseArgs(process.argv.slice(2))
12
+ const outer = await mkdtemp(join(tmpdir(), 'dsh-turn-rewind-bench-'))
13
+ const workspace = join(outer, 'workspace')
14
+ const storageDir = join(outer, 'state')
15
+ const result = {
16
+ fixture: options,
17
+ status: 'running',
18
+ captures: [],
19
+ }
20
+
21
+ try {
22
+ await mkdir(workspace)
23
+ await git(workspace, 'init', '-b', 'main')
24
+ await git(workspace, 'config', 'user.name', 'Turn Rewind Benchmark')
25
+ await git(workspace, 'config', 'user.email', 'turn-rewind-benchmark@example.invalid')
26
+ const content = Buffer.alloc(options.bytesPerFile, 0x61)
27
+ await parallelRange(options.files, 64, async (index) => {
28
+ const directory = join(workspace, 'fixture', String(Math.floor(index / 250)).padStart(4, '0'))
29
+ await mkdir(directory, { recursive: true })
30
+ await writeFile(join(directory, `${String(index).padStart(6, '0')}.bin`), content)
31
+ })
32
+ await git(workspace, 'add', '--all')
33
+ await git(workspace, 'commit', '-m', 'benchmark fixture')
34
+
35
+ const engine = new ChangeLedgerEngine({
36
+ storageDir,
37
+ turnCheckpointMode: 'git-native',
38
+ maxFiles: options.files + 100,
39
+ maxSnapshotBytes: options.files * options.bytesPerFile + 1024 * 1024,
40
+ turnCheckpointTimeoutMs: options.timeoutMs,
41
+ turnCheckpointMaxNewBytes: Math.max(options.dirtyFiles * options.bytesPerFile * 2, 1024 * 1024),
42
+ })
43
+ await engine.initialize()
44
+ result.captures.push(await measure(engine, workspace, 'clean', 1))
45
+ await parallelRange(options.dirtyFiles, 32, async (index) => {
46
+ const path = join(workspace, 'fixture', String(Math.floor(index / 250)).padStart(4, '0'), `${String(index).padStart(6, '0')}.bin`)
47
+ await writeFile(path, Buffer.alloc(options.bytesPerFile, 0x62))
48
+ })
49
+ result.captures.push(await measure(engine, workspace, 'dirty-cold-cache', 2))
50
+ result.captures.push(await measure(engine, workspace, 'dirty-warm-cache', 3))
51
+ const warmPoint = await engine.findTurnCheckpoint({ cwd: workspace, sessionId: 'benchmark-session', turn: 3 })
52
+ assert.notEqual(warmPoint, undefined)
53
+ const comparisonStarted = performance.now()
54
+ const inspection = await engine.inspect({ cwd: workspace, restorePointId: warmPoint.id })
55
+ assert.deepEqual(inspection.changes, [])
56
+ result.comparison = {
57
+ label: 'warm-checkpoint-vs-unchanged-worktree',
58
+ wallMs: Math.round((performance.now() - comparisonStarted) * 100) / 100,
59
+ changeCount: inspection.changes.length,
60
+ }
61
+
62
+ result.status = 'passed'
63
+ } catch (error) {
64
+ result.status = 'failed'
65
+ result.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
66
+ process.exitCode = 1
67
+ } finally {
68
+ try {
69
+ await rm(outer, { recursive: true, force: true })
70
+ result.cleanup = 'passed'
71
+ } catch (error) {
72
+ result.cleanup = 'failed'
73
+ result.cleanupError = error instanceof Error ? error.message : String(error)
74
+ process.exitCode = 1
75
+ }
76
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
77
+ }
78
+
79
+ async function measure(engine, workspace, label, turn) {
80
+ const statusBefore = await git(workspace, 'status', '--porcelain=v2', '--untracked-files=all')
81
+ const headsBefore = await git(workspace, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads')
82
+ const indexBefore = await readFile(join(workspace, '.git', 'index'))
83
+ const started = performance.now()
84
+ const point = await engine.createTurnCheckpoint({
85
+ cwd: workspace,
86
+ sessionId: 'benchmark-session',
87
+ turn,
88
+ turnStartSeq: turn * 4 - 3,
89
+ })
90
+ const manifest = await engine.store.readManifest(point.workspace, point.id)
91
+ assert.equal(manifest.version, 2)
92
+ assert.equal(await git(workspace, 'status', '--porcelain=v2', '--untracked-files=all'), statusBefore)
93
+ assert.equal(await git(workspace, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads'), headsBefore)
94
+ assert.deepEqual(await readFile(join(workspace, '.git', 'index')), indexBefore)
95
+ return {
96
+ label,
97
+ wallMs: Math.round((performance.now() - started) * 100) / 100,
98
+ fileCount: manifest.fileCount,
99
+ logicalBytes: manifest.totalBytes,
100
+ worktreeBytesRead: manifest.git.newlyStoredBytes,
101
+ }
102
+ }
103
+
104
+ async function git(cwd, ...args) {
105
+ const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], {
106
+ encoding: 'utf8',
107
+ maxBuffer: 64 * 1024 * 1024,
108
+ env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
109
+ })
110
+ return stdout.trim()
111
+ }
112
+
113
+ async function parallelRange(count, concurrency, task) {
114
+ let next = 0
115
+ await Promise.all(Array.from({ length: Math.min(count, concurrency) }, async () => {
116
+ while (next < count) {
117
+ const current = next
118
+ next += 1
119
+ await task(current)
120
+ }
121
+ }))
122
+ }
123
+
124
+ function parseArgs(args) {
125
+ if (args[0] === '--') args = args.slice(1)
126
+ const values = { files: 15_500, bytesPerFile: 24 * 1024, dirtyFiles: 10, timeoutMs: 5_000 }
127
+ for (let index = 0; index < args.length; index += 2) {
128
+ const flag = args[index]
129
+ const raw = args[index + 1]
130
+ const key = {
131
+ '--files': 'files',
132
+ '--bytes-per-file': 'bytesPerFile',
133
+ '--dirty-files': 'dirtyFiles',
134
+ '--timeout-ms': 'timeoutMs',
135
+ }[flag]
136
+ if (key === undefined || raw === undefined || !/^\d+$/.test(raw)) usage()
137
+ values[key] = Number(raw)
138
+ }
139
+ if (!Number.isSafeInteger(values.files) || values.files <= 0
140
+ || !Number.isSafeInteger(values.bytesPerFile) || values.bytesPerFile <= 0
141
+ || !Number.isSafeInteger(values.dirtyFiles) || values.dirtyFiles < 0 || values.dirtyFiles > values.files
142
+ || !Number.isSafeInteger(values.timeoutMs) || values.timeoutMs <= 0) usage()
143
+ return values
144
+ }
145
+
146
+ function usage() {
147
+ process.stderr.write('usage: pnpm benchmark:git-checkpoint -- [--files N] [--bytes-per-file N] [--dirty-files N] [--timeout-ms N]\n')
148
+ process.exit(2)
149
+ }
@@ -0,0 +1,18 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const built = new URL('../.client-build/index.js', import.meta.url)
6
+ const builtMap = new URL('../.client-build/index.js.map', import.meta.url)
7
+ const lib = new URL('../lib/', import.meta.url)
8
+ const source = (await readFile(built, 'utf8')).replace(/\n?\/\/# sourceMappingURL=.*\n?$/, '\n')
9
+ const sourceMap = JSON.parse(await readFile(builtMap, 'utf8'))
10
+ sourceMap.file = 'client.js'
11
+ sourceMap.sources = sourceMap.sources.map(sourcePath => `../src/client/${sourcePath.replace(/^\.\.\//, '')}`)
12
+ const banner = 'window.__ModuleLoader__.load({ id: "@anionex/dsh-turn-rewind", factory: (require) => {\nvar module = { exports: {} }; var exports = module.exports;\n'
13
+ const footer = '\nreturn module.exports; } });\n//# sourceMappingURL=client.js.map\n'
14
+
15
+ await mkdir(lib, { recursive: true })
16
+ await writeFile(join(fileURLToPath(lib), 'client.js'), `${banner}${source}${footer}`)
17
+ await writeFile(join(fileURLToPath(lib), 'client.js.map'), `${JSON.stringify(sourceMap)}\n`)
18
+ await rm(new URL('../.client-build/', import.meta.url), { recursive: true, force: true })
@@ -111,6 +111,7 @@ interface ReadyPreview {
111
111
  type Preview = ReadyPreview
112
112
  | { readonly status: 'pending' }
113
113
  | { readonly status: 'missing' }
114
+ | { readonly status: 'skipped'; readonly reason: string }
114
115
  | { readonly status: 'failed'; readonly error: string }
115
116
 
116
117
  const PATH = '/turn-rewind'
@@ -407,6 +408,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
407
408
  {loading && <p className="dcl-rewind-status">正在检查可以恢复的项目文件…</p>}
408
409
  {preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
409
410
  {preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能,或记录已超过保留期限。</p>}
411
+ {preview?.status === 'skipped' && <p className="dcl-rewind-status">为避免阻塞消息发送,本轮没有自动保存文件:{preview.reason}</p>}
410
412
  {preview?.status === 'failed' && <p className="dcl-rewind-error">没能保存这条消息发送前的文件:{preview.error}</p>}
411
413
  {ready !== null && (
412
414
  <>
@@ -460,6 +462,7 @@ function decodePreview(value: unknown): Preview {
460
462
  const record = recordOf(value)
461
463
  const status = requiredString(record.status, 'status')
462
464
  if (status === 'pending' || status === 'missing') return { status }
465
+ if (status === 'skipped') return { status, reason: requiredString(record.reason, 'reason') }
463
466
  if (status === 'failed') return { status, error: requiredString(record.error, 'error') }
464
467
  if (status !== 'ready') throw new Error(`未知回退状态:${status}`)
465
468
  const changesValue = record.changes
@@ -0,0 +1,18 @@
1
+ export interface Deadline {
2
+ readonly signal: AbortSignal
3
+ cancel(): void
4
+ }
5
+
6
+ /** Create a cancellable timeout whose timer keeps Node alive until it fires. */
7
+ export function createDeadline(timeoutMs: number): Deadline {
8
+ const controller = new AbortController()
9
+ const timer = setTimeout(() => {
10
+ controller.abort(new DOMException(`deadline exceeded after ${String(timeoutMs)} ms`, 'TimeoutError'))
11
+ }, timeoutMs)
12
+ return {
13
+ signal: controller.signal,
14
+ cancel() {
15
+ clearTimeout(timer)
16
+ },
17
+ }
18
+ }