@davideasden/pi-undo 0.2.1 → 0.2.3

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,146 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, writeFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+
7
+ import type { DurablePack } from "./durable-pack.ts";
8
+ import type { MutationJournal } from "./mutation-journal.ts";
9
+
10
+ const NATIVE_TIMEOUT_MS = 120_000;
11
+ const NATIVE_OUTPUT_LIMIT = 64 * 1024;
12
+
13
+ export interface NativeFileBatch {
14
+ readonly available: boolean;
15
+ run(pack: DurablePack): Promise<void>;
16
+ verifySource(pack: DurablePack): Promise<boolean>;
17
+ }
18
+
19
+ export async function createNativeFileBatch(options: {
20
+ readonly workspaceRoot: string;
21
+ readonly planDigest: string;
22
+ readonly journal: MutationJournal;
23
+ }): Promise<NativeFileBatch | undefined> {
24
+ if (process.env.PI_UNDO_DISABLE_NATIVE === "1") return undefined;
25
+ const executable = nativeExecutable();
26
+ if (executable === undefined) return undefined;
27
+ try {
28
+ await access(executable, constants.X_OK);
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ const execute = async (pack: DurablePack, requestPath: string, verifyOnly: boolean): Promise<void> => {
33
+ const paths = pack.paths();
34
+ if (paths.length === 0) return;
35
+ if (pack.planDigest !== options.planDigest) throw new Error("native file batch planDigest 不匹配");
36
+ const request = {
37
+ schemaVersion: 1,
38
+ opId: options.journal.operationId,
39
+ packOpId: pack.opId,
40
+ planDigest: pack.planDigest,
41
+ workspaceRoot: options.workspaceRoot,
42
+ packPath: pack.storagePath,
43
+ packChecksum: pack.packChecksum,
44
+ verifyOnly,
45
+ entries: paths.map((path) => {
46
+ const artifacts = pack.artifacts(path);
47
+ const sourceFingerprint = pack.sourceFingerprint(path);
48
+ const targetFingerprint = pack.targetFingerprint(path);
49
+ if (
50
+ artifacts === undefined ||
51
+ artifacts.target === null ||
52
+ sourceFingerprint === undefined ||
53
+ targetFingerprint === undefined ||
54
+ targetFingerprint === null
55
+ ) {
56
+ throw new Error(`native file batch pack entry 无效:${path}`);
57
+ }
58
+ return {
59
+ path,
60
+ sourceArtifact: artifacts.source,
61
+ targetArtifact: artifacts.target,
62
+ sourceFingerprint,
63
+ targetFingerprint,
64
+ };
65
+ }),
66
+ };
67
+ await writeFile(requestPath, JSON.stringify(request), { mode: 0o600 });
68
+ await runNative(executable, requestPath, paths.length);
69
+ };
70
+ return {
71
+ available: true,
72
+ run: (pack) => execute(pack, join(dirname(options.journal.storagePath), "native-request-v1.json"), false),
73
+ verifySource: async (pack) => {
74
+ try {
75
+ await execute(pack, join(dirname(pack.storagePath), `native-verify-${process.pid}.json`), true);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ },
81
+ };
82
+ }
83
+
84
+ function nativeExecutable(): string | undefined {
85
+ const platform = process.platform === "darwin"
86
+ ? "darwin"
87
+ : process.platform === "linux" ? "linux" : undefined;
88
+ const architecture = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : undefined;
89
+ if (platform === undefined || architecture === undefined) return undefined;
90
+ return fileURLToPath(new URL(`../native/bin/pi-undo-fs-${platform}-${architecture}`, import.meta.url));
91
+ }
92
+
93
+ function runNative(executable: string, requestPath: string, expected: number): Promise<void> {
94
+ return new Promise((resolve, reject) => {
95
+ const child = spawn(executable, [requestPath], {
96
+ shell: false,
97
+ stdio: ["ignore", "pipe", "pipe"],
98
+ windowsHide: true,
99
+ });
100
+ const stdout: Buffer[] = [];
101
+ const stderr: Buffer[] = [];
102
+ let outputBytes = 0;
103
+ let settled = false;
104
+ const timeout = setTimeout(() => child.kill("SIGKILL"), NATIVE_TIMEOUT_MS);
105
+ const capture = (target: Buffer[]) => (chunk: Buffer | string): void => {
106
+ if (outputBytes >= NATIVE_OUTPUT_LIMIT) return;
107
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
108
+ const captured = bytes.subarray(0, NATIVE_OUTPUT_LIMIT - outputBytes);
109
+ target.push(captured);
110
+ outputBytes += captured.length;
111
+ };
112
+ child.stdout?.on("data", capture(stdout));
113
+ child.stderr?.on("data", capture(stderr));
114
+ child.once("error", (error) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ clearTimeout(timeout);
118
+ reject(error);
119
+ });
120
+ child.once("close", (code) => {
121
+ if (settled) return;
122
+ settled = true;
123
+ clearTimeout(timeout);
124
+ if (code !== 0) {
125
+ reject(new Error(`native restore 失败:${Buffer.concat(stderr).toString("utf8").trim()}`));
126
+ return;
127
+ }
128
+ try {
129
+ const result: unknown = JSON.parse(Buffer.concat(stdout).toString("utf8"));
130
+ if (
131
+ typeof result !== "object" ||
132
+ result === null ||
133
+ !("ok" in result) ||
134
+ result.ok !== true ||
135
+ !("processed" in result) ||
136
+ result.processed !== expected
137
+ ) {
138
+ throw new Error("native restore 响应无效");
139
+ }
140
+ resolve();
141
+ } catch (error) {
142
+ reject(error);
143
+ }
144
+ });
145
+ });
146
+ }
@@ -0,0 +1,324 @@
1
+ import { link, lstat, rm, unlink } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ import { fsyncDirectory, writeBytesExclusive } from "./atomic-fs.ts";
5
+ import { loadDurablePack, type DurableLeaf, type DurablePack } from "./durable-pack.ts";
6
+ import type { MutationJournal } from "./mutation-journal.ts";
7
+ import type { MutationRecord, MutationState } from "./model.ts";
8
+ import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
9
+ import { fingerprintAbsent, fingerprintFile, fingerprintLeaf } from "./quarantine.ts";
10
+
11
+ const PACKED_RECOVERY_CONCURRENCY = 32;
12
+
13
+ const stateOrder: readonly MutationState[] = [
14
+ "INTENT",
15
+ "SOURCE_QUARANTINED",
16
+ "SOURCE_VERIFIED",
17
+ "TARGET_INSTALLED",
18
+ "TARGET_VERIFIED",
19
+ "CLEANED",
20
+ ];
21
+
22
+ export async function materializePackedMutationJournal(
23
+ journal: MutationJournal,
24
+ pack: DurablePack,
25
+ ): Promise<void> {
26
+ const records = await ensurePackedIntents(journal, pack, await journal.load());
27
+ const terminalIndex = stateOrder.indexOf("TARGET_VERIFIED");
28
+ const advances = records.map((record) => ({
29
+ ordinal: record.ordinal,
30
+ states: stateOrder.slice(stateOrder.indexOf(record.state) + 1, terminalIndex + 1),
31
+ })).filter((advance) => advance.states.length > 0);
32
+ if (advances.length > 0) await journal.advanceBatch(advances);
33
+ }
34
+
35
+ export async function recoverPackedMutations(options: {
36
+ readonly workspaceRoot: string;
37
+ readonly journal: MutationJournal;
38
+ readonly planDigest: string;
39
+ readonly decision: "rollback" | "roll_forward";
40
+ readonly retainArtifacts?: boolean;
41
+ }): Promise<{ readonly kind: "clean" } | { readonly kind: "conflict"; readonly paths: number }> {
42
+ try {
43
+ const workspaceRoot = resolve(options.workspaceRoot);
44
+ const pack = await loadDurablePack(options.journal, options.planDigest, true);
45
+ const records = await ensurePackedIntents(options.journal, pack, await options.journal.load());
46
+ await mapConcurrent(records, PACKED_RECOVERY_CONCURRENCY, async (record) => {
47
+ if (record.kind !== "write" || record.targetArtifact === null) throw new Error("packed recovery 只支持普通文件 write");
48
+ const source = pack.leaf(record.path, record.sourceFingerprint);
49
+ const target = pack.leaf(record.path, record.targetFingerprint);
50
+ if (source === undefined || target === undefined || target.kind !== "file") {
51
+ throw new Error(`packed recovery variant 缺失:${record.path}`);
52
+ }
53
+ await normalizeRecord(
54
+ workspaceRoot,
55
+ record,
56
+ source,
57
+ target,
58
+ options.decision,
59
+ options.retainArtifacts === true,
60
+ );
61
+ });
62
+ const terminalState: MutationState = options.retainArtifacts === true ? "TARGET_VERIFIED" : "CLEANED";
63
+ const terminalIndex = stateOrder.indexOf(terminalState);
64
+ const advances = records.map((record) => ({
65
+ ordinal: record.ordinal,
66
+ states: stateOrder.slice(stateOrder.indexOf(record.state) + 1, terminalIndex + 1),
67
+ })).filter((advance) => advance.states.length > 0);
68
+ if (advances.length > 0) await options.journal.advanceBatch(advances);
69
+ if (options.retainArtifacts !== true) await options.journal.assertCleaned();
70
+ return { kind: "clean" };
71
+ } catch {
72
+ const active = await options.journal.load().catch(() => []);
73
+ return {
74
+ kind: "conflict",
75
+ paths: Math.max(1, active.filter((record) => record.state !== "CLEANED").length),
76
+ };
77
+ }
78
+ }
79
+
80
+ export async function cleanupPackedMutations(options: {
81
+ readonly workspaceRoot: string;
82
+ readonly journal: MutationJournal;
83
+ readonly planDigest: string;
84
+ }): Promise<void> {
85
+ const workspaceRoot = resolve(options.workspaceRoot);
86
+ const pack = await loadDurablePack(options.journal, options.planDigest, true);
87
+ const records = [...await options.journal.load()];
88
+ const cleanupDirectories = new Set<string>();
89
+ await mapConcurrent(records, PACKED_RECOVERY_CONCURRENCY, async (record) => {
90
+ if (
91
+ (record.state !== "TARGET_VERIFIED" && record.state !== "CLEANED") ||
92
+ record.kind !== "write" ||
93
+ record.targetArtifact === null
94
+ ) {
95
+ throw new Error(`packed cleanup mutation 状态无效:${record.path}`);
96
+ }
97
+ relativeSafePath(workspaceRoot, record.path);
98
+ await assertNoSymlinkEscape(workspaceRoot, record.path);
99
+ const original = join(workspaceRoot, ...record.path.split("/"));
100
+ const sourceArtifact = join(workspaceRoot, ...record.sourceArtifact.split("/"));
101
+ const targetArtifact = join(workspaceRoot, ...record.targetArtifact.split("/"));
102
+ const source = pack.leaf(record.path, record.sourceFingerprint);
103
+ if (source === undefined) throw new Error(`packed cleanup source variant 缺失:${record.path}`);
104
+ if (await pathExists(targetArtifact)) {
105
+ await assertSameFileIdentity(original, targetArtifact, record.path);
106
+ } else if (record.state !== "CLEANED") {
107
+ throw new Error(`packed cleanup target ownership 缺失:${record.path}`);
108
+ }
109
+ await cleanupArtifact(sourceArtifact, record.path, record.sourceFingerprint, cleanupDirectories);
110
+ await cleanupArtifact(targetArtifact, record.path, record.targetFingerprint, cleanupDirectories);
111
+ });
112
+ for (const directory of cleanupDirectories) await fsyncDirectory(directory);
113
+ const advances = records
114
+ .filter((record) => record.state === "TARGET_VERIFIED")
115
+ .map((record) => ({ ordinal: record.ordinal, states: ["CLEANED" as const] }));
116
+ if (advances.length > 0) await options.journal.advanceBatch(advances);
117
+ await options.journal.assertCleaned();
118
+ }
119
+
120
+ async function ensurePackedIntents(
121
+ journal: MutationJournal,
122
+ pack: DurablePack,
123
+ loaded: readonly MutationRecord[],
124
+ ): Promise<MutationRecord[]> {
125
+ const paths = pack.paths();
126
+ if (loaded.length > paths.length) throw new Error("packed mutation journal 路径数量不匹配");
127
+ const records = [...loaded];
128
+ for (let index = 0; index < records.length; index += 1) {
129
+ assertPackedRecord(pack, paths[index]!, records[index]!);
130
+ }
131
+ if (records.length < paths.length) {
132
+ records.push(...await journal.beginMany(
133
+ paths.slice(records.length).map((path) => packedIntent(pack, path)),
134
+ ));
135
+ }
136
+ return records;
137
+ }
138
+
139
+ function packedIntent(pack: DurablePack, path: string) {
140
+ const artifacts = pack.artifacts(path);
141
+ const sourceFingerprint = pack.sourceFingerprint(path);
142
+ const targetFingerprint = pack.targetFingerprint(path);
143
+ if (
144
+ artifacts === undefined ||
145
+ artifacts.target === null ||
146
+ sourceFingerprint === undefined ||
147
+ targetFingerprint === undefined ||
148
+ targetFingerprint === null
149
+ ) {
150
+ throw new Error(`packed recovery intent 缺失:${path}`);
151
+ }
152
+ return {
153
+ kind: "write" as const,
154
+ path,
155
+ sourceArtifact: artifacts.source,
156
+ targetArtifact: artifacts.target,
157
+ sourceFingerprint,
158
+ targetFingerprint,
159
+ };
160
+ }
161
+
162
+ function assertPackedRecord(pack: DurablePack, path: string, record: MutationRecord): void {
163
+ const expected = packedIntent(pack, path);
164
+ if (
165
+ record.kind !== expected.kind ||
166
+ record.path !== expected.path ||
167
+ record.sourceArtifact !== expected.sourceArtifact ||
168
+ record.targetArtifact !== expected.targetArtifact ||
169
+ record.sourceFingerprint !== expected.sourceFingerprint ||
170
+ record.targetFingerprint !== expected.targetFingerprint
171
+ ) {
172
+ throw new Error(`packed mutation journal 与 pack 不匹配:${path}`);
173
+ }
174
+ }
175
+
176
+ async function normalizeRecord(
177
+ workspaceRoot: string,
178
+ record: MutationRecord,
179
+ source: DurableLeaf,
180
+ target: DurableLeaf,
181
+ decision: "rollback" | "roll_forward",
182
+ retainArtifacts: boolean,
183
+ ): Promise<void> {
184
+ relativeSafePath(workspaceRoot, record.path);
185
+ await assertNoSymlinkEscape(workspaceRoot, record.path);
186
+ const original = join(workspaceRoot, ...record.path.split("/"));
187
+ const sourceArtifact = join(workspaceRoot, ...record.sourceArtifact.split("/"));
188
+ const targetArtifact = join(workspaceRoot, ...record.targetArtifact!.split("/"));
189
+ const observed = await fingerprintLeaf(original, record.path);
190
+ const absent = fingerprintAbsent(record.path);
191
+ if (observed !== absent && observed !== record.sourceFingerprint && observed !== record.targetFingerprint) {
192
+ throw new Error(`packed recovery original 冲突:${record.path}`);
193
+ }
194
+ await assertArtifact(sourceArtifact, record.path, record.sourceFingerprint, source.kind === "absent");
195
+ await assertArtifact(targetArtifact, record.path, record.targetFingerprint, false);
196
+ if (observed === record.targetFingerprint) {
197
+ if (await pathExists(targetArtifact)) {
198
+ await assertSameFileIdentity(original, targetArtifact, record.path);
199
+ } else if (!(record.state === "CLEANED" && decision === "roll_forward")) {
200
+ throw new Error(`packed recovery target ownership 缺失:${record.path}`);
201
+ }
202
+ }
203
+ const desired = decision === "rollback" ? source : target;
204
+ if (decision === "roll_forward" && observed === record.sourceFingerprint && observed !== absent) {
205
+ throw new Error(`packed recovery source ownership 冲突:${record.path}`);
206
+ }
207
+ if (observed !== desired.fingerprint) {
208
+ if (observed !== absent) {
209
+ await unlink(original);
210
+ await fsyncDirectory(dirname(original));
211
+ }
212
+ await materialize(original, record.path, desired);
213
+ if (retainArtifacts && desired === target) {
214
+ await linkOwnershipMarker(original, targetArtifact, record.path);
215
+ }
216
+ }
217
+ if (!retainArtifacts) {
218
+ await cleanupArtifact(sourceArtifact, record.path, record.sourceFingerprint);
219
+ await cleanupArtifact(targetArtifact, record.path, record.targetFingerprint);
220
+ }
221
+ }
222
+
223
+ async function pathExists(path: string): Promise<boolean> {
224
+ return lstat(path).then(() => true, (error) => {
225
+ if (hasErrorCode(error, "ENOENT")) return false;
226
+ throw error;
227
+ });
228
+ }
229
+
230
+ async function assertArtifact(
231
+ path: string,
232
+ logicalPath: string,
233
+ expectedFingerprint: string,
234
+ mustBeAbsent: boolean,
235
+ ): Promise<void> {
236
+ const exists = await lstat(path).then(() => true, (error) => {
237
+ if (hasErrorCode(error, "ENOENT")) return false;
238
+ throw error;
239
+ });
240
+ if (!exists) return;
241
+ if (mustBeAbsent || await fingerprintFile(path, logicalPath) !== expectedFingerprint) {
242
+ throw new Error(`packed recovery artifact 冲突:${logicalPath}`);
243
+ }
244
+ }
245
+
246
+ async function linkOwnershipMarker(original: string, artifact: string, logicalPath: string): Promise<void> {
247
+ try {
248
+ await link(original, artifact);
249
+ } catch (error) {
250
+ if (!hasErrorCode(error, "EEXIST")) throw error;
251
+ }
252
+ await assertSameFileIdentity(original, artifact, logicalPath);
253
+ await fsyncDirectory(dirname(artifact));
254
+ }
255
+
256
+ async function assertSameFileIdentity(original: string, artifact: string, logicalPath: string): Promise<void> {
257
+ const [originalMetadata, artifactMetadata] = await Promise.all([lstat(original), lstat(artifact)]);
258
+ if (
259
+ !originalMetadata.isFile() ||
260
+ !artifactMetadata.isFile() ||
261
+ originalMetadata.dev !== artifactMetadata.dev ||
262
+ originalMetadata.ino !== artifactMetadata.ino
263
+ ) {
264
+ throw new Error(`packed recovery target ownership 冲突:${logicalPath}`);
265
+ }
266
+ }
267
+
268
+ async function cleanupArtifact(
269
+ path: string,
270
+ logicalPath: string,
271
+ expectedFingerprint: string,
272
+ deferredDirectories?: Set<string>,
273
+ ): Promise<void> {
274
+ try {
275
+ if (await fingerprintFile(path, logicalPath) !== expectedFingerprint) {
276
+ throw new Error(`packed recovery cleanup artifact 冲突:${logicalPath}`);
277
+ }
278
+ await unlink(path);
279
+ if (deferredDirectories === undefined) {
280
+ await fsyncDirectory(dirname(path));
281
+ } else {
282
+ deferredDirectories.add(dirname(path));
283
+ }
284
+ } catch (error) {
285
+ if (!hasErrorCode(error, "ENOENT")) throw error;
286
+ }
287
+ }
288
+
289
+ async function materialize(path: string, logicalPath: string, leaf: DurableLeaf): Promise<void> {
290
+ if (leaf.kind === "absent") return;
291
+ if (leaf.kind !== "file") throw new Error(`packed recovery 暂不支持 symlink:${logicalPath}`);
292
+ await writeBytesExclusive(path, leaf.bytes, leaf.mode);
293
+ if (await fingerprintFile(path, logicalPath) !== leaf.fingerprint) {
294
+ await rm(path, { force: true });
295
+ throw new Error(`packed recovery materialize 校验失败:${logicalPath}`);
296
+ }
297
+ }
298
+
299
+ async function mapConcurrent<T>(
300
+ values: readonly T[],
301
+ concurrency: number,
302
+ worker: (value: T) => Promise<void>,
303
+ ): Promise<void> {
304
+ let next = 0;
305
+ let firstFailure: unknown;
306
+ const runners = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
307
+ while (firstFailure === undefined) {
308
+ const index = next;
309
+ next += 1;
310
+ if (index >= values.length) return;
311
+ try {
312
+ await worker(values[index]!);
313
+ } catch (error) {
314
+ firstFailure = error;
315
+ }
316
+ }
317
+ });
318
+ await Promise.all(runners);
319
+ if (firstFailure !== undefined) throw firstFailure;
320
+ }
321
+
322
+ function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
323
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
324
+ }
package/src/pi-runtime.ts CHANGED
@@ -12,9 +12,15 @@ import {
12
12
  type ControllerDependencies,
13
13
  type ControllerInitialState,
14
14
  } from "./controller.ts";
15
+ import { finalizeDurablePack, hasDurablePack, loadDurablePack } from "./durable-pack.ts";
15
16
  import { assertCursor, canonicalJson, checksum } from "./encoding.ts";
16
17
  import { JournalStore, finalizeCursorMarker, inspectCursorMarkers } from "./journal.ts";
17
18
  import type { CheckpointRecord, ManifestId, SessionFileIdentity } from "./model.ts";
19
+ import {
20
+ cleanupPackedMutations,
21
+ materializePackedMutationJournal,
22
+ recoverPackedMutations,
23
+ } from "./packed-recovery.ts";
18
24
  import { JournalRecovery } from "./recovery.ts";
19
25
  import { QuarantineManager } from "./quarantine.ts";
20
26
  import { RestoreEngine } from "./restore-engine.ts";
@@ -58,9 +64,34 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
58
64
  ),
59
65
  recoverMutations: async (pending, decision) => {
60
66
  const mutationJournal = journal.mutationJournal(pending.descriptor.opId);
67
+ if (await hasDurablePack(mutationJournal)) {
68
+ const result = await recoverPackedMutations({
69
+ workspaceRoot: context.cwd,
70
+ journal: mutationJournal,
71
+ planDigest: pending.descriptor.planDigest,
72
+ decision,
73
+ retainArtifacts: decision === "roll_forward",
74
+ });
75
+ if (result.kind === "clean" && decision === "roll_forward") {
76
+ await finalizeDurablePack(mutationJournal, context.cwd);
77
+ await cleanupPackedMutations({
78
+ workspaceRoot: context.cwd,
79
+ journal: mutationJournal,
80
+ planDigest: pending.descriptor.planDigest,
81
+ });
82
+ }
83
+ return result;
84
+ }
61
85
  const quarantine = new QuarantineManager({ workspaceRoot: context.cwd, journal: mutationJournal });
62
86
  try {
63
- const records = (await mutationJournal.load()).filter((record) => record.state !== "CLEANED");
87
+ const loaded = await mutationJournal.load();
88
+ const scopePaths = pending.descriptor.scopePaths;
89
+ if (loaded.some((record) => !scopePaths.some((scope) =>
90
+ scope === "." || record.path === scope || record.path.startsWith(`${scope}/`)
91
+ ))) {
92
+ throw new Error("mutation journal path 超出 descriptor scope");
93
+ }
94
+ const records = loaded.filter((record) => record.state !== "CLEANED");
64
95
  if (decision === "rollback") records.reverse();
65
96
  for (const record of records) {
66
97
  if (decision === "rollback") {
@@ -88,10 +119,60 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
88
119
  applyRestore: (plan, target, operation) => restore.apply(plan, target, {
89
120
  opId: operation.opId,
90
121
  mutationJournal: journal.mutationJournal(operation.opId),
122
+ forceTargetArtifactSync: true,
91
123
  }),
92
124
  settle: (opId, phase) => journal.settleRecovery(opId, phase),
93
125
  });
94
126
 
127
+ let finalizationQueue: Promise<void> = Promise.resolve();
128
+ let finalizationFailure: unknown;
129
+ const waitForFinalization = async (): Promise<void> => {
130
+ await finalizationQueue;
131
+ if (finalizationFailure !== undefined) throw finalizationFailure;
132
+ };
133
+ const scheduleFinalization = (opId: string): void => {
134
+ finalizationQueue = finalizationQueue.then(async () => {
135
+ const lease = await workspaceLock.acquire(initialTopology.workspaceIdentity);
136
+ try {
137
+ const mutationJournal = journal.mutationJournal(opId);
138
+ const pack = await loadDurablePack(mutationJournal, undefined, true);
139
+ const [materialized, finalized] = await Promise.allSettled([
140
+ materializePackedMutationJournal(mutationJournal, pack),
141
+ finalizeDurablePack(mutationJournal, context.cwd, {
142
+ allowCleanedOwnershipWithoutMarker: false,
143
+ }),
144
+ ]);
145
+ if (materialized.status === "rejected") throw materialized.reason;
146
+ if (finalized.status === "rejected") throw finalized.reason;
147
+ await cleanupPackedMutations({
148
+ workspaceRoot: context.cwd,
149
+ journal: mutationJournal,
150
+ planDigest: pack.planDigest,
151
+ });
152
+ await journal.markCommitted(opId);
153
+ } finally {
154
+ await lease.release();
155
+ }
156
+ }).catch((error: unknown) => {
157
+ finalizationFailure = error;
158
+ });
159
+ };
160
+ const transactionJournal: ControllerDependencies["journal"] = {
161
+ prepare: (descriptor, plan) => journal.prepare(descriptor, plan),
162
+ setPhase: (opId, phase, options) => journal.setPhase(opId, phase, options),
163
+ setPhases: (opId, transitions) => journal.setPhases(opId, transitions),
164
+ markCommitted: async (opId) => {
165
+ const mutationJournal = journal.mutationJournal(opId);
166
+ if (!await hasDurablePack(mutationJournal)) {
167
+ await journal.markCommitted(opId);
168
+ return;
169
+ }
170
+ await journal.assertLogicalCommitReady(opId, true);
171
+ scheduleFinalization(opId);
172
+ },
173
+ loadPending: () => journal.loadPending(),
174
+ };
175
+
95
176
  const dependencies: ControllerDependencies = {
96
177
  workspaceIdentity: initialTopology.workspaceIdentity,
97
178
  sessionIdentity,
@@ -99,7 +180,10 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
99
180
  abortAgent: async () => { context.abort(); },
100
181
  waitForIdle: async (deadlineMs) => waitForIdle(commandContext, deadlineMs),
101
182
  getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
102
- acquireWorkspaceLock: () => workspaceLock.acquire(initialTopology.workspaceIdentity),
183
+ acquireWorkspaceLock: async () => {
184
+ await waitForFinalization();
185
+ return workspaceLock.acquire(initialTopology.workspaceIdentity);
186
+ },
103
187
  findUserEntryAfter: (startEntryId) => findUserEntryAfter(manager, startEntryId),
104
188
  resolveSessionTarget: (action, checkpoint) => action === "undo"
105
189
  ? logicalLeafAt(manager, entryParent(manager, checkpoint.userEntryId))
@@ -129,18 +213,32 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
129
213
  appendControl: async (customType, data) => appendControlEntry(pi, manager, customType, data),
130
214
  appendCursor: async (cursor) => cursorWriter.appendCursor(cursor, pi, sourceFor(manager)),
131
215
  capture,
216
+ captureSafety: async (referenceManifestId, targetManifestId, scopePaths) => {
217
+ const [reference, target] = await Promise.all([
218
+ store.loadManifest(referenceManifestId),
219
+ store.loadManifest(targetManifestId),
220
+ ]);
221
+ if (await restore.canReuseDurableSource(reference, target, scopePaths)) return reference;
222
+ return capture(scopePaths);
223
+ },
132
224
  changedPaths: async (before, after) => {
133
225
  const plan = await restore.plan(before, after);
134
226
  return [...new Set([...plan.deletePaths, ...plan.writePaths])].sort();
135
227
  },
136
228
  loadManifest: (manifestId) => store.loadManifest(manifestId),
137
229
  planRestore: (current, target, scopePaths) => restore.plan(current, target, scopePaths),
230
+ prepareDurableRestore: (current, target, scopePaths) =>
231
+ restore.prepareDurableRestore(current, target, scopePaths),
138
232
  applyRestore: (plan, target, operation) => restore.apply(plan, target, {
139
233
  opId: operation.opId,
140
234
  mutationJournal: journal.mutationJournal(operation.opId),
235
+ deferDurability: true,
141
236
  }),
142
- recoverPending: () => workspaceLock.withLock(initialTopology.workspaceIdentity, () => recovery.recover()),
143
- journal,
237
+ recoverPending: async () => {
238
+ await waitForFinalization();
239
+ return workspaceLock.withLock(initialTopology.workspaceIdentity, () => recovery.recover());
240
+ },
241
+ journal: transactionJournal,
144
242
  clock: Date.now,
145
243
  };
146
244
  const startupRecovery = await workspaceLock.withLock(