@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.
- 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 +175 -16
- 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 +102 -4
- package/src/quarantine.ts +57 -31
- package/src/recovery.ts +15 -13
- 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,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
|
|
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: () =>
|
|
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:
|
|
143
|
-
|
|
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(
|