@davideasden/pi-undo 0.2.2 → 0.2.4

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 128 files per batch), parallel target artifact I/O (32 concurrent), concurrent request preparation (32 concurrent scoped blob reads plus fingerprint computation), scoped safety snapshots, and batch multi-file deletes keep restore fast at scale.
18
+ - **Performance** — batch WAL operations (up to 1,024 files per batch), scoped safety snapshots, prebuilt durable transaction packs, and an optional precompiled native regular-file helper keep restore fast at scale. Unsupported platforms and failed integrity checks automatically use the TypeScript path.
19
19
 
20
20
  ## Requirements
21
21
 
@@ -123,7 +123,10 @@ ok files:104 total:~1050ms apply:~850ms capture:~90ms journal:~65ms
123
123
 
124
124
  Performance gains come from:
125
125
 
126
- - **Batch WAL durability** — write-once, fsync-once per batch of up to 128 entries instead of per-file.
126
+ - **Batch WAL durability** — write-once, fsync-once per batch of up to 1,024 entries instead of per-file.
127
+ - **Durable transaction packs** — source and target variants, fingerprints, modes, artifact names, and checksums are published and fsynced before native workspace mutation.
128
+ - **Prebuilt reverse/forward packs** — completed agent runs prepare both directions and pin their manifests outside the undo/redo command hot path.
129
+ - **Optional native helper** — verified regular-file batches use platform no-clobber primitives while retaining same-inode ownership artifacts; missing binaries or failed validation fall back before mutation.
127
130
  - **Scoped safety snapshots** — only paths recorded in a checkpoint's `changedPaths` are snapshotted for undo/redo, not the entire workspace.
128
131
  - **Parallel target I/O** — up to 32 target artifact files are written and synced concurrently.
129
132
  - **Concurrent request preparation** — blob reads, live-state checks, and fingerprint computation run at 32-wide concurrency.
@@ -148,8 +151,8 @@ Snapshots use a private Git object database and a temporary Git index. They neve
148
151
  The restore flow is:
149
152
 
150
153
  1. Validate current session, workspace topology, and target manifest.
151
- 2. Write a durable WAL (`INTENT`) before changing any files.
152
- 3. Capture a safety or rescue snapshot when required.
154
+ 2. Establish mutation authority before changing files: either durable JSON `INTENT` records on the TypeScript path or a complete fsynced transaction pack on the native path.
155
+ 3. Capture a safety snapshot when content differs from the preverified checkpoint source; otherwise reuse the pinned checkpoint manifest.
153
156
  4. Move the Pi session cursor to the target boundary.
154
157
  5. Restore workspace paths with fingerprint and inode checks and no-clobber installation.
155
158
  6. Verify the resulting session and workspace before committing the journal (`TARGET_VERIFIED` → `CLEANED`).
@@ -169,9 +172,9 @@ INTENT → SOURCE_QUARANTINED → SOURCE_VERIFIED → TARGET_INSTALLED → TARGE
169
172
  - **TARGET_VERIFIED** — workspace confirmed matching target state.
170
173
  - **CLEANED** — source and target artifacts removed.
171
174
 
172
- Batch operations (`beginMany`, `advanceBatch`) maintain the same per-file six-state contract while reducing physical fsync calls by grouping entries.
175
+ Batch operations (`beginMany`, `advanceBatch`) maintain the same per-file six-state contract while reducing physical fsync calls by grouping entries. On the native path, the transaction pack is the pre-mutation `INTENT` authority; finalization or startup recovery materializes the same six per-file states into the append-only WAL before cleanup.
173
176
 
174
- Every record is checksum-linked to its predecessor. The journal is append-only and never mutated in place.
177
+ Every WAL record is checksum-linked to its predecessor. The journal is append-only and never mutated in place. Native finalization advances through `TARGET_VERIFIED`, fsyncs the installed inode while its ownership marker is still present, then removes exact artifacts and appends `CLEANED`.
175
178
 
176
179
  ### Quarantine and External Concurrency
177
180
 
@@ -198,7 +201,7 @@ When `recovery_required` appears, first back up the workspace and Pi session JSO
198
201
  <sessionDir>/.pi-undo/transactions/
199
202
  ```
200
203
 
201
- A transaction directory may contain `descriptor.json`, `restore-plan.json`, `state.json`, and the mutation journal. Do not delete `.pi-undo` without a backup: unresolved quarantine artifacts may be the only surviving copy of a file version.
204
+ 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.
202
205
 
203
206
  ## Limitations
204
207
 
@@ -235,6 +238,9 @@ src/
235
238
  journal.ts Transaction journal (session/descriptor/phase)
236
239
  model.ts Core types: manifest, root, session, plan
237
240
  mutation-journal.ts WAL mutation journal (hash chain, batch ops)
241
+ durable-pack.ts Pre-mutation source/target authority and finalization
242
+ native-restore.ts Optional platform helper adapter with TypeScript fallback
243
+ packed-recovery.ts Pack-to-WAL rollback/roll-forward recovery
238
244
  path-safety.ts Symlink escape, relative path safety
239
245
  pi-runtime.ts Pi integration layer (session/extension bridge)
240
246
  quarantine.ts File isolation, no-clobber install, external concurrency
@@ -245,13 +251,16 @@ src/
245
251
  snapshot-store.ts Git-backed content-addressed snapshot store
246
252
  status-reporter.ts Phase timing and footer status
247
253
  workspace-lock.ts Cross-instance workspace lock
254
+ native/pi-undo-fs/ Rust no-clobber regular-file helper
255
+ native/bin/ Precompiled platform binaries included in release packages
248
256
  test/ Unit, integration, recovery, and fault-injection tests
249
257
  ```
250
258
 
251
259
  ### Testing
252
260
 
253
261
  ```bash
254
- npm test # Full test suite (381+ tests)
262
+ npm test # Full test suite (400+ tests)
263
+ npm run test:native # Rust helper and durable-pack recovery tests
255
264
  npm run test:watch # Watch mode
256
265
  npm run test:integration # Pi runtime and extension integration tests
257
266
  npm run typecheck # TypeScript type checking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -32,12 +32,15 @@
32
32
  "files": [
33
33
  "extensions",
34
34
  "src",
35
+ "native/bin",
35
36
  "README.md"
36
37
  ],
37
38
  "scripts": {
38
39
  "test": "vitest run",
39
40
  "test:watch": "vitest",
40
41
  "test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts",
42
+ "test:native": "cargo test --manifest-path native/pi-undo-fs/Cargo.toml && vitest run test/native-restore.test.ts test/durable-pack.test.ts test/packed-recovery.test.ts",
43
+ "build:native": "cargo build --release --manifest-path native/pi-undo-fs/Cargo.toml",
41
44
  "typecheck": "tsc --noEmit",
42
45
  "pack:dry-run": "npm pack --dry-run"
43
46
  },
@@ -49,7 +52,7 @@
49
52
  "proper-lockfile": "4.1.2"
50
53
  },
51
54
  "devDependencies": {
52
- "@earendil-works/pi-coding-agent": "file:resources/pi-0.80.10/packages/coding-agent",
55
+ "@earendil-works/pi-coding-agent": "0.80.10",
53
56
  "@types/node": "24.12.4",
54
57
  "@types/proper-lockfile": "4.1.4",
55
58
  "typescript": "5.9.3",
package/src/atomic-fs.ts CHANGED
@@ -99,13 +99,13 @@ export async function writeBytesExclusive(
99
99
  file: string,
100
100
  bytes: Uint8Array,
101
101
  mode: number,
102
- options: { readonly syncDirectory?: boolean } = {},
102
+ options: { readonly syncDirectory?: boolean; readonly syncFile?: boolean } = {},
103
103
  ): Promise<void> {
104
104
  const handle = await open(file, "wx", mode);
105
105
  try {
106
106
  await handle.writeFile(Buffer.from(bytes));
107
107
  await handle.chmod(mode);
108
- await handle.sync();
108
+ if (options.syncFile !== false) await handle.sync();
109
109
  } finally {
110
110
  await handle.close();
111
111
  }
package/src/controller.ts CHANGED
@@ -37,6 +37,11 @@ export interface ControllerDependencies {
37
37
  readonly appendControl: (customType: string, data?: unknown) => Promise<string | null>;
38
38
  readonly appendCursor: (cursor: CursorState) => Promise<CursorAppendResult>;
39
39
  readonly capture: (scopePaths?: readonly string[]) => Promise<SnapshotManifest>;
40
+ readonly captureSafety?: (
41
+ referenceManifestId: ManifestId,
42
+ targetManifestId: ManifestId,
43
+ scopePaths: readonly string[],
44
+ ) => Promise<SnapshotManifest>;
40
45
  readonly changedPaths: (before: SnapshotManifest, after: SnapshotManifest) => Promise<readonly string[]>;
41
46
  readonly loadManifest: (id: ManifestId) => Promise<SnapshotManifest>;
42
47
  readonly planRestore: (
@@ -44,6 +49,11 @@ export interface ControllerDependencies {
44
49
  target: SnapshotManifest,
45
50
  scopePaths?: readonly string[],
46
51
  ) => Promise<RestorePlan>;
52
+ readonly prepareDurableRestore?: (
53
+ current: SnapshotManifest,
54
+ target: SnapshotManifest,
55
+ scopePaths: readonly string[],
56
+ ) => Promise<void>;
47
57
  readonly applyRestore: (
48
58
  plan: RestorePlan,
49
59
  target: SnapshotManifest,
@@ -65,6 +75,13 @@ export interface JournalPort {
65
75
  phase: "SESSION_MOVED" | "APPLYING" | "FILES_VERIFIED" | "CURSOR_COMMITTED" | "ABORTING" | "ABORTED" | "RECOVERY_REQUIRED",
66
76
  options?: { readonly observedLogicalLeaf?: string | null },
67
77
  ): Promise<void>;
78
+ setPhases?(
79
+ opId: string,
80
+ transitions: ReadonlyArray<{
81
+ readonly phase: Parameters<JournalPort["setPhase"]>[1];
82
+ readonly observedLogicalLeaf?: string | null;
83
+ }>,
84
+ ): Promise<void>;
68
85
  markCommitted(opId: string): Promise<void>;
69
86
  loadPending(): Promise<readonly unknown[]>;
70
87
  }
@@ -247,6 +264,12 @@ export class UndoControllerImpl implements UndoController {
247
264
  try {
248
265
  const after = await this.captureWithWorkspaceLock();
249
266
  const changedPaths = await this.dependencies.changedPaths(staged.before, after);
267
+ 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(() => {});
272
+ }
250
273
  const endLeafId = this.dependencies.getLogicalLeafId() ?? staged.startEntryId;
251
274
  const checkpoint = this.createCheckpoint(staged, after, changedPaths, userEntryId, endLeafId);
252
275
  const checkpointEntryId = await this.dependencies.appendControl("pi-undo:checkpoint", checkpoint);
@@ -326,10 +349,10 @@ export class UndoControllerImpl implements UndoController {
326
349
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
327
350
  return;
328
351
  }
329
- await this.dependencies.journal.setPhase(pending.descriptor.opId, "SESSION_MOVED", {
330
- observedLogicalLeaf: event.newLeafId,
331
- });
332
- await this.dependencies.journal.setPhase(pending.descriptor.opId, "APPLYING");
352
+ await this.setJournalPhases(pending.descriptor.opId, [
353
+ { phase: "SESSION_MOVED", observedLogicalLeaf: event.newLeafId },
354
+ { phase: "APPLYING" },
355
+ ]);
333
356
  const applied = await this.dependencies.applyRestore(
334
357
  pending.plan,
335
358
  pending.target,
@@ -409,10 +432,22 @@ export class UndoControllerImpl implements UndoController {
409
432
  if (checkpoint.changedPaths.length === 0) {
410
433
  return done(await this.runSessionOnlyOperation(action, checkpoint, targetManifestId, profile));
411
434
  }
435
+ const restoreTargetManifestId = targetManifestId ?? (
436
+ action === "undo" ? checkpoint.beforeManifestId : checkpoint.afterManifestId
437
+ );
438
+ const referenceManifestId = action === "undo"
439
+ ? checkpoint.afterManifestId
440
+ : checkpoint.beforeManifestId;
412
441
  let rollback: SnapshotManifest;
413
442
  try {
414
443
  rollback = await profile.measure("capture", () =>
415
- this.dependencies.capture(checkpoint.changedPaths));
444
+ this.dependencies.captureSafety === undefined
445
+ ? this.dependencies.capture(checkpoint.changedPaths)
446
+ : this.dependencies.captureSafety(
447
+ referenceManifestId,
448
+ restoreTargetManifestId,
449
+ checkpoint.changedPaths,
450
+ ));
416
451
  } catch {
417
452
  return done({ code: "capture_failed", changedFiles: 0 });
418
453
  }
@@ -420,9 +455,8 @@ export class UndoControllerImpl implements UndoController {
420
455
  let plan: RestorePlan;
421
456
  let targetLogicalLeaf: string | null;
422
457
  try {
423
- target = await profile.measure("load", () => this.dependencies.loadManifest(
424
- targetManifestId ?? (action === "undo" ? checkpoint.beforeManifestId : checkpoint.afterManifestId),
425
- ));
458
+ target = await profile.measure("load", () =>
459
+ this.dependencies.loadManifest(restoreTargetManifestId));
426
460
  plan = await profile.measure("plan", () =>
427
461
  this.dependencies.planRestore(rollback, target, checkpoint.changedPaths));
428
462
  targetLogicalLeaf = this.dependencies.resolveSessionTarget(action, checkpoint);
@@ -446,12 +480,10 @@ export class UndoControllerImpl implements UndoController {
446
480
  this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
447
481
  return done({ code: "recovery_required", changedFiles: 0 });
448
482
  }
449
- await profile.measure("journal", async () => {
450
- await this.dependencies.journal.setPhase(descriptor.opId, "SESSION_MOVED", {
451
- observedLogicalLeaf: navigation.logicalLeafId,
452
- });
453
- await this.dependencies.journal.setPhase(descriptor.opId, "APPLYING");
454
- });
483
+ await profile.measure("journal", () => this.setJournalPhases(descriptor.opId, [
484
+ { phase: "SESSION_MOVED", observedLogicalLeaf: navigation.logicalLeafId },
485
+ { phase: "APPLYING" },
486
+ ]));
455
487
  const applied = await profile.measure("apply", () =>
456
488
  this.dependencies.applyRestore(plan, target, { opId: descriptor.opId }));
457
489
  if (applied.code !== "ok") {
@@ -532,13 +564,11 @@ export class UndoControllerImpl implements UndoController {
532
564
  this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
533
565
  return { code: "recovery_required", changedFiles: 0 };
534
566
  }
535
- await profile.measure("journal", async () => {
536
- await this.dependencies.journal.setPhase(descriptor.opId, "SESSION_MOVED", {
537
- observedLogicalLeaf: navigation.logicalLeafId,
538
- });
539
- await this.dependencies.journal.setPhase(descriptor.opId, "APPLYING");
540
- await this.dependencies.journal.setPhase(descriptor.opId, "FILES_VERIFIED");
541
- });
567
+ await profile.measure("journal", () => this.setJournalPhases(descriptor.opId, [
568
+ { phase: "SESSION_MOVED", observedLogicalLeaf: navigation.logicalLeafId },
569
+ { phase: "APPLYING" },
570
+ { phase: "FILES_VERIFIED" },
571
+ ]));
542
572
  const cursorResult = await profile.measure("cursor", () =>
543
573
  this.dependencies.appendCursor(this.createCursor(descriptor, action, checkpoint)));
544
574
  if (cursorResult.kind === "recovery_required") {
@@ -573,6 +603,26 @@ export class UndoControllerImpl implements UndoController {
573
603
  }
574
604
  }
575
605
 
606
+ private async setJournalPhases(
607
+ opId: string,
608
+ transitions: ReadonlyArray<{
609
+ readonly phase: Parameters<JournalPort["setPhase"]>[1];
610
+ readonly observedLogicalLeaf?: string | null;
611
+ }>,
612
+ ): Promise<void> {
613
+ if (this.dependencies.journal.setPhases !== undefined) {
614
+ await this.dependencies.journal.setPhases(opId, transitions);
615
+ return;
616
+ }
617
+ for (const transition of transitions) {
618
+ await this.dependencies.journal.setPhase(opId, transition.phase, {
619
+ ...(transition.observedLogicalLeaf === undefined
620
+ ? {}
621
+ : { observedLogicalLeaf: transition.observedLogicalLeaf }),
622
+ });
623
+ }
624
+ }
625
+
576
626
  private async ensureIdle(): Promise<boolean> {
577
627
  if (this.dependencies.isAgentIdle()) return true;
578
628
  try {