@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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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
- if (targetFingerprint === undefined || targetFingerprint === null) return;
319
- const leaf = pack.leaf(path, targetFingerprint);
320
- if (leaf === undefined || leaf.kind === "absent") throw new Error(`durable pack target leaf 缺失:${path}`);
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) {
@@ -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
  }
@@ -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.kind !== "write" || record.targetArtifact === null) throw new Error("packed recovery 只支持普通文件 write");
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 || target.kind !== "file") {
55
+ if (source === undefined || target === undefined) {
51
56
  throw new Error(`packed recovery variant 缺失:${record.path}`);
52
57
  }
53
- await normalizeRecord(
54
- workspaceRoot,
55
- record,
56
- source,
57
- target,
58
- options.decision,
59
- options.retainArtifacts === true,
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: "write" as const,
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);
@@ -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
- return plan.deletePaths.length === 0 &&
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: plan.writePaths.length };
708
+ return { code: "recovery_required", verifiedPaths: 0, totalPaths };
699
709
  }
700
- return { code: "ok", verifiedPaths: plan.writePaths.length, totalPaths: plan.writePaths.length };
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 { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.writePaths.length };
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 ?? null,
778
+ targetFingerprint: targetLeaf?.fingerprint ?? absent.fingerprint,
765
779
  variants: [...variants.values()],
766
780
  });
767
781
  }