@davideasden/pi-undo 0.2.16 → 0.2.17
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/extensions/pi-undo.ts +34 -10
- package/package.json +1 -1
- package/src/controller.ts +57 -28
- package/src/journal.ts +99 -0
- package/src/pi-runtime.ts +3 -1
- package/src/recovery.ts +14 -0
- package/src/snapshot-store.ts +26 -15
- package/src/status-reporter.ts +3 -2
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 { UndoController } from "../src/controller.ts";
|
|
12
|
+
import type { OperationResult, 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";
|
|
@@ -19,7 +19,7 @@ export interface PiUndoRuntime {
|
|
|
19
19
|
readonly controller: UndoController;
|
|
20
20
|
readonly reporter: StatusReporter;
|
|
21
21
|
readonly diffSource?: DiffSource;
|
|
22
|
-
readonly recovery?: { readonly files?: number; readonly opId?: string };
|
|
22
|
+
readonly recovery?: { readonly reason?: string; readonly files?: number; readonly opId?: string };
|
|
23
23
|
setCommandContext?(context: ExtensionCommandContext | undefined): void;
|
|
24
24
|
isInternalNavigation?(): boolean;
|
|
25
25
|
}
|
|
@@ -63,8 +63,12 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
63
63
|
runtime = next;
|
|
64
64
|
await next.controller.recover();
|
|
65
65
|
const history = next.controller.history();
|
|
66
|
-
if (history.locked)
|
|
67
|
-
|
|
66
|
+
if (history.locked) {
|
|
67
|
+
next.reporter.setRecoveryRequired(
|
|
68
|
+
next.controller.recoveryReason?.() ?? next.recovery?.reason ?? "pending journal",
|
|
69
|
+
next.recovery,
|
|
70
|
+
);
|
|
71
|
+
} else next.reporter.setReady(history.undoCount, history.redoCount);
|
|
68
72
|
// 后台预热快照缓存,把新会话首次冷 capture 移出第一条 prompt 的关键路径。
|
|
69
73
|
next.controller.warmUp();
|
|
70
74
|
} catch (error) {
|
|
@@ -117,7 +121,9 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
117
121
|
if (expectedGeneration !== generation || runtime !== active) return;
|
|
118
122
|
const history = active.controller.history();
|
|
119
123
|
if (history.locked) {
|
|
120
|
-
active.reporter.setRecoveryRequired(
|
|
124
|
+
active.reporter.setRecoveryRequired(
|
|
125
|
+
active.controller.recoveryReason?.() ?? lockedReason,
|
|
126
|
+
);
|
|
121
127
|
restoreDeferredPrompts(runtimeContext);
|
|
122
128
|
} else {
|
|
123
129
|
active.reporter.setReady(history.undoCount, history.redoCount);
|
|
@@ -137,22 +143,32 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
137
143
|
}
|
|
138
144
|
const commandToken = Symbol(action);
|
|
139
145
|
const commandSet = activeCommands;
|
|
146
|
+
// 只有当前执行命令能安装/清理导航上下文;busy 的第二个命令不得覆盖或清空它。
|
|
147
|
+
const ownsCommandContext = commandSet.size === 0;
|
|
140
148
|
commandSet.add(commandToken);
|
|
141
149
|
activeAction = action;
|
|
142
150
|
active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
|
|
143
|
-
active.setCommandContext?.(context);
|
|
151
|
+
if (ownsCommandContext) active.setCommandContext?.(context);
|
|
144
152
|
const commandStarted = performance.now();
|
|
145
|
-
let result;
|
|
153
|
+
let result: OperationResult;
|
|
146
154
|
try {
|
|
147
155
|
result = action === "undo"
|
|
148
156
|
? await active.controller.undo()
|
|
149
157
|
: await active.controller.redo();
|
|
150
158
|
} finally {
|
|
151
|
-
active.setCommandContext?.(undefined);
|
|
159
|
+
if (ownsCommandContext) active.setCommandContext?.(undefined);
|
|
152
160
|
commandSet.delete(commandToken);
|
|
153
161
|
if (commandSet === activeCommands && commandSet.size === 0) activeAction = undefined;
|
|
154
162
|
}
|
|
155
163
|
if (commandGeneration !== generation || runtime !== active) return;
|
|
164
|
+
const history = active.controller.history();
|
|
165
|
+
if (history.locked && result.code === "busy") {
|
|
166
|
+
result = {
|
|
167
|
+
...result,
|
|
168
|
+
code: "recovery_required",
|
|
169
|
+
message: active.controller.recoveryReason?.() ?? "pending journal",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
156
172
|
active.reporter.result(result, performance.now() - commandStarted);
|
|
157
173
|
const hasDeferredPrompt = deferredPrompts.length > 0 || replaying !== undefined;
|
|
158
174
|
if (
|
|
@@ -305,7 +321,11 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
305
321
|
if (active === undefined) return;
|
|
306
322
|
await active.controller.agentSettled();
|
|
307
323
|
if (runtime !== active || generation !== settledGeneration) return;
|
|
308
|
-
resumeDeferredPrompts(
|
|
324
|
+
resumeDeferredPrompts(
|
|
325
|
+
active,
|
|
326
|
+
settledGeneration,
|
|
327
|
+
active.controller.recoveryReason?.() ?? "session state ambiguous",
|
|
328
|
+
);
|
|
309
329
|
});
|
|
310
330
|
pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
|
|
311
331
|
if (runtime === undefined) return { cancel: true };
|
|
@@ -326,7 +346,11 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
326
346
|
navigationTargetLeafId: event.summaryEntry?.parentId ?? event.newLeafId,
|
|
327
347
|
});
|
|
328
348
|
if (active !== undefined && runtime === active && generation === treeGeneration) {
|
|
329
|
-
resumeDeferredPrompts(
|
|
349
|
+
resumeDeferredPrompts(
|
|
350
|
+
active,
|
|
351
|
+
treeGeneration,
|
|
352
|
+
active.controller.recoveryReason?.() ?? "session state ambiguous",
|
|
353
|
+
);
|
|
330
354
|
}
|
|
331
355
|
});
|
|
332
356
|
pi.on("session_shutdown", async () => {
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -150,6 +150,8 @@ export interface UndoController {
|
|
|
150
150
|
cancelTree?(): Promise<void>;
|
|
151
151
|
recover(): Promise<void>;
|
|
152
152
|
history(): HistoryState;
|
|
153
|
+
/** 当前 recovery lock 的原因;未锁定时返回 undefined。 */
|
|
154
|
+
recoveryReason?(): string | undefined;
|
|
153
155
|
/** 后台预热快照缓存:立即返回,失败静默;后续 capture 会先等预热完成。 */
|
|
154
156
|
warmUp(): void;
|
|
155
157
|
/** 最近一次输入前快照是否失败(此时本次 run 不可 undo,但输入不受影响)。 */
|
|
@@ -175,6 +177,7 @@ export interface ControllerInitialState {
|
|
|
175
177
|
readonly redoStack?: readonly ControllerRedoEntry[];
|
|
176
178
|
readonly historyPaused?: boolean;
|
|
177
179
|
readonly locked?: boolean;
|
|
180
|
+
readonly recoveryReason?: string;
|
|
178
181
|
readonly recoveryCompleted?: boolean;
|
|
179
182
|
}
|
|
180
183
|
|
|
@@ -198,6 +201,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
198
201
|
private staged: StagedRun | undefined;
|
|
199
202
|
private pendingTree: PendingTree | undefined;
|
|
200
203
|
private locked = false;
|
|
204
|
+
private lockedReason: string | undefined;
|
|
201
205
|
private historyPaused = false;
|
|
202
206
|
private operationInFlight = false;
|
|
203
207
|
private operationAction: "undo" | "redo" | undefined;
|
|
@@ -219,6 +223,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
219
223
|
this.redoStack.push(...(initialState.redoStack ?? []));
|
|
220
224
|
this.historyPaused = initialState.historyPaused ?? false;
|
|
221
225
|
this.locked = initialState.locked ?? false;
|
|
226
|
+
this.lockedReason = initialState.recoveryReason;
|
|
222
227
|
this.recoveryCompleted = initialState.recoveryCompleted ?? false;
|
|
223
228
|
}
|
|
224
229
|
|
|
@@ -226,6 +231,26 @@ export class UndoControllerImpl implements UndoController {
|
|
|
226
231
|
return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
|
|
227
232
|
}
|
|
228
233
|
|
|
234
|
+
recoveryReason(): string | undefined {
|
|
235
|
+
return this.locked ? this.lockedReason ?? "pending journal" : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private lock(reason: string): void {
|
|
239
|
+
this.locked = true;
|
|
240
|
+
if (this.lockedReason === undefined) {
|
|
241
|
+
const normalized = truncateReason(reason);
|
|
242
|
+
this.lockedReason = normalized.length === 0 ? "recovery_required" : normalized;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private recoveryResult(changedFiles = 0): OperationResult {
|
|
247
|
+
return {
|
|
248
|
+
code: "recovery_required",
|
|
249
|
+
changedFiles,
|
|
250
|
+
message: this.recoveryReason(),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
229
254
|
captureFailed(): boolean {
|
|
230
255
|
return this.lastCaptureFailed;
|
|
231
256
|
}
|
|
@@ -306,13 +331,13 @@ export class UndoControllerImpl implements UndoController {
|
|
|
306
331
|
sourceLogicalLeaf: this.staged.sourceLogicalLeaf,
|
|
307
332
|
});
|
|
308
333
|
if (this.staged.startEntryId === null) {
|
|
309
|
-
this.
|
|
334
|
+
this.lock("start_entry_missing");
|
|
310
335
|
this.staged = undefined;
|
|
311
336
|
await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
|
|
312
337
|
return;
|
|
313
338
|
}
|
|
314
339
|
} catch {
|
|
315
|
-
this.
|
|
340
|
+
this.lock("start_entry_append_failed");
|
|
316
341
|
this.staged = undefined;
|
|
317
342
|
return;
|
|
318
343
|
}
|
|
@@ -361,13 +386,13 @@ export class UndoControllerImpl implements UndoController {
|
|
|
361
386
|
if (this.locked || staged === undefined) return;
|
|
362
387
|
if (staged.startEntryId === undefined || staged.startEntryId === null) {
|
|
363
388
|
// Pi 没有提供已落盘的 start entry ID,不能把后续 assistant 输出归属到该 checkpoint。
|
|
364
|
-
this.
|
|
389
|
+
this.lock("start_entry_missing");
|
|
365
390
|
await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
|
|
366
391
|
return;
|
|
367
392
|
}
|
|
368
393
|
const userEntryId = this.dependencies.findUserEntryAfter(staged.startEntryId);
|
|
369
394
|
if (userEntryId === null) {
|
|
370
|
-
this.
|
|
395
|
+
this.lock("user_entry_missing");
|
|
371
396
|
await this.dependencies.appendControl("pi-undo:barrier", { reason: "user_entry_missing" }).catch(() => {});
|
|
372
397
|
return;
|
|
373
398
|
}
|
|
@@ -375,7 +400,8 @@ export class UndoControllerImpl implements UndoController {
|
|
|
375
400
|
const measure = <T>(phase: string, operation: () => Promise<T>): Promise<T> =>
|
|
376
401
|
profiler === undefined ? operation() : profiler.measure(phase, operation);
|
|
377
402
|
try {
|
|
378
|
-
|
|
403
|
+
// settled 复用 run 开始时的 before;helper 在缺少 captureBaseline 时回退完整 capture。
|
|
404
|
+
const after = await measure("settled.capture", () => this.captureBaselineWithWorkspaceLock(staged.before));
|
|
379
405
|
const changedPaths = await measure("settled.changedPaths", () =>
|
|
380
406
|
this.dependencies.changedPaths(staged.before, after));
|
|
381
407
|
if (changedPaths.length > 0 && this.dependencies.prepareDurableRestore !== undefined) {
|
|
@@ -396,7 +422,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
396
422
|
const checkpointEntryId = await measure("settled.checkpoint", () =>
|
|
397
423
|
this.dependencies.appendControl("pi-undo:checkpoint", checkpoint));
|
|
398
424
|
if (checkpointEntryId === null) {
|
|
399
|
-
this.
|
|
425
|
+
this.lock("checkpoint_entry_missing");
|
|
400
426
|
await this.dependencies.appendControl("pi-undo:barrier", { reason: "checkpoint_entry_missing" }).catch(() => {});
|
|
401
427
|
return;
|
|
402
428
|
}
|
|
@@ -410,11 +436,13 @@ export class UndoControllerImpl implements UndoController {
|
|
|
410
436
|
}
|
|
411
437
|
|
|
412
438
|
async undo(): Promise<OperationResult> {
|
|
439
|
+
if (this.locked) return this.recoveryResult();
|
|
413
440
|
if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
|
|
414
441
|
return this.runOperation("undo");
|
|
415
442
|
}
|
|
416
443
|
|
|
417
444
|
async redo(): Promise<OperationResult> {
|
|
445
|
+
if (this.locked) return this.recoveryResult();
|
|
418
446
|
if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
|
|
419
447
|
// 新 run 开始时 redo frontier 已失效;空栈命令不得为了确认 noop 而中断正在运行的 Agent。
|
|
420
448
|
if (this.redoStack.length === 0) return noop();
|
|
@@ -441,7 +469,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
441
469
|
return undefined;
|
|
442
470
|
} catch {
|
|
443
471
|
if (lease !== undefined) {
|
|
444
|
-
await lease.release().catch(() => { this.
|
|
472
|
+
await lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
|
|
445
473
|
}
|
|
446
474
|
this.operationInFlight = false;
|
|
447
475
|
return { cancel: true };
|
|
@@ -454,7 +482,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
454
482
|
this.pendingTree = undefined;
|
|
455
483
|
try {
|
|
456
484
|
if ((event.navigationTargetLeafId ?? event.newLeafId) !== pending.descriptor.toLogicalLeaf) {
|
|
457
|
-
this.
|
|
485
|
+
this.lock("session_navigation_diverged");
|
|
458
486
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
459
487
|
return;
|
|
460
488
|
}
|
|
@@ -468,7 +496,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
468
496
|
{ opId: pending.descriptor.opId },
|
|
469
497
|
);
|
|
470
498
|
if (applied.code !== "ok") {
|
|
471
|
-
this.
|
|
499
|
+
this.lock("restore_failed");
|
|
472
500
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
473
501
|
return;
|
|
474
502
|
}
|
|
@@ -477,7 +505,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
477
505
|
this.createTreeCursor(pending.descriptor, event.newLeafId, pending.undoStack),
|
|
478
506
|
);
|
|
479
507
|
if (cursorResult.kind !== "durable") {
|
|
480
|
-
this.
|
|
508
|
+
this.lock(cursorResult.kind === "recovery_required" ? cursorResult.reason : "cursor_recovery_required");
|
|
481
509
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
482
510
|
return;
|
|
483
511
|
}
|
|
@@ -486,9 +514,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
486
514
|
this.undoStack.splice(0, this.undoStack.length, ...pending.undoStack);
|
|
487
515
|
this.redoStack.length = 0;
|
|
488
516
|
} catch {
|
|
489
|
-
this.
|
|
517
|
+
this.lock("tree_recovery_failed");
|
|
490
518
|
} finally {
|
|
491
|
-
await pending.lease.release().catch(() => { this.
|
|
519
|
+
await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
|
|
492
520
|
this.operationInFlight = false;
|
|
493
521
|
}
|
|
494
522
|
}
|
|
@@ -501,9 +529,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
501
529
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTING");
|
|
502
530
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTED");
|
|
503
531
|
} catch {
|
|
504
|
-
this.
|
|
532
|
+
this.lock("tree_cancel_failed");
|
|
505
533
|
} finally {
|
|
506
|
-
await pending.lease.release().catch(() => { this.
|
|
534
|
+
await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
|
|
507
535
|
this.operationInFlight = false;
|
|
508
536
|
}
|
|
509
537
|
}
|
|
@@ -518,9 +546,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
518
546
|
const recovery = (async (): Promise<void> => {
|
|
519
547
|
try {
|
|
520
548
|
const result = await this.dependencies.recoverPending();
|
|
521
|
-
if (result.kind === "locked") this.
|
|
549
|
+
if (result.kind === "locked") this.lock(result.reason ?? "recovery_failed");
|
|
522
550
|
} catch {
|
|
523
|
-
this.
|
|
551
|
+
this.lock("recovery_failed");
|
|
524
552
|
} finally {
|
|
525
553
|
this.recoveryCompleted = true;
|
|
526
554
|
}
|
|
@@ -530,7 +558,8 @@ export class UndoControllerImpl implements UndoController {
|
|
|
530
558
|
}
|
|
531
559
|
|
|
532
560
|
private async runOperation(action: "undo" | "redo"): Promise<OperationResult> {
|
|
533
|
-
if (this.locked
|
|
561
|
+
if (this.locked) return this.recoveryResult();
|
|
562
|
+
if (this.operationInFlight) return { code: "busy", changedFiles: 0 };
|
|
534
563
|
const profile = new OperationProfiler();
|
|
535
564
|
const done = (result: OperationResult): OperationResult => profile.attach(result);
|
|
536
565
|
this.operationInFlight = true;
|
|
@@ -600,7 +629,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
600
629
|
return done({ code: "restore_failed_safe", changedFiles: 0 });
|
|
601
630
|
}
|
|
602
631
|
if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
|
|
603
|
-
this.
|
|
632
|
+
this.lock("session_navigation_diverged");
|
|
604
633
|
await profile.measure("journal", () =>
|
|
605
634
|
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
|
|
606
635
|
return done({ code: "recovery_required", changedFiles: 0 });
|
|
@@ -620,7 +649,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
620
649
|
const cursor = this.createCursor(descriptor, action, checkpoint);
|
|
621
650
|
const cursorResult = await profile.measure("cursor", () => this.dependencies.appendCursor(cursor));
|
|
622
651
|
if (cursorResult.kind === "recovery_required") {
|
|
623
|
-
this.
|
|
652
|
+
this.lock(cursorResult.reason);
|
|
624
653
|
await profile.measure("journal", () =>
|
|
625
654
|
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
|
|
626
655
|
return done({ code: "recovery_required", changedFiles: applied.verifiedPaths });
|
|
@@ -639,13 +668,13 @@ export class UndoControllerImpl implements UndoController {
|
|
|
639
668
|
this.lastSafetyManifestId = rollback.manifestId;
|
|
640
669
|
return done(this.advanceHistory(action, checkpoint, { code: "ok", changedFiles: applied.verifiedPaths }));
|
|
641
670
|
} catch {
|
|
642
|
-
this.
|
|
643
|
-
return done(
|
|
671
|
+
this.lock("operation_failed");
|
|
672
|
+
return done(this.recoveryResult());
|
|
644
673
|
} finally {
|
|
645
674
|
const activeLease = lease;
|
|
646
675
|
if (activeLease !== undefined) {
|
|
647
676
|
await profile.measure("unlock", () =>
|
|
648
|
-
activeLease.release().catch(() => { this.
|
|
677
|
+
activeLease.release().catch(() => { this.lock("workspace_lock_release_failed"); }));
|
|
649
678
|
}
|
|
650
679
|
if (this.operationProfiler === profile) this.operationProfiler = undefined;
|
|
651
680
|
this.operationAction = undefined;
|
|
@@ -703,7 +732,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
703
732
|
return { code: "restore_failed_safe", changedFiles: 0 };
|
|
704
733
|
}
|
|
705
734
|
if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
|
|
706
|
-
this.
|
|
735
|
+
this.lock("session_navigation_diverged");
|
|
707
736
|
await profile.measure("journal", () =>
|
|
708
737
|
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
|
|
709
738
|
return { code: "recovery_required", changedFiles: 0 };
|
|
@@ -716,7 +745,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
716
745
|
const cursorResult = await profile.measure("cursor", () =>
|
|
717
746
|
this.dependencies.appendCursor(this.createCursor(descriptor, action, checkpoint)));
|
|
718
747
|
if (cursorResult.kind === "recovery_required") {
|
|
719
|
-
this.
|
|
748
|
+
this.lock(cursorResult.reason);
|
|
720
749
|
await profile.measure("journal", () =>
|
|
721
750
|
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
|
|
722
751
|
return { code: "recovery_required", changedFiles: 0 };
|
|
@@ -741,9 +770,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
741
770
|
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
|
|
742
771
|
return { code: "restore_failed_safe", changedFiles: 0 };
|
|
743
772
|
} catch {
|
|
744
|
-
this.
|
|
773
|
+
this.lock("session_only_recovery_failed");
|
|
745
774
|
await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
|
|
746
|
-
return
|
|
775
|
+
return this.recoveryResult();
|
|
747
776
|
}
|
|
748
777
|
}
|
|
749
778
|
|
|
@@ -825,9 +854,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
825
854
|
} catch {
|
|
826
855
|
// 下面统一进入 recovery lock。
|
|
827
856
|
}
|
|
828
|
-
this.
|
|
857
|
+
this.lock("compensation_failed");
|
|
829
858
|
await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
|
|
830
|
-
return
|
|
859
|
+
return this.recoveryResult(failure.verifiedPaths);
|
|
831
860
|
}
|
|
832
861
|
|
|
833
862
|
private createCheckpoint(
|
package/src/journal.ts
CHANGED
|
@@ -133,6 +133,27 @@ export class JournalStore {
|
|
|
133
133
|
return result;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 判断 foreign PREPARED transaction 是否是严格意义上的 session-only 空操作。
|
|
138
|
+
* 这是纯只读检查;任何目录、控制文件或 plan 证据不确定时都返回 false。
|
|
139
|
+
*/
|
|
140
|
+
async isInertForeignPrepared(pending: PendingJournal): Promise<boolean> {
|
|
141
|
+
if (
|
|
142
|
+
pending.state.phase !== "PREPARED" ||
|
|
143
|
+
pending.descriptor.scopePaths.length !== 0 ||
|
|
144
|
+
!isInertRestorePlan(pending.plan, pending.descriptor)
|
|
145
|
+
) return false;
|
|
146
|
+
try {
|
|
147
|
+
const current = await this.load(pending.descriptor.opId);
|
|
148
|
+
if (!samePendingJournal(current, pending) || !isInertRestorePlan(current.plan, current.descriptor)) return false;
|
|
149
|
+
if (!await hasOnlyControlFiles(this.operationDirectory(pending.descriptor.opId))) return false;
|
|
150
|
+
const verified = await this.load(pending.descriptor.opId);
|
|
151
|
+
return samePendingJournal(verified, current) && await hasOnlyControlFiles(this.operationDirectory(pending.descriptor.opId));
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
136
157
|
async assertLogicalCommitReady(opId: string, allowPendingMutations = false): Promise<void> {
|
|
137
158
|
if (!allowPendingMutations) await this.mutationJournal(opId).assertCleaned();
|
|
138
159
|
const pending = await this.load(opId);
|
|
@@ -319,6 +340,84 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
319
340
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
320
341
|
}
|
|
321
342
|
|
|
343
|
+
function isInertRestorePlan(value: unknown, descriptor: OperationDescriptor): boolean {
|
|
344
|
+
if (!isRecord(value)) return false;
|
|
345
|
+
const expectedKeys = [
|
|
346
|
+
"boundaryRoots",
|
|
347
|
+
"currentManifestId",
|
|
348
|
+
"deletePaths",
|
|
349
|
+
"planDigest",
|
|
350
|
+
"scopePaths",
|
|
351
|
+
"targetManifestId",
|
|
352
|
+
"writePaths",
|
|
353
|
+
].sort();
|
|
354
|
+
if (canonicalJson(Object.keys(value).sort()) !== canonicalJson(expectedKeys)) return false;
|
|
355
|
+
if (
|
|
356
|
+
value.currentManifestId !== descriptor.rollbackManifestId ||
|
|
357
|
+
value.targetManifestId !== descriptor.targetManifestId ||
|
|
358
|
+
!isEmptyArray(value.boundaryRoots) ||
|
|
359
|
+
!isEmptyArray(value.deletePaths) ||
|
|
360
|
+
!isEmptyArray(value.writePaths) ||
|
|
361
|
+
!isEmptyArray(value.scopePaths) ||
|
|
362
|
+
typeof value.planDigest !== "string" ||
|
|
363
|
+
!/^[0-9a-f]{64}$/.test(value.planDigest)
|
|
364
|
+
) return false;
|
|
365
|
+
try {
|
|
366
|
+
return checksum(canonicalJson({
|
|
367
|
+
currentManifestId: value.currentManifestId,
|
|
368
|
+
targetManifestId: value.targetManifestId,
|
|
369
|
+
boundaryRoots: value.boundaryRoots,
|
|
370
|
+
deletePaths: value.deletePaths,
|
|
371
|
+
writePaths: value.writePaths,
|
|
372
|
+
scopePaths: value.scopePaths,
|
|
373
|
+
})) === value.planDigest;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function hasOnlyControlFiles(directory: string): Promise<boolean> {
|
|
380
|
+
const expected = ["descriptor.json", "restore-plan.json", "state.json"];
|
|
381
|
+
try {
|
|
382
|
+
const metadata = await lstat(directory);
|
|
383
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) return false;
|
|
384
|
+
} catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
let entries;
|
|
388
|
+
try {
|
|
389
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
if (entries.length !== expected.length || !expected.every((name) => entries.some((entry) => entry.name === name))) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
for (const name of expected) {
|
|
398
|
+
const entry = entries.find((candidate) => candidate.name === name);
|
|
399
|
+
if (entry === undefined || !entry.isFile() || entry.isSymbolicLink()) return false;
|
|
400
|
+
const stats = await lstat(join(directory, name));
|
|
401
|
+
if (!stats.isFile() || stats.isSymbolicLink()) return false;
|
|
402
|
+
}
|
|
403
|
+
return true;
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function samePendingJournal(left: PendingJournal, right: PendingJournal): boolean {
|
|
410
|
+
try {
|
|
411
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
412
|
+
} catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isEmptyArray(value: unknown): value is readonly unknown[] {
|
|
418
|
+
return Array.isArray(value) && value.length === 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
322
421
|
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
323
422
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
324
423
|
}
|
package/src/pi-runtime.ts
CHANGED
|
@@ -64,6 +64,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
64
64
|
workspaceIdentity: initialTopology.workspaceIdentity,
|
|
65
65
|
getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
|
|
66
66
|
loadPending: () => journal.loadPending(),
|
|
67
|
+
assessForeignTransaction: (pending) => journal.isInertForeignPrepared(pending),
|
|
67
68
|
inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor),
|
|
68
69
|
finalizeCursor: (pending, inspection) => finalizeCursorMarker(
|
|
69
70
|
pending.descriptor.sessionIdentity.path,
|
|
@@ -259,6 +260,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
259
260
|
const controller = new UndoControllerImpl(dependencies, {
|
|
260
261
|
...rebuildControllerState(manager, sessionIdentity),
|
|
261
262
|
locked: startupRecovery.kind === "locked",
|
|
263
|
+
recoveryReason: startupRecovery.kind === "locked" ? startupRecovery.reason : undefined,
|
|
262
264
|
recoveryCompleted: true,
|
|
263
265
|
});
|
|
264
266
|
return {
|
|
@@ -266,7 +268,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
266
268
|
reporter: new StatusReporter(context),
|
|
267
269
|
diffSource: store,
|
|
268
270
|
recovery: startupRecovery.kind === "locked"
|
|
269
|
-
? { files: startupRecovery.files, opId: startupRecovery.opId }
|
|
271
|
+
? { reason: startupRecovery.reason, files: startupRecovery.files, opId: startupRecovery.opId }
|
|
270
272
|
: undefined,
|
|
271
273
|
setCommandContext(next: ExtensionCommandContext | undefined): void {
|
|
272
274
|
commandContext = next;
|
package/src/recovery.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface JournalRecoveryDependencies {
|
|
|
9
9
|
readonly workspaceIdentity: string;
|
|
10
10
|
readonly getLogicalLeafId: () => string | null;
|
|
11
11
|
readonly loadPending: () => Promise<readonly PendingJournal[]>;
|
|
12
|
+
/** 仅允许严格证明无工作区 mutation 的 foreign PREPARED 空事务被忽略。 */
|
|
13
|
+
readonly assessForeignTransaction?: (journal: PendingJournal) => Promise<boolean>;
|
|
12
14
|
readonly inspectCursor: (journal: PendingJournal) => Promise<CursorMarkerInspection>;
|
|
13
15
|
readonly finalizeCursor: (journal: PendingJournal, inspection: Extract<CursorMarkerInspection, { kind: "match" }>) => Promise<void>;
|
|
14
16
|
readonly recoverMutations: (
|
|
@@ -65,6 +67,7 @@ export class JournalRecovery {
|
|
|
65
67
|
for (const journal of pending) {
|
|
66
68
|
const identityError = this.identityError(journal);
|
|
67
69
|
if (identityError !== null) {
|
|
70
|
+
if (identityError === "session_identity_mismatch" && await this.canIgnoreForeignTransaction(journal)) continue;
|
|
68
71
|
return { kind: "locked", reason: identityError, operations: recovered };
|
|
69
72
|
}
|
|
70
73
|
let inspection: CursorMarkerInspection;
|
|
@@ -131,6 +134,17 @@ export class JournalRecovery {
|
|
|
131
134
|
return { kind: "recovered", operations: recovered };
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
private async canIgnoreForeignTransaction(journal: PendingJournal): Promise<boolean> {
|
|
138
|
+
const assess = this.dependencies.assessForeignTransaction;
|
|
139
|
+
if (assess === undefined) return false;
|
|
140
|
+
try {
|
|
141
|
+
return await assess(journal);
|
|
142
|
+
} catch {
|
|
143
|
+
// 评估失败等同于证据不足,保留 foreign identity lock。
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
134
148
|
private identityError(journal: PendingJournal): string | null {
|
|
135
149
|
if (journal.descriptor.workspaceIdentity !== this.dependencies.workspaceIdentity) {
|
|
136
150
|
return "workspace_identity_mismatch";
|
package/src/snapshot-store.ts
CHANGED
|
@@ -40,7 +40,7 @@ const HASH_BATCH_MAX_ARGUMENT_BYTES = process.platform === "win32" ? 24 * 1024 :
|
|
|
40
40
|
const HASH_BATCH_CONCURRENCY = 4;
|
|
41
41
|
const ROOT_CAPTURE_CONCURRENCY = 4;
|
|
42
42
|
const FILE_SYSTEM_INSPECTION_CONCURRENCY = 32;
|
|
43
|
-
const
|
|
43
|
+
const METADATA_BATCH_SIZE = 1_024;
|
|
44
44
|
const INDEX_BATCH_MAX_ENTRIES = 4_096;
|
|
45
45
|
const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
46
46
|
const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
@@ -1010,7 +1010,7 @@ export class SnapshotStore {
|
|
|
1010
1010
|
}
|
|
1011
1011
|
// ignored build/vendor trees 常含数万叶子;复用同一批量 metadata 协议,避免逐路径重复
|
|
1012
1012
|
// 遍历父目录。Native 与 fallback 都在叶子扫描前后复核共享父目录。
|
|
1013
|
-
const nativeEntries = await this.
|
|
1013
|
+
const nativeEntries = await this.inspectNativeMetadataBatches(cwd, candidates, requestDirectory);
|
|
1014
1014
|
const kinds = nativeEntries === undefined
|
|
1015
1015
|
? await this.collectIgnoredPresentKindsFallback(cwd, candidates)
|
|
1016
1016
|
: nativeEntries.map((entry) => entry.kind);
|
|
@@ -1027,18 +1027,20 @@ export class SnapshotStore {
|
|
|
1027
1027
|
return result.sort(comparePaths);
|
|
1028
1028
|
}
|
|
1029
1029
|
|
|
1030
|
-
private async
|
|
1030
|
+
private async inspectNativeMetadataBatches(
|
|
1031
1031
|
cwd: string,
|
|
1032
1032
|
paths: readonly string[],
|
|
1033
1033
|
requestDirectory: string,
|
|
1034
1034
|
): Promise<readonly NativeMetadataEntry[] | undefined> {
|
|
1035
|
+
// 空路径不得触发 native inspect;首批 unsupported 才整体回退,中途变化必须 fail closed。
|
|
1036
|
+
if (paths.length === 0) return [];
|
|
1035
1037
|
const result: NativeMetadataEntry[] = [];
|
|
1036
|
-
for (let offset = 0; offset < paths.length; offset +=
|
|
1037
|
-
const batch = paths.slice(offset, offset +
|
|
1038
|
+
for (let offset = 0; offset < paths.length; offset += METADATA_BATCH_SIZE) {
|
|
1039
|
+
const batch = paths.slice(offset, offset + METADATA_BATCH_SIZE);
|
|
1038
1040
|
const inspected = await this.nativeMetadata.inspect(cwd, batch, requestDirectory);
|
|
1039
1041
|
if (inspected === undefined) {
|
|
1040
1042
|
if (result.length > 0) {
|
|
1041
|
-
throw new SnapshotStoreError("capture_failed", "native
|
|
1043
|
+
throw new SnapshotStoreError("capture_failed", "native metadata 能力在批次间变化");
|
|
1042
1044
|
}
|
|
1043
1045
|
return undefined;
|
|
1044
1046
|
}
|
|
@@ -1052,8 +1054,8 @@ export class SnapshotStore {
|
|
|
1052
1054
|
paths: readonly string[],
|
|
1053
1055
|
): Promise<readonly NativeMetadataEntry["kind"][]> {
|
|
1054
1056
|
const result: NativeMetadataEntry["kind"][] = [];
|
|
1055
|
-
for (let offset = 0; offset < paths.length; offset +=
|
|
1056
|
-
const batch = paths.slice(offset, offset +
|
|
1057
|
+
for (let offset = 0; offset < paths.length; offset += METADATA_BATCH_SIZE) {
|
|
1058
|
+
const batch = paths.slice(offset, offset + METADATA_BATCH_SIZE);
|
|
1057
1059
|
await assertNoSymlinkParents(cwd, batch);
|
|
1058
1060
|
const kinds = await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
|
|
1059
1061
|
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
@@ -1243,12 +1245,12 @@ export class SnapshotStore {
|
|
|
1243
1245
|
leaves: readonly VisibleLeaf[],
|
|
1244
1246
|
requestDirectory?: string,
|
|
1245
1247
|
): Promise<void> {
|
|
1248
|
+
if (leaves.length === 0) return;
|
|
1249
|
+
const paths = leaves.map((leaf) => leaf.relativePath);
|
|
1246
1250
|
if (requestDirectory !== undefined) {
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
requestDirectory,
|
|
1251
|
-
);
|
|
1251
|
+
// 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
|
|
1252
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1253
|
+
const inspected = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
|
|
1252
1254
|
if (inspected !== undefined) {
|
|
1253
1255
|
for (let index = 0; index < leaves.length; index += 1) {
|
|
1254
1256
|
const leaf = leaves[index]!;
|
|
@@ -1257,10 +1259,15 @@ export class SnapshotStore {
|
|
|
1257
1259
|
throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
|
|
1258
1260
|
}
|
|
1259
1261
|
}
|
|
1262
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1260
1263
|
return;
|
|
1261
1264
|
}
|
|
1265
|
+
await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
1266
|
+
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
1267
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1268
|
+
return;
|
|
1262
1269
|
}
|
|
1263
|
-
await assertNoSymlinkParents(cwd,
|
|
1270
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1264
1271
|
await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
1265
1272
|
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
1266
1273
|
}
|
|
@@ -1335,10 +1342,14 @@ export class SnapshotStore {
|
|
|
1335
1342
|
exclusions,
|
|
1336
1343
|
exactExclusions,
|
|
1337
1344
|
);
|
|
1338
|
-
|
|
1345
|
+
if (paths.length === 0) return [];
|
|
1346
|
+
// 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
|
|
1347
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1348
|
+
const nativeEntries = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
|
|
1339
1349
|
const metadataEntries = nativeEntries === undefined
|
|
1340
1350
|
? await this.collectVisibleLeafMetadataFallback(cwd, paths)
|
|
1341
1351
|
: nativeEntries.map((entry) => nativeVisibleLeafMetadata(entry));
|
|
1352
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1342
1353
|
const leaves: VisibleLeaf[] = [];
|
|
1343
1354
|
for (let index = 0; index < paths.length; index += 1) {
|
|
1344
1355
|
const relativePath = paths[index]!;
|
package/src/status-reporter.ts
CHANGED
|
@@ -32,11 +32,12 @@ export class StatusReporter {
|
|
|
32
32
|
reason: string,
|
|
33
33
|
details?: { readonly files?: number; readonly opId?: string },
|
|
34
34
|
): void {
|
|
35
|
+
const safeReason = sanitize(reason) || "recovery_required";
|
|
35
36
|
if (details?.files !== undefined && details.opId !== undefined) {
|
|
36
|
-
this.setStatus(`recovery_required files:${details.files} op:${sanitize(details.opId)}`);
|
|
37
|
+
this.setStatus(`recovery_required reason:${safeReason} files:${details.files} op:${sanitize(details.opId)}`);
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
|
-
this.setStatus(`recovery required: ${
|
|
40
|
+
this.setStatus(`recovery required: ${safeReason}`);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
clear(): void {
|