@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 +17 -8
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +5 -2
- package/src/atomic-fs.ts +2 -2
- package/src/controller.ts +71 -21
- package/src/durable-pack.ts +573 -0
- package/src/journal.ts +33 -6
- package/src/mutation-journal.ts +6 -0
- package/src/native-restore.ts +146 -0
- package/src/packed-recovery.ts +324 -0
- package/src/path-safety.ts +31 -0
- package/src/pi-runtime.ts +96 -5
- package/src/quarantine.ts +58 -32
- package/src/restore-engine.ts +448 -36
- package/src/snapshot-store.ts +110 -41
- package/src/workspace-lock.ts +5 -1
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
|
|
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
|
|
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.
|
|
152
|
-
3. Capture a safety
|
|
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
|
|
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 (
|
|
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
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davideasden/pi-undo",
|
|
3
|
-
"version": "0.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": "
|
|
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.
|
|
330
|
-
observedLogicalLeaf: event.newLeafId,
|
|
331
|
-
|
|
332
|
-
|
|
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.
|
|
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", () =>
|
|
424
|
-
|
|
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",
|
|
450
|
-
|
|
451
|
-
|
|
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",
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
|
|
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 {
|