@davideasden/pi-undo 0.2.2 → 0.2.4

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
+ }
@@ -64,6 +64,23 @@ export async function assertNoSymlinkEscape(root: string, relativePath: string):
64
64
  }
65
65
  }
66
66
 
67
+ export function pathSetsOverlap(leftPaths: readonly string[], rightPaths: readonly string[]): boolean {
68
+ for (const path of leftPaths) assertRelativeCandidate(path);
69
+ for (const path of rightPaths) assertRelativeCandidate(path);
70
+ const rightSet = new Set(rightPaths);
71
+ const sortedRight = [...rightSet].sort(comparePaths);
72
+ for (const path of leftPaths) {
73
+ if (rightSet.has(path)) return true;
74
+ for (let separator = path.lastIndexOf("/"); separator >= 0; separator = path.lastIndexOf("/", separator - 1)) {
75
+ if (rightSet.has(path.slice(0, separator))) return true;
76
+ }
77
+ const descendantPrefix = `${path}/`;
78
+ const candidate = sortedRight[lowerBound(sortedRight, descendantPrefix)];
79
+ if (candidate?.startsWith(descendantPrefix)) return true;
80
+ }
81
+ return false;
82
+ }
83
+
67
84
  export function sortDeletePaths(paths: readonly string[]): string[] {
68
85
  return sortPaths(paths, -1);
69
86
  }
@@ -108,6 +125,20 @@ function pathDepth(path: string): number {
108
125
  return path === "." ? 0 : path.split("/").length;
109
126
  }
110
127
 
128
+ function lowerBound(paths: readonly string[], target: string): number {
129
+ let low = 0;
130
+ let high = paths.length;
131
+ while (low < high) {
132
+ const middle = low + Math.floor((high - low) / 2);
133
+ if (comparePaths(paths[middle]!, target) < 0) {
134
+ low = middle + 1;
135
+ } else {
136
+ high = middle;
137
+ }
138
+ }
139
+ return low;
140
+ }
141
+
111
142
  function comparePaths(left: string, right: string): number {
112
143
  return left < right ? -1 : left > right ? 1 : 0;
113
144
  }
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,8 +64,26 @@ 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
- try {
86
+ try {
63
87
  const loaded = await mutationJournal.load();
64
88
  const scopePaths = pending.descriptor.scopePaths;
65
89
  if (loaded.some((record) => !scopePaths.some((scope) =>
@@ -75,7 +99,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
75
99
  continue;
76
100
  }
77
101
  await quarantine.rollForwardMutation(record);
78
- const latest = (await mutationJournal.load()).find((candidate) => candidate.ordinal === record.ordinal);
102
+ const latest = await mutationJournal.loadOrdinal(record.ordinal);
79
103
  if (latest === undefined) throw new Error("mutation ordinal 在恢复期间丢失");
80
104
  await quarantine.cleanupMutation(latest);
81
105
  }
@@ -95,10 +119,60 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
95
119
  applyRestore: (plan, target, operation) => restore.apply(plan, target, {
96
120
  opId: operation.opId,
97
121
  mutationJournal: journal.mutationJournal(operation.opId),
122
+ forceTargetArtifactSync: true,
98
123
  }),
99
124
  settle: (opId, phase) => journal.settleRecovery(opId, phase),
100
125
  });
101
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
+
102
176
  const dependencies: ControllerDependencies = {
103
177
  workspaceIdentity: initialTopology.workspaceIdentity,
104
178
  sessionIdentity,
@@ -106,7 +180,10 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
106
180
  abortAgent: async () => { context.abort(); },
107
181
  waitForIdle: async (deadlineMs) => waitForIdle(commandContext, deadlineMs),
108
182
  getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
109
- acquireWorkspaceLock: () => workspaceLock.acquire(initialTopology.workspaceIdentity),
183
+ acquireWorkspaceLock: async () => {
184
+ await waitForFinalization();
185
+ return workspaceLock.acquire(initialTopology.workspaceIdentity);
186
+ },
110
187
  findUserEntryAfter: (startEntryId) => findUserEntryAfter(manager, startEntryId),
111
188
  resolveSessionTarget: (action, checkpoint) => action === "undo"
112
189
  ? logicalLeafAt(manager, entryParent(manager, checkpoint.userEntryId))
@@ -136,18 +213,32 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
136
213
  appendControl: async (customType, data) => appendControlEntry(pi, manager, customType, data),
137
214
  appendCursor: async (cursor) => cursorWriter.appendCursor(cursor, pi, sourceFor(manager)),
138
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
+ },
139
224
  changedPaths: async (before, after) => {
140
225
  const plan = await restore.plan(before, after);
141
226
  return [...new Set([...plan.deletePaths, ...plan.writePaths])].sort();
142
227
  },
143
228
  loadManifest: (manifestId) => store.loadManifest(manifestId),
144
229
  planRestore: (current, target, scopePaths) => restore.plan(current, target, scopePaths),
230
+ prepareDurableRestore: (current, target, scopePaths) =>
231
+ restore.prepareDurableRestore(current, target, scopePaths),
145
232
  applyRestore: (plan, target, operation) => restore.apply(plan, target, {
146
233
  opId: operation.opId,
147
234
  mutationJournal: journal.mutationJournal(operation.opId),
235
+ deferDurability: true,
148
236
  }),
149
- recoverPending: () => workspaceLock.withLock(initialTopology.workspaceIdentity, () => recovery.recover()),
150
- journal,
237
+ recoverPending: async () => {
238
+ await waitForFinalization();
239
+ return workspaceLock.withLock(initialTopology.workspaceIdentity, () => recovery.recover());
240
+ },
241
+ journal: transactionJournal,
151
242
  clock: Date.now,
152
243
  };
153
244
  const startupRecovery = await workspaceLock.withLock(