@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.
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/lib/client.js +3 -1
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +482 -118
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +1 -1
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +119 -29
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1281 -106
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +27 -0
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +3 -1
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +70 -6
- package/lib/types/types.d.ts +44 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +3 -1
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +3 -0
- package/src/deadline.ts +18 -0
- package/src/engine.ts +542 -124
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +1 -1
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +134 -30
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1403 -91
- package/src/types.ts +48 -1
- package/src/validate.ts +67 -7
package/lib/types/engine.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export declare class ChangeLedgerEngine {
|
|
|
11
11
|
constructor(config?: ChangeLedgerConfig);
|
|
12
12
|
/** Wait for startup reconciliation and return the number of interrupted journals found. */
|
|
13
13
|
initialize(): Promise<number>;
|
|
14
|
+
private initializeStore;
|
|
15
|
+
private acquireWorkspace;
|
|
16
|
+
private reconcileGitCheckpointJournals;
|
|
17
|
+
private publishGitManifest;
|
|
18
|
+
private deleteManifestWithGit;
|
|
19
|
+
private tryReadManifest;
|
|
14
20
|
/** Create a durable restore point for the current Git worktree. */
|
|
15
21
|
create(options: {
|
|
16
22
|
readonly cwd: string;
|
|
@@ -26,6 +32,25 @@ export declare class ChangeLedgerEngine {
|
|
|
26
32
|
readonly turnStartSeq: number;
|
|
27
33
|
readonly signal?: AbortSignal;
|
|
28
34
|
}): Promise<RestorePointSummary>;
|
|
35
|
+
/** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
|
|
36
|
+
recordTurnCheckpointSkip(options: {
|
|
37
|
+
readonly cwd: string;
|
|
38
|
+
readonly sessionId: string;
|
|
39
|
+
readonly turn: number;
|
|
40
|
+
readonly turnStartSeq: number;
|
|
41
|
+
readonly reason: string;
|
|
42
|
+
readonly signal?: AbortSignal;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
/** Read a durable skip marker for one exact prompt boundary. */
|
|
45
|
+
findTurnCheckpointSkip(options: {
|
|
46
|
+
readonly cwd: string;
|
|
47
|
+
readonly sessionId: string;
|
|
48
|
+
readonly turn: number;
|
|
49
|
+
readonly turnStartSeq: number;
|
|
50
|
+
readonly signal?: AbortSignal;
|
|
51
|
+
}): Promise<{
|
|
52
|
+
readonly reason: string;
|
|
53
|
+
} | undefined>;
|
|
29
54
|
/** Find the prompt-anchored checkpoint captured before one session turn. */
|
|
30
55
|
findTurnCheckpoint(options: {
|
|
31
56
|
readonly cwd: string;
|
|
@@ -82,7 +107,9 @@ export declare class ChangeLedgerEngine {
|
|
|
82
107
|
}): Promise<RecoverySummary[]>;
|
|
83
108
|
private createLocked;
|
|
84
109
|
private restorePaths;
|
|
110
|
+
private assertPathFresh;
|
|
85
111
|
private verifyPaths;
|
|
112
|
+
private captureSelectedEntries;
|
|
86
113
|
private assertStorageSeparated;
|
|
87
114
|
private expirePlans;
|
|
88
115
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ResolvedChangeLedgerConfig, type RestorePointManifestV2 } from './types.js';
|
|
2
|
+
interface CacheRecord {
|
|
3
|
+
readonly kind: 'file' | 'symlink';
|
|
4
|
+
readonly mode: number;
|
|
5
|
+
readonly size: string;
|
|
6
|
+
readonly mtimeNs: string;
|
|
7
|
+
readonly ctimeNs: string;
|
|
8
|
+
readonly dev: string;
|
|
9
|
+
readonly ino: string;
|
|
10
|
+
readonly indexOid?: string;
|
|
11
|
+
readonly attributes: string;
|
|
12
|
+
readonly oid: string;
|
|
13
|
+
readonly target?: string;
|
|
14
|
+
}
|
|
15
|
+
interface GitCache {
|
|
16
|
+
readonly version: 2;
|
|
17
|
+
readonly storeId: string;
|
|
18
|
+
readonly worktreeId: string;
|
|
19
|
+
readonly paths: Readonly<Record<string, CacheRecord>>;
|
|
20
|
+
readonly checksum: string;
|
|
21
|
+
}
|
|
22
|
+
export interface GitCheckpointCapture {
|
|
23
|
+
readonly manifest: RestorePointManifestV2;
|
|
24
|
+
readonly cache: GitCache;
|
|
25
|
+
}
|
|
26
|
+
/** Create a complete Git-native turn checkpoint without writing the real index. */
|
|
27
|
+
export declare function captureGitTurnCheckpoint(options: {
|
|
28
|
+
readonly cwd: string;
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly sessionId: string;
|
|
31
|
+
readonly turn: number;
|
|
32
|
+
readonly turnStartSeq: number;
|
|
33
|
+
readonly config: ResolvedChangeLedgerConfig;
|
|
34
|
+
readonly signal?: AbortSignal;
|
|
35
|
+
}): Promise<GitCheckpointCapture>;
|
|
36
|
+
/** Publish a prepared checkpoint under its exact private ref. */
|
|
37
|
+
export declare function publishGitCheckpoint(manifest: RestorePointManifestV2, storageDir: string, signal?: AbortSignal): Promise<void>;
|
|
38
|
+
/** Verify persisted Git metadata before exposing or restoring a v2 point. */
|
|
39
|
+
export declare function verifyGitCheckpoint(manifest: RestorePointManifestV2, signal?: AbortSignal): Promise<void>;
|
|
40
|
+
/** Compare-and-swap delete a v2 private ref. */
|
|
41
|
+
export declare function deleteGitCheckpoint(manifest: RestorePointManifestV2, storageDir: string, signal?: AbortSignal): Promise<void>;
|
|
42
|
+
/** Delete an exact private ref target, including during startup journal reconciliation. */
|
|
43
|
+
export declare function deleteGitCheckpointRef(workspace: string, ref: string, commit: string, storageDir: string, signal?: AbortSignal): Promise<void>;
|
|
44
|
+
/** Read one v2 Git blob for selective restore. */
|
|
45
|
+
export declare function readGitBlob(workspace: string, oid: string, expectedSize: number, signal?: AbortSignal): Promise<Buffer>;
|
|
46
|
+
export {};
|
package/lib/types/git.d.ts
CHANGED
|
@@ -4,11 +4,42 @@ export interface RepositorySnapshotSource {
|
|
|
4
4
|
readonly state: RepositoryState;
|
|
5
5
|
readonly paths: readonly string[];
|
|
6
6
|
}
|
|
7
|
+
/** Durable identity and shared lock location for one concrete Git worktree. */
|
|
8
|
+
export interface GitWorktreeIdentity {
|
|
9
|
+
readonly root: string;
|
|
10
|
+
readonly gitDir: string;
|
|
11
|
+
readonly commonDir: string;
|
|
12
|
+
readonly worktreeId: string;
|
|
13
|
+
readonly lockPath: string;
|
|
14
|
+
}
|
|
7
15
|
/** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
|
|
8
16
|
export declare function discoverRepository(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource>;
|
|
9
17
|
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
10
18
|
export declare function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string>;
|
|
19
|
+
/** Create or read the per-worktree identity used by refs and cross-store locking. */
|
|
20
|
+
export declare function ensureGitWorktreeIdentity(cwd: string, signal?: AbortSignal): Promise<GitWorktreeIdentity>;
|
|
21
|
+
/** Resolve a moved linked worktree from its common directory and durable identity. */
|
|
22
|
+
export declare function resolveGitWorktreeRoot(commonDir: string, worktreeId: string, fallback: string, signal?: AbortSignal): Promise<string>;
|
|
11
23
|
/** Return true when two repository fences refer to the same checkout state. */
|
|
12
24
|
export declare function sameRepositoryFence(left: RepositoryState, right: RepositoryState): boolean;
|
|
25
|
+
/** Run Git with stable non-interactive defaults and return UTF-8 output. */
|
|
26
|
+
export declare function runGit(cwd: string, args: readonly string[], signal?: AbortSignal, options?: {
|
|
27
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
28
|
+
readonly maxBuffer?: number;
|
|
29
|
+
}): Promise<string>;
|
|
30
|
+
/** Run Git and return undefined for its ordinary "not found" exit status. */
|
|
31
|
+
export declare function runGitOptional(cwd: string, args: readonly string[], signal?: AbortSignal, options?: {
|
|
32
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
33
|
+
readonly maxBuffer?: number;
|
|
34
|
+
}): Promise<string | undefined>;
|
|
35
|
+
/** Run Git with binary stdin and return UTF-8 stdout. */
|
|
36
|
+
export declare function runGitInput(cwd: string, args: readonly string[], input: Buffer | string, signal?: AbortSignal, options?: {
|
|
37
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
38
|
+
}): Promise<string>;
|
|
39
|
+
/** Run Git and return raw stdout bytes. */
|
|
40
|
+
export declare function runGitBuffer(cwd: string, args: readonly string[], signal?: AbortSignal, options?: {
|
|
41
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
42
|
+
readonly maxBuffer?: number;
|
|
43
|
+
}): Promise<Buffer>;
|
|
13
44
|
/** Return the Git metadata directory for diagnostics. */
|
|
14
45
|
export declare function gitMetadataParent(state: RepositoryState): string;
|
|
@@ -16,6 +16,8 @@ export declare function readJson(path: string): Promise<unknown>;
|
|
|
16
16
|
export declare function pathExists(path: string): Promise<boolean>;
|
|
17
17
|
/** Ensure every existing parent below `root` is a real directory, never a symlink. */
|
|
18
18
|
export declare function ensureSafeParents(root: string, target: string): Promise<void>;
|
|
19
|
+
/** Validate existing parents without creating missing directories. */
|
|
20
|
+
export declare function assertSafeParents(root: string, target: string): Promise<void>;
|
|
19
21
|
/** Replace a path with a regular file using a sibling temporary file. */
|
|
20
22
|
export declare function replaceRegularFile(path: string, content: Buffer, mode: number): Promise<void>;
|
|
21
23
|
/** Replace a path with a symbolic link using a sibling temporary name. */
|
|
@@ -115,14 +115,16 @@ export declare class TurnCheckpointCoordinator {
|
|
|
115
115
|
private readonly captures;
|
|
116
116
|
private readonly pending;
|
|
117
117
|
private readonly failures;
|
|
118
|
+
private readonly skips;
|
|
118
119
|
private readonly workspaceTails;
|
|
119
120
|
constructor(engine: ChangeLedgerEngine);
|
|
120
121
|
/** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
|
|
121
122
|
install(ctx: Context): void;
|
|
122
123
|
/** Current capture state for a session turn when no durable checkpoint exists yet. */
|
|
123
124
|
state(sessionId: string, turn: number): {
|
|
124
|
-
readonly status: 'pending' | 'failed' | 'missing';
|
|
125
|
+
readonly status: 'pending' | 'failed' | 'skipped' | 'missing';
|
|
125
126
|
readonly error?: string;
|
|
127
|
+
readonly reason?: string;
|
|
126
128
|
};
|
|
127
129
|
private capture;
|
|
128
130
|
private serializeWorkspace;
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ResolvedChangeLedgerConfig, SnapshotEntry, WorkspaceChange } from
|
|
|
5
5
|
export interface CapturedTree {
|
|
6
6
|
readonly source: RepositorySnapshotSource;
|
|
7
7
|
readonly entries: Readonly<Record<string, SnapshotEntry>>;
|
|
8
|
+
readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>;
|
|
8
9
|
readonly treeHash: string;
|
|
9
10
|
readonly fileCount: number;
|
|
10
11
|
readonly totalBytes: number;
|
|
@@ -14,6 +15,7 @@ export declare function captureTree(options: {
|
|
|
14
15
|
readonly cwd: string;
|
|
15
16
|
readonly config: ResolvedChangeLedgerConfig;
|
|
16
17
|
readonly store?: LedgerStore;
|
|
18
|
+
readonly gitObjectFormat?: 'sha1' | 'sha256';
|
|
17
19
|
readonly signal?: AbortSignal;
|
|
18
20
|
}): Promise<CapturedTree>;
|
|
19
21
|
/**
|
|
@@ -25,11 +27,14 @@ export declare function captureStableTree(options: {
|
|
|
25
27
|
readonly cwd: string;
|
|
26
28
|
readonly config: ResolvedChangeLedgerConfig;
|
|
27
29
|
readonly store?: LedgerStore;
|
|
30
|
+
readonly gitObjectFormat?: 'sha1' | 'sha256';
|
|
28
31
|
readonly signal?: AbortSignal;
|
|
29
32
|
}): Promise<CapturedTree>;
|
|
30
33
|
/** Compute stable path-level differences between two captured trees. */
|
|
31
|
-
export declare function diffTrees(before: Readonly<Record<string, SnapshotEntry>>, after: Readonly<Record<string, SnapshotEntry>>): WorkspaceChange[];
|
|
34
|
+
export declare function diffTrees(before: Readonly<Record<string, SnapshotEntry>>, after: Readonly<Record<string, SnapshotEntry>>, comparisonBefore?: Readonly<Record<string, SnapshotEntry>>, comparisonAfter?: Readonly<Record<string, SnapshotEntry>>): WorkspaceChange[];
|
|
32
35
|
/** Return whether two snapshot entries are byte/type/mode equivalent. */
|
|
33
36
|
export declare function entriesEqual(left: SnapshotEntry | undefined, right: SnapshotEntry | undefined): boolean;
|
|
34
37
|
/** Hash a complete path map into a deterministic tree identity. */
|
|
35
38
|
export declare function hashTree(entries: Readonly<Record<string, SnapshotEntry>>): string;
|
|
39
|
+
/** Byte-verify one workspace-relative path without following a final symlink. */
|
|
40
|
+
export declare function captureSnapshotEntry(root: string, path: string, maxFileBytes: number, signal?: AbortSignal, gitObjectFormat?: 'sha1' | 'sha256'): Promise<SnapshotEntry | undefined>;
|
package/lib/types/store.d.ts
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
-
import type { ResolvedChangeLedgerConfig, RestoreOperation, RestorePointManifest } from './types.js';
|
|
1
|
+
import type { ResolvedChangeLedgerConfig, RestoreOperation, RestorePointManifest, RestorePointManifestV2 } from './types.js';
|
|
2
|
+
export interface GitCheckpointJournal {
|
|
3
|
+
readonly version: 1;
|
|
4
|
+
readonly action: 'publish' | 'delete';
|
|
5
|
+
readonly storeId: string;
|
|
6
|
+
readonly workspace: string;
|
|
7
|
+
readonly commonDir: string;
|
|
8
|
+
readonly worktreeId: string;
|
|
9
|
+
readonly restorePointId: string;
|
|
10
|
+
readonly ref: string;
|
|
11
|
+
readonly commit: string;
|
|
12
|
+
readonly createdAt: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TurnCheckpointSkip {
|
|
15
|
+
readonly version: 1;
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
readonly turn: number;
|
|
18
|
+
readonly turnStartSeq: number;
|
|
19
|
+
readonly reason: string;
|
|
20
|
+
readonly createdAt: number;
|
|
21
|
+
}
|
|
2
22
|
/** Durable content-addressed storage and per-workspace locking. */
|
|
3
23
|
export declare class LedgerStore {
|
|
4
24
|
readonly config: ResolvedChangeLedgerConfig;
|
|
25
|
+
private readonly workspaceKeys;
|
|
26
|
+
private storeIdValue;
|
|
5
27
|
constructor(config: ResolvedChangeLedgerConfig);
|
|
6
28
|
/** Create the state root and reconcile crash-interrupted operations. */
|
|
7
29
|
initialize(): Promise<number>;
|
|
30
|
+
/** Durable owner of this storage root's private Git namespace. */
|
|
31
|
+
get storeId(): string;
|
|
8
32
|
/** Acquire the exclusive lock for one canonical workspace. */
|
|
9
|
-
acquire(workspace: string
|
|
33
|
+
acquire(workspace: string, sharedLockPath?: string, binding?: {
|
|
34
|
+
readonly commonDir: string;
|
|
35
|
+
readonly gitDir: string;
|
|
36
|
+
readonly worktreeId: string;
|
|
37
|
+
}, signal?: AbortSignal): Promise<() => Promise<void>>;
|
|
38
|
+
private acquireOne;
|
|
10
39
|
/** Persist a blob if it is not already present, and verify existing content. */
|
|
11
40
|
putBlob(workspace: string, hash: string, content: Buffer): Promise<void>;
|
|
12
41
|
/** Read and verify one content-addressed blob. */
|
|
@@ -16,9 +45,25 @@ export declare class LedgerStore {
|
|
|
16
45
|
/** Load and validate one restore-point manifest. */
|
|
17
46
|
readManifest(workspace: string, id: string): Promise<RestorePointManifest>;
|
|
18
47
|
/** List all validated restore points for one workspace, newest first. */
|
|
19
|
-
listManifests(workspace: string): Promise<RestorePointManifest[]>;
|
|
48
|
+
listManifests(workspace: string, signal?: AbortSignal): Promise<RestorePointManifest[]>;
|
|
20
49
|
/** Delete one restore-point manifest. Blobs remain until garbage collection succeeds. */
|
|
21
50
|
deleteManifest(workspace: string, id: string): Promise<void>;
|
|
51
|
+
/** Persist the intent needed to finish or roll back a Git ref/manifest transition after a crash. */
|
|
52
|
+
writeGitCheckpointJournal(action: GitCheckpointJournal['action'], manifest: RestorePointManifestV2): Promise<void>;
|
|
53
|
+
/** List every pending Git checkpoint transition across this storage root. */
|
|
54
|
+
listGitCheckpointJournals(): Promise<GitCheckpointJournal[]>;
|
|
55
|
+
/** Remove one completed Git checkpoint transition journal. */
|
|
56
|
+
deleteGitCheckpointJournal(workspace: string, id: string): Promise<void>;
|
|
57
|
+
/** Persist one explicit automatic-checkpoint skip so the UI survives restarts. */
|
|
58
|
+
writeTurnCheckpointSkip(workspace: string, skip: TurnCheckpointSkip): Promise<void>;
|
|
59
|
+
/** Read one durable automatic-checkpoint skip. */
|
|
60
|
+
readTurnCheckpointSkip(workspace: string, sessionId: string, turn: number, turnStartSeq: number): Promise<TurnCheckpointSkip | undefined>;
|
|
61
|
+
/** Remove a stale skip after the same turn obtains a ready checkpoint. */
|
|
62
|
+
deleteTurnCheckpointSkip(workspace: string, sessionId: string, turn: number, turnStartSeq: number): Promise<void>;
|
|
63
|
+
/** Mark a legacy capture whose unreferenced blobs need crash recovery. */
|
|
64
|
+
writeSnapshotCleanup(workspace: string): Promise<void>;
|
|
65
|
+
/** Clear a completed legacy-capture cleanup marker. */
|
|
66
|
+
deleteSnapshotCleanup(workspace: string): Promise<void>;
|
|
22
67
|
/** Persist one restore-operation journal. */
|
|
23
68
|
writeOperation(operation: RestoreOperation): Promise<void>;
|
|
24
69
|
/** List validated restore operations for one workspace. */
|
|
@@ -26,14 +71,33 @@ export declare class LedgerStore {
|
|
|
26
71
|
/** Return whether an incomplete operation still references a restore point. */
|
|
27
72
|
isReferencedByRecovery(workspace: string, restorePointId: string): Promise<boolean>;
|
|
28
73
|
/** Delete blobs not referenced by any remaining manifest. */
|
|
29
|
-
collectGarbage(workspace: string, additionalReferenced?: Iterable<string
|
|
74
|
+
collectGarbage(workspace: string, additionalReferenced?: Iterable<string>, signal?: AbortSignal): Promise<{
|
|
30
75
|
deletedBlobs: number;
|
|
31
76
|
retainedBlobs: number;
|
|
32
77
|
}>;
|
|
78
|
+
/** Finish a prior bounded legacy capture's orphan cleanup, if one is pending. */
|
|
79
|
+
reconcileSnapshotCleanup(workspace: string, signal?: AbortSignal): Promise<boolean>;
|
|
33
80
|
private workspaceDir;
|
|
81
|
+
private assertManifestOwned;
|
|
82
|
+
private assertGitCheckpointJournalOwned;
|
|
83
|
+
private assertManifestWorkspaceIdentity;
|
|
84
|
+
private assertGitCheckpointJournalWorkspaceIdentity;
|
|
34
85
|
private manifestPath;
|
|
35
86
|
private operationPath;
|
|
87
|
+
private gitCheckpointJournalPath;
|
|
88
|
+
private turnCheckpointSkipPath;
|
|
36
89
|
private blobPath;
|
|
37
|
-
private
|
|
38
|
-
private
|
|
90
|
+
private bindGitWorkspace;
|
|
91
|
+
private reconcileWorkspaceMigrations;
|
|
92
|
+
private completeWorkspaceMigration;
|
|
93
|
+
private assertNoUnresolvedWorkspaceOwnership;
|
|
94
|
+
private ensureWorktreeClaim;
|
|
95
|
+
private completeWorkspaceRebind;
|
|
96
|
+
private rewriteWorkspaceRoot;
|
|
97
|
+
private prepareLockReclaim;
|
|
98
|
+
private completeLockReclaims;
|
|
99
|
+
private acquireLockReclaimOwnership;
|
|
100
|
+
private lockIsReclaimable;
|
|
39
101
|
}
|
|
102
|
+
/** Create or read the durable owner of one storage root's private Git namespace. */
|
|
103
|
+
export declare function ensureStoreId(storageDir: string): Promise<string>;
|
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 {
|
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.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
}
|
|
60
61
|
},
|
|
61
62
|
"scripts": {
|
|
63
|
+
"benchmark:git-checkpoint": "pnpm run build && node scripts/benchmark-git-checkpoint.mjs",
|
|
62
64
|
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.client.json && node scripts/wrap-client.mjs",
|
|
63
65
|
"check": "tsc -p tsconfig.json && pnpm run build && node --test tests/*.test.mjs && pnpm pack --dry-run",
|
|
64
66
|
"prepack": "pnpm run build",
|