@davideasden/pi-undo 0.2.2 → 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.
- package/README.md +17 -8
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +5 -2
- package/src/atomic-fs.ts +2 -2
- package/src/controller.ts +71 -21
- package/src/durable-pack.ts +573 -0
- package/src/journal.ts +33 -6
- package/src/native-restore.ts +146 -0
- package/src/packed-recovery.ts +324 -0
- package/src/pi-runtime.ts +95 -4
- package/src/quarantine.ts +57 -31
- package/src/restore-engine.ts +400 -35
- package/src/snapshot-store.ts +7 -0
- package/src/workspace-lock.ts +5 -1
|
@@ -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,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
|
-
|
|
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) =>
|
|
@@ -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: () =>
|
|
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:
|
|
150
|
-
|
|
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(
|