@davideasden/pi-undo 0.2.0 → 0.2.1
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 +147 -85
- package/extensions/pi-undo.ts +4 -1
- package/package.json +1 -1
- package/src/atomic-fs.ts +7 -2
- package/src/controller.ts +89 -35
- package/src/mutation-journal.ts +180 -40
- package/src/pi-runtime.ts +2 -2
- package/src/quarantine.ts +357 -17
- package/src/restore-engine.ts +269 -49
- package/src/snapshot-store.ts +316 -63
- package/src/status-reporter.ts +15 -2
package/README.md
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
1
|
# pi-undo
|
|
2
2
|
|
|
3
|
-
Persistent undo and redo for [Pi](https://github.com/badlogic/pi-mono)
|
|
3
|
+
**Persistent workspace undo and redo for [Pi](https://github.com/badlogic/pi-mono).**
|
|
4
4
|
|
|
5
|
-
Each
|
|
5
|
+
Each completed agent run creates a checkpoint that captures both the Pi session boundary and a content-addressed workspace snapshot. `/undo` moves Pi's session tree to a prior run and restores the matching workspace. `/redo` reverses the undo with a safety snapshot taken before the operation. The system is crash-safe: an interrupted restore is rolled forward or back on next startup using durable journals, write-ahead logging, and quarantine artifacts.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- Persistent
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
9
|
+
- **Persistent undo / redo** across Pi restarts. History is stored per-session and survives crashes.
|
|
10
|
+
- **Diff viewer** — `/diff` and `/diff N` show before-and-after file changes with colored, scrollable output in TUI mode and a summary in non-TUI modes.
|
|
11
|
+
- **Session + workspace** — Pi's session tree and workspace files are restored together as a unit.
|
|
12
|
+
- **Safety snapshots** — `undo` captures the current workspace as a redo safety snapshot; `tree navigation` records a rescue snapshot before switching branches.
|
|
13
|
+
- **Deferred prompts** — text, images, `@file` references, and paste content entered during an undo/redo are queued and processed in FIFO order once the operation completes. Prompts are never lost.
|
|
14
|
+
- **Crash recovery** — a write-ahead log (WAL), mutation journal, and quarantine artifacts allow in-progress operations to be rolled forward or back on restart.
|
|
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
|
+
- **No Git workflow** — snapshots use a private object database. No `git commit`, `git stash`, `git reset`, branches, or forges are required.
|
|
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.
|
|
17
19
|
|
|
18
20
|
## Requirements
|
|
19
21
|
|
|
20
22
|
- Pi `0.80.10` or a compatible release.
|
|
21
23
|
- Node.js `22.19.0` or later.
|
|
22
|
-
- Git available on `PATH
|
|
23
|
-
|
|
24
|
-
Git is used internally to create private, content-addressed snapshots. You do not need to initialize a repository or run Git commands yourself.
|
|
24
|
+
- Git available on `PATH` (used internally for content-addressed snapshots).
|
|
25
25
|
|
|
26
26
|
## Installation
|
|
27
27
|
|
|
28
|
-
Install the published
|
|
28
|
+
Install the published package from npm:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
31
|
pi install npm:@davideasden/pi-undo
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
Restart Pi
|
|
34
|
+
Restart Pi if the extension is not already loaded.
|
|
35
35
|
|
|
36
|
-
To install a local checkout
|
|
36
|
+
To install from a local checkout:
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
pi install
|
|
39
|
+
pi install /path/to/pi-undo
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
During development,
|
|
42
|
+
During development, load the extension directly:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
45
|
pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
|
|
@@ -47,7 +47,7 @@ pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
|
|
|
47
47
|
|
|
48
48
|
## Usage
|
|
49
49
|
|
|
50
|
-
Work with Pi normally. `pi-undo` records a boundary after each completed agent run.
|
|
50
|
+
Work with Pi normally. `pi-undo` automatically records a boundary after each completed agent run.
|
|
51
51
|
|
|
52
52
|
### Diff
|
|
53
53
|
|
|
@@ -55,15 +55,15 @@ Work with Pi normally. `pi-undo` records a boundary after each completed agent r
|
|
|
55
55
|
/diff
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
Shows files changed by the most recent completed run. In TUI mode, select a file to open a colored, scrollable before-and-after comparison. Binary files are listed without line-by-line diff.
|
|
59
59
|
|
|
60
|
-
Use a one-based
|
|
60
|
+
Use a one-based position to inspect an earlier run (`1` is the most recent):
|
|
61
61
|
|
|
62
62
|
```text
|
|
63
|
-
/diff
|
|
63
|
+
/diff 3
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
Print, JSON, and RPC modes report a one-line file and line-count summary
|
|
66
|
+
Print, JSON, and RPC modes report a one-line file and line-count summary.
|
|
67
67
|
|
|
68
68
|
### Undo
|
|
69
69
|
|
|
@@ -71,9 +71,9 @@ Print, JSON, and RPC modes report a one-line file and line-count summary instead
|
|
|
71
71
|
/undo
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
Returns to the previous completed run on the current branch. Before restoring, it captures the current workspace as a redo safety snapshot.
|
|
75
75
|
|
|
76
|
-
After a successful undo,
|
|
76
|
+
After a successful undo, text entered during the operation is replayed into the editor. RPC mode reports the refill request; print and JSON modes do not replay prompts.
|
|
77
77
|
|
|
78
78
|
### Redo
|
|
79
79
|
|
|
@@ -81,7 +81,7 @@ After a successful undo, `pi-undo` tries to put the original prompt back into an
|
|
|
81
81
|
/redo
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
Restores the session and workspace captured immediately before the corresponding undo. The redo safety snapshot preserves edits made between the undo and the redo.
|
|
85
85
|
|
|
86
86
|
### Tree Navigation
|
|
87
87
|
|
|
@@ -91,16 +91,45 @@ Continue to use Pi's native command:
|
|
|
91
91
|
/tree
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
Before Pi moves to another session-tree boundary, `pi-undo` validates the target and records a rescue snapshot. After navigation
|
|
94
|
+
Before Pi moves to another session-tree boundary, `pi-undo` validates the target and records a rescue snapshot. After navigation it restores the matching workspace.
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
While the agent is streaming, `/undo` and `/redo` request an abort and wait for idle. If the wait times out, no files are restored. Native `/tree` is cancelled while streaming and can be retried when idle.
|
|
97
97
|
|
|
98
|
-
The footer shows
|
|
98
|
+
The footer shows available history, for example:
|
|
99
99
|
|
|
100
100
|
```text
|
|
101
101
|
undo:2 redo:1
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
When crash recovery is pending, the footer shows:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
recovery_required
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Performance Notes
|
|
111
|
+
|
|
112
|
+
Real-world measurements from a 104-file undo operation before optimizations:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
ok files:104 total:8797ms apply:8591ms capture:89ms journal:64ms commit:27ms plan:11ms
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
After batch WAL, scoped safety snapshots, parallel restore I/O, and batch file deletes:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
ok files:104 total:~1050ms apply:~850ms capture:~90ms journal:~65ms
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Performance gains come from:
|
|
125
|
+
|
|
126
|
+
- **Batch WAL durability** — write-once, fsync-once per batch of up to 128 entries instead of per-file.
|
|
127
|
+
- **Scoped safety snapshots** — only paths recorded in a checkpoint's `changedPaths` are snapshotted for undo/redo, not the entire workspace.
|
|
128
|
+
- **Parallel target I/O** — up to 32 target artifact files are written and synced concurrently.
|
|
129
|
+
- **Concurrent request preparation** — blob reads, live-state checks, and fingerprint computation run at 32-wide concurrency.
|
|
130
|
+
- **Batch file deletes** — delete operations share the same WAL batch and directory fsync merging.
|
|
131
|
+
- **Directory fsync merging** — barriers are applied once per unique directory per phase, not once per file.
|
|
132
|
+
|
|
104
133
|
## How It Works
|
|
105
134
|
|
|
106
135
|
Private state is stored alongside the Pi session:
|
|
@@ -109,48 +138,78 @@ Private state is stored alongside the Pi session:
|
|
|
109
138
|
<sessionDir>/.pi-undo/
|
|
110
139
|
```
|
|
111
140
|
|
|
112
|
-
For every completed agent run, `pi-undo` captures
|
|
141
|
+
For every completed agent run, `pi-undo` captures:
|
|
142
|
+
|
|
143
|
+
1. A **session boundary** — the logical point in Pi's session tree.
|
|
144
|
+
2. A **workspace manifest** — a content-addressed snapshot of the workspace root(s).
|
|
145
|
+
|
|
146
|
+
Snapshots use a private Git object database and a temporary Git index. They never touch the repository's normal history, `HEAD`, reflog, or stash.
|
|
113
147
|
|
|
114
|
-
|
|
148
|
+
The restore flow is:
|
|
115
149
|
|
|
116
|
-
1. Validate
|
|
117
|
-
2. Write a durable
|
|
150
|
+
1. Validate current session, workspace topology, and target manifest.
|
|
151
|
+
2. Write a durable WAL (`INTENT`) before changing any files.
|
|
118
152
|
3. Capture a safety or rescue snapshot when required.
|
|
119
|
-
4. Move the Pi session to the target
|
|
120
|
-
5. Restore workspace paths with fingerprint checks and no-clobber installation.
|
|
121
|
-
6. Verify the resulting session and workspace before committing the journal.
|
|
153
|
+
4. Move the Pi session cursor to the target boundary.
|
|
154
|
+
5. Restore workspace paths with fingerprint and inode checks and no-clobber installation.
|
|
155
|
+
6. Verify the resulting session and workspace before committing the journal (`TARGET_VERIFIED` → `CLEANED`).
|
|
122
156
|
|
|
123
|
-
|
|
157
|
+
### Mutation Journal (WAL)
|
|
124
158
|
|
|
125
|
-
|
|
159
|
+
Each file change is tracked through a six-state hash chain:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
INTENT → SOURCE_QUARANTINED → SOURCE_VERIFIED → TARGET_INSTALLED → TARGET_VERIFIED → CLEANED
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- **INTENT** — durable intent recorded before any mutation.
|
|
166
|
+
- **SOURCE_QUARANTINED** — original file hard-linked to a random artifact name.
|
|
167
|
+
- **SOURCE_VERIFIED** — original removed, artifact content verified.
|
|
168
|
+
- **TARGET_INSTALLED** — new file hard-linked into place (no-clobber).
|
|
169
|
+
- **TARGET_VERIFIED** — workspace confirmed matching target state.
|
|
170
|
+
- **CLEANED** — source and target artifacts removed.
|
|
126
171
|
|
|
127
|
-
|
|
172
|
+
Batch operations (`beginMany`, `advanceBatch`) maintain the same per-file six-state contract while reducing physical fsync calls by grouping entries.
|
|
128
173
|
|
|
129
|
-
|
|
174
|
+
Every record is checksum-linked to its predecessor. The journal is append-only and never mutated in place.
|
|
130
175
|
|
|
131
|
-
|
|
132
|
-
- With a trusted cursor marker, it restores the target manifest, completes cursor durability, and commits the transaction.
|
|
133
|
-
- If the journal, session identity, logical leaf, manifest, cursor, or workspace contents conflict, it enters `recovery required` and blocks further history mutation.
|
|
176
|
+
### Quarantine and External Concurrency
|
|
134
177
|
|
|
135
|
-
|
|
178
|
+
Ordinary files and symlinks are restored through same-directory, same-filesystem artifacts:
|
|
179
|
+
|
|
180
|
+
- **Source capture**: `link(original, sourceArtifact)` — fails with `EEXIST` if artifact already exists.
|
|
181
|
+
- **Target installation**: `link(targetArtifact, original)` — fails with `EEXIST` if original was externally recreated.
|
|
182
|
+
- **Fingerprint + inode**: before each state transition, both content fingerprint and `(dev, ino)` identity are verified. A matching fingerprint with a different inode is treated as external replacement and triggers a safe fail-closed state.
|
|
183
|
+
- **Artifact cleanup**: only artifacts registered in the journal with matching paths, names, fingerprints, and ownership are removed. Unclaimed files are never deleted.
|
|
184
|
+
- **Rollback**: from any state, `restoreMutation` converges to the pre-operation state. If a previous target was externally replaced with identical content but a different inode, the external file is preserved and the journal remains active.
|
|
185
|
+
|
|
186
|
+
## Safety and Recovery
|
|
187
|
+
|
|
188
|
+
- Pi `HEAD`, index, refs, reflog, stash, config, and other repository metadata are never modified.
|
|
189
|
+
- Nested repositories and submodules are restored as independent roots. Their `.git` directories are not created or deleted.
|
|
190
|
+
- If the process stops during a restore, startup recovery reads the transaction journal:
|
|
191
|
+
- **Without a trusted cursor marker**: restores the rollback manifest and marks the transaction aborted.
|
|
192
|
+
- **With a trusted cursor marker**: completes cursor durability and commits the transaction.
|
|
193
|
+
- **On conflict** (identity, leaf, manifest, cursor, or workspace mismatch): enters `recovery required` and blocks further history mutation.
|
|
194
|
+
|
|
195
|
+
When `recovery_required` appears, first back up the workspace and Pi session JSONL. Transaction diagnostics are stored in:
|
|
136
196
|
|
|
137
197
|
```text
|
|
138
198
|
<sessionDir>/.pi-undo/transactions/
|
|
139
199
|
```
|
|
140
200
|
|
|
141
|
-
|
|
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.
|
|
142
202
|
|
|
143
203
|
## Limitations
|
|
144
204
|
|
|
145
205
|
- Git-ignored files are not included in snapshots and are not created or deleted during restore.
|
|
146
206
|
- Empty directories are not represented in snapshots.
|
|
147
|
-
- Real `.git` metadata is never
|
|
148
|
-
- The workspace lock
|
|
149
|
-
-
|
|
150
|
-
- When ownership or contents cannot be proven, `pi-undo` preserves the available versions and enters `recovery required` instead of guessing, deleting, or overwriting.
|
|
207
|
+
- Real `.git` metadata is never created, modified, or deleted.
|
|
208
|
+
- The workspace lock prevents concurrent `pi-undo` instances, but cannot prevent editors, watchers, or other processes from writing files concurrently.
|
|
209
|
+
- Fingerprint and inode checks reduce the external-concurrency race window, but cannot make the final check-and-unlink atomic in pure Node.js.
|
|
151
210
|
- `--no-session` mode has no durable session cursor. Undo and redo can work in-process, but crash persistence is not guaranteed.
|
|
152
211
|
- Uninitialized gitlinks are not initialized automatically. A broken nested repository causes capture to fail instead of being silently skipped.
|
|
153
|
-
- Workspace files and Pi JSONL are not an operating-system-level ACID transaction. Snapshots, WAL
|
|
212
|
+
- Workspace files and Pi session JSONL are not an operating-system-level ACID transaction. Snapshots, WAL, cursor markers, verification, and idempotent recovery are used to converge to the old or new state.
|
|
154
213
|
|
|
155
214
|
## Development
|
|
156
215
|
|
|
@@ -162,53 +221,56 @@ cd pi-undo
|
|
|
162
221
|
npm install
|
|
163
222
|
```
|
|
164
223
|
|
|
165
|
-
|
|
224
|
+
### Project Layout
|
|
166
225
|
|
|
167
226
|
```text
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
227
|
+
extensions/pi-undo.ts Pi extension entry point
|
|
228
|
+
src/
|
|
229
|
+
atomic-fs.ts Atomic file writes, fsync helpers
|
|
230
|
+
controller.ts Undo/redo/diff controller orchestrator
|
|
231
|
+
diff-ui.ts TUI diff view (Ink/React components)
|
|
232
|
+
diff-view.ts Diff computation and rendering
|
|
233
|
+
encoding.ts checksum, canonical JSON
|
|
234
|
+
git-runner.ts Git subprocess runner with counting
|
|
235
|
+
journal.ts Transaction journal (session/descriptor/phase)
|
|
236
|
+
model.ts Core types: manifest, root, session, plan
|
|
237
|
+
mutation-journal.ts WAL mutation journal (hash chain, batch ops)
|
|
238
|
+
path-safety.ts Symlink escape, relative path safety
|
|
239
|
+
pi-runtime.ts Pi integration layer (session/extension bridge)
|
|
240
|
+
quarantine.ts File isolation, no-clobber install, external concurrency
|
|
241
|
+
recovery.ts Startup crash recovery
|
|
242
|
+
restore-engine.ts Workspace restore engine (plan → apply → verify)
|
|
243
|
+
root-discovery.ts Workspace root topology detection (nested repos, submodules)
|
|
244
|
+
session-state.ts Pi session cursor management
|
|
245
|
+
snapshot-store.ts Git-backed content-addressed snapshot store
|
|
246
|
+
status-reporter.ts Phase timing and footer status
|
|
247
|
+
workspace-lock.ts Cross-instance workspace lock
|
|
248
|
+
test/ Unit, integration, recovery, and fault-injection tests
|
|
187
249
|
```
|
|
188
250
|
|
|
189
|
-
|
|
251
|
+
### Testing
|
|
190
252
|
|
|
191
253
|
```bash
|
|
192
|
-
npm
|
|
254
|
+
npm test # Full test suite (381+ tests)
|
|
255
|
+
npm run test:watch # Watch mode
|
|
256
|
+
npm run test:integration # Pi runtime and extension integration tests
|
|
257
|
+
npm run typecheck # TypeScript type checking
|
|
258
|
+
npm run pack:dry-run # Inspect npm package contents
|
|
193
259
|
```
|
|
194
260
|
|
|
195
|
-
|
|
261
|
+
The typecheck filters known pre-existing issues in Pi's own dependencies (`undici-types`, `@modelcontextprotocol`, `@google/genai`, `ReadonlySessionManager`). Only new project-level errors are enforced.
|
|
196
262
|
|
|
197
|
-
|
|
198
|
-
npm run test:integration
|
|
199
|
-
```
|
|
263
|
+
## Performance Benchmarks
|
|
200
264
|
|
|
201
|
-
|
|
265
|
+
Benchmark tests assert Git call counts and WAL record counts, not wall-clock thresholds, to avoid environmental flakiness. Selected synthetic results (104-file restore, includes fixture setup, capture, plan, and apply):
|
|
202
266
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
267
|
+
| Scenario | Git calls | WAL records | Duration |
|
|
268
|
+
|---|---|---|---|
|
|
269
|
+
| 104-file restore (write) — batch + parallel I/O | ≤12 | 624 | ~1.1s |
|
|
270
|
+
| 100-file restore (delete) — batch deletes | ≤12 | 600 | ~0.6s |
|
|
271
|
+
| 4,000-file rollback snapshot — batch Git | ≤24 (4 x `mktree`, 4 x `commit-tree`) | — | ~7s |
|
|
208
272
|
|
|
209
|
-
|
|
210
|
-
npm run pack:dry-run
|
|
211
|
-
```
|
|
273
|
+
The 104-file standalone apply probe (10 warmup iterations + 10 measured) completes in approximately 3.9s post-optimization, down from 14.3s before batching.
|
|
212
274
|
|
|
213
275
|
## License
|
|
214
276
|
|
package/extensions/pi-undo.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
|
|
1
3
|
import type {
|
|
2
4
|
ExtensionAPI,
|
|
3
5
|
ExtensionCommandContext,
|
|
@@ -135,6 +137,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
135
137
|
activeAction = action;
|
|
136
138
|
active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
|
|
137
139
|
active.setCommandContext?.(context);
|
|
140
|
+
const commandStarted = performance.now();
|
|
138
141
|
let result;
|
|
139
142
|
try {
|
|
140
143
|
result = action === "undo"
|
|
@@ -146,7 +149,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
146
149
|
if (commandSet === activeCommands && commandSet.size === 0) activeAction = undefined;
|
|
147
150
|
}
|
|
148
151
|
if (commandGeneration !== generation || runtime !== active) return;
|
|
149
|
-
active.reporter.result(result);
|
|
152
|
+
active.reporter.result(result, performance.now() - commandStarted);
|
|
150
153
|
const hasDeferredPrompt = deferredPrompts.length > 0 || replaying !== undefined;
|
|
151
154
|
if (
|
|
152
155
|
action === "undo" && result.code === "ok" && result.refillPrompt !== undefined &&
|
package/package.json
CHANGED
package/src/atomic-fs.ts
CHANGED
|
@@ -95,7 +95,12 @@ export async function fsyncDirectory(directory: string): Promise<void> {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export async function writeBytesExclusive(
|
|
98
|
+
export async function writeBytesExclusive(
|
|
99
|
+
file: string,
|
|
100
|
+
bytes: Uint8Array,
|
|
101
|
+
mode: number,
|
|
102
|
+
options: { readonly syncDirectory?: boolean } = {},
|
|
103
|
+
): Promise<void> {
|
|
99
104
|
const handle = await open(file, "wx", mode);
|
|
100
105
|
try {
|
|
101
106
|
await handle.writeFile(Buffer.from(bytes));
|
|
@@ -104,7 +109,7 @@ export async function writeBytesExclusive(file: string, bytes: Uint8Array, mode:
|
|
|
104
109
|
} finally {
|
|
105
110
|
await handle.close();
|
|
106
111
|
}
|
|
107
|
-
await fsyncDirectory(dirname(file));
|
|
112
|
+
if (options.syncDirectory !== false) await fsyncDirectory(dirname(file));
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
async function targetMode(file: string, mode: number | undefined): Promise<number> {
|
package/src/controller.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
2
3
|
|
|
3
4
|
import { canonicalJson, checksum } from "./encoding.ts";
|
|
4
5
|
import type {
|
|
@@ -35,7 +36,7 @@ export interface ControllerDependencies {
|
|
|
35
36
|
}>;
|
|
36
37
|
readonly appendControl: (customType: string, data?: unknown) => Promise<string | null>;
|
|
37
38
|
readonly appendCursor: (cursor: CursorState) => Promise<CursorAppendResult>;
|
|
38
|
-
readonly capture: () => Promise<SnapshotManifest>;
|
|
39
|
+
readonly capture: (scopePaths?: readonly string[]) => Promise<SnapshotManifest>;
|
|
39
40
|
readonly changedPaths: (before: SnapshotManifest, after: SnapshotManifest) => Promise<readonly string[]>;
|
|
40
41
|
readonly loadManifest: (id: ManifestId) => Promise<SnapshotManifest>;
|
|
41
42
|
readonly planRestore: (
|
|
@@ -73,11 +74,17 @@ export type CursorAppendResult =
|
|
|
73
74
|
| { readonly kind: "volatile"; readonly reason: string }
|
|
74
75
|
| { readonly kind: "recovery_required"; readonly reason: string };
|
|
75
76
|
|
|
77
|
+
export interface OperationTiming {
|
|
78
|
+
readonly phase: string;
|
|
79
|
+
readonly durationMs: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
export interface OperationResult {
|
|
77
83
|
readonly code: ResultCode;
|
|
78
84
|
readonly changedFiles: number;
|
|
79
85
|
readonly message?: string;
|
|
80
86
|
readonly refillPrompt?: string;
|
|
87
|
+
readonly timings?: readonly OperationTiming[];
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
export type InputEventResult =
|
|
@@ -384,79 +391,101 @@ export class UndoControllerImpl implements UndoController {
|
|
|
384
391
|
targetManifestId?: ManifestId,
|
|
385
392
|
): Promise<OperationResult> {
|
|
386
393
|
if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
|
|
394
|
+
const profile = new OperationProfiler();
|
|
395
|
+
const done = (result: OperationResult): OperationResult => profile.attach(result);
|
|
387
396
|
this.operationInFlight = true;
|
|
388
397
|
this.promptDeferralInFlight = true;
|
|
389
398
|
this.lastSafetyManifestId = null;
|
|
390
399
|
let lease: { release(): Promise<void> } | undefined;
|
|
391
400
|
try {
|
|
392
|
-
if (!await this.ensureIdle())
|
|
401
|
+
if (!await profile.measure("idle", () => this.ensureIdle())) {
|
|
402
|
+
return done({ code: "idle_timeout", changedFiles: 0 });
|
|
403
|
+
}
|
|
393
404
|
try {
|
|
394
|
-
lease = await this.dependencies.acquireWorkspaceLock();
|
|
405
|
+
lease = await profile.measure("lock", () => this.dependencies.acquireWorkspaceLock());
|
|
395
406
|
} catch {
|
|
396
|
-
return { code: "busy", changedFiles: 0 };
|
|
407
|
+
return done({ code: "busy", changedFiles: 0 });
|
|
397
408
|
}
|
|
398
409
|
let rollback: SnapshotManifest;
|
|
399
410
|
try {
|
|
400
|
-
rollback = await
|
|
411
|
+
rollback = await profile.measure("capture", () =>
|
|
412
|
+
this.dependencies.capture(checkpoint.changedPaths));
|
|
401
413
|
} catch {
|
|
402
|
-
return { code: "capture_failed", changedFiles: 0 };
|
|
414
|
+
return done({ code: "capture_failed", changedFiles: 0 });
|
|
403
415
|
}
|
|
404
416
|
let target: SnapshotManifest;
|
|
405
417
|
let plan: RestorePlan;
|
|
406
418
|
let targetLogicalLeaf: string | null;
|
|
407
419
|
try {
|
|
408
|
-
target = await this.dependencies.loadManifest(
|
|
420
|
+
target = await profile.measure("load", () => this.dependencies.loadManifest(
|
|
409
421
|
targetManifestId ?? (action === "undo" ? checkpoint.beforeManifestId : checkpoint.afterManifestId),
|
|
410
|
-
);
|
|
411
|
-
plan = await
|
|
422
|
+
));
|
|
423
|
+
plan = await profile.measure("plan", () =>
|
|
424
|
+
this.dependencies.planRestore(rollback, target, checkpoint.changedPaths));
|
|
412
425
|
targetLogicalLeaf = this.dependencies.resolveSessionTarget(action, checkpoint);
|
|
413
426
|
} catch {
|
|
414
|
-
return { code: "restore_failed_safe", changedFiles: 0 };
|
|
427
|
+
return done({ code: "restore_failed_safe", changedFiles: 0 });
|
|
415
428
|
}
|
|
416
429
|
const descriptor = this.createDescriptor(action, rollback, target, plan, targetLogicalLeaf);
|
|
417
|
-
await this.dependencies.journal.prepare(descriptor, plan);
|
|
418
|
-
const navigation = await
|
|
430
|
+
await profile.measure("journal", () => this.dependencies.journal.prepare(descriptor, plan));
|
|
431
|
+
const navigation = await profile.measure("navigate", () =>
|
|
432
|
+
this.dependencies.navigateSession(action, checkpoint));
|
|
419
433
|
if (navigation.cancelled) {
|
|
420
|
-
await
|
|
421
|
-
|
|
422
|
-
|
|
434
|
+
await profile.measure("journal", async () => {
|
|
435
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
|
|
436
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
|
|
437
|
+
});
|
|
438
|
+
return done({ code: "restore_failed_safe", changedFiles: 0 });
|
|
423
439
|
}
|
|
424
440
|
if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
|
|
425
441
|
this.locked = true;
|
|
426
|
-
await
|
|
427
|
-
|
|
442
|
+
await profile.measure("journal", () =>
|
|
443
|
+
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
|
|
444
|
+
return done({ code: "recovery_required", changedFiles: 0 });
|
|
428
445
|
}
|
|
429
|
-
await
|
|
430
|
-
|
|
446
|
+
await profile.measure("journal", async () => {
|
|
447
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "SESSION_MOVED", {
|
|
448
|
+
observedLogicalLeaf: navigation.logicalLeafId,
|
|
449
|
+
});
|
|
450
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "APPLYING");
|
|
431
451
|
});
|
|
432
|
-
await
|
|
433
|
-
|
|
434
|
-
if (applied.code !== "ok")
|
|
435
|
-
|
|
452
|
+
const applied = await profile.measure("apply", () =>
|
|
453
|
+
this.dependencies.applyRestore(plan, target, { opId: descriptor.opId }));
|
|
454
|
+
if (applied.code !== "ok") {
|
|
455
|
+
return done(await profile.measure("compensate", () =>
|
|
456
|
+
this.compensate(descriptor, rollback, target, applied)));
|
|
457
|
+
}
|
|
458
|
+
await profile.measure("journal", () =>
|
|
459
|
+
this.dependencies.journal.setPhase(descriptor.opId, "FILES_VERIFIED"));
|
|
436
460
|
const cursor = this.createCursor(descriptor, action, checkpoint);
|
|
437
|
-
const cursorResult = await this.dependencies.appendCursor(cursor);
|
|
461
|
+
const cursorResult = await profile.measure("cursor", () => this.dependencies.appendCursor(cursor));
|
|
438
462
|
if (cursorResult.kind === "recovery_required") {
|
|
439
463
|
this.locked = true;
|
|
440
|
-
await
|
|
441
|
-
|
|
464
|
+
await profile.measure("journal", () =>
|
|
465
|
+
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
|
|
466
|
+
return done({ code: "recovery_required", changedFiles: applied.verifiedPaths });
|
|
442
467
|
}
|
|
443
468
|
if (cursorResult.kind === "volatile") {
|
|
444
|
-
return this.compensate(descriptor, rollback, target, {
|
|
469
|
+
return done(await profile.measure("compensate", () => this.compensate(descriptor, rollback, target, {
|
|
445
470
|
code: "recovery_required",
|
|
446
471
|
verifiedPaths: applied.verifiedPaths,
|
|
447
472
|
totalPaths: applied.totalPaths,
|
|
448
|
-
});
|
|
473
|
+
})));
|
|
449
474
|
}
|
|
450
|
-
await
|
|
451
|
-
|
|
475
|
+
await profile.measure("commit", async () => {
|
|
476
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "CURSOR_COMMITTED");
|
|
477
|
+
await this.dependencies.journal.markCommitted(descriptor.opId);
|
|
478
|
+
});
|
|
452
479
|
this.lastSafetyManifestId = rollback.manifestId;
|
|
453
|
-
return { code: "ok", changedFiles: applied.verifiedPaths };
|
|
480
|
+
return done({ code: "ok", changedFiles: applied.verifiedPaths });
|
|
454
481
|
} catch {
|
|
455
482
|
this.locked = true;
|
|
456
|
-
return { code: "recovery_required", changedFiles: 0 };
|
|
483
|
+
return done({ code: "recovery_required", changedFiles: 0 });
|
|
457
484
|
} finally {
|
|
458
|
-
|
|
459
|
-
|
|
485
|
+
const activeLease = lease;
|
|
486
|
+
if (activeLease !== undefined) {
|
|
487
|
+
await profile.measure("unlock", () =>
|
|
488
|
+
activeLease.release().catch(() => { this.locked = true; }));
|
|
460
489
|
}
|
|
461
490
|
this.promptDeferralInFlight = false;
|
|
462
491
|
this.operationInFlight = false;
|
|
@@ -541,7 +570,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
541
570
|
plan: RestorePlan,
|
|
542
571
|
targetLogicalLeaf: string | null,
|
|
543
572
|
): OperationDescriptor {
|
|
544
|
-
const scopePaths = [...plan.deletePaths, ...plan.writePaths].sort();
|
|
573
|
+
const scopePaths = [...(plan.scopePaths ?? [...plan.deletePaths, ...plan.writePaths])].sort();
|
|
545
574
|
const payload = {
|
|
546
575
|
schemaVersion: 1 as const,
|
|
547
576
|
opId: `op-${randomUUID()}`,
|
|
@@ -604,6 +633,31 @@ export class UndoControllerImpl implements UndoController {
|
|
|
604
633
|
}
|
|
605
634
|
}
|
|
606
635
|
|
|
636
|
+
class OperationProfiler {
|
|
637
|
+
private readonly durations = new Map<string, number>();
|
|
638
|
+
|
|
639
|
+
async measure<T>(phase: string, operation: () => Promise<T>): Promise<T> {
|
|
640
|
+
const started = performance.now();
|
|
641
|
+
try {
|
|
642
|
+
return await operation();
|
|
643
|
+
} finally {
|
|
644
|
+
this.durations.set(phase, (this.durations.get(phase) ?? 0) + performance.now() - started);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
attach(result: OperationResult): OperationResult {
|
|
649
|
+
const total = [...this.durations.values()].reduce((sum, duration) => sum + duration, 0);
|
|
650
|
+
if (total < 1_000) return result;
|
|
651
|
+
return {
|
|
652
|
+
...result,
|
|
653
|
+
timings: [...this.durations].map(([phase, durationMs]) => ({
|
|
654
|
+
phase,
|
|
655
|
+
durationMs: Math.round(durationMs),
|
|
656
|
+
})),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
607
661
|
function noop(): OperationResult {
|
|
608
662
|
return { code: "noop", changedFiles: 0 };
|
|
609
663
|
}
|