@anionex/dsh-turn-rewind 0.1.1 → 0.2.0
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 +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +291 -22
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +622 -121
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +18 -2
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +250 -48
- package/lib/settings.js +58 -0
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1361 -119
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +48 -2
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +8 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +90 -6
- package/lib/types/types.d.ts +63 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +21 -3
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +536 -41
- package/src/deadline.ts +18 -0
- package/src/engine.ts +695 -126
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +21 -2
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +278 -50
- package/src/settings.ts +93 -0
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1502 -104
- package/src/types.ts +69 -1
- package/src/validate.ts +67 -7
package/lib/types/types.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Durable format version. Readers reject every other version. */
|
|
2
2
|
export declare const LEDGER_FORMAT_VERSION: 1;
|
|
3
|
+
/** Git-native checkpoint format used by bounded automatic turn capture. */
|
|
4
|
+
export declare const GIT_CHECKPOINT_FORMAT_VERSION: 2;
|
|
3
5
|
/** A persisted restore-point identifier. */
|
|
4
6
|
export type RestorePointId = string;
|
|
5
7
|
/** A persisted restore-operation identifier. */
|
|
@@ -12,6 +14,8 @@ export interface FileSnapshotEntry {
|
|
|
12
14
|
readonly blob: string;
|
|
13
15
|
readonly size: number;
|
|
14
16
|
readonly mode: number;
|
|
17
|
+
/** Absent for v1 Ledger blobs; `git` means `blob` is a Git object ID. */
|
|
18
|
+
readonly provider?: 'git';
|
|
15
19
|
}
|
|
16
20
|
/** One symbolic-link snapshot. */
|
|
17
21
|
export interface SymlinkSnapshotEntry {
|
|
@@ -33,7 +37,7 @@ export interface RepositoryState {
|
|
|
33
37
|
/** Why a restore point exists. */
|
|
34
38
|
export type RestorePointKind = 'user' | 'rescue' | 'turn';
|
|
35
39
|
/** Durable workspace restore point. */
|
|
36
|
-
export interface
|
|
40
|
+
export interface RestorePointManifestV1 {
|
|
37
41
|
readonly version: typeof LEDGER_FORMAT_VERSION;
|
|
38
42
|
readonly id: RestorePointId;
|
|
39
43
|
readonly kind: RestorePointKind;
|
|
@@ -56,6 +60,31 @@ export interface RestorePointManifest {
|
|
|
56
60
|
readonly restoreCount: number;
|
|
57
61
|
readonly lastRestoredAt?: number;
|
|
58
62
|
}
|
|
63
|
+
/** Git-native metadata pinned by a private ref. */
|
|
64
|
+
export interface GitCheckpointMetadata {
|
|
65
|
+
readonly objectFormat: 'sha1' | 'sha256';
|
|
66
|
+
readonly trust: 'metadata-fenced' | 'byte-verified';
|
|
67
|
+
/** Stable owner of refs created by one Change Ledger storage root. */
|
|
68
|
+
readonly storeId: string;
|
|
69
|
+
/** Durable identity stored inside this specific Git worktree metadata directory. */
|
|
70
|
+
readonly worktreeId: string;
|
|
71
|
+
readonly headTree: string;
|
|
72
|
+
readonly indexTree: string;
|
|
73
|
+
readonly worktreeTree: string;
|
|
74
|
+
readonly envelopeTree: string;
|
|
75
|
+
readonly commit: string;
|
|
76
|
+
readonly ref: string;
|
|
77
|
+
readonly newlyStoredBytes: number;
|
|
78
|
+
}
|
|
79
|
+
/** Automatic turn checkpoint whose content is stored in the repository object database. */
|
|
80
|
+
export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash'> {
|
|
81
|
+
readonly version: typeof GIT_CHECKPOINT_FORMAT_VERSION;
|
|
82
|
+
/** Deterministic sidecar identity; Git tree integrity is verified separately. */
|
|
83
|
+
readonly treeHash: string;
|
|
84
|
+
readonly git: GitCheckpointMetadata;
|
|
85
|
+
}
|
|
86
|
+
/** Durable workspace restore point in either supported format. */
|
|
87
|
+
export type RestorePointManifest = RestorePointManifestV1 | RestorePointManifestV2;
|
|
59
88
|
/** Observable difference between a restore point and the current workspace. */
|
|
60
89
|
export interface WorkspaceChange {
|
|
61
90
|
readonly path: string;
|
|
@@ -77,6 +106,7 @@ export interface RestorePointInspection {
|
|
|
77
106
|
}
|
|
78
107
|
/** Compact restore-point metadata for lists and tool results. */
|
|
79
108
|
export interface RestorePointSummary {
|
|
109
|
+
readonly format: typeof LEDGER_FORMAT_VERSION | typeof GIT_CHECKPOINT_FORMAT_VERSION;
|
|
80
110
|
readonly id: RestorePointId;
|
|
81
111
|
readonly kind: RestorePointKind;
|
|
82
112
|
readonly workspace: string;
|
|
@@ -96,6 +126,7 @@ export interface RestorePointSummary {
|
|
|
96
126
|
readonly branch?: string;
|
|
97
127
|
readonly operation?: string;
|
|
98
128
|
readonly stagedPathCount: number;
|
|
129
|
+
readonly trust?: GitCheckpointMetadata['trust'];
|
|
99
130
|
}
|
|
100
131
|
/** An expiring restore plan whose confirmation must be echoed exactly. */
|
|
101
132
|
export interface RestorePlan {
|
|
@@ -152,6 +183,14 @@ export interface ChangeLedgerConfig {
|
|
|
152
183
|
readonly planTtlMs?: number;
|
|
153
184
|
/** Age after which a lock whose owner is gone may be reclaimed. */
|
|
154
185
|
readonly staleLockMs?: number;
|
|
186
|
+
/** Automatic turn-checkpoint implementation. Git-native is bounded and avoids rereading clean tracked content. */
|
|
187
|
+
readonly turnCheckpointMode?: 'off' | 'git-native' | 'legacy';
|
|
188
|
+
/** Maximum time one automatic checkpoint may block the first Agent step. */
|
|
189
|
+
readonly turnCheckpointTimeoutMs?: number;
|
|
190
|
+
/** Maximum uncached worktree bytes read by one automatic Git-native checkpoint. */
|
|
191
|
+
readonly turnCheckpointMaxNewBytes?: number;
|
|
192
|
+
/** Fast trusts fenced Git/stat metadata; strict rereads every eligible path. */
|
|
193
|
+
readonly turnCheckpointTrust?: 'fast' | 'strict';
|
|
155
194
|
}
|
|
156
195
|
/** Fully resolved plugin configuration. */
|
|
157
196
|
export interface ResolvedChangeLedgerConfig {
|
|
@@ -163,6 +202,10 @@ export interface ResolvedChangeLedgerConfig {
|
|
|
163
202
|
readonly maxSnapshotBytes: number;
|
|
164
203
|
readonly planTtlMs: number;
|
|
165
204
|
readonly staleLockMs: number;
|
|
205
|
+
readonly turnCheckpointMode: 'off' | 'git-native' | 'legacy';
|
|
206
|
+
readonly turnCheckpointTimeoutMs: number;
|
|
207
|
+
readonly turnCheckpointMaxNewBytes: number;
|
|
208
|
+
readonly turnCheckpointTrust: 'fast' | 'strict';
|
|
166
209
|
}
|
|
167
210
|
/** Current incomplete restore operation. */
|
|
168
211
|
export interface RecoverySummary {
|
|
@@ -175,3 +218,22 @@ export interface RecoverySummary {
|
|
|
175
218
|
readonly error?: string;
|
|
176
219
|
readonly rollbackError?: string;
|
|
177
220
|
}
|
|
221
|
+
/** Aggregated checkpoint inventory for one workspace, addressed by its recorded path. */
|
|
222
|
+
export interface WorkspaceOverview {
|
|
223
|
+
/** Canonical workspace path recorded in the persisted manifests. */
|
|
224
|
+
readonly workspace: string;
|
|
225
|
+
/** Every restore point stored for the workspace, newest first. */
|
|
226
|
+
readonly restorePoints: readonly RestorePointSummary[];
|
|
227
|
+
/** Sum of every restore point's logical bytes. */
|
|
228
|
+
readonly totalBytes: number;
|
|
229
|
+
/** Incomplete restore operations that still reference this workspace's points. */
|
|
230
|
+
readonly recoveryCount: number;
|
|
231
|
+
}
|
|
232
|
+
/** Aggregate cleanup report for one workspace purge. */
|
|
233
|
+
export interface WorkspacePurgeReport {
|
|
234
|
+
readonly workspace: string;
|
|
235
|
+
readonly deletedRestorePoints: number;
|
|
236
|
+
readonly retainedRestorePoints: number;
|
|
237
|
+
readonly deletedBlobs: number;
|
|
238
|
+
readonly retainedBlobs: number;
|
|
239
|
+
}
|
package/lib/types.js
CHANGED
package/lib/validate.js
CHANGED
|
@@ -2,7 +2,7 @@ import { ChangeLedgerError } from './errors.js';
|
|
|
2
2
|
import { isAbsolute } from 'node:path';
|
|
3
3
|
import { validateRelativePath } from './path-utils.js';
|
|
4
4
|
import { hashTree } from './snapshot.js';
|
|
5
|
-
import { LEDGER_FORMAT_VERSION, } from './types.js';
|
|
5
|
+
import { GIT_CHECKPOINT_FORMAT_VERSION, LEDGER_FORMAT_VERSION, } from './types.js';
|
|
6
6
|
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
7
|
const GIT_OBJECT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
8
8
|
const ID_PATTERN = /^(?:rp|op)_[0-9a-z]+_[0-9a-f]{12}$/;
|
|
@@ -23,14 +23,17 @@ export function validateOperationId(value) {
|
|
|
23
23
|
/** Parse an untrusted durable restore-point manifest. */
|
|
24
24
|
export function parseManifest(value) {
|
|
25
25
|
const record = objectRecord(value, 'restore-point manifest');
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const version = record.version;
|
|
27
|
+
if (version !== LEDGER_FORMAT_VERSION && version !== GIT_CHECKPOINT_FORMAT_VERSION) {
|
|
28
|
+
corrupt(`unsupported restore-point version ${String(version)}`);
|
|
29
|
+
}
|
|
28
30
|
const id = stringField(record, 'id');
|
|
29
31
|
validateRestorePointId(id);
|
|
30
32
|
const kind = record.kind;
|
|
31
33
|
if (kind !== 'user' && kind !== 'rescue' && kind !== 'turn') {
|
|
32
34
|
corrupt('restore-point kind must be "user", "rescue", or "turn"');
|
|
33
35
|
}
|
|
36
|
+
const restorePointKind = kind;
|
|
34
37
|
const workspace = absoluteString(record, 'workspace');
|
|
35
38
|
const repository = parseRepository(record.repository);
|
|
36
39
|
if (repository.root !== workspace)
|
|
@@ -40,7 +43,7 @@ export function parseManifest(value) {
|
|
|
40
43
|
let totalBytes = 0;
|
|
41
44
|
for (const [path, entryValue] of Object.entries(entriesRecord)) {
|
|
42
45
|
validateRelativePath(path);
|
|
43
|
-
const entry = parseEntry(entryValue, path);
|
|
46
|
+
const entry = parseEntry(entryValue, path, version);
|
|
44
47
|
entries[path] = entry;
|
|
45
48
|
if (entry.kind === 'file')
|
|
46
49
|
totalBytes += entry.size;
|
|
@@ -73,10 +76,9 @@ export function parseManifest(value) {
|
|
|
73
76
|
corrupt('only turn restore points may carry turn metadata');
|
|
74
77
|
}
|
|
75
78
|
const lastRestoredAt = optionalNonNegativeInteger(record, 'lastRestoredAt');
|
|
76
|
-
|
|
77
|
-
version: LEDGER_FORMAT_VERSION,
|
|
79
|
+
const common = {
|
|
78
80
|
id,
|
|
79
|
-
kind,
|
|
81
|
+
kind: restorePointKind,
|
|
80
82
|
workspace,
|
|
81
83
|
repository,
|
|
82
84
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
@@ -93,6 +95,11 @@ export function parseManifest(value) {
|
|
|
93
95
|
restoreCount,
|
|
94
96
|
...(lastRestoredAt === undefined ? {} : { lastRestoredAt }),
|
|
95
97
|
};
|
|
98
|
+
if (version === LEDGER_FORMAT_VERSION)
|
|
99
|
+
return { version, ...common };
|
|
100
|
+
if (restorePointKind !== 'turn')
|
|
101
|
+
corrupt('Git-native v2 restore points are automatic turn checkpoints');
|
|
102
|
+
return { version, ...common, git: parseGitCheckpoint(record.git, id) };
|
|
96
103
|
}
|
|
97
104
|
/** Parse an untrusted durable restore-operation journal. */
|
|
98
105
|
export function parseOperation(value) {
|
|
@@ -168,18 +175,24 @@ function parseRepository(value) {
|
|
|
168
175
|
stagedPaths,
|
|
169
176
|
};
|
|
170
177
|
}
|
|
171
|
-
function parseEntry(value, path) {
|
|
178
|
+
function parseEntry(value, path, version) {
|
|
172
179
|
const record = objectRecord(value, `snapshot entry ${JSON.stringify(path)}`);
|
|
173
180
|
const kind = record.kind;
|
|
174
181
|
const mode = nonNegativeInteger(record, 'mode');
|
|
175
182
|
if (mode > 0o777)
|
|
176
183
|
corrupt(`snapshot mode is out of range for ${JSON.stringify(path)}`);
|
|
177
184
|
if (kind === 'file') {
|
|
185
|
+
const blob = stringField(record, 'blob');
|
|
186
|
+
if (version === LEDGER_FORMAT_VERSION)
|
|
187
|
+
validateBlobHash(blob);
|
|
188
|
+
else
|
|
189
|
+
validateGitObject(blob);
|
|
178
190
|
return {
|
|
179
191
|
kind,
|
|
180
|
-
blob
|
|
192
|
+
blob,
|
|
181
193
|
size: nonNegativeInteger(record, 'size'),
|
|
182
194
|
mode,
|
|
195
|
+
...(version === GIT_CHECKPOINT_FORMAT_VERSION ? { provider: 'git' } : {}),
|
|
183
196
|
};
|
|
184
197
|
}
|
|
185
198
|
if (kind === 'symlink') {
|
|
@@ -190,6 +203,52 @@ function parseEntry(value, path) {
|
|
|
190
203
|
}
|
|
191
204
|
corrupt(`invalid snapshot entry kind for ${JSON.stringify(path)}`);
|
|
192
205
|
}
|
|
206
|
+
function parseGitCheckpoint(value, restorePointId) {
|
|
207
|
+
const record = objectRecord(value, 'Git checkpoint metadata');
|
|
208
|
+
const objectFormat = record.objectFormat;
|
|
209
|
+
if (objectFormat !== 'sha1' && objectFormat !== 'sha256')
|
|
210
|
+
corrupt('Git checkpoint objectFormat must be sha1 or sha256');
|
|
211
|
+
const trust = record.trust;
|
|
212
|
+
if (trust !== 'metadata-fenced' && trust !== 'byte-verified')
|
|
213
|
+
corrupt('Git checkpoint trust is invalid');
|
|
214
|
+
const expectedLength = objectFormat === 'sha1' ? 40 : 64;
|
|
215
|
+
const object = (key) => {
|
|
216
|
+
const oid = validateGitObject(stringField(record, key));
|
|
217
|
+
if (oid.length !== expectedLength)
|
|
218
|
+
corrupt(`${key} length does not match Git object format`);
|
|
219
|
+
return oid;
|
|
220
|
+
};
|
|
221
|
+
const storeId = hexIdentifier(record, 'storeId');
|
|
222
|
+
const worktreeId = hexIdentifier(record, 'worktreeId');
|
|
223
|
+
const ref = stringField(record, 'ref');
|
|
224
|
+
if (ref !== `refs/dsh-turn-rewind/v2/${storeId}/${worktreeId}/${restorePointId}`) {
|
|
225
|
+
corrupt('Git checkpoint ref does not match its durable store/worktree/restore-point identity');
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
objectFormat,
|
|
229
|
+
trust,
|
|
230
|
+
storeId,
|
|
231
|
+
worktreeId,
|
|
232
|
+
headTree: object('headTree'),
|
|
233
|
+
indexTree: object('indexTree'),
|
|
234
|
+
worktreeTree: object('worktreeTree'),
|
|
235
|
+
envelopeTree: object('envelopeTree'),
|
|
236
|
+
commit: object('commit'),
|
|
237
|
+
ref,
|
|
238
|
+
newlyStoredBytes: nonNegativeInteger(record, 'newlyStoredBytes'),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function hexIdentifier(record, key) {
|
|
242
|
+
const value = stringField(record, key);
|
|
243
|
+
if (!/^[0-9a-f]{32}$/.test(value))
|
|
244
|
+
corrupt(`${key} must be a 128-bit lowercase hexadecimal identifier`);
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
function validateGitObject(value) {
|
|
248
|
+
if (!GIT_OBJECT_PATTERN.test(value))
|
|
249
|
+
corrupt('invalid Git object id');
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
193
252
|
function objectRecord(value, label) {
|
|
194
253
|
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
195
254
|
corrupt(`${label} must be an object`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anionex/dsh-turn-rewind",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"lib",
|
|
41
41
|
"src",
|
|
42
|
+
"scripts",
|
|
42
43
|
"docs",
|
|
43
44
|
"cordis.patch.yml",
|
|
44
45
|
"README.md",
|
|
@@ -54,11 +55,24 @@
|
|
|
54
55
|
"platform": "web",
|
|
55
56
|
"inject": [
|
|
56
57
|
"@deepseek-ai/dsh-client-runtime",
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"compatibility": {
|
|
63
|
+
"dsh": ">=0.1.0-rc.8 <0.2.0",
|
|
64
|
+
"dshReleases": {
|
|
65
|
+
"0.1.0-rc.8": "compatible",
|
|
66
|
+
"0.1.1-rc.1": "compatible",
|
|
67
|
+
"0.1.1-rc.2": "compatible"
|
|
68
|
+
},
|
|
69
|
+
"profiles": [
|
|
70
|
+
"web"
|
|
58
71
|
]
|
|
59
72
|
}
|
|
60
73
|
},
|
|
61
74
|
"scripts": {
|
|
75
|
+
"benchmark:git-checkpoint": "pnpm run build && node scripts/benchmark-git-checkpoint.mjs",
|
|
62
76
|
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.client.json && node scripts/wrap-client.mjs",
|
|
63
77
|
"check": "tsc -p tsconfig.json && pnpm run build && node --test tests/*.test.mjs && pnpm pack --dry-run",
|
|
64
78
|
"prepack": "pnpm run build",
|
|
@@ -75,10 +89,14 @@
|
|
|
75
89
|
],
|
|
76
90
|
"license": "BSD-3-Clause",
|
|
77
91
|
"peerDependencies": {
|
|
78
|
-
"@deepseek-ai/cordis": "^4.0.1"
|
|
92
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
93
|
+
"@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0",
|
|
94
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
79
95
|
},
|
|
80
96
|
"devDependencies": {
|
|
81
97
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
98
|
+
"@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0",
|
|
99
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
82
100
|
"@types/node": "^24.0.0",
|
|
83
101
|
"@types/react": "18.3.28",
|
|
84
102
|
"react": "18.3.1",
|
|
@@ -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 })
|