@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 +56 -16
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +1 -1
- package/src/controller.ts +55 -31
- package/src/durable-pack.ts +27 -4
- package/src/native-restore.ts +1 -3
- package/src/packed-recovery.ts +67 -25
- package/src/pi-runtime.ts +2 -1
- package/src/restore-engine.ts +22 -8
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
|
|
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
|
-
|
|
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
|
-
###
|
|
43
|
+
### Install from npm (Recommended)
|
|
44
44
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
>
|
|
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
|
-
###
|
|
299
|
+
### Build the Native Rust Helper
|
|
260
300
|
|
|
261
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
if (
|
|
307
|
-
|
|
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
|
-
|
|
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,
|
package/src/durable-pack.ts
CHANGED
|
@@ -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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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) {
|
package/src/native-restore.ts
CHANGED
|
@@ -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
|
}
|
package/src/packed-recovery.ts
CHANGED
|
@@ -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.
|
|
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
|
|
55
|
+
if (source === undefined || target === undefined) {
|
|
51
56
|
throw new Error(`packed recovery variant 缺失:${record.path}`);
|
|
52
57
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
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);
|
package/src/restore-engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
708
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths };
|
|
699
709
|
}
|
|
700
|
-
return { code: "ok", verifiedPaths:
|
|
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 {
|
|
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 ??
|
|
778
|
+
targetFingerprint: targetLeaf?.fingerprint ?? absent.fingerprint,
|
|
765
779
|
variants: [...variants.values()],
|
|
766
780
|
});
|
|
767
781
|
}
|