@davideasden/pi-undo 0.2.7 → 0.2.8
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/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +1 -1
- package/src/durable-pack.ts +27 -4
- package/src/native-restore.ts +1 -3
- package/src/packed-recovery.ts +67 -25
- package/src/pi-runtime.ts +2 -1
- package/src/restore-engine.ts +22 -8
|
Binary file
|
package/package.json
CHANGED
package/src/durable-pack.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { fsyncDirectory, fsyncFile } from "./atomic-fs.ts";
|
|
|
6
6
|
import { canonicalJson, checksum } from "./encoding.ts";
|
|
7
7
|
import type { MutationJournal } from "./mutation-journal.ts";
|
|
8
8
|
import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
|
|
9
|
-
import { fingerprintAbsent, fingerprintBytes, fingerprintFile, fingerprintSymlink } from "./quarantine.ts";
|
|
9
|
+
import { fingerprintAbsent, fingerprintBytes, fingerprintFile, fingerprintLeaf, fingerprintSymlink } from "./quarantine.ts";
|
|
10
10
|
|
|
11
11
|
const PACK_FILE = "durable-pack-v1.bin";
|
|
12
12
|
const MAGIC = Buffer.from("PIUNDO-PACK-V1\0", "ascii");
|
|
@@ -315,13 +315,36 @@ export async function finalizeDurablePack(
|
|
|
315
315
|
const directories = new Set<string>();
|
|
316
316
|
await mapConcurrent(pack.paths(), FINALIZE_CONCURRENCY, async (path) => {
|
|
317
317
|
const targetFingerprint = pack.targetFingerprint(path);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
318
|
+
const targetLeaf = targetFingerprint === undefined || targetFingerprint === null
|
|
319
|
+
? undefined
|
|
320
|
+
: pack.leaf(path, targetFingerprint);
|
|
321
321
|
relativeSafePath(canonicalRoot, path);
|
|
322
322
|
await assertNoSymlinkEscape(canonicalRoot, path);
|
|
323
323
|
const absolute = join(canonicalRoot, ...path.split("/"));
|
|
324
324
|
directories.add(dirname(absolute));
|
|
325
|
+
if (targetLeaf?.kind === "absent") {
|
|
326
|
+
if (await fingerprintLeaf(absolute, path) !== fingerprintAbsent(path)) {
|
|
327
|
+
throw new Error(`durable finalization delete 原路径不是 absent:${path}`);
|
|
328
|
+
}
|
|
329
|
+
const sourceFingerprint = pack.sourceFingerprint(path);
|
|
330
|
+
const sourceArtifact = pack.artifacts(path)?.source;
|
|
331
|
+
if (sourceFingerprint === undefined || sourceArtifact === undefined) {
|
|
332
|
+
throw new Error(`durable finalization delete source 缺失:${path}`);
|
|
333
|
+
}
|
|
334
|
+
const sourcePath = join(canonicalRoot, ...sourceArtifact.split("/"));
|
|
335
|
+
try {
|
|
336
|
+
if (await fingerprintFile(sourcePath, path) !== sourceFingerprint) {
|
|
337
|
+
throw new Error(`durable finalization delete source fingerprint 冲突:${path}`);
|
|
338
|
+
}
|
|
339
|
+
await fsyncFile(sourcePath);
|
|
340
|
+
directories.add(dirname(sourcePath));
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (!hasErrorCode(error, "ENOENT") || mutationStates?.get(path) !== "CLEANED") throw error;
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const leaf = targetLeaf;
|
|
347
|
+
if (leaf === undefined) throw new Error(`durable pack target leaf 缺失:${path}`);
|
|
325
348
|
if (leaf.kind === "file") {
|
|
326
349
|
const artifacts = pack.artifacts(path);
|
|
327
350
|
if (artifacts?.target === null || artifacts?.target === undefined) {
|
package/src/native-restore.ts
CHANGED
|
@@ -48,10 +48,8 @@ export async function createNativeFileBatch(options: {
|
|
|
48
48
|
const targetFingerprint = pack.targetFingerprint(path);
|
|
49
49
|
if (
|
|
50
50
|
artifacts === undefined ||
|
|
51
|
-
artifacts.target === null ||
|
|
52
51
|
sourceFingerprint === undefined ||
|
|
53
|
-
targetFingerprint === undefined
|
|
54
|
-
targetFingerprint === null
|
|
52
|
+
targetFingerprint === undefined
|
|
55
53
|
) {
|
|
56
54
|
throw new Error(`native file batch pack entry 无效:${path}`);
|
|
57
55
|
}
|
package/src/packed-recovery.ts
CHANGED
|
@@ -44,20 +44,28 @@ export async function recoverPackedMutations(options: {
|
|
|
44
44
|
const pack = await loadDurablePack(options.journal, options.planDigest, true);
|
|
45
45
|
const records = await ensurePackedIntents(options.journal, pack, await options.journal.load());
|
|
46
46
|
await mapConcurrent(records, PACKED_RECOVERY_CONCURRENCY, async (record) => {
|
|
47
|
-
if (record.
|
|
47
|
+
if (record.state === "CLEANED" && record.kind === "delete") {
|
|
48
|
+
if (options.decision === "rollback") {
|
|
49
|
+
throw new Error(`CLEANED delete mutation 不能 rollback:${record.path}`);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
48
53
|
const source = pack.leaf(record.path, record.sourceFingerprint);
|
|
49
54
|
const target = pack.leaf(record.path, record.targetFingerprint);
|
|
50
|
-
if (source === undefined || target === undefined
|
|
55
|
+
if (source === undefined || target === undefined) {
|
|
51
56
|
throw new Error(`packed recovery variant 缺失:${record.path}`);
|
|
52
57
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
58
|
+
if (record.kind === "delete") {
|
|
59
|
+
if (record.targetArtifact !== null || source.kind !== "file" || target.kind !== "absent") {
|
|
60
|
+
throw new Error(`packed recovery delete variant 无效:${record.path}`);
|
|
61
|
+
}
|
|
62
|
+
await normalizeDeletedRecord(workspaceRoot, record, source, options.decision, options.retainArtifacts === true);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (record.kind !== "write" || record.targetArtifact === null || target.kind !== "file") {
|
|
66
|
+
throw new Error(`packed recovery write variant 无效:${record.path}`);
|
|
67
|
+
}
|
|
68
|
+
await normalizeRecord(workspaceRoot, record, source, target, options.decision, options.retainArtifacts === true);
|
|
61
69
|
});
|
|
62
70
|
const terminalState: MutationState = options.retainArtifacts === true ? "TARGET_VERIFIED" : "CLEANED";
|
|
63
71
|
const terminalIndex = stateOrder.indexOf(terminalState);
|
|
@@ -87,20 +95,29 @@ export async function cleanupPackedMutations(options: {
|
|
|
87
95
|
const records = [...await options.journal.load()];
|
|
88
96
|
const cleanupDirectories = new Set<string>();
|
|
89
97
|
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
|
-
) {
|
|
98
|
+
if (record.state !== "TARGET_VERIFIED" && record.state !== "CLEANED") {
|
|
95
99
|
throw new Error(`packed cleanup mutation 状态无效:${record.path}`);
|
|
96
100
|
}
|
|
97
101
|
relativeSafePath(workspaceRoot, record.path);
|
|
98
102
|
await assertNoSymlinkEscape(workspaceRoot, record.path);
|
|
99
103
|
const original = join(workspaceRoot, ...record.path.split("/"));
|
|
100
104
|
const sourceArtifact = join(workspaceRoot, ...record.sourceArtifact.split("/"));
|
|
101
|
-
const targetArtifact = join(workspaceRoot, ...record.targetArtifact.split("/"));
|
|
102
105
|
const source = pack.leaf(record.path, record.sourceFingerprint);
|
|
103
106
|
if (source === undefined) throw new Error(`packed cleanup source variant 缺失:${record.path}`);
|
|
107
|
+
if (record.kind === "delete") {
|
|
108
|
+
if (record.targetArtifact !== null || pack.targetFingerprint(record.path) !== fingerprintAbsent(record.path)) {
|
|
109
|
+
throw new Error(`packed cleanup delete mutation 无效:${record.path}`);
|
|
110
|
+
}
|
|
111
|
+
if (await fingerprintLeaf(original, record.path) !== fingerprintAbsent(record.path)) {
|
|
112
|
+
throw new Error(`packed cleanup delete original 不是 absent:${record.path}`);
|
|
113
|
+
}
|
|
114
|
+
await cleanupArtifact(sourceArtifact, record.path, record.sourceFingerprint, cleanupDirectories);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (record.kind !== "write" || record.targetArtifact === null) {
|
|
118
|
+
throw new Error(`packed cleanup write mutation 无效:${record.path}`);
|
|
119
|
+
}
|
|
120
|
+
const targetArtifact = join(workspaceRoot, ...record.targetArtifact.split("/"));
|
|
104
121
|
if (await pathExists(targetArtifact)) {
|
|
105
122
|
await assertSameFileIdentity(original, targetArtifact, record.path);
|
|
106
123
|
} else if (record.state !== "CLEANED") {
|
|
@@ -140,22 +157,18 @@ function packedIntent(pack: DurablePack, path: string) {
|
|
|
140
157
|
const artifacts = pack.artifacts(path);
|
|
141
158
|
const sourceFingerprint = pack.sourceFingerprint(path);
|
|
142
159
|
const targetFingerprint = pack.targetFingerprint(path);
|
|
143
|
-
if (
|
|
144
|
-
artifacts === undefined ||
|
|
145
|
-
artifacts.target === null ||
|
|
146
|
-
sourceFingerprint === undefined ||
|
|
147
|
-
targetFingerprint === undefined ||
|
|
148
|
-
targetFingerprint === null
|
|
149
|
-
) {
|
|
160
|
+
if (artifacts === undefined || sourceFingerprint === undefined || targetFingerprint === undefined) {
|
|
150
161
|
throw new Error(`packed recovery intent 缺失:${path}`);
|
|
151
162
|
}
|
|
152
163
|
return {
|
|
153
|
-
kind:
|
|
164
|
+
kind: artifacts.target === null && (targetFingerprint === null || targetFingerprint === fingerprintAbsent(path))
|
|
165
|
+
? "delete" as const
|
|
166
|
+
: "write" as const,
|
|
154
167
|
path,
|
|
155
168
|
sourceArtifact: artifacts.source,
|
|
156
169
|
targetArtifact: artifacts.target,
|
|
157
170
|
sourceFingerprint,
|
|
158
|
-
targetFingerprint,
|
|
171
|
+
targetFingerprint: targetFingerprint ?? fingerprintAbsent(path),
|
|
159
172
|
};
|
|
160
173
|
}
|
|
161
174
|
|
|
@@ -173,6 +186,35 @@ function assertPackedRecord(pack: DurablePack, path: string, record: MutationRec
|
|
|
173
186
|
}
|
|
174
187
|
}
|
|
175
188
|
|
|
189
|
+
async function normalizeDeletedRecord(
|
|
190
|
+
workspaceRoot: string,
|
|
191
|
+
record: MutationRecord,
|
|
192
|
+
source: DurableLeaf,
|
|
193
|
+
decision: "rollback" | "roll_forward",
|
|
194
|
+
retainArtifacts: boolean,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
if (source.kind !== "file" || record.targetArtifact !== null) {
|
|
197
|
+
throw new Error(`delete mutation variant 无效:${record.path}`);
|
|
198
|
+
}
|
|
199
|
+
relativeSafePath(workspaceRoot, record.path);
|
|
200
|
+
await assertNoSymlinkEscape(workspaceRoot, record.path);
|
|
201
|
+
const original = join(workspaceRoot, ...record.path.split("/"));
|
|
202
|
+
const sourceArtifact = join(workspaceRoot, ...record.sourceArtifact.split("/"));
|
|
203
|
+
const absent = fingerprintAbsent(record.path);
|
|
204
|
+
const observed = await fingerprintLeaf(original, record.path);
|
|
205
|
+
if (observed !== absent && observed !== record.sourceFingerprint) {
|
|
206
|
+
throw new Error(`delete mutation original 冲突:${record.path}`);
|
|
207
|
+
}
|
|
208
|
+
await assertArtifact(sourceArtifact, record.path, record.sourceFingerprint, false);
|
|
209
|
+
if (decision === "rollback") {
|
|
210
|
+
if (observed === absent) await materialize(original, record.path, source);
|
|
211
|
+
if (!retainArtifacts) await cleanupArtifact(sourceArtifact, record.path, record.sourceFingerprint);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (observed !== absent) throw new Error(`delete mutation roll-forward original 未删除:${record.path}`);
|
|
215
|
+
if (!retainArtifacts) await cleanupArtifact(sourceArtifact, record.path, record.sourceFingerprint);
|
|
216
|
+
}
|
|
217
|
+
|
|
176
218
|
async function normalizeRecord(
|
|
177
219
|
workspaceRoot: string,
|
|
178
220
|
record: MutationRecord,
|
package/src/pi-runtime.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
ExtensionAPI,
|
|
5
5
|
ExtensionCommandContext,
|
|
6
6
|
ExtensionContext,
|
|
7
|
-
ReadonlySessionManager,
|
|
8
7
|
} from "@earendil-works/pi-coding-agent";
|
|
9
8
|
|
|
10
9
|
import {
|
|
@@ -30,6 +29,8 @@ import { SnapshotStore } from "./snapshot-store.ts";
|
|
|
30
29
|
import { StatusReporter } from "./status-reporter.ts";
|
|
31
30
|
import { WorkspaceLock } from "./workspace-lock.ts";
|
|
32
31
|
|
|
32
|
+
type ReadonlySessionManager = ExtensionContext["sessionManager"];
|
|
33
|
+
|
|
33
34
|
export async function createPiUndoRuntime(context: ExtensionContext, pi: ExtensionAPI) {
|
|
34
35
|
const manager = context.sessionManager;
|
|
35
36
|
const sessionState = sessionStateFor(manager);
|
package/src/restore-engine.ts
CHANGED
|
@@ -253,7 +253,7 @@ export class RestoreEngine {
|
|
|
253
253
|
): Promise<void> {
|
|
254
254
|
const plan = await this.plan(current, target, scopePaths);
|
|
255
255
|
const prepared = this.takePreparedPlan(plan);
|
|
256
|
-
if (prepared === undefined || !this.canUseNativeFilePlan(plan, prepared.targetPaths)) return;
|
|
256
|
+
if (prepared === undefined || !this.canUseNativeFilePlan(plan, prepared.currentPaths, prepared.targetPaths)) return;
|
|
257
257
|
const cacheRoot = await this.store.durableCacheDirectory();
|
|
258
258
|
const cacheOpId = `cache-${plan.planDigest}`;
|
|
259
259
|
const cacheJournal = new MutationJournal(
|
|
@@ -528,7 +528,7 @@ export class RestoreEngine {
|
|
|
528
528
|
!compatibilityMode &&
|
|
529
529
|
options.deferDurability === true &&
|
|
530
530
|
options.forceTargetArtifactSync !== true &&
|
|
531
|
-
this.canUseNativeFilePlan(plan, targetPaths)
|
|
531
|
+
this.canUseNativeFilePlan(plan, currentPaths, targetPaths)
|
|
532
532
|
) {
|
|
533
533
|
try {
|
|
534
534
|
const cached = this.durablePackCache.get(plan.planDigest);
|
|
@@ -574,7 +574,7 @@ export class RestoreEngine {
|
|
|
574
574
|
);
|
|
575
575
|
}
|
|
576
576
|
const durablePackEnabled = durablePack !== undefined;
|
|
577
|
-
if (nativeFileBatch !== undefined && durablePack !== undefined && this.canUseNativeFilePlan(plan, targetPaths)) {
|
|
577
|
+
if (nativeFileBatch !== undefined && durablePack !== undefined && this.canUseNativeFilePlan(plan, currentPaths, targetPaths)) {
|
|
578
578
|
const result = await this.applyNativeFilePlan(
|
|
579
579
|
plan,
|
|
580
580
|
current,
|
|
@@ -661,11 +661,20 @@ export class RestoreEngine {
|
|
|
661
661
|
|
|
662
662
|
private canUseNativeFilePlan(
|
|
663
663
|
plan: RestorePlan,
|
|
664
|
+
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
664
665
|
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
665
666
|
): boolean {
|
|
666
|
-
|
|
667
|
+
const writeOnly = plan.deletePaths.length === 0 &&
|
|
667
668
|
plan.writePaths.length > 0 &&
|
|
668
669
|
plan.writePaths.every((path) => targetPaths.get(path)?.entry.kind === "file");
|
|
670
|
+
const deleteOnly = process.platform !== "win32" &&
|
|
671
|
+
plan.writePaths.length === 0 &&
|
|
672
|
+
plan.deletePaths.length > 0 &&
|
|
673
|
+
plan.deletePaths.every((path) =>
|
|
674
|
+
currentPaths.get(path)?.entry.kind === "file" &&
|
|
675
|
+
targetPaths.get(path) === undefined &&
|
|
676
|
+
!path.includes("/"));
|
|
677
|
+
return writeOnly || deleteOnly;
|
|
669
678
|
}
|
|
670
679
|
|
|
671
680
|
private async applyNativeFilePlan(
|
|
@@ -694,10 +703,11 @@ export class RestoreEngine {
|
|
|
694
703
|
: [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
|
|
695
704
|
}),
|
|
696
705
|
);
|
|
706
|
+
const totalPaths = plan.deletePaths.length + plan.writePaths.length;
|
|
697
707
|
if ((await options.mutationJournal.load()).length !== 0) {
|
|
698
|
-
return { code: "recovery_required", verifiedPaths: 0, totalPaths
|
|
708
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths };
|
|
699
709
|
}
|
|
700
|
-
return { code: "ok", verifiedPaths:
|
|
710
|
+
return { code: "ok", verifiedPaths: totalPaths, totalPaths };
|
|
701
711
|
} catch {
|
|
702
712
|
const packedRecovery = await recoverPackedMutations({
|
|
703
713
|
workspaceRoot: this.workspaceRoot,
|
|
@@ -706,7 +716,11 @@ export class RestoreEngine {
|
|
|
706
716
|
decision: "rollback",
|
|
707
717
|
});
|
|
708
718
|
if (packedRecovery.kind !== "clean") {
|
|
709
|
-
return {
|
|
719
|
+
return {
|
|
720
|
+
code: "recovery_required",
|
|
721
|
+
verifiedPaths: 0,
|
|
722
|
+
totalPaths: plan.deletePaths.length + plan.writePaths.length,
|
|
723
|
+
};
|
|
710
724
|
}
|
|
711
725
|
return this.rollback(
|
|
712
726
|
current,
|
|
@@ -761,7 +775,7 @@ export class RestoreEngine {
|
|
|
761
775
|
sourceArtifact: artifact("source"),
|
|
762
776
|
targetArtifact: targetLeaf?.kind === "file" ? artifact("target") : null,
|
|
763
777
|
sourceFingerprint: currentLeaf?.fingerprint ?? absent.fingerprint,
|
|
764
|
-
targetFingerprint: targetLeaf?.fingerprint ??
|
|
778
|
+
targetFingerprint: targetLeaf?.fingerprint ?? absent.fingerprint,
|
|
765
779
|
variants: [...variants.values()],
|
|
766
780
|
});
|
|
767
781
|
}
|