@davideasden/pi-undo 0.2.7 → 0.2.9

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 CHANGED
@@ -23,11 +23,11 @@ Each completed agent run creates a checkpoint that captures both the Pi session
23
23
  - Node.js `22.19.0` or later.
24
24
  - Git available on `PATH` (used internally for content-addressed snapshots).
25
25
 
26
- ### Rust 原生加速
26
+ ### Native Rust Acceleration
27
27
 
28
- pi-undo 包含跨平台 Rust 原生 helper(`pi-undo-fs`),用于加速文件系统操作。发布包会同时包含以下六个预编译二进制:
28
+ pi-undo includes a cross-platform native Rust helper (`pi-undo-fs`) to accelerate filesystem operations. The published package contains these six precompiled binaries:
29
29
 
30
- | 平台 | 架构 | 二进制名称 |
30
+ | Platform | Architecture | Binary |
31
31
  |---|---|---|
32
32
  | macOS | arm64 | `pi-undo-fs-darwin-arm64` |
33
33
  | macOS | x64 | `pi-undo-fs-darwin-x64` |
@@ -36,33 +36,33 @@ pi-undo 包含跨平台 Rust 原生 helper(`pi-undo-fs`),用于加速文
36
36
  | Windows | arm64 | `pi-undo-fs-win32-arm64.exe` |
37
37
  | Windows | x64 | `pi-undo-fs-win32-x64.exe` |
38
38
 
39
- 扩展会根据当前运行环境自动选择对应二进制,因此用户无需安装 Rust,也无需手动选择平台。Windows 二进制带有 `.exe` 后缀。对于尚未提供预编译二进制的平台,扩展会自动回退到 TypeScript 路径,功能仍然可用。
39
+ The extension automatically selects the correct binary for the current runtime, so users do not need to install Rust or choose a platform manually. Windows binaries use the `.exe` suffix. On platforms without a precompiled binary, the extension automatically falls back to the TypeScript implementation with the same functionality.
40
40
 
41
41
  ## Installation
42
42
 
43
- ### npm 安装(推荐)
43
+ ### Install from npm (Recommended)
44
44
 
45
- 所有受支持的平台使用同一个安装命令;npm 包会包含 macOS/Linux/Windows arm64/x64 预编译 Rust helper,扩展启动时自动选择当前平台的版本:
45
+ All supported platforms use the same installation command. The npm package includes precompiled arm64 and x64 Rust helpers for macOS, Linux, and Windows, and the extension selects the correct version at startup:
46
46
 
47
47
  ```bash
48
48
  pi install npm:@davideasden/pi-undo
49
49
  ```
50
50
 
51
- 重启 Pi 即可加载扩展。用户不需要安装 Rust,也不需要手动选择或安装平台专用包。
51
+ Restart Pi to load the extension. Users do not need to install Rust or select and install a platform-specific package.
52
52
 
53
- ### 从本地源码安装
53
+ ### Install from Local Source
54
54
 
55
55
  ```bash
56
56
  pi install /path/to/pi-undo
57
57
  ```
58
58
 
59
- ### 开发模式直接加载
59
+ ### Load Directly for Development
60
60
 
61
61
  ```bash
62
62
  pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
63
63
  ```
64
64
 
65
- > **注意**:`pi install` 会将整个包(包括 `native/bin/` 下的预编译二进制)复制到 Pi 的扩展目录。如果你从源码构建后想要包含新编译的原生二进制,确保运行 `npm run build:native` 后再执行 `pi install`。
65
+ > **Note:** `pi install` copies the entire package, including the precompiled binaries under `native/bin/`, into Pi's extension directory. To include a newly compiled native binary in a source installation, run `npm run build:native` before `pi install`.
66
66
 
67
67
  ## Usage
68
68
 
@@ -233,6 +233,46 @@ When `recovery_required` appears, first back up the workspace and Pi session JSO
233
233
 
234
234
  A transaction directory may contain `descriptor.json`, `restore-plan.json`, `state.json`, `mutations.jsonl`, `durable-pack-v1.bin`, and a native helper request. Do not delete `.pi-undo` without a backup: unresolved packs or quarantine artifacts may be the only surviving copy of a file version.
235
235
 
236
+ ### Troubleshooting `recovery_required`
237
+
238
+ First stop other Pi instances, editors, formatters, and watchers that may write to the same workspace. Then completely quit and restart Pi once. Startup recovery is idempotent and normally finishes an interrupted transaction automatically. Deleting `.pi-undo` while Pi is still running does not clear the in-memory recovery lock, and the active process may recreate the directory.
239
+
240
+ If the footer includes an `opId`, locate that exact transaction first. Recovery data is stored under the session directory for each workspace. Inspecting `.pi-undo` for a different workspace can therefore produce a misleading result that no pending journal exists:
241
+
242
+ ```bash
243
+ OP_ID="op-..."; TX="$(find "${PI_AGENT_DIR:-$HOME/.pi/agent}/sessions" -type d -path "*/.pi-undo/transactions/$OP_ID" -print -quit)"; test -n "$TX" && printf 'transaction=%s\n' "$TX"
244
+ ```
245
+
246
+ Back up the workspace before continuing. Inspect the transaction phase and descriptor without editing them:
247
+
248
+ ```bash
249
+ jq '{opId,phase,revision,observedLogicalLeaf}' "$TX/state.json"
250
+ jq '{action,fromLogicalLeaf,toLogicalLeaf,workspaceIdentity,sessionIdentity,scopeCount:(.scopePaths | length)}' "$TX/descriptor.json"
251
+ ```
252
+
253
+ List every non-terminal transaction under the same `.pi-undo` root. This command is intentionally kept on one line because trailing whitespace after a continuation backslash can break `find -exec` when a multiline command is pasted:
254
+
255
+ ```bash
256
+ ROOT="$(dirname "$(dirname "$TX")")"; find "$ROOT/transactions" -name state.json -type f -exec jq -r 'select(.phase != "COMMITTED" and .phase != "ABORTED") | "\(.opId) \(.phase)"' {} +
257
+ ```
258
+
259
+ If `mutations.jsonl` exists, summarize the final state of each mutation ordinal and list mutations that have not been cleaned:
260
+
261
+ ```bash
262
+ jq -s 'group_by(.ordinal) | map(.[-1]) | group_by(.state) | map({state: .[0].state, count: length})' "$TX/mutations.jsonl"
263
+ jq -s 'group_by(.ordinal) | map(.[-1]) | map(select(.state != "CLEANED")) | .[] | {ordinal,path,kind,state}' "$TX/mutations.jsonl"
264
+ ```
265
+
266
+ - If the second command prints any records, artifacts or file mutations are still active. Do not delete or move the transaction. Preserve the workspace, session JSONL, transaction directory, and same-directory `.pi-undo-*` artifacts for manual recovery.
267
+ - If the second command prints nothing, every WAL mutation is already `CLEANED`. A footer such as `recovery_required files:1 op:...` may still appear because the conflict path count has a minimum fallback of one. `files:1` alone does not prove that one active file remains.
268
+ - Only when every mutation is `CLEANED`, the transaction phase is still `RECOVERY_REQUIRED`, and you have independently verified that the current workspace and Pi session are the result you want to keep, back up and isolate that transaction:
269
+
270
+ ```bash
271
+ SESSION="$(jq -r '.sessionIdentity.path' "$TX/descriptor.json")"; STAMP="$(date '+%Y%m%d-%H%M%S')"; BACKUP="$ROOT/recovery-backup/$STAMP"; mkdir -p "$BACKUP"; cp -p "$SESSION" "$BACKUP/$(basename "$SESSION").backup"; mv "$TX" "$BACKUP/"
272
+ ```
273
+
274
+ After isolating a fully cleaned transaction, completely quit every Pi process for that workspace and start Pi again. Reloading the session alone may retain the in-memory recovery lock. Do not edit `state.json` by hand because journal states and descriptors are checksum-bound. Do not remove the entire `.pi-undo` directory because it may still contain committed history, snapshots, packs, or the only recoverable copy of a file.
275
+
236
276
  ## Limitations
237
277
 
238
278
  - Git-ignored files are not included in snapshots and are not created or deleted during restore.
@@ -246,7 +286,7 @@ A transaction directory may contain `descriptor.json`, `restore-plan.json`, `sta
246
286
 
247
287
  ## Development
248
288
 
249
- ### 依赖
289
+ ### Dependencies
250
290
 
251
291
  Clone the repository and install dependencies:
252
292
 
@@ -256,24 +296,24 @@ cd pi-undo
256
296
  npm install
257
297
  ```
258
298
 
259
- ### 构建 Rust 原生 helper
299
+ ### Build the Native Rust Helper
260
300
 
261
- 如果需要构建或更新原生二进制,确保已安装 [Rust 工具链](https://rustup.rs/)
301
+ Install the [Rust toolchain](https://rustup.rs/) before building or updating a native binary:
262
302
 
263
303
  ```bash
264
304
  npm run build:native
265
305
  ```
266
306
 
267
- `npm run build:native` 会在 `native/pi-undo-fs/target/release/` 下生成当前构建平台的 `pi-undo-fs`。发布流程会在 macOSLinux Windows runner 上分别构建 arm64/x64 版本,统一重命名后放入 `native/bin/`,再打包成包含六个二进制的 npm 包。
307
+ `npm run build:native` creates `pi-undo-fs` for the current build platform under `native/pi-undo-fs/target/release/`. The release workflow builds arm64 and x64 versions on macOS, Linux, and Windows runners, renames them consistently under `native/bin/`, and packages all six binaries in the npm package.
268
308
 
269
- 本地开发时,如果只需要验证当前平台,可以将生成的文件复制到 `native/bin/` 并按平台重命名,例如:Windows 生成的文件应使用对应的 `.exe` 文件名。
309
+ For local development on the current platform, copy the generated file into `native/bin/` and rename it for the platform. For example, a Windows build must use the corresponding `.exe` filename.
270
310
 
271
311
  ```bash
272
312
  cp native/pi-undo-fs/target/release/pi-undo-fs native/bin/pi-undo-fs-darwin-arm64
273
313
  chmod +x native/bin/pi-undo-fs-darwin-arm64
274
314
  ```
275
315
 
276
- 发布包必须包含 Requirements 中列出的六个平台二进制;CI 会在打包前检查这一点,并在推送 `v*` tag 时发布该 CI 构建的完整 npm 包。npm 仓库需要为此 GitHub Actions workflow 配置 npm Trusted Publishing(OIDC)。如果当前平台没有对应二进制,扩展会自动使用 TypeScript 回退路径。
316
+ The published package must contain all six platform binaries listed under Requirements. CI verifies them before packaging and publishes the complete CI-built npm package when a `v*` tag is pushed. The npm package must configure Trusted Publishing (OIDC) for this GitHub Actions workflow. If no binary is available for the current platform, the extension automatically uses the TypeScript fallback.
277
317
 
278
318
  ### Project Layout
279
319
 
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.9",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/controller.ts CHANGED
@@ -188,6 +188,8 @@ export class UndoControllerImpl implements UndoController {
188
188
  private locked = false;
189
189
  private historyPaused = false;
190
190
  private operationInFlight = false;
191
+ private operationAction: "undo" | "redo" | undefined;
192
+ private operationProfiler: OperationProfiler | undefined;
191
193
  private promptDeferralInFlight = false;
192
194
  private lastSafetyManifestId: ManifestId | null = null;
193
195
 
@@ -261,18 +263,30 @@ export class UndoControllerImpl implements UndoController {
261
263
  await this.dependencies.appendControl("pi-undo:barrier", { reason: "user_entry_missing" }).catch(() => {});
262
264
  return;
263
265
  }
266
+ const profiler = this.operationProfiler;
267
+ const measure = <T>(phase: string, operation: () => Promise<T>): Promise<T> =>
268
+ profiler === undefined ? operation() : profiler.measure(phase, operation);
264
269
  try {
265
- const after = await this.captureWithWorkspaceLock();
266
- const changedPaths = await this.dependencies.changedPaths(staged.before, after);
270
+ const after = await measure("settled.capture", () => this.captureWithWorkspaceLock());
271
+ const changedPaths = await measure("settled.changedPaths", () =>
272
+ this.dependencies.changedPaths(staged.before, after));
267
273
  if (changedPaths.length > 0 && this.dependencies.prepareDurableRestore !== undefined) {
268
- await Promise.all([
269
- this.dependencies.prepareDurableRestore(staged.before, after, changedPaths),
270
- this.dependencies.prepareDurableRestore(after, staged.before, changedPaths),
271
- ]).catch(() => {});
274
+ // /undo 在流式中断后正等待本次 settled;关键路径只预制立即使用的 after → before。
275
+ const preparations = this.operationAction === "undo"
276
+ ? [measure("settled.prepareUndo", () =>
277
+ this.dependencies.prepareDurableRestore!(after, staged.before, changedPaths))]
278
+ : [
279
+ measure("settled.prepareRedo", () =>
280
+ this.dependencies.prepareDurableRestore!(staged.before, after, changedPaths)),
281
+ measure("settled.prepareUndo", () =>
282
+ this.dependencies.prepareDurableRestore!(after, staged.before, changedPaths)),
283
+ ];
284
+ await Promise.allSettled(preparations);
272
285
  }
273
286
  const endLeafId = this.dependencies.getLogicalLeafId() ?? staged.startEntryId;
274
287
  const checkpoint = this.createCheckpoint(staged, after, changedPaths, userEntryId, endLeafId);
275
- const checkpointEntryId = await this.dependencies.appendControl("pi-undo:checkpoint", checkpoint);
288
+ const checkpointEntryId = await measure("settled.checkpoint", () =>
289
+ this.dependencies.appendControl("pi-undo:checkpoint", checkpoint));
276
290
  if (checkpointEntryId === null) {
277
291
  this.locked = true;
278
292
  await this.dependencies.appendControl("pi-undo:barrier", { reason: "checkpoint_entry_missing" }).catch(() => {});
@@ -289,27 +303,14 @@ export class UndoControllerImpl implements UndoController {
289
303
 
290
304
  async undo(): Promise<OperationResult> {
291
305
  if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
292
- const checkpoint = this.undoStack.at(-1);
293
- if (checkpoint === undefined) return noop();
294
- const result = await this.runOperation("undo", checkpoint);
295
- if (result.code === "ok" && this.lastSafetyManifestId !== null) {
296
- this.undoStack.pop();
297
- this.redoStack.push({ checkpoint, targetManifestId: this.lastSafetyManifestId });
298
- return { ...result, refillPrompt: checkpoint.rawPrompt };
299
- }
300
- return result;
306
+ return this.runOperation("undo");
301
307
  }
302
308
 
303
309
  async redo(): Promise<OperationResult> {
304
310
  if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
305
- const redo = this.redoStack.at(-1);
306
- if (redo === undefined) return noop();
307
- const result = await this.runOperation("redo", redo.checkpoint, redo.targetManifestId);
308
- if (result.code === "ok") {
309
- this.redoStack.pop();
310
- this.undoStack.push(redo.checkpoint);
311
- }
312
- return result;
311
+ // 新 run 开始时 redo frontier 已失效;空栈命令不得为了确认 noop 而中断正在运行的 Agent。
312
+ if (this.redoStack.length === 0) return noop();
313
+ return this.runOperation("redo");
313
314
  }
314
315
 
315
316
  async beforeTree(event: SessionBeforeTreeEvent): Promise<SessionBeforeTreeResult | undefined> {
@@ -408,15 +409,13 @@ export class UndoControllerImpl implements UndoController {
408
409
  }
409
410
  }
410
411
 
411
- private async runOperation(
412
- action: "undo" | "redo",
413
- checkpoint: CheckpointRecord,
414
- targetManifestId?: ManifestId,
415
- ): Promise<OperationResult> {
412
+ private async runOperation(action: "undo" | "redo"): Promise<OperationResult> {
416
413
  if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
417
414
  const profile = new OperationProfiler();
418
415
  const done = (result: OperationResult): OperationResult => profile.attach(result);
419
416
  this.operationInFlight = true;
417
+ this.operationAction = action;
418
+ this.operationProfiler = profile;
420
419
  this.promptDeferralInFlight = true;
421
420
  this.lastSafetyManifestId = null;
422
421
  let lease: { release(): Promise<void> } | undefined;
@@ -424,13 +423,19 @@ export class UndoControllerImpl implements UndoController {
424
423
  if (!await profile.measure("idle", () => this.ensureIdle())) {
425
424
  return done({ code: "idle_timeout", changedFiles: 0 });
426
425
  }
426
+ // 中断中的 run 会在 waitForIdle() 内由 agentSettled() 推入栈,必须在此之后选择目标。
427
+ const redo = action === "redo" ? this.redoStack.at(-1) : undefined;
428
+ const checkpoint = action === "undo" ? this.undoStack.at(-1) : redo?.checkpoint;
429
+ if (checkpoint === undefined) return done(noop());
430
+ const targetManifestId = redo?.targetManifestId;
427
431
  try {
428
432
  lease = await profile.measure("lock", () => this.dependencies.acquireWorkspaceLock());
429
433
  } catch {
430
434
  return done({ code: "busy", changedFiles: 0 });
431
435
  }
432
436
  if (checkpoint.changedPaths.length === 0) {
433
- return done(await this.runSessionOnlyOperation(action, checkpoint, targetManifestId, profile));
437
+ const result = await this.runSessionOnlyOperation(action, checkpoint, targetManifestId, profile);
438
+ return done(this.advanceHistory(action, checkpoint, result));
434
439
  }
435
440
  const restoreTargetManifestId = targetManifestId ?? (
436
441
  action === "undo" ? checkpoint.beforeManifestId : checkpoint.afterManifestId
@@ -512,7 +517,7 @@ export class UndoControllerImpl implements UndoController {
512
517
  await this.dependencies.journal.markCommitted(descriptor.opId);
513
518
  });
514
519
  this.lastSafetyManifestId = rollback.manifestId;
515
- return done({ code: "ok", changedFiles: applied.verifiedPaths });
520
+ return done(this.advanceHistory(action, checkpoint, { code: "ok", changedFiles: applied.verifiedPaths }));
516
521
  } catch {
517
522
  this.locked = true;
518
523
  return done({ code: "recovery_required", changedFiles: 0 });
@@ -522,11 +527,30 @@ export class UndoControllerImpl implements UndoController {
522
527
  await profile.measure("unlock", () =>
523
528
  activeLease.release().catch(() => { this.locked = true; }));
524
529
  }
530
+ if (this.operationProfiler === profile) this.operationProfiler = undefined;
531
+ this.operationAction = undefined;
525
532
  this.promptDeferralInFlight = false;
526
533
  this.operationInFlight = false;
527
534
  }
528
535
  }
529
536
 
537
+ private advanceHistory(
538
+ action: "undo" | "redo",
539
+ checkpoint: CheckpointRecord,
540
+ result: OperationResult,
541
+ ): OperationResult {
542
+ if (result.code !== "ok") return result;
543
+ if (action === "undo") {
544
+ if (this.lastSafetyManifestId === null) return result;
545
+ this.undoStack.pop();
546
+ this.redoStack.push({ checkpoint, targetManifestId: this.lastSafetyManifestId });
547
+ return { ...result, refillPrompt: checkpoint.rawPrompt };
548
+ }
549
+ this.redoStack.pop();
550
+ this.undoStack.push(checkpoint);
551
+ return result;
552
+ }
553
+
530
554
  private async runSessionOnlyOperation(
531
555
  action: "undo" | "redo",
532
556
  checkpoint: CheckpointRecord,
@@ -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
  }