@davideasden/pi-undo 0.2.16 → 0.2.18
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 +2 -1
- package/src/controller.ts +57 -28
- package/src/durable-pack.ts +7 -2
- package/src/journal.ts +99 -0
- package/src/pi-runtime.ts +3 -1
- package/src/recovery.ts +14 -0
- package/src/restore-engine.ts +125 -33
- package/src/snapshot-store.ts +121 -16
- 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/durable-pack.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { copyFile, link, lstat, open, readFile, rename, rm, type FileHandle } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
|
|
@@ -194,7 +194,12 @@ export async function createDurablePack(
|
|
|
194
194
|
await rm(temporary, { force: true }).catch(() => {});
|
|
195
195
|
throw error;
|
|
196
196
|
}
|
|
197
|
-
const
|
|
197
|
+
const digest = createHash("sha256");
|
|
198
|
+
digest.update(MAGIC);
|
|
199
|
+
digest.update(lengthBytes);
|
|
200
|
+
digest.update(headerBytes);
|
|
201
|
+
for (const payload of payloads) digest.update(payload);
|
|
202
|
+
const packChecksum = digest.digest("hex");
|
|
198
203
|
return durablePackFromInput(input.opId, input.planDigest, packPath, packChecksum, entries);
|
|
199
204
|
}
|
|
200
205
|
|
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/restore-engine.ts
CHANGED
|
@@ -38,6 +38,7 @@ const PREPARED_PLAN_CACHE_LIMIT = 16;
|
|
|
38
38
|
const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
|
|
39
39
|
const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
|
|
40
40
|
const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
|
|
41
|
+
const RESTORE_FILE_VERIFY_CONCURRENCY = 32;
|
|
41
42
|
|
|
42
43
|
export interface RestorePlan {
|
|
43
44
|
currentManifestId: ManifestId;
|
|
@@ -519,7 +520,13 @@ export class RestoreEngine {
|
|
|
519
520
|
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
520
521
|
}
|
|
521
522
|
try {
|
|
522
|
-
await this.assertCompleteVisibleSubset(
|
|
523
|
+
await this.assertCompleteVisibleSubset(
|
|
524
|
+
topologyBefore,
|
|
525
|
+
[current, target],
|
|
526
|
+
options.mutationJournal,
|
|
527
|
+
[],
|
|
528
|
+
this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
|
|
529
|
+
);
|
|
523
530
|
} catch {
|
|
524
531
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
525
532
|
}
|
|
@@ -588,6 +595,11 @@ export class RestoreEngine {
|
|
|
588
595
|
);
|
|
589
596
|
return result;
|
|
590
597
|
}
|
|
598
|
+
try {
|
|
599
|
+
await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
|
|
600
|
+
} catch {
|
|
601
|
+
// 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
|
|
602
|
+
}
|
|
591
603
|
const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
|
|
592
604
|
if (!preflight.ok) {
|
|
593
605
|
return {
|
|
@@ -625,7 +637,13 @@ export class RestoreEngine {
|
|
|
625
637
|
|
|
626
638
|
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
627
639
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
628
|
-
await this.assertCompleteVisibleSubset(
|
|
640
|
+
await this.assertCompleteVisibleSubset(
|
|
641
|
+
topologyAfter,
|
|
642
|
+
[target],
|
|
643
|
+
options.mutationJournal,
|
|
644
|
+
[],
|
|
645
|
+
this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
|
|
646
|
+
);
|
|
629
647
|
const verification = await this.verifyTarget(
|
|
630
648
|
target,
|
|
631
649
|
currentPaths,
|
|
@@ -702,6 +720,7 @@ export class RestoreEngine {
|
|
|
702
720
|
? []
|
|
703
721
|
: [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
|
|
704
722
|
}),
|
|
723
|
+
this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
|
|
705
724
|
);
|
|
706
725
|
const totalPaths = plan.deletePaths.length + plan.writePaths.length;
|
|
707
726
|
if ((await options.mutationJournal.load()).length !== 0) {
|
|
@@ -887,6 +906,41 @@ export class RestoreEngine {
|
|
|
887
906
|
return result;
|
|
888
907
|
}
|
|
889
908
|
|
|
909
|
+
private completeCoverageOwnedPaths(
|
|
910
|
+
scopePaths: readonly string[] | undefined,
|
|
911
|
+
ownedPaths: readonly ReadonlyMap<string, OwnedPath>[],
|
|
912
|
+
): readonly ReadonlyMap<string, OwnedPath>[] | undefined {
|
|
913
|
+
return scopePaths === undefined ? ownedPaths : undefined;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private async prefetchCompleteRestoreBlobs(
|
|
917
|
+
plan: RestorePlan,
|
|
918
|
+
current: SnapshotManifest,
|
|
919
|
+
target: SnapshotManifest,
|
|
920
|
+
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
921
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
922
|
+
): Promise<void> {
|
|
923
|
+
if (plan.scopePaths !== undefined || !SnapshotStore.supportsValidatedBlobBatch(this.store)) return;
|
|
924
|
+
const requestsFor = (paths: ReadonlyMap<string, OwnedPath>, extraPaths: readonly string[] = []) => {
|
|
925
|
+
const requests = [];
|
|
926
|
+
const requested = new Set([...plan.writePaths, ...extraPaths]);
|
|
927
|
+
for (const path of requested) {
|
|
928
|
+
const owned = paths.get(path);
|
|
929
|
+
if (owned === undefined || owned.entry.kind !== "file" || owned.entry.blobId === null) continue;
|
|
930
|
+
requests.push({
|
|
931
|
+
rootPath: owned.root.relativeRoot,
|
|
932
|
+
blobId: owned.entry.blobId,
|
|
933
|
+
relativePath: owned.entry.relativePath,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
return requests;
|
|
937
|
+
};
|
|
938
|
+
await Promise.all([
|
|
939
|
+
this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
|
|
940
|
+
this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
|
|
941
|
+
]);
|
|
942
|
+
}
|
|
943
|
+
|
|
890
944
|
private assertCurrentTopology(
|
|
891
945
|
current: SnapshotManifest,
|
|
892
946
|
target: SnapshotManifest,
|
|
@@ -926,12 +980,13 @@ export class RestoreEngine {
|
|
|
926
980
|
const paths = [...new Set([...currentPaths.keys(), ...targetPaths.keys()])]
|
|
927
981
|
.filter((path) => scope === undefined || scope.has(path))
|
|
928
982
|
.sort(comparePaths);
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
983
|
+
const results: Array<boolean | undefined> = new Array(paths.length);
|
|
984
|
+
let nextIndex = 0;
|
|
985
|
+
let stop = false;
|
|
986
|
+
let failure: unknown;
|
|
987
|
+
let failureIndex: number | undefined;
|
|
988
|
+
const verifyPath = async (path: string): Promise<boolean> => {
|
|
989
|
+
if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) return true;
|
|
935
990
|
const currentPath = currentPaths.get(path);
|
|
936
991
|
const targetPath = targetPaths.get(path);
|
|
937
992
|
const matchesCurrent = currentPath !== undefined &&
|
|
@@ -941,7 +996,37 @@ export class RestoreEngine {
|
|
|
941
996
|
const matchesAbsentSide = !matchesCurrent && !matchesTarget &&
|
|
942
997
|
(currentPath === undefined || targetPath === undefined) &&
|
|
943
998
|
await this.pathIsAbsent(path);
|
|
944
|
-
|
|
999
|
+
return matchesCurrent || matchesTarget || matchesAbsentSide;
|
|
1000
|
+
};
|
|
1001
|
+
const worker = async (): Promise<void> => {
|
|
1002
|
+
while (!stop && nextIndex < paths.length) {
|
|
1003
|
+
const index = nextIndex;
|
|
1004
|
+
nextIndex += 1;
|
|
1005
|
+
try {
|
|
1006
|
+
const ok = await verifyPath(paths[index]!);
|
|
1007
|
+
results[index] = ok;
|
|
1008
|
+
if (!ok) stop = true;
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
if (failureIndex === undefined || index < failureIndex) {
|
|
1011
|
+
failure = error;
|
|
1012
|
+
failureIndex = index;
|
|
1013
|
+
}
|
|
1014
|
+
stop = true;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
if (paths.length > 0) {
|
|
1019
|
+
await Promise.all(Array.from(
|
|
1020
|
+
{ length: Math.min(RESTORE_FILE_VERIFY_CONCURRENCY, paths.length) },
|
|
1021
|
+
() => worker(),
|
|
1022
|
+
));
|
|
1023
|
+
}
|
|
1024
|
+
let verifiedPaths = 0;
|
|
1025
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
1026
|
+
const ok = results[index];
|
|
1027
|
+
if (ok === false) return { ok: false, verifiedPaths, totalPaths: paths.length };
|
|
1028
|
+
if (ok === undefined) {
|
|
1029
|
+
if (failureIndex === index && failure !== undefined) throw failure;
|
|
945
1030
|
return { ok: false, verifiedPaths, totalPaths: paths.length };
|
|
946
1031
|
}
|
|
947
1032
|
verifiedPaths += 1;
|
|
@@ -1168,7 +1253,13 @@ export class RestoreEngine {
|
|
|
1168
1253
|
await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
|
|
1169
1254
|
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1170
1255
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
1171
|
-
await this.assertCompleteVisibleSubset(
|
|
1256
|
+
await this.assertCompleteVisibleSubset(
|
|
1257
|
+
topologyAfter,
|
|
1258
|
+
[current],
|
|
1259
|
+
options.mutationJournal,
|
|
1260
|
+
[],
|
|
1261
|
+
this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
|
|
1262
|
+
);
|
|
1172
1263
|
const verification = await this.verifyTarget(
|
|
1173
1264
|
current,
|
|
1174
1265
|
targetPaths,
|
|
@@ -1205,7 +1296,13 @@ export class RestoreEngine {
|
|
|
1205
1296
|
try {
|
|
1206
1297
|
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1207
1298
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
1208
|
-
await this.assertCompleteVisibleSubset(
|
|
1299
|
+
await this.assertCompleteVisibleSubset(
|
|
1300
|
+
topologyAfter,
|
|
1301
|
+
[current],
|
|
1302
|
+
options.mutationJournal,
|
|
1303
|
+
[],
|
|
1304
|
+
this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
|
|
1305
|
+
);
|
|
1209
1306
|
const verification = await this.verifyTarget(
|
|
1210
1307
|
current,
|
|
1211
1308
|
targetPaths,
|
|
@@ -1397,16 +1494,17 @@ export class RestoreEngine {
|
|
|
1397
1494
|
allowedManifests: readonly SnapshotManifest[],
|
|
1398
1495
|
mutationJournal?: MutationJournal,
|
|
1399
1496
|
extraExclusions: readonly string[] = [],
|
|
1497
|
+
ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
|
|
1400
1498
|
): Promise<void> {
|
|
1401
1499
|
if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
|
|
1402
1500
|
return;
|
|
1403
1501
|
}
|
|
1404
1502
|
const allowedPaths = new Set<string>();
|
|
1405
|
-
for (const manifest of allowedManifests) {
|
|
1503
|
+
for (const [index, manifest] of allowedManifests.entries()) {
|
|
1406
1504
|
for (const path of ignoredWorkspacePaths(manifest)) {
|
|
1407
1505
|
allowedPaths.add(path);
|
|
1408
1506
|
}
|
|
1409
|
-
const paths = await this.readOwnedPaths(manifest);
|
|
1507
|
+
const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
|
|
1410
1508
|
for (const [path, owned] of paths) {
|
|
1411
1509
|
if (owned.entry.kind !== "directory") {
|
|
1412
1510
|
allowedPaths.add(path);
|
|
@@ -1435,23 +1533,18 @@ export class RestoreEngine {
|
|
|
1435
1533
|
deletePaths: readonly string[],
|
|
1436
1534
|
scopePaths?: readonly string[],
|
|
1437
1535
|
): Promise<{ verifiedPaths: number; totalPaths: number; pathFingerprints: string[] }> {
|
|
1438
|
-
const pathFingerprints: string[] = [];
|
|
1439
1536
|
const scope = scopePaths === undefined ? undefined : new Set(scopePaths);
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
) {
|
|
1452
|
-
continue;
|
|
1453
|
-
}
|
|
1454
|
-
totalPaths += 1;
|
|
1537
|
+
const scopedTargets = [...targetPaths].filter(([path]) => scope === undefined || scope.has(path));
|
|
1538
|
+
const pathFingerprints = await mapConcurrentOrdered(
|
|
1539
|
+
scopedTargets,
|
|
1540
|
+
RESTORE_FILE_VERIFY_CONCURRENCY,
|
|
1541
|
+
([, owned]) => this.verifyEntry(target.manifestId, owned),
|
|
1542
|
+
);
|
|
1543
|
+
const remainingDeletes = deletePaths.filter((path) =>
|
|
1544
|
+
!targetPaths.has(path) &&
|
|
1545
|
+
currentPaths.get(path)?.entry.kind !== "directory" &&
|
|
1546
|
+
!hasNonDirectoryAncestor(path, targetPaths));
|
|
1547
|
+
await mapConcurrentOrdered(remainingDeletes, RESTORE_FILE_VERIFY_CONCURRENCY, async (path) => {
|
|
1455
1548
|
try {
|
|
1456
1549
|
await lstat(this.absolutePath(path));
|
|
1457
1550
|
throw new Error(`目标应删除的路径仍然存在:${path}`);
|
|
@@ -1460,11 +1553,10 @@ export class RestoreEngine {
|
|
|
1460
1553
|
throw error;
|
|
1461
1554
|
}
|
|
1462
1555
|
}
|
|
1463
|
-
|
|
1464
|
-
}
|
|
1556
|
+
});
|
|
1465
1557
|
return {
|
|
1466
|
-
verifiedPaths,
|
|
1467
|
-
totalPaths,
|
|
1558
|
+
verifiedPaths: pathFingerprints.length + remainingDeletes.length,
|
|
1559
|
+
totalPaths: pathFingerprints.length + remainingDeletes.length,
|
|
1468
1560
|
pathFingerprints,
|
|
1469
1561
|
};
|
|
1470
1562
|
}
|
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;
|
|
@@ -210,6 +210,8 @@ export class SnapshotStore {
|
|
|
210
210
|
private readonly blobCache = new Map<string, CachedBlob>();
|
|
211
211
|
private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
|
|
212
212
|
private readonly leafCacheDirectoriesLoaded = new Set<string>();
|
|
213
|
+
private readonly configuredPrivateRepositories = new Set<string>();
|
|
214
|
+
private readonly leafCacheDirtyDirectories = new Set<string>();
|
|
213
215
|
private blobCacheBytes = 0;
|
|
214
216
|
|
|
215
217
|
constructor(options: SnapshotStoreOptions = {}) {
|
|
@@ -741,6 +743,61 @@ export class SnapshotStore {
|
|
|
741
743
|
return (await this.readBlobOperation(id, [{ rootPath, blobId, relativePath }], false))[0]!;
|
|
742
744
|
}
|
|
743
745
|
|
|
746
|
+
/** 按 root 批量预取普通文件 blob;membership 与 manifest 校验仍走只读路径。 */
|
|
747
|
+
async prefetchBlobs(id: ManifestId, requests: readonly SnapshotBlobRequest[]): Promise<void> {
|
|
748
|
+
if (requests.length === 0) return;
|
|
749
|
+
for (const request of requests) {
|
|
750
|
+
relativeSafePath("/", request.rootPath);
|
|
751
|
+
if (!isObjectId(request.blobId)) {
|
|
752
|
+
throw new SnapshotStoreError("object_missing", "blob ID 无效");
|
|
753
|
+
}
|
|
754
|
+
if (request.relativePath === undefined) {
|
|
755
|
+
throw new SnapshotStoreError("object_missing", "blob 预取必须提供 root-relative path");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const manifestPath = await this.findManifestPath(id);
|
|
759
|
+
const manifest = await this.loadManifest(id);
|
|
760
|
+
const roots = new Map(manifest.roots.map((root) => [root.relativeRoot, root]));
|
|
761
|
+
const storeDirectory = dirname(dirname(manifestPath));
|
|
762
|
+
const byRoot = new Map<string, SnapshotBlobRequest[]>();
|
|
763
|
+
for (const request of requests) {
|
|
764
|
+
const grouped = byRoot.get(request.rootPath) ?? [];
|
|
765
|
+
grouped.push(request);
|
|
766
|
+
byRoot.set(request.rootPath, grouped);
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
const grouped = new Map<string, Map<string, CapturedTreeEntry>>();
|
|
770
|
+
for (const [rootPath, rootRequests] of byRoot) {
|
|
771
|
+
const root = roots.get(rootPath);
|
|
772
|
+
if (root === undefined) {
|
|
773
|
+
throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
|
|
774
|
+
}
|
|
775
|
+
if (root.state !== "active" || root.treeId === null) {
|
|
776
|
+
throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
|
|
777
|
+
}
|
|
778
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
779
|
+
const entries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
780
|
+
const byPath = new Map(entries.map((entry) => [entry.relativePath, entry]));
|
|
781
|
+
const unique = grouped.get(gitDirectory) ?? new Map<string, CapturedTreeEntry>();
|
|
782
|
+
for (const request of rootRequests) {
|
|
783
|
+
const safeRelativePath = relativeSafePath("/", request.relativePath!);
|
|
784
|
+
const entry = byPath.get(safeRelativePath);
|
|
785
|
+
if (entry === undefined || entry.objectId !== request.blobId) {
|
|
786
|
+
throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
|
|
787
|
+
}
|
|
788
|
+
unique.set(entry.objectId, entry);
|
|
789
|
+
}
|
|
790
|
+
grouped.set(gitDirectory, unique);
|
|
791
|
+
}
|
|
792
|
+
for (const [gitDirectory, unique] of grouped) {
|
|
793
|
+
await this.preloadBlobBytes(gitDirectory, [...unique.values()]);
|
|
794
|
+
}
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (error instanceof SnapshotStoreError) throw error;
|
|
797
|
+
throw new SnapshotStoreError("object_missing", "blob 无法预取", { cause: error });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
744
801
|
private readBlobsValidated(
|
|
745
802
|
id: ManifestId,
|
|
746
803
|
requests: readonly SnapshotBlobRequest[],
|
|
@@ -1010,7 +1067,7 @@ export class SnapshotStore {
|
|
|
1010
1067
|
}
|
|
1011
1068
|
// ignored build/vendor trees 常含数万叶子;复用同一批量 metadata 协议,避免逐路径重复
|
|
1012
1069
|
// 遍历父目录。Native 与 fallback 都在叶子扫描前后复核共享父目录。
|
|
1013
|
-
const nativeEntries = await this.
|
|
1070
|
+
const nativeEntries = await this.inspectNativeMetadataBatches(cwd, candidates, requestDirectory);
|
|
1014
1071
|
const kinds = nativeEntries === undefined
|
|
1015
1072
|
? await this.collectIgnoredPresentKindsFallback(cwd, candidates)
|
|
1016
1073
|
: nativeEntries.map((entry) => entry.kind);
|
|
@@ -1027,18 +1084,20 @@ export class SnapshotStore {
|
|
|
1027
1084
|
return result.sort(comparePaths);
|
|
1028
1085
|
}
|
|
1029
1086
|
|
|
1030
|
-
private async
|
|
1087
|
+
private async inspectNativeMetadataBatches(
|
|
1031
1088
|
cwd: string,
|
|
1032
1089
|
paths: readonly string[],
|
|
1033
1090
|
requestDirectory: string,
|
|
1034
1091
|
): Promise<readonly NativeMetadataEntry[] | undefined> {
|
|
1092
|
+
// 空路径不得触发 native inspect;首批 unsupported 才整体回退,中途变化必须 fail closed。
|
|
1093
|
+
if (paths.length === 0) return [];
|
|
1035
1094
|
const result: NativeMetadataEntry[] = [];
|
|
1036
|
-
for (let offset = 0; offset < paths.length; offset +=
|
|
1037
|
-
const batch = paths.slice(offset, offset +
|
|
1095
|
+
for (let offset = 0; offset < paths.length; offset += METADATA_BATCH_SIZE) {
|
|
1096
|
+
const batch = paths.slice(offset, offset + METADATA_BATCH_SIZE);
|
|
1038
1097
|
const inspected = await this.nativeMetadata.inspect(cwd, batch, requestDirectory);
|
|
1039
1098
|
if (inspected === undefined) {
|
|
1040
1099
|
if (result.length > 0) {
|
|
1041
|
-
throw new SnapshotStoreError("capture_failed", "native
|
|
1100
|
+
throw new SnapshotStoreError("capture_failed", "native metadata 能力在批次间变化");
|
|
1042
1101
|
}
|
|
1043
1102
|
return undefined;
|
|
1044
1103
|
}
|
|
@@ -1052,8 +1111,8 @@ export class SnapshotStore {
|
|
|
1052
1111
|
paths: readonly string[],
|
|
1053
1112
|
): Promise<readonly NativeMetadataEntry["kind"][]> {
|
|
1054
1113
|
const result: NativeMetadataEntry["kind"][] = [];
|
|
1055
|
-
for (let offset = 0; offset < paths.length; offset +=
|
|
1056
|
-
const batch = paths.slice(offset, offset +
|
|
1114
|
+
for (let offset = 0; offset < paths.length; offset += METADATA_BATCH_SIZE) {
|
|
1115
|
+
const batch = paths.slice(offset, offset + METADATA_BATCH_SIZE);
|
|
1057
1116
|
await assertNoSymlinkParents(cwd, batch);
|
|
1058
1117
|
const kinds = await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
|
|
1059
1118
|
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
@@ -1153,9 +1212,10 @@ export class SnapshotStore {
|
|
|
1153
1212
|
|
|
1154
1213
|
private rememberVisibleLeaves(update: VisibleLeafCacheUpdate): void {
|
|
1155
1214
|
const { gitDirectory, staged, inclusions } = update;
|
|
1215
|
+
const previous = this.visibleLeafCache.get(gitDirectory);
|
|
1156
1216
|
const cache = inclusions !== null && inclusions.length === 0
|
|
1157
1217
|
? new Map<string, CachedVisibleLeaf>()
|
|
1158
|
-
: new Map(
|
|
1218
|
+
: new Map(previous);
|
|
1159
1219
|
if (inclusions !== null && inclusions.length > 0) {
|
|
1160
1220
|
for (const relativePath of cache.keys()) {
|
|
1161
1221
|
if (inclusions.some((inclusion) => isPathAtOrBelow(inclusion, relativePath))) {
|
|
@@ -1172,6 +1232,9 @@ export class SnapshotStore {
|
|
|
1172
1232
|
}
|
|
1173
1233
|
cache.set(leaf.relativePath, { ...leaf, objectId, verifiedAtNs: staged.verifiedAtNs });
|
|
1174
1234
|
}
|
|
1235
|
+
if (!samePersistedLeafCache(previous, cache)) {
|
|
1236
|
+
this.leafCacheDirtyDirectories.add(storeDirectoryForGitDirectory(gitDirectory));
|
|
1237
|
+
}
|
|
1175
1238
|
this.visibleLeafCache.set(gitDirectory, cache);
|
|
1176
1239
|
}
|
|
1177
1240
|
|
|
@@ -1208,6 +1271,7 @@ export class SnapshotStore {
|
|
|
1208
1271
|
|
|
1209
1272
|
/** 把当前 storeDirectory 范围内的叶子缓存原子写入磁盘(best-effort)。 */
|
|
1210
1273
|
private async persistLeafCache(storeDirectory: string): Promise<void> {
|
|
1274
|
+
if (!this.leafCacheDirtyDirectories.has(storeDirectory)) return;
|
|
1211
1275
|
const prefix = `${storeDirectory}${sep}`;
|
|
1212
1276
|
const entries: Record<string, Record<string, PersistedLeafCacheEntry>> = {};
|
|
1213
1277
|
for (const [gitDirectory, cache] of this.visibleLeafCache) {
|
|
@@ -1233,6 +1297,7 @@ export class SnapshotStore {
|
|
|
1233
1297
|
Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), "utf8"),
|
|
1234
1298
|
0o600,
|
|
1235
1299
|
);
|
|
1300
|
+
this.leafCacheDirtyDirectories.delete(storeDirectory);
|
|
1236
1301
|
} catch {
|
|
1237
1302
|
// 缓存写入是 best-effort:失败只影响下次性能,不影响正确性。
|
|
1238
1303
|
}
|
|
@@ -1243,12 +1308,12 @@ export class SnapshotStore {
|
|
|
1243
1308
|
leaves: readonly VisibleLeaf[],
|
|
1244
1309
|
requestDirectory?: string,
|
|
1245
1310
|
): Promise<void> {
|
|
1311
|
+
if (leaves.length === 0) return;
|
|
1312
|
+
const paths = leaves.map((leaf) => leaf.relativePath);
|
|
1246
1313
|
if (requestDirectory !== undefined) {
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
requestDirectory,
|
|
1251
|
-
);
|
|
1314
|
+
// 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
|
|
1315
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1316
|
+
const inspected = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
|
|
1252
1317
|
if (inspected !== undefined) {
|
|
1253
1318
|
for (let index = 0; index < leaves.length; index += 1) {
|
|
1254
1319
|
const leaf = leaves[index]!;
|
|
@@ -1257,10 +1322,15 @@ export class SnapshotStore {
|
|
|
1257
1322
|
throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
|
|
1258
1323
|
}
|
|
1259
1324
|
}
|
|
1325
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1260
1326
|
return;
|
|
1261
1327
|
}
|
|
1328
|
+
await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
1329
|
+
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
1330
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1331
|
+
return;
|
|
1262
1332
|
}
|
|
1263
|
-
await assertNoSymlinkParents(cwd,
|
|
1333
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1264
1334
|
await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
1265
1335
|
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
1266
1336
|
}
|
|
@@ -1335,10 +1405,14 @@ export class SnapshotStore {
|
|
|
1335
1405
|
exclusions,
|
|
1336
1406
|
exactExclusions,
|
|
1337
1407
|
);
|
|
1338
|
-
|
|
1408
|
+
if (paths.length === 0) return [];
|
|
1409
|
+
// 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
|
|
1410
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1411
|
+
const nativeEntries = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
|
|
1339
1412
|
const metadataEntries = nativeEntries === undefined
|
|
1340
1413
|
? await this.collectVisibleLeafMetadataFallback(cwd, paths)
|
|
1341
1414
|
: nativeEntries.map((entry) => nativeVisibleLeafMetadata(entry));
|
|
1415
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1342
1416
|
const leaves: VisibleLeaf[] = [];
|
|
1343
1417
|
for (let index = 0; index < paths.length; index += 1) {
|
|
1344
1418
|
const relativePath = paths[index]!;
|
|
@@ -1417,9 +1491,11 @@ export class SnapshotStore {
|
|
|
1417
1491
|
}
|
|
1418
1492
|
|
|
1419
1493
|
private async configurePrivateRepository(gitDirectory: string): Promise<void> {
|
|
1494
|
+
if (this.configuredPrivateRepositories.has(gitDirectory)) return;
|
|
1420
1495
|
const environment = cleanGitEnvironment();
|
|
1421
1496
|
await this.runGit(["--git-dir", gitDirectory, "config", "gc.auto", "0"], { env: environment });
|
|
1422
1497
|
await this.runGit(["--git-dir", gitDirectory, "config", "maintenance.auto", "false"], { env: environment });
|
|
1498
|
+
this.configuredPrivateRepositories.add(gitDirectory);
|
|
1423
1499
|
}
|
|
1424
1500
|
|
|
1425
1501
|
private async assertNoAlternates(gitDirectory: string): Promise<void> {
|
|
@@ -1971,6 +2047,35 @@ function blobCacheKey(gitDirectory: string, objectId: string): string {
|
|
|
1971
2047
|
return `${gitDirectory}\0${objectId}`;
|
|
1972
2048
|
}
|
|
1973
2049
|
|
|
2050
|
+
function storeDirectoryForGitDirectory(gitDirectory: string): string {
|
|
2051
|
+
return dirname(dirname(dirname(gitDirectory)));
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function samePersistedLeafCache(
|
|
2055
|
+
left: ReadonlyMap<string, CachedVisibleLeaf> | undefined,
|
|
2056
|
+
right: ReadonlyMap<string, CachedVisibleLeaf>,
|
|
2057
|
+
): boolean {
|
|
2058
|
+
if (left === undefined) return right.size === 0;
|
|
2059
|
+
if (left.size !== right.size) return false;
|
|
2060
|
+
for (const [path, entry] of right) {
|
|
2061
|
+
const existing = left.get(path);
|
|
2062
|
+
const existingTrusted = existing !== undefined &&
|
|
2063
|
+
existing.verifiedAtNs > existing.changedAtNs + RACY_CLEAN_WINDOW_NS;
|
|
2064
|
+
const entryTrusted = entry.verifiedAtNs > entry.changedAtNs + RACY_CLEAN_WINDOW_NS;
|
|
2065
|
+
if (
|
|
2066
|
+
existing === undefined ||
|
|
2067
|
+
existing.kind !== entry.kind ||
|
|
2068
|
+
existing.mode !== entry.mode ||
|
|
2069
|
+
existing.fingerprint !== entry.fingerprint ||
|
|
2070
|
+
existing.cacheable !== entry.cacheable ||
|
|
2071
|
+
existing.objectId !== entry.objectId ||
|
|
2072
|
+
existing.changedAtNs !== entry.changedAtNs ||
|
|
2073
|
+
existingTrusted !== entryTrusted
|
|
2074
|
+
) return false;
|
|
2075
|
+
}
|
|
2076
|
+
return true;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
1974
2079
|
function blobReadBatches(entries: readonly CapturedTreeEntry[]): CapturedTreeEntry[][] {
|
|
1975
2080
|
const result: CapturedTreeEntry[][] = [];
|
|
1976
2081
|
let batch: CapturedTreeEntry[] = [];
|
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 {
|