@davideasden/pi-undo 0.2.24 → 0.2.25
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 +34 -1
- package/extensions/pi-undo.ts +75 -15
- package/package.json +7 -6
- package/src/controller.ts +377 -45
- package/src/git-runner.ts +356 -51
- package/src/journal.ts +5 -2
- package/src/model.ts +2 -0
- package/src/native-metadata.ts +47 -87
- package/src/native-restore.ts +49 -56
- package/src/operation-context.ts +266 -0
- package/src/pi-runtime.ts +119 -15
- package/src/quarantine.ts +3 -0
- package/src/recovery.ts +3 -3
- package/src/restore-engine.ts +102 -76
- package/src/root-discovery.ts +46 -5
- package/src/snapshot-store.ts +39 -16
- package/src/status-reporter.ts +41 -1
- package/src/workspace-lock.ts +4 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Each completed agent run creates a checkpoint that captures both the Pi session
|
|
|
19
19
|
|
|
20
20
|
## Requirements
|
|
21
21
|
|
|
22
|
-
- Pi `0.
|
|
22
|
+
- Pi `0.86.1` 或更高版本(已在 0.86.1 上验证)。`0.84.4` 及更早版本不再受支持,也不在 CI 中测试:早期版本的会话树与扩展生命周期行为不同,不保证撤回语义正确。
|
|
23
23
|
- Node.js `22.19.0` or later.
|
|
24
24
|
- Git available on `PATH` (used internally for content-addressed snapshots).
|
|
25
25
|
|
|
@@ -104,6 +104,29 @@ Returns to the previous completed run on the current branch. Before restoring, i
|
|
|
104
104
|
|
|
105
105
|
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.
|
|
106
106
|
|
|
107
|
+
撤回会等待当前轮次的输入快照和检查点完成,再选择撤回目标;Pi 已空闲但检查点仍在生成时,不会提前撤回上一轮。检查点生成失败会明确暂停历史。
|
|
108
|
+
|
|
109
|
+
### 取消与超时
|
|
110
|
+
|
|
111
|
+
执行较慢时,状态栏会显示当前阶段、经过的秒数和操作 ID。可以输入:
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
/undo-cancel
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
此命令请求安全停止正在执行的撤回或重做。已开始的文件写入会先结束,未提交的事务随后按日志恢复。已经持久提交的操作会继续完成清理。取消或超时分别报告 `operation_cancelled`、`operation_timeout`;无法确认子进程停止或恢复一致性时,保留隔离并报告 `recovery_required`。
|
|
118
|
+
|
|
119
|
+
默认单次 Git 调用预算为 120 秒,每次撤回、捕获及恢复预算为 300 秒。可在启动 Pi 前用正整数毫秒调整:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
export PI_UNDO_GIT_TIMEOUT_MS=180000
|
|
123
|
+
export PI_UNDO_OPERATION_TIMEOUT_MS=600000
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
补偿使用独立预算,避免继承前向操作的取消信号。磁盘 I/O 若无法中断,状态会保留到在途任务结束;超时不表示可以立即释放仍在写入的工作区锁。
|
|
127
|
+
|
|
128
|
+
长操作或失败会在 `<sessionDir>/.pi-undo/diagnostics/<sessionId>-latest.json` 保存最近一次诊断,包含操作 ID、阶段、耗时、Git 子命令名和退出结果,不包含提示词、文件内容、完整参数或子进程输出。重新运行 `/undo-recover`、切换会话或退出时,会先等待旧 runtime 的任务和后台持久化结束。
|
|
129
|
+
|
|
107
130
|
### Redo
|
|
108
131
|
|
|
109
132
|
```text
|
|
@@ -138,6 +161,16 @@ recovery_required
|
|
|
138
161
|
|
|
139
162
|
### Performance Notes
|
|
140
163
|
|
|
164
|
+
本次优化将普通文件原生恢复路径的完整拓扑扫描从 7 次减到 5 次,并以有界并发枚举目录。仍会发现 Git 忽略目录中的嵌套仓库,保留路径枚举后与文件恢复后的拓扑复核。没有使用 TTL 缓存或默认跳过 `node_modules`。
|
|
165
|
+
|
|
166
|
+
可通过真实 Pi 0.86.1 SDK 和离线 faux provider 运行大型工作区基准;它覆盖 3,000/10,000 个依赖包、修改 1/100 个文件,每组撤回三次并验证重做:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
PI_UNDO_LARGE_WORKSPACE=1 npx vitest run test/large-workspace.test.ts
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
输出包含扫描次数、每次耗时、进程峰值 RSS 和事件循环延迟;常规测试以扫描次数作为性能门槛,避免使用易受机器负载影响的墙钟阈值。
|
|
173
|
+
|
|
141
174
|
Real-world measurements from a 104-file undo operation before optimizations:
|
|
142
175
|
|
|
143
176
|
```text
|
package/extensions/pi-undo.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
SessionTreeEvent as PiSessionTreeEvent,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
11
|
|
|
12
|
-
import type { OperationResult, UndoController } from "../src/controller.ts";
|
|
12
|
+
import type { OperationResult, SessionTreeEvent, UndoController } from "../src/controller.ts";
|
|
13
13
|
import { browseDiff } from "../src/diff-ui.ts";
|
|
14
14
|
import { computeCheckpointDiff, type DiffSource, formatDiffSummary, sanitizeDisplayText } from "../src/diff-view.ts";
|
|
15
15
|
import { createPiUndoRuntime } from "../src/pi-runtime.ts";
|
|
@@ -22,6 +22,8 @@ export interface PiUndoRuntime {
|
|
|
22
22
|
readonly recovery?: { readonly reason?: string; readonly files?: number; readonly opId?: string };
|
|
23
23
|
setCommandContext?(context: ExtensionCommandContext | undefined): void;
|
|
24
24
|
isInternalNavigation?(): boolean;
|
|
25
|
+
normalizeTreeEvent?(event: PiSessionTreeEvent): SessionTreeEvent;
|
|
26
|
+
dispose?(): Promise<void>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export type PiUndoRuntimeFactory = (
|
|
@@ -55,9 +57,24 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
55
57
|
let activeAction: "undo" | "redo" | undefined;
|
|
56
58
|
let captureFailureNotified = false;
|
|
57
59
|
let recoveryHintNotified = false;
|
|
60
|
+
let clearTreeWatch: (() => void) | undefined;
|
|
61
|
+
let initializing = false;
|
|
58
62
|
|
|
59
63
|
const initialize = async (context: ExtensionContext): Promise<void> => {
|
|
64
|
+
if (initializing) return;
|
|
65
|
+
initializing = true;
|
|
66
|
+
try {
|
|
67
|
+
clearTreeWatch?.();
|
|
68
|
+
if (runtime?.dispose !== undefined) await runtime.dispose();
|
|
69
|
+
else await runtime?.controller.dispose?.();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
initializing = false;
|
|
72
|
+
runtime?.reporter.setRecoveryRequired(errorMessage(error));
|
|
73
|
+
context.ui.notify(`旧任务尚未安全结束:${errorMessage(error)}`, "error");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
60
76
|
const currentGeneration = ++generation;
|
|
77
|
+
restoreDeferredPrompts(context);
|
|
61
78
|
runtimeContext = context;
|
|
62
79
|
deferredPrompts = [];
|
|
63
80
|
replaying = undefined;
|
|
@@ -82,19 +99,21 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
82
99
|
if (currentGeneration !== generation) return;
|
|
83
100
|
runtime = undefined;
|
|
84
101
|
new StatusReporter(context).setRecoveryRequired(errorMessage(error));
|
|
102
|
+
} finally {
|
|
103
|
+
initializing = false;
|
|
85
104
|
}
|
|
86
105
|
};
|
|
87
106
|
|
|
88
107
|
const dispatchDeferredPrompt = (active: PiUndoRuntime, expectedGeneration: number): void => {
|
|
89
108
|
if (
|
|
90
|
-
expectedGeneration !== generation || runtime !== active || activeCommands.size > 0 ||
|
|
109
|
+
initializing || expectedGeneration !== generation || runtime !== active || activeCommands.size > 0 ||
|
|
91
110
|
replaying !== undefined || deferredPrompts.length === 0 || active.controller.history().locked
|
|
92
111
|
) return;
|
|
93
112
|
const prompt = deferredPrompts[0]!;
|
|
94
113
|
replaying = prompt;
|
|
95
114
|
acceptedReplay = undefined;
|
|
96
115
|
queueMicrotask(() => {
|
|
97
|
-
if (expectedGeneration !== generation || runtime !== active || replaying !== prompt) return;
|
|
116
|
+
if (initializing || expectedGeneration !== generation || runtime !== active || replaying !== prompt) return;
|
|
98
117
|
try {
|
|
99
118
|
pi.sendUserMessage(prompt.images === undefined || prompt.images.length === 0
|
|
100
119
|
? prompt.text
|
|
@@ -144,7 +163,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
144
163
|
): Promise<void> => {
|
|
145
164
|
const active = runtime;
|
|
146
165
|
const commandGeneration = generation;
|
|
147
|
-
if (active === undefined) {
|
|
166
|
+
if (active === undefined || initializing) {
|
|
148
167
|
context.ui.notify("pi-undo session unavailable", "warning");
|
|
149
168
|
return;
|
|
150
169
|
}
|
|
@@ -153,8 +172,8 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
153
172
|
// 只有当前执行命令能安装/清理导航上下文;busy 的第二个命令不得覆盖或清空它。
|
|
154
173
|
const ownsCommandContext = commandSet.size === 0;
|
|
155
174
|
commandSet.add(commandToken);
|
|
156
|
-
activeAction = action;
|
|
157
|
-
active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
|
|
175
|
+
if (ownsCommandContext) activeAction = action;
|
|
176
|
+
if (ownsCommandContext) active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
|
|
158
177
|
if (ownsCommandContext) active.setCommandContext?.(context);
|
|
159
178
|
const commandStarted = performance.now();
|
|
160
179
|
let result: OperationResult;
|
|
@@ -176,6 +195,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
176
195
|
message: active.controller.recoveryReason?.() ?? "pending journal",
|
|
177
196
|
};
|
|
178
197
|
}
|
|
198
|
+
if (!ownsCommandContext) {
|
|
199
|
+
context.ui.notify(`${result.code} files:${result.changedFiles}`, "warning");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
179
202
|
active.reporter.result(result, performance.now() - commandStarted);
|
|
180
203
|
if (result.code === "recovery_required" && !recoveryHintNotified) {
|
|
181
204
|
recoveryHintNotified = true;
|
|
@@ -240,6 +263,13 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
240
263
|
description: "Review files changed by an Agent run (latest, or /diff N)",
|
|
241
264
|
handler: async (args: string, context: ExtensionCommandContext) => runDiff(args, context),
|
|
242
265
|
});
|
|
266
|
+
pi.registerCommand("undo-cancel", {
|
|
267
|
+
description: "安全停止正在执行的撤回或重做",
|
|
268
|
+
handler: async (_args: string, context: ExtensionCommandContext) => {
|
|
269
|
+
const requested = runtime?.controller.cancelOperation?.() ?? false;
|
|
270
|
+
context.ui.notify(requested ? "已请求停止,正在等待写入结束并恢复一致状态" : "当前没有可取消的撤回操作", "info");
|
|
271
|
+
},
|
|
272
|
+
});
|
|
243
273
|
pi.registerCommand("undo-recover", {
|
|
244
274
|
description: "Re-run pi-undo recovery and refresh undo history",
|
|
245
275
|
handler: async (_args: string, context: ExtensionCommandContext) => {
|
|
@@ -266,6 +296,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
266
296
|
pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
|
|
267
297
|
pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
|
|
268
298
|
const active = runtime;
|
|
299
|
+
if (initializing) {
|
|
300
|
+
restoreEditorText(context, event.text);
|
|
301
|
+
return { action: "handled" as const };
|
|
302
|
+
}
|
|
269
303
|
if (active === undefined) return { action: "continue" as const };
|
|
270
304
|
const inputContext = { streaming: event.streamingBehavior !== undefined };
|
|
271
305
|
const result = active.controller.beginInput !== undefined
|
|
@@ -354,29 +388,52 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
354
388
|
if (active === undefined) return;
|
|
355
389
|
await active.controller.agentSettled();
|
|
356
390
|
if (runtime !== active || generation !== settledGeneration) return;
|
|
357
|
-
resumeDeferredPrompts(
|
|
391
|
+
if (activeCommands.size === 0) resumeDeferredPrompts(
|
|
358
392
|
active,
|
|
359
393
|
settledGeneration,
|
|
360
394
|
active.controller.recoveryReason?.() ?? "session state ambiguous",
|
|
361
395
|
);
|
|
362
396
|
});
|
|
363
|
-
pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
|
|
364
|
-
if (runtime === undefined) return { cancel: true };
|
|
397
|
+
pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent, context: ExtensionContext) => {
|
|
398
|
+
if (runtime === undefined || initializing) return { cancel: true };
|
|
365
399
|
if (runtime.isInternalNavigation?.()) return undefined;
|
|
366
400
|
const active = runtime;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
401
|
+
if (event.signal?.aborted) return { cancel: true };
|
|
402
|
+
clearTreeWatch?.();
|
|
403
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
404
|
+
const clear = (): void => {
|
|
405
|
+
if (timer !== undefined) clearInterval(timer);
|
|
406
|
+
event.signal?.removeEventListener("abort", cancel);
|
|
407
|
+
if (clearTreeWatch === clear) clearTreeWatch = undefined;
|
|
408
|
+
};
|
|
409
|
+
const cancel = (): void => { void active.controller.cancelTree?.().catch(() => {}); };
|
|
410
|
+
clearTreeWatch = clear;
|
|
411
|
+
event.signal?.addEventListener("abort", cancel, { once: true });
|
|
412
|
+
const result = await active.controller.beforeTree({ targetLeafId: event.preparation.targetId, signal: event.signal });
|
|
413
|
+
if (result !== undefined || event.signal?.aborted) {
|
|
414
|
+
await active.controller.cancelTree?.();
|
|
415
|
+
clear();
|
|
416
|
+
return { cancel: true };
|
|
370
417
|
}
|
|
371
|
-
|
|
418
|
+
// 摘要错误或其他扩展取消可能没有 session_tree;Pi 离开导航后才终结准备事务。
|
|
419
|
+
timer = setInterval(() => {
|
|
420
|
+
if (!context.isIdle()) return;
|
|
421
|
+
clear();
|
|
422
|
+
void active.controller.cancelTree?.().then(() => {
|
|
423
|
+
resumeDeferredPrompts(active, generation, "tree_navigation_incomplete");
|
|
424
|
+
});
|
|
425
|
+
}, 100);
|
|
426
|
+
timer.unref();
|
|
427
|
+
return undefined;
|
|
372
428
|
});
|
|
373
429
|
pi.on("session_tree", async (event: PiSessionTreeEvent) => {
|
|
374
430
|
const active = runtime;
|
|
375
431
|
if (active?.isInternalNavigation?.()) return;
|
|
376
432
|
const treeGeneration = generation;
|
|
377
|
-
|
|
433
|
+
clearTreeWatch?.();
|
|
434
|
+
await active?.controller.afterTree(active.normalizeTreeEvent?.(event) ?? {
|
|
378
435
|
newLeafId: event.newLeafId,
|
|
379
|
-
navigationTargetLeafId: event.summaryEntry
|
|
436
|
+
navigationTargetLeafId: event.summaryEntry === undefined ? event.newLeafId : event.summaryEntry.parentId,
|
|
380
437
|
});
|
|
381
438
|
if (active !== undefined && runtime === active && generation === treeGeneration) {
|
|
382
439
|
resumeDeferredPrompts(
|
|
@@ -387,6 +444,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
387
444
|
}
|
|
388
445
|
});
|
|
389
446
|
pi.on("session_shutdown", async () => {
|
|
447
|
+
clearTreeWatch?.();
|
|
390
448
|
generation += 1;
|
|
391
449
|
deferredPrompts = [];
|
|
392
450
|
replaying = undefined;
|
|
@@ -394,6 +452,8 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
394
452
|
activeCommands = new Set<symbol>();
|
|
395
453
|
activeAction = undefined;
|
|
396
454
|
runtimeContext = undefined;
|
|
455
|
+
if (runtime?.dispose !== undefined) await runtime.dispose();
|
|
456
|
+
else await runtime?.controller.dispose?.();
|
|
397
457
|
await runtime?.controller.cancelTree?.();
|
|
398
458
|
runtime?.reporter.clear();
|
|
399
459
|
runtime = undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davideasden/pi-undo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.25",
|
|
4
4
|
"description": "Persistent workspace undo and redo for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,22 +39,23 @@
|
|
|
39
39
|
"scripts": {
|
|
40
40
|
"test": "vitest run",
|
|
41
41
|
"test:watch": "vitest",
|
|
42
|
-
"test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts",
|
|
42
|
+
"test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts test/pi-sdk.integration.test.ts",
|
|
43
43
|
"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",
|
|
44
44
|
"build:native": "cargo build --release --manifest-path native/pi-undo-fs/Cargo.toml",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
46
|
"pack:dry-run": "npm pack --dry-run"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
50
|
-
"@earendil-works/pi-tui": "
|
|
49
|
+
"@earendil-works/pi-coding-agent": ">=0.86.1",
|
|
50
|
+
"@earendil-works/pi-tui": ">=0.86.1"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"proper-lockfile": "4.1.2"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-
|
|
57
|
-
"@earendil-works/pi-
|
|
56
|
+
"@earendil-works/pi-ai": "0.86.1",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "0.86.1",
|
|
58
|
+
"@earendil-works/pi-tui": "0.86.1",
|
|
58
59
|
"@types/node": "24.12.4",
|
|
59
60
|
"@types/proper-lockfile": "4.1.4",
|
|
60
61
|
"typescript": "5.9.3",
|