@anionex/dsh-turn-rewind 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +182 -0
  3. package/README.zh.md +180 -0
  4. package/SECURITY.md +53 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/FORMAT.md +101 -0
  7. package/docs/IMPLEMENTATION_PLAN.md +56 -0
  8. package/docs/assets/turn-rewind-action.png +0 -0
  9. package/docs/assets/turn-rewind-dialog.png +0 -0
  10. package/lib/client.js +473 -0
  11. package/lib/client.js.map +1 -0
  12. package/lib/engine.js +616 -0
  13. package/lib/errors.js +20 -0
  14. package/lib/git.js +155 -0
  15. package/lib/index.js +71 -0
  16. package/lib/path-utils.js +262 -0
  17. package/lib/rewind-host.js +421 -0
  18. package/lib/snapshot.js +183 -0
  19. package/lib/store.js +391 -0
  20. package/lib/types/client/index.d.ts +81 -0
  21. package/lib/types/engine.d.ts +90 -0
  22. package/lib/types/errors.d.ts +15 -0
  23. package/lib/types/git.d.ts +14 -0
  24. package/lib/types/index.d.ts +43 -0
  25. package/lib/types/path-utils.d.ts +34 -0
  26. package/lib/types/rewind-host.d.ts +137 -0
  27. package/lib/types/snapshot.d.ts +35 -0
  28. package/lib/types/store.d.ts +39 -0
  29. package/lib/types/types.d.ts +177 -0
  30. package/lib/types/validate.d.ts +11 -0
  31. package/lib/types.js +2 -0
  32. package/lib/validate.js +242 -0
  33. package/package.json +91 -0
  34. package/src/client/index.tsx +648 -0
  35. package/src/client/platform.d.ts +27 -0
  36. package/src/engine.ts +770 -0
  37. package/src/errors.ts +23 -0
  38. package/src/git.ts +178 -0
  39. package/src/index.ts +100 -0
  40. package/src/path-utils.ts +255 -0
  41. package/src/rewind-host.ts +584 -0
  42. package/src/snapshot.ts +229 -0
  43. package/src/store.ts +383 -0
  44. package/src/types.ts +195 -0
  45. package/src/validate.ts +245 -0
@@ -0,0 +1,137 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { ChangeLedgerEngine } from './engine.js';
3
+ interface SessionEventLike {
4
+ readonly type: string;
5
+ readonly seq: number;
6
+ readonly data: Record<string, unknown>;
7
+ }
8
+ interface SessionHeaderLike {
9
+ readonly cwd?: string;
10
+ readonly parentSession?: string;
11
+ readonly seedLength?: number;
12
+ }
13
+ interface SessionLike {
14
+ readonly id: string;
15
+ readonly header: SessionHeaderLike;
16
+ readonly events: readonly SessionEventLike[];
17
+ }
18
+ interface AgentLike {
19
+ readonly id: string;
20
+ readonly status: 'idle' | 'running';
21
+ readonly session: SessionLike;
22
+ }
23
+ interface AgentsLike {
24
+ list(): AgentLike[];
25
+ }
26
+ interface SessionsLike {
27
+ get(id: string): SessionLike | undefined;
28
+ }
29
+ interface SessionQueryLike {
30
+ readSession(id: string): Promise<{
31
+ readonly session: SessionHeaderLike;
32
+ readonly events: readonly SessionEventLike[];
33
+ }>;
34
+ }
35
+ interface HttpRequestLike {
36
+ method?: string;
37
+ url?: string;
38
+ on(event: 'data', listener: (chunk: Uint8Array | string) => void): this;
39
+ on(event: 'end', listener: () => void): this;
40
+ on(event: 'error', listener: (error: unknown) => void): this;
41
+ }
42
+ interface HttpResponseLike {
43
+ writeHead(status: number, headers?: Record<string, string>): unknown;
44
+ end(body?: string): void;
45
+ }
46
+ interface HttpServerLike {
47
+ register(route: {
48
+ kind: 'exact';
49
+ path: string;
50
+ handler: (request: HttpRequestLike, response: HttpResponseLike) => void | Promise<void>;
51
+ }): () => void;
52
+ }
53
+ interface ApiProxyLike {
54
+ readonly sessions: {
55
+ create(request: {
56
+ readonly rpcId: string;
57
+ readonly payload: {
58
+ readonly cwd: string;
59
+ };
60
+ }): Promise<{
61
+ readonly result: {
62
+ readonly ok: true;
63
+ readonly value: {
64
+ readonly sessionId: string;
65
+ };
66
+ } | {
67
+ readonly ok: false;
68
+ readonly error: {
69
+ readonly message: string;
70
+ };
71
+ };
72
+ }>;
73
+ fork(request: {
74
+ readonly rpcId: string;
75
+ readonly payload: {
76
+ readonly sessionId: string;
77
+ readonly atSeq: number;
78
+ };
79
+ }): Promise<{
80
+ readonly result: {
81
+ readonly ok: true;
82
+ readonly value: {
83
+ readonly sessionId: string;
84
+ };
85
+ } | {
86
+ readonly ok: false;
87
+ readonly error: {
88
+ readonly message: string;
89
+ };
90
+ };
91
+ }>;
92
+ };
93
+ }
94
+ declare module '@deepseek-ai/cordis' {
95
+ interface Context {
96
+ agents: AgentsLike;
97
+ sessions: SessionsLike;
98
+ sessionQuery: SessionQueryLike;
99
+ webServer: HttpServerLike;
100
+ apiProxy: ApiProxyLike;
101
+ }
102
+ interface Events {
103
+ 'agent/pre-step'(payload: {
104
+ readonly agent: AgentLike;
105
+ readonly turn: number;
106
+ readonly step: number;
107
+ readonly signal: AbortSignal;
108
+ }, next: () => Promise<unknown>): Promise<unknown>;
109
+ }
110
+ }
111
+ export declare const REWIND_HTTP_PATH = "/turn-rewind";
112
+ /** Capture each turn before its opening user message can trigger model or tool work. */
113
+ export declare class TurnCheckpointCoordinator {
114
+ private readonly engine;
115
+ private readonly captures;
116
+ private readonly pending;
117
+ private readonly failures;
118
+ private readonly workspaceTails;
119
+ constructor(engine: ChangeLedgerEngine);
120
+ /** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
121
+ install(ctx: Context): void;
122
+ /** Current capture state for a session turn when no durable checkpoint exists yet. */
123
+ state(sessionId: string, turn: number): {
124
+ readonly status: 'pending' | 'failed' | 'missing';
125
+ readonly error?: string;
126
+ };
127
+ private capture;
128
+ private serializeWorkspace;
129
+ private recordFailure;
130
+ }
131
+ /** Register the same-origin preview/apply endpoint consumed by the browser half. */
132
+ export declare function installRewindHttp(ctx: Context, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): void;
133
+ /** Build the exact-route handler as a testable unit. */
134
+ export declare function createRewindHttpHandler(ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & {
135
+ readonly agents?: AgentsLike;
136
+ }, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
137
+ export {};
@@ -0,0 +1,35 @@
1
+ import { type RepositorySnapshotSource } from './git.js';
2
+ import type { LedgerStore } from './store.js';
3
+ import type { ResolvedChangeLedgerConfig, SnapshotEntry, WorkspaceChange } from './types.js';
4
+ /** One captured tree, optionally persisted into the blob store. */
5
+ export interface CapturedTree {
6
+ readonly source: RepositorySnapshotSource;
7
+ readonly entries: Readonly<Record<string, SnapshotEntry>>;
8
+ readonly treeHash: string;
9
+ readonly fileCount: number;
10
+ readonly totalBytes: number;
11
+ }
12
+ /** Capture the current tracked and non-ignored Git working tree. */
13
+ export declare function captureTree(options: {
14
+ readonly cwd: string;
15
+ readonly config: ResolvedChangeLedgerConfig;
16
+ readonly store?: LedgerStore;
17
+ readonly signal?: AbortSignal;
18
+ }): Promise<CapturedTree>;
19
+ /**
20
+ * Capture the complete tree twice and accept it only when both path/content and
21
+ * repository fences agree. This prevents a point from silently mixing files
22
+ * observed at incompatible moments while another process is editing the tree.
23
+ */
24
+ export declare function captureStableTree(options: {
25
+ readonly cwd: string;
26
+ readonly config: ResolvedChangeLedgerConfig;
27
+ readonly store?: LedgerStore;
28
+ readonly signal?: AbortSignal;
29
+ }): Promise<CapturedTree>;
30
+ /** 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[];
32
+ /** Return whether two snapshot entries are byte/type/mode equivalent. */
33
+ export declare function entriesEqual(left: SnapshotEntry | undefined, right: SnapshotEntry | undefined): boolean;
34
+ /** Hash a complete path map into a deterministic tree identity. */
35
+ export declare function hashTree(entries: Readonly<Record<string, SnapshotEntry>>): string;
@@ -0,0 +1,39 @@
1
+ import type { ResolvedChangeLedgerConfig, RestoreOperation, RestorePointManifest } from './types.js';
2
+ /** Durable content-addressed storage and per-workspace locking. */
3
+ export declare class LedgerStore {
4
+ readonly config: ResolvedChangeLedgerConfig;
5
+ constructor(config: ResolvedChangeLedgerConfig);
6
+ /** Create the state root and reconcile crash-interrupted operations. */
7
+ initialize(): Promise<number>;
8
+ /** Acquire the exclusive lock for one canonical workspace. */
9
+ acquire(workspace: string): Promise<() => Promise<void>>;
10
+ /** Persist a blob if it is not already present, and verify existing content. */
11
+ putBlob(workspace: string, hash: string, content: Buffer): Promise<void>;
12
+ /** Read and verify one content-addressed blob. */
13
+ readBlob(workspace: string, hash: string): Promise<Buffer>;
14
+ /** Write one restore-point manifest atomically. */
15
+ writeManifest(manifest: RestorePointManifest): Promise<void>;
16
+ /** Load and validate one restore-point manifest. */
17
+ readManifest(workspace: string, id: string): Promise<RestorePointManifest>;
18
+ /** List all validated restore points for one workspace, newest first. */
19
+ listManifests(workspace: string): Promise<RestorePointManifest[]>;
20
+ /** Delete one restore-point manifest. Blobs remain until garbage collection succeeds. */
21
+ deleteManifest(workspace: string, id: string): Promise<void>;
22
+ /** Persist one restore-operation journal. */
23
+ writeOperation(operation: RestoreOperation): Promise<void>;
24
+ /** List validated restore operations for one workspace. */
25
+ listOperations(workspace: string): Promise<RestoreOperation[]>;
26
+ /** Return whether an incomplete operation still references a restore point. */
27
+ isReferencedByRecovery(workspace: string, restorePointId: string): Promise<boolean>;
28
+ /** Delete blobs not referenced by any remaining manifest. */
29
+ collectGarbage(workspace: string, additionalReferenced?: Iterable<string>): Promise<{
30
+ deletedBlobs: number;
31
+ retainedBlobs: number;
32
+ }>;
33
+ private workspaceDir;
34
+ private manifestPath;
35
+ private operationPath;
36
+ private blobPath;
37
+ private reclaimStaleLock;
38
+ private workspaceAppearsActive;
39
+ }
@@ -0,0 +1,177 @@
1
+ /** Durable format version. Readers reject every other version. */
2
+ export declare const LEDGER_FORMAT_VERSION: 1;
3
+ /** A persisted restore-point identifier. */
4
+ export type RestorePointId = string;
5
+ /** A persisted restore-operation identifier. */
6
+ export type RestoreOperationId = string;
7
+ /** An in-memory, expiring restore-plan identifier. */
8
+ export type RestorePlanId = string;
9
+ /** One regular-file snapshot. */
10
+ export interface FileSnapshotEntry {
11
+ readonly kind: 'file';
12
+ readonly blob: string;
13
+ readonly size: number;
14
+ readonly mode: number;
15
+ }
16
+ /** One symbolic-link snapshot. */
17
+ export interface SymlinkSnapshotEntry {
18
+ readonly kind: 'symlink';
19
+ readonly target: string;
20
+ readonly mode: number;
21
+ }
22
+ /** One path captured by a restore point. */
23
+ export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry;
24
+ /** Git facts that fence a restore point to the repository state it observed. */
25
+ export interface RepositoryState {
26
+ readonly root: string;
27
+ readonly commonDir: string;
28
+ readonly head?: string;
29
+ readonly branch?: string;
30
+ readonly operation?: string;
31
+ readonly stagedPaths: readonly string[];
32
+ }
33
+ /** Why a restore point exists. */
34
+ export type RestorePointKind = 'user' | 'rescue' | 'turn';
35
+ /** Durable workspace restore point. */
36
+ export interface RestorePointManifest {
37
+ readonly version: typeof LEDGER_FORMAT_VERSION;
38
+ readonly id: RestorePointId;
39
+ readonly kind: RestorePointKind;
40
+ readonly workspace: string;
41
+ readonly repository: RepositoryState;
42
+ readonly sessionId?: string;
43
+ readonly label?: string;
44
+ readonly parentRestorePoint?: RestorePointId;
45
+ /** DSH turn whose opening user request owns this automatic Web rewind checkpoint. */
46
+ readonly turn?: number;
47
+ /** Inclusive `turn/start` event sequence; the snapshot was captured before the turn entered its first step. */
48
+ readonly turnStartSeq?: number;
49
+ /** Legacy inclusive `turn/end` event sequence used by checkpoints created before prompt-anchored rewind. */
50
+ readonly turnEndSeq?: number;
51
+ readonly createdAt: number;
52
+ readonly treeHash: string;
53
+ readonly fileCount: number;
54
+ readonly totalBytes: number;
55
+ readonly entries: Readonly<Record<string, SnapshotEntry>>;
56
+ readonly restoreCount: number;
57
+ readonly lastRestoredAt?: number;
58
+ }
59
+ /** Observable difference between a restore point and the current workspace. */
60
+ export interface WorkspaceChange {
61
+ readonly path: string;
62
+ readonly kind: 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed';
63
+ readonly before?: SnapshotEntry;
64
+ readonly after?: SnapshotEntry;
65
+ }
66
+ /** Result of comparing one restore point with its workspace. */
67
+ export interface RestorePointInspection {
68
+ readonly restorePoint: RestorePointSummary;
69
+ readonly currentTreeHash: string;
70
+ readonly currentRepository: RepositoryState;
71
+ readonly currentHead?: string;
72
+ readonly currentBranch?: string;
73
+ readonly currentOperation?: string;
74
+ readonly headChanged: boolean;
75
+ readonly operationChanged: boolean;
76
+ readonly changes: readonly WorkspaceChange[];
77
+ }
78
+ /** Compact restore-point metadata for lists and tool results. */
79
+ export interface RestorePointSummary {
80
+ readonly id: RestorePointId;
81
+ readonly kind: RestorePointKind;
82
+ readonly workspace: string;
83
+ readonly sessionId?: string;
84
+ readonly label?: string;
85
+ readonly parentRestorePoint?: RestorePointId;
86
+ readonly turn?: number;
87
+ readonly turnStartSeq?: number;
88
+ readonly turnEndSeq?: number;
89
+ readonly createdAt: number;
90
+ readonly treeHash: string;
91
+ readonly fileCount: number;
92
+ readonly totalBytes: number;
93
+ readonly restoreCount: number;
94
+ readonly lastRestoredAt?: number;
95
+ readonly head?: string;
96
+ readonly branch?: string;
97
+ readonly operation?: string;
98
+ readonly stagedPathCount: number;
99
+ }
100
+ /** An expiring restore plan whose confirmation must be echoed exactly. */
101
+ export interface RestorePlan {
102
+ readonly id: RestorePlanId;
103
+ readonly restorePointId: RestorePointId;
104
+ readonly workspace: string;
105
+ readonly repository: RepositoryState;
106
+ readonly sessionId?: string;
107
+ readonly createdAt: number;
108
+ readonly expiresAt: number;
109
+ readonly confirmation: string;
110
+ readonly allowHeadChange: boolean;
111
+ readonly paths: readonly string[];
112
+ readonly changes: readonly WorkspaceChange[];
113
+ readonly expected: Readonly<Record<string, SnapshotEntry | null>>;
114
+ }
115
+ /** Durable journal for one attempted restore. */
116
+ export interface RestoreOperation {
117
+ readonly version: typeof LEDGER_FORMAT_VERSION;
118
+ readonly id: RestoreOperationId;
119
+ readonly workspace: string;
120
+ readonly restorePointId: RestorePointId;
121
+ readonly rescuePointId: RestorePointId;
122
+ readonly sessionId?: string;
123
+ readonly paths: readonly string[];
124
+ readonly startedAt: number;
125
+ readonly finishedAt?: number;
126
+ readonly state: 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'interrupted' | 'recovery-required';
127
+ readonly error?: string;
128
+ readonly rollbackError?: string;
129
+ }
130
+ /** A completed restore result. */
131
+ export interface RestoreResult {
132
+ readonly operationId: RestoreOperationId;
133
+ readonly restorePointId: RestorePointId;
134
+ readonly rescuePointId: RestorePointId;
135
+ readonly restoredPaths: readonly string[];
136
+ }
137
+ /** Public plugin configuration. */
138
+ export interface ChangeLedgerConfig {
139
+ /** State directory. Must live outside every managed workspace. */
140
+ readonly storageDir?: string;
141
+ /** Maximum user and rescue restore points retained per workspace. */
142
+ readonly maxRestorePoints?: number;
143
+ /** Maximum automatic turn checkpoints retained per session. */
144
+ readonly maxTurnCheckpointsPerSession?: number;
145
+ /** Maximum number of files in one restore point. */
146
+ readonly maxFiles?: number;
147
+ /** Maximum bytes read from one regular file. */
148
+ readonly maxFileBytes?: number;
149
+ /** Maximum aggregate regular-file bytes in one restore point. */
150
+ readonly maxSnapshotBytes?: number;
151
+ /** Restore-plan lifetime in milliseconds. */
152
+ readonly planTtlMs?: number;
153
+ /** Age after which a lock whose owner is gone may be reclaimed. */
154
+ readonly staleLockMs?: number;
155
+ }
156
+ /** Fully resolved plugin configuration. */
157
+ export interface ResolvedChangeLedgerConfig {
158
+ readonly storageDir: string;
159
+ readonly maxRestorePoints: number;
160
+ readonly maxTurnCheckpointsPerSession: number;
161
+ readonly maxFiles: number;
162
+ readonly maxFileBytes: number;
163
+ readonly maxSnapshotBytes: number;
164
+ readonly planTtlMs: number;
165
+ readonly staleLockMs: number;
166
+ }
167
+ /** Current incomplete restore operation. */
168
+ export interface RecoverySummary {
169
+ readonly operationId: RestoreOperationId;
170
+ readonly restorePointId: RestorePointId;
171
+ readonly rescuePointId: RestorePointId;
172
+ readonly state: 'interrupted' | 'recovery-required';
173
+ readonly paths: readonly string[];
174
+ readonly startedAt: number;
175
+ readonly error?: string;
176
+ readonly rollbackError?: string;
177
+ }
@@ -0,0 +1,11 @@
1
+ import { type RestoreOperation, type RestorePointManifest } from './types.js';
2
+ /** Validate a restore-point id before using it in a filesystem path. */
3
+ export declare function validateRestorePointId(value: string): string;
4
+ /** Validate a restore-operation id before using it in a filesystem path. */
5
+ export declare function validateOperationId(value: string): string;
6
+ /** Parse an untrusted durable restore-point manifest. */
7
+ export declare function parseManifest(value: unknown): RestorePointManifest;
8
+ /** Parse an untrusted durable restore-operation journal. */
9
+ export declare function parseOperation(value: unknown): RestoreOperation;
10
+ /** Validate one SHA-256 blob name. */
11
+ export declare function validateBlobHash(value: string): string;
package/lib/types.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Durable format version. Readers reject every other version. */
2
+ export const LEDGER_FORMAT_VERSION = 1;
@@ -0,0 +1,242 @@
1
+ import { ChangeLedgerError } from './errors.js';
2
+ import { isAbsolute } from 'node:path';
3
+ import { validateRelativePath } from './path-utils.js';
4
+ import { hashTree } from './snapshot.js';
5
+ import { LEDGER_FORMAT_VERSION, } from './types.js';
6
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
7
+ const GIT_OBJECT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
8
+ const ID_PATTERN = /^(?:rp|op)_[0-9a-z]+_[0-9a-f]{12}$/;
9
+ /** Validate a restore-point id before using it in a filesystem path. */
10
+ export function validateRestorePointId(value) {
11
+ if (!ID_PATTERN.test(value) || !value.startsWith('rp_')) {
12
+ throw new ChangeLedgerError('INVALID_RESTORE_POINT_ID', `invalid restore-point id ${JSON.stringify(value)}`);
13
+ }
14
+ return value;
15
+ }
16
+ /** Validate a restore-operation id before using it in a filesystem path. */
17
+ export function validateOperationId(value) {
18
+ if (!ID_PATTERN.test(value) || !value.startsWith('op_')) {
19
+ throw new ChangeLedgerError('INVALID_OPERATION_ID', `invalid restore-operation id ${JSON.stringify(value)}`);
20
+ }
21
+ return value;
22
+ }
23
+ /** Parse an untrusted durable restore-point manifest. */
24
+ export function parseManifest(value) {
25
+ const record = objectRecord(value, 'restore-point manifest');
26
+ if (record.version !== LEDGER_FORMAT_VERSION)
27
+ corrupt(`unsupported restore-point version ${String(record.version)}`);
28
+ const id = stringField(record, 'id');
29
+ validateRestorePointId(id);
30
+ const kind = record.kind;
31
+ if (kind !== 'user' && kind !== 'rescue' && kind !== 'turn') {
32
+ corrupt('restore-point kind must be "user", "rescue", or "turn"');
33
+ }
34
+ const workspace = absoluteString(record, 'workspace');
35
+ const repository = parseRepository(record.repository);
36
+ if (repository.root !== workspace)
37
+ corrupt('repository.root must equal workspace');
38
+ const entriesRecord = objectRecord(record.entries, 'restore-point entries');
39
+ const entries = Object.create(null);
40
+ let totalBytes = 0;
41
+ for (const [path, entryValue] of Object.entries(entriesRecord)) {
42
+ validateRelativePath(path);
43
+ const entry = parseEntry(entryValue, path);
44
+ entries[path] = entry;
45
+ if (entry.kind === 'file')
46
+ totalBytes += entry.size;
47
+ }
48
+ const fileCount = nonNegativeInteger(record, 'fileCount');
49
+ if (fileCount !== Object.keys(entries).length)
50
+ corrupt('restore-point fileCount does not match entries');
51
+ const storedTotal = nonNegativeInteger(record, 'totalBytes');
52
+ if (storedTotal !== totalBytes)
53
+ corrupt('restore-point totalBytes does not match entries');
54
+ const treeHash = hashField(record, 'treeHash');
55
+ if (treeHash !== hashTree(entries))
56
+ corrupt('restore-point treeHash does not match entries');
57
+ const createdAt = nonNegativeInteger(record, 'createdAt');
58
+ const restoreCount = nonNegativeInteger(record, 'restoreCount');
59
+ const sessionId = optionalString(record, 'sessionId');
60
+ const label = optionalString(record, 'label');
61
+ const parentRestorePoint = optionalString(record, 'parentRestorePoint');
62
+ if (parentRestorePoint !== undefined)
63
+ validateRestorePointId(parentRestorePoint);
64
+ const turn = optionalNonNegativeInteger(record, 'turn');
65
+ const turnStartSeq = optionalNonNegativeInteger(record, 'turnStartSeq');
66
+ const turnEndSeq = optionalNonNegativeInteger(record, 'turnEndSeq');
67
+ if (kind === 'turn') {
68
+ if (sessionId === undefined || turn === undefined || (turnStartSeq === undefined) === (turnEndSeq === undefined)) {
69
+ corrupt('turn restore points require sessionId, turn, and exactly one turn boundary');
70
+ }
71
+ }
72
+ else if (turn !== undefined || turnStartSeq !== undefined || turnEndSeq !== undefined) {
73
+ corrupt('only turn restore points may carry turn metadata');
74
+ }
75
+ const lastRestoredAt = optionalNonNegativeInteger(record, 'lastRestoredAt');
76
+ return {
77
+ version: LEDGER_FORMAT_VERSION,
78
+ id,
79
+ kind,
80
+ workspace,
81
+ repository,
82
+ ...(sessionId === undefined ? {} : { sessionId }),
83
+ ...(label === undefined ? {} : { label }),
84
+ ...(parentRestorePoint === undefined ? {} : { parentRestorePoint }),
85
+ ...(turn === undefined ? {} : { turn }),
86
+ ...(turnStartSeq === undefined ? {} : { turnStartSeq }),
87
+ ...(turnEndSeq === undefined ? {} : { turnEndSeq }),
88
+ createdAt,
89
+ treeHash,
90
+ fileCount,
91
+ totalBytes,
92
+ entries,
93
+ restoreCount,
94
+ ...(lastRestoredAt === undefined ? {} : { lastRestoredAt }),
95
+ };
96
+ }
97
+ /** Parse an untrusted durable restore-operation journal. */
98
+ export function parseOperation(value) {
99
+ const record = objectRecord(value, 'restore operation');
100
+ if (record.version !== LEDGER_FORMAT_VERSION)
101
+ corrupt(`unsupported restore-operation version ${String(record.version)}`);
102
+ const id = stringField(record, 'id');
103
+ validateOperationId(id);
104
+ const restorePointId = stringField(record, 'restorePointId');
105
+ const rescuePointId = stringField(record, 'rescuePointId');
106
+ validateRestorePointId(restorePointId);
107
+ validateRestorePointId(rescuePointId);
108
+ const workspace = absoluteString(record, 'workspace');
109
+ const pathsValue = record.paths;
110
+ if (!Array.isArray(pathsValue))
111
+ corrupt('restore-operation paths must be an array');
112
+ const paths = pathsValue.map((path) => validateRelativePath(requireString(path, 'restore-operation path')));
113
+ if (new Set(paths).size !== paths.length)
114
+ corrupt('restore-operation paths contain duplicates');
115
+ const state = record.state;
116
+ if (state !== 'running'
117
+ && state !== 'rollback-running'
118
+ && state !== 'completed'
119
+ && state !== 'rolled-back'
120
+ && state !== 'interrupted'
121
+ && state !== 'recovery-required') {
122
+ corrupt(`invalid restore-operation state ${JSON.stringify(state)}`);
123
+ }
124
+ const sessionId = optionalString(record, 'sessionId');
125
+ const startedAt = nonNegativeInteger(record, 'startedAt');
126
+ const finishedAt = optionalNonNegativeInteger(record, 'finishedAt');
127
+ const error = optionalString(record, 'error');
128
+ const rollbackError = optionalString(record, 'rollbackError');
129
+ return {
130
+ version: LEDGER_FORMAT_VERSION,
131
+ id,
132
+ workspace,
133
+ restorePointId,
134
+ rescuePointId,
135
+ ...(sessionId === undefined ? {} : { sessionId }),
136
+ paths,
137
+ startedAt,
138
+ ...(finishedAt === undefined ? {} : { finishedAt }),
139
+ state,
140
+ ...(error === undefined ? {} : { error }),
141
+ ...(rollbackError === undefined ? {} : { rollbackError }),
142
+ };
143
+ }
144
+ /** Validate one SHA-256 blob name. */
145
+ export function validateBlobHash(value) {
146
+ if (!HASH_PATTERN.test(value)) {
147
+ throw new ChangeLedgerError('STATE_CORRUPT', `invalid blob hash ${JSON.stringify(value)}`);
148
+ }
149
+ return value;
150
+ }
151
+ function parseRepository(value) {
152
+ const record = objectRecord(value, 'repository state');
153
+ const root = absoluteString(record, 'root');
154
+ const commonDir = absoluteString(record, 'commonDir');
155
+ const head = optionalHash(record, 'head');
156
+ const branch = optionalString(record, 'branch');
157
+ const operation = optionalString(record, 'operation');
158
+ const stagedValue = record.stagedPaths;
159
+ if (!Array.isArray(stagedValue))
160
+ corrupt('repository stagedPaths must be an array');
161
+ const stagedPaths = stagedValue.map((path) => validateRelativePath(requireString(path, 'staged path')));
162
+ return {
163
+ root,
164
+ commonDir,
165
+ ...(head === undefined ? {} : { head }),
166
+ ...(branch === undefined ? {} : { branch }),
167
+ ...(operation === undefined ? {} : { operation }),
168
+ stagedPaths,
169
+ };
170
+ }
171
+ function parseEntry(value, path) {
172
+ const record = objectRecord(value, `snapshot entry ${JSON.stringify(path)}`);
173
+ const kind = record.kind;
174
+ const mode = nonNegativeInteger(record, 'mode');
175
+ if (mode > 0o777)
176
+ corrupt(`snapshot mode is out of range for ${JSON.stringify(path)}`);
177
+ if (kind === 'file') {
178
+ return {
179
+ kind,
180
+ blob: validateBlobHash(stringField(record, 'blob')),
181
+ size: nonNegativeInteger(record, 'size'),
182
+ mode,
183
+ };
184
+ }
185
+ if (kind === 'symlink') {
186
+ const target = stringField(record, 'target');
187
+ if (target.includes('\0'))
188
+ corrupt(`snapshot symlink target contains a NUL byte for ${JSON.stringify(path)}`);
189
+ return { kind, target, mode };
190
+ }
191
+ corrupt(`invalid snapshot entry kind for ${JSON.stringify(path)}`);
192
+ }
193
+ function objectRecord(value, label) {
194
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
195
+ corrupt(`${label} must be an object`);
196
+ const prototype = Object.getPrototypeOf(value);
197
+ if (prototype !== Object.prototype && prototype !== null)
198
+ corrupt(`${label} must be a plain object`);
199
+ return value;
200
+ }
201
+ function stringField(record, key) {
202
+ return requireString(record[key], key);
203
+ }
204
+ function requireString(value, label) {
205
+ if (typeof value !== 'string' || value === '')
206
+ corrupt(`${label} must be a non-empty string`);
207
+ return value;
208
+ }
209
+ function optionalString(record, key) {
210
+ const value = record[key];
211
+ return value === undefined ? undefined : requireString(value, key);
212
+ }
213
+ function absoluteString(record, key) {
214
+ const value = stringField(record, key);
215
+ if (!isAbsolute(value))
216
+ corrupt(`${key} must be an absolute path`);
217
+ return value;
218
+ }
219
+ function hashField(record, key) {
220
+ return validateBlobHash(stringField(record, key));
221
+ }
222
+ function optionalHash(record, key) {
223
+ const value = record[key];
224
+ if (value === undefined)
225
+ return undefined;
226
+ const hash = requireString(value, key);
227
+ if (!GIT_OBJECT_PATTERN.test(hash))
228
+ corrupt(`${key} must be a Git object id`);
229
+ return hash;
230
+ }
231
+ function nonNegativeInteger(record, key) {
232
+ const value = record[key];
233
+ if (!Number.isSafeInteger(value) || value < 0)
234
+ corrupt(`${key} must be a non-negative safe integer`);
235
+ return value;
236
+ }
237
+ function optionalNonNegativeInteger(record, key) {
238
+ return record[key] === undefined ? undefined : nonNegativeInteger(record, key);
239
+ }
240
+ function corrupt(message) {
241
+ throw new ChangeLedgerError('STATE_CORRUPT', message);
242
+ }