@davideasden/pi-undo 0.2.5 → 0.2.7

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
@@ -15,7 +15,7 @@ Each completed agent run creates a checkpoint that captures both the Pi session
15
15
  - **External concurrency detection** — file fingerprint and inode checks detect external modification. Conflicting changes are never silently overwritten; the system fails closed or enters `recovery required`.
16
16
  - **No Git workflow** — snapshots use a private object database. No `git commit`, `git stash`, `git reset`, branches, or forges are required.
17
17
  - **Nested repositories** and **initialized submodules** are handled as independent roots. Their `.git` metadata is never modified.
18
- - **Performance** — batch WAL operations (up to 1,024 files per batch), scoped safety snapshots, prebuilt durable transaction packs, native Rust no-clobber file helper for macOS arm64, indexed WAL record and conflict lookups, and parallel manifest blob reads keep restore fast at scale. Unsupported platforms and failed integrity checks automatically use the TypeScript path.
18
+ - **Performance** — batch WAL operations (up to 1,024 files per batch), scoped safety snapshots, prebuilt durable transaction packs, native Rust no-clobber file helper (macOS/Linux, arm64/x64), indexed WAL record and conflict lookups, and parallel manifest blob reads keep restore fast at scale. Unsupported platforms and failed integrity checks automatically use the TypeScript fallback.
19
19
 
20
20
  ## Requirements
21
21
 
@@ -23,28 +23,47 @@ 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 原生加速
27
+
28
+ pi-undo 包含跨平台 Rust 原生 helper(`pi-undo-fs`),用于加速文件系统操作。发布包会同时包含以下六个预编译二进制:
29
+
30
+ | 平台 | 架构 | 二进制名称 |
31
+ |---|---|---|
32
+ | macOS | arm64 | `pi-undo-fs-darwin-arm64` |
33
+ | macOS | x64 | `pi-undo-fs-darwin-x64` |
34
+ | Linux | arm64 | `pi-undo-fs-linux-arm64` |
35
+ | Linux | x64 | `pi-undo-fs-linux-x64` |
36
+ | Windows | arm64 | `pi-undo-fs-win32-arm64.exe` |
37
+ | Windows | x64 | `pi-undo-fs-win32-x64.exe` |
38
+
39
+ 扩展会根据当前运行环境自动选择对应二进制,因此用户无需安装 Rust,也无需手动选择平台。Windows 二进制带有 `.exe` 后缀。对于尚未提供预编译二进制的平台,扩展会自动回退到 TypeScript 路径,功能仍然可用。
40
+
26
41
  ## Installation
27
42
 
28
- Install the published package from npm:
43
+ ### npm 安装(推荐)
44
+
45
+ 所有受支持的平台使用同一个安装命令;npm 包会包含 macOS/Linux/Windows 的 arm64/x64 预编译 Rust helper,扩展启动时自动选择当前平台的版本:
29
46
 
30
47
  ```bash
31
48
  pi install npm:@davideasden/pi-undo
32
49
  ```
33
50
 
34
- Restart Pi if the extension is not already loaded.
51
+ 重启 Pi 即可加载扩展。用户不需要安装 Rust,也不需要手动选择或安装平台专用包。
35
52
 
36
- To install from a local checkout:
53
+ ### 从本地源码安装
37
54
 
38
55
  ```bash
39
56
  pi install /path/to/pi-undo
40
57
  ```
41
58
 
42
- During development, load the extension directly:
59
+ ### 开发模式直接加载
43
60
 
44
61
  ```bash
45
62
  pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
46
63
  ```
47
64
 
65
+ > **注意**:`pi install` 会将整个包(包括 `native/bin/` 下的预编译二进制)复制到 Pi 的扩展目录。如果你从源码构建后想要包含新编译的原生二进制,确保运行 `npm run build:native` 后再执行 `pi install`。
66
+
48
67
  ## Usage
49
68
 
50
69
  Work with Pi normally. `pi-undo` automatically records a boundary after each completed agent run.
@@ -227,6 +246,8 @@ A transaction directory may contain `descriptor.json`, `restore-plan.json`, `sta
227
246
 
228
247
  ## Development
229
248
 
249
+ ### 依赖
250
+
230
251
  Clone the repository and install dependencies:
231
252
 
232
253
  ```bash
@@ -235,6 +256,25 @@ cd pi-undo
235
256
  npm install
236
257
  ```
237
258
 
259
+ ### 构建 Rust 原生 helper
260
+
261
+ 如果需要构建或更新原生二进制,确保已安装 [Rust 工具链](https://rustup.rs/):
262
+
263
+ ```bash
264
+ npm run build:native
265
+ ```
266
+
267
+ `npm run build:native` 会在 `native/pi-undo-fs/target/release/` 下生成当前构建平台的 `pi-undo-fs`。发布流程会在 macOS、Linux 和 Windows runner 上分别构建 arm64/x64 版本,统一重命名后放入 `native/bin/`,再打包成包含六个二进制的 npm 包。
268
+
269
+ 本地开发时,如果只需要验证当前平台,可以将生成的文件复制到 `native/bin/` 并按平台重命名,例如:Windows 生成的文件应使用对应的 `.exe` 文件名。
270
+
271
+ ```bash
272
+ cp native/pi-undo-fs/target/release/pi-undo-fs native/bin/pi-undo-fs-darwin-arm64
273
+ chmod +x native/bin/pi-undo-fs-darwin-arm64
274
+ ```
275
+
276
+ 发布包必须包含 Requirements 中列出的六个平台二进制;CI 会在打包前检查这一点,并在推送 `v*` tag 时发布该 CI 构建的完整 npm 包。npm 仓库需要为此 GitHub Actions workflow 配置 npm Trusted Publishing(OIDC)。如果当前平台没有对应二进制,扩展会自动使用 TypeScript 回退路径。
277
+
238
278
  ### Project Layout
239
279
 
240
280
  ```text
@@ -290,7 +330,54 @@ Benchmark tests assert Git call counts and WAL record counts, not wall-clock thr
290
330
  | 100-file restore (delete) — batch deletes | ≤12 | 600 | ~0.6s |
291
331
  | 4,000-file rollback snapshot — batch Git | ≤24 (4 x `mktree`, 4 x `commit-tree`) | — | ~7s |
292
332
 
293
- The 104-file standalone apply probe (10 warmup iterations + 10 measured) completes in approximately 3.9s post-optimization on the TypeScript path (down from 14.3s before batching), and approximately 2.8s on the native Rust path with durable pack caching and indexed blob reads.
333
+ The 104-file standalone apply probe (10 warmup iterations + 10 measured) completes in approximately 3.9s post-optimization on the TypeScript path, and approximately 2.8s on the native Rust path.
334
+
335
+ ### Performance Optimizations
336
+
337
+ Several hot paths were profiled and corrected from quadratic (n doubled ≈ 4× cost) to near-linear scaling. Each change was verified against the existing test suite (425+ tests) plus fault-injection and adversarial-diff tests. Optimization order was guided by real profile data rather than static code review alone. After this phase, attention shifted to the native Rust helper path, which dominates production workloads.
338
+
339
+ #### Wall-clock comparison (n changed files + n ignored files)
340
+
341
+ **Plan phase** — undo diff derivation and workspace topology check:
342
+
343
+ | n | Before | After | Improvement |
344
+ |---|---|---|---|
345
+ | 1,000 | ~178ms | ~131ms | 1.4× |
346
+ | 2,000 | ~399ms | ~259ms | 1.5× |
347
+ | 5,000 | ~2.5s | ~699ms | ~3.6× |
348
+
349
+ **Bidirectional durable pack prepare** — agent-settled caching for undo/redo:
350
+
351
+ | n | Before (no batch) | After (batched) | Improvement |
352
+ |---|---|---|---|
353
+ | 500 | ~949ms | ~249ms | 3.8× |
354
+ | 1,000 | ~2,940ms | ~386ms | 7.6× |
355
+ | 2,000 | ~10,512ms | ~677ms | 15.5× |
356
+
357
+ **Native apply** — the production hot path:
358
+
359
+ | n | TypeScript fallback | Native Rust helper | Improvement |
360
+ |---|---|---|---|
361
+ | 500 | ~2,572ms | ~253ms | 10.2× |
362
+ | 1,000 | ~5,726ms | ~374ms | 15.3× |
363
+ | 2,000 | ~10,521ms | ~646ms | 16.3× |
364
+
365
+ #### Algorithm microbenchmarks
366
+
367
+ | Scenario | Before | After | Improvement |
368
+ |---|---|---|---|
369
+ | 8,000×8,000 path overlap check | ~1,352ms | ~7ms | **193×** |
370
+ | 4,000-directory ignored prefix scan (plan) | ~544ms | ~161ms | **3.4×** |
371
+ | 5,000×5,000 manifest integrity verify | ~610ms | ~7ms | **87×** |
372
+
373
+ #### Key enablers
374
+
375
+ - **Manifest context batching** (`SnapshotStore.readBlobs`): one manifest read and full revalidation serves all blob fetches per operation instead of one manifest load per blob. 2,000-file pack creation dropped from 10.5s to 796ms.
376
+ - **Blob read batch sizing** (`BLOB_BATCH_MAX_ENTRIES`: 256 → 2,048): 40 `cat-file --batch` processes collapsed to 6 for 5,000 files by letting the 16MiB byte budget drive batch boundaries.
377
+ - **Path overlap indexing** (`pathSetsOverlap` in `path-safety.ts`): sorted binary search plus ancestor enumeration replaces a nested `some()` pattern. 8,000×8,000 path pairs from 1.35s to 7ms.
378
+ - **Ignored-proof prefix index** (`IgnoredProofIndex` in `restore-engine.ts`): lazily sorted binary lower-bound search for directory prefix lookups replaces a full-set spread per candidate. 4,000 directories from 544ms to 161ms.
379
+ - **WAL ordinal indexing** (`loadOrdinal` in `mutation-journal.ts`): direct array access by contiguous ordinal replaces linear `.find()` throughout quarantine and legacy recovery.
380
+ - **Native Rust no-clobber helper** (`native/pi-undo-fs`): platform native hardlink operations with EEXIST guard. Processes in ~0.3ms per file vs ~5.3ms for the TypeScript fallback, with WAL materialized from the durable pack.
294
381
 
295
382
  ## License
296
383
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -84,10 +84,12 @@ export async function createNativeFileBatch(options: {
84
84
  function nativeExecutable(): string | undefined {
85
85
  const platform = process.platform === "darwin"
86
86
  ? "darwin"
87
- : process.platform === "linux" ? "linux" : undefined;
87
+ : process.platform === "linux" ? "linux"
88
+ : process.platform === "win32" ? "win32" : undefined;
88
89
  const architecture = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : undefined;
89
90
  if (platform === undefined || architecture === undefined) return undefined;
90
- return fileURLToPath(new URL(`../native/bin/pi-undo-fs-${platform}-${architecture}`, import.meta.url));
91
+ const extension = process.platform === "win32" ? ".exe" : "";
92
+ return fileURLToPath(new URL(`../native/bin/pi-undo-fs-${platform}-${architecture}${extension}`, import.meta.url));
91
93
  }
92
94
 
93
95
  function runNative(executable: string, requestPath: string, expected: number): Promise<void> {
@@ -169,7 +169,7 @@ export class RestoreEngine {
169
169
  this.readOwnedPaths(current, canonicalScopePaths),
170
170
  this.readOwnedPaths(target, canonicalScopePaths),
171
171
  ]);
172
- const targetIgnoredPaths = ignoredWorkspacePaths(target);
172
+ const targetIgnoredProof = new IgnoredProofIndex(ignoredWorkspacePaths(target));
173
173
  const deleteByRoot = new Map<string, string[]>();
174
174
  const writeByRoot = new Map<string, string[]>();
175
175
 
@@ -183,7 +183,7 @@ export class RestoreEngine {
183
183
  continue;
184
184
  }
185
185
  if (targetOwned === undefined) {
186
- if (isProtectedByIgnoredProof(path, owned.entry.kind, targetIgnoredPaths)) {
186
+ if (targetIgnoredProof.isProtected(path, owned.entry.kind)) {
187
187
  continue;
188
188
  }
189
189
  appendPath(deleteByRoot, owned.root.relativeRoot, path);
@@ -1728,15 +1728,42 @@ function ignoredWorkspacePaths(manifest: SnapshotManifest): Set<string> {
1728
1728
  ));
1729
1729
  }
1730
1730
 
1731
- function isProtectedByIgnoredProof(
1732
- path: string,
1733
- kind: RestorePath["kind"],
1734
- ignoredPaths: ReadonlySet<string>,
1735
- ): boolean {
1736
- if (kind !== "directory") {
1737
- return ignoredPaths.has(path);
1731
+ /**
1732
+ * ignored 证明的前缀索引。
1733
+ *
1734
+ * 目录判定需要回答"是否存在以 `${path}/` 开头的 ignored 路径"。逐次线性扫描
1735
+ * 整个 set 会让"目录数 × ignored 数"变成平方项,因此这里预排序一次,
1736
+ * 之后每次判定用二分下界定位第一个不小于前缀的元素。
1737
+ *
1738
+ * 语义与线性扫描完全一致:只回答存在性,不改变 fail-closed 行为,也不放宽
1739
+ * 任何 ignored 保护。非目录仍走精确 `has()`。
1740
+ */
1741
+ class IgnoredProofIndex {
1742
+ private readonly exact: ReadonlySet<string>;
1743
+ private sortedPaths: readonly string[] | undefined;
1744
+
1745
+ constructor(paths: ReadonlySet<string>) {
1746
+ this.exact = paths;
1747
+ }
1748
+
1749
+ isProtected(path: string, kind: RestorePath["kind"]): boolean {
1750
+ if (kind !== "directory") {
1751
+ return this.exact.has(path);
1752
+ }
1753
+ if (this.exact.size === 0) return false;
1754
+ // 排序成本只在第一次目录判定时付出,纯文件计划完全不触发。
1755
+ this.sortedPaths ??= [...this.exact].sort();
1756
+ const sorted = this.sortedPaths;
1757
+ const prefix = `${path}/`;
1758
+ let low = 0;
1759
+ let high = sorted.length;
1760
+ while (low < high) {
1761
+ const middle = (low + high) >>> 1;
1762
+ if (sorted[middle]! < prefix) low = middle + 1;
1763
+ else high = middle;
1764
+ }
1765
+ return low < sorted.length && sorted[low]!.startsWith(prefix);
1738
1766
  }
1739
- return [...ignoredPaths].some((ignoredPath) => ignoredPath.startsWith(`${path}/`));
1740
1767
  }
1741
1768
 
1742
1769
  function rootBoundaryDirectories(root: string): string[] {