@davideasden/pi-undo 0.2.15 → 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 +58 -14
- package/package.json +1 -1
- package/src/controller.ts +166 -39
- package/src/journal.ts +99 -0
- package/src/pi-runtime.ts +14 -3
- package/src/recovery.ts +14 -0
- package/src/snapshot-store.ts +235 -18
- 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 (
|
|
@@ -218,9 +234,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
218
234
|
pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
|
|
219
235
|
const active = runtime;
|
|
220
236
|
if (active === undefined) return { action: "continue" as const };
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
237
|
+
const inputContext = { streaming: event.streamingBehavior !== undefined };
|
|
238
|
+
const result = active.controller.beginInput !== undefined
|
|
239
|
+
? active.controller.beginInput(event.text, inputContext)
|
|
240
|
+
: await active.controller.prepareInput(event.text, inputContext);
|
|
224
241
|
const replay = replaying;
|
|
225
242
|
if (result.action === "defer") {
|
|
226
243
|
if (replay !== undefined && event.source === "extension" && samePrompt(event.text, event.images, replay)) {
|
|
@@ -254,7 +271,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
254
271
|
}
|
|
255
272
|
}
|
|
256
273
|
}
|
|
257
|
-
if (
|
|
274
|
+
if (
|
|
275
|
+
active.controller.beginInput === undefined &&
|
|
276
|
+
result.action === "continue" && active.controller.captureFailed() && !captureFailureNotified
|
|
277
|
+
) {
|
|
258
278
|
captureFailureNotified = true;
|
|
259
279
|
const reason = active.controller.captureFailureReason();
|
|
260
280
|
context.ui.notify(
|
|
@@ -264,6 +284,22 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
264
284
|
}
|
|
265
285
|
return result;
|
|
266
286
|
});
|
|
287
|
+
pi.on("message_end", async (event, context: ExtensionContext) => {
|
|
288
|
+
if (event.message.role !== "user") return;
|
|
289
|
+
const active = runtime;
|
|
290
|
+
const messageGeneration = generation;
|
|
291
|
+
if (active === undefined || active.controller.commitInput === undefined) return;
|
|
292
|
+
await active.controller.commitInput();
|
|
293
|
+
if (runtime !== active || generation !== messageGeneration) return;
|
|
294
|
+
if (active.controller.captureFailed() && !captureFailureNotified) {
|
|
295
|
+
captureFailureNotified = true;
|
|
296
|
+
const reason = active.controller.captureFailureReason();
|
|
297
|
+
context.ui.notify(
|
|
298
|
+
`pi-undo: pre-input snapshot failed${reason === undefined || reason.length === 0 ? "" : ` (${reason})`}; this run will not be undoable`,
|
|
299
|
+
"warning",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
267
303
|
pi.on("before_agent_start", async () => {
|
|
268
304
|
const active = runtime;
|
|
269
305
|
const startGeneration = generation;
|
|
@@ -285,7 +321,11 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
285
321
|
if (active === undefined) return;
|
|
286
322
|
await active.controller.agentSettled();
|
|
287
323
|
if (runtime !== active || generation !== settledGeneration) return;
|
|
288
|
-
resumeDeferredPrompts(
|
|
324
|
+
resumeDeferredPrompts(
|
|
325
|
+
active,
|
|
326
|
+
settledGeneration,
|
|
327
|
+
active.controller.recoveryReason?.() ?? "session state ambiguous",
|
|
328
|
+
);
|
|
289
329
|
});
|
|
290
330
|
pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
|
|
291
331
|
if (runtime === undefined) return { cancel: true };
|
|
@@ -306,7 +346,11 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
306
346
|
navigationTargetLeafId: event.summaryEntry?.parentId ?? event.newLeafId,
|
|
307
347
|
});
|
|
308
348
|
if (active !== undefined && runtime === active && generation === treeGeneration) {
|
|
309
|
-
resumeDeferredPrompts(
|
|
349
|
+
resumeDeferredPrompts(
|
|
350
|
+
active,
|
|
351
|
+
treeGeneration,
|
|
352
|
+
active.controller.recoveryReason?.() ?? "session state ambiguous",
|
|
353
|
+
);
|
|
310
354
|
}
|
|
311
355
|
});
|
|
312
356
|
pi.on("session_shutdown", async () => {
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface ControllerDependencies {
|
|
|
37
37
|
readonly appendControl: (customType: string, data?: unknown) => Promise<string | null>;
|
|
38
38
|
readonly appendCursor: (cursor: CursorState) => Promise<CursorAppendResult>;
|
|
39
39
|
readonly capture: (scopePaths?: readonly string[]) => Promise<SnapshotManifest>;
|
|
40
|
+
readonly captureBaseline?: (baseline: SnapshotManifest) => Promise<SnapshotManifest>;
|
|
40
41
|
readonly captureSafety?: (
|
|
41
42
|
referenceManifestId: ManifestId,
|
|
42
43
|
targetManifestId: ManifestId,
|
|
@@ -136,6 +137,10 @@ export interface UndoController {
|
|
|
136
137
|
/** 只读的 undo 栈视图(栈底在前);仅供 /diff 等展示使用。 */
|
|
137
138
|
listCheckpoints(): readonly CheckpointRecord[];
|
|
138
139
|
prepareInput(text: string, context: InputContext): Promise<InputEventResult>;
|
|
140
|
+
/** 在 input hook 中启动快照,但不等待,供 message_end 前的快速路径使用。 */
|
|
141
|
+
beginInput?(text: string, context: InputContext): InputEventResult;
|
|
142
|
+
/** 在用户 message_end 后等待快照并写入 start entry。 */
|
|
143
|
+
commitInput?(): Promise<void>;
|
|
139
144
|
beforeAgentStart(): Promise<void>;
|
|
140
145
|
agentSettled(): Promise<void>;
|
|
141
146
|
undo(): Promise<OperationResult>;
|
|
@@ -145,6 +150,8 @@ export interface UndoController {
|
|
|
145
150
|
cancelTree?(): Promise<void>;
|
|
146
151
|
recover(): Promise<void>;
|
|
147
152
|
history(): HistoryState;
|
|
153
|
+
/** 当前 recovery lock 的原因;未锁定时返回 undefined。 */
|
|
154
|
+
recoveryReason?(): string | undefined;
|
|
148
155
|
/** 后台预热快照缓存:立即返回,失败静默;后续 capture 会先等预热完成。 */
|
|
149
156
|
warmUp(): void;
|
|
150
157
|
/** 最近一次输入前快照是否失败(此时本次 run 不可 undo,但输入不受影响)。 */
|
|
@@ -170,6 +177,8 @@ export interface ControllerInitialState {
|
|
|
170
177
|
readonly redoStack?: readonly ControllerRedoEntry[];
|
|
171
178
|
readonly historyPaused?: boolean;
|
|
172
179
|
readonly locked?: boolean;
|
|
180
|
+
readonly recoveryReason?: string;
|
|
181
|
+
readonly recoveryCompleted?: boolean;
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
interface PendingTree {
|
|
@@ -192,6 +201,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
192
201
|
private staged: StagedRun | undefined;
|
|
193
202
|
private pendingTree: PendingTree | undefined;
|
|
194
203
|
private locked = false;
|
|
204
|
+
private lockedReason: string | undefined;
|
|
195
205
|
private historyPaused = false;
|
|
196
206
|
private operationInFlight = false;
|
|
197
207
|
private operationAction: "undo" | "redo" | undefined;
|
|
@@ -201,6 +211,11 @@ export class UndoControllerImpl implements UndoController {
|
|
|
201
211
|
private lastCaptureFailed = false;
|
|
202
212
|
private lastCaptureFailureMessage: string | undefined;
|
|
203
213
|
private warmUpInFlight: Promise<void> | undefined;
|
|
214
|
+
private warmUpManifest: SnapshotManifest | undefined;
|
|
215
|
+
private pendingInputCapture: { readonly token: symbol; readonly promise: Promise<void> } | undefined;
|
|
216
|
+
private deferAgentStartUntilMessageEnd = false;
|
|
217
|
+
private recoveryInFlight: Promise<void> | undefined;
|
|
218
|
+
private recoveryCompleted = false;
|
|
204
219
|
|
|
205
220
|
constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
|
|
206
221
|
this.dependencies = dependencies;
|
|
@@ -208,12 +223,34 @@ export class UndoControllerImpl implements UndoController {
|
|
|
208
223
|
this.redoStack.push(...(initialState.redoStack ?? []));
|
|
209
224
|
this.historyPaused = initialState.historyPaused ?? false;
|
|
210
225
|
this.locked = initialState.locked ?? false;
|
|
226
|
+
this.lockedReason = initialState.recoveryReason;
|
|
227
|
+
this.recoveryCompleted = initialState.recoveryCompleted ?? false;
|
|
211
228
|
}
|
|
212
229
|
|
|
213
230
|
history(): HistoryState {
|
|
214
231
|
return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
|
|
215
232
|
}
|
|
216
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
|
+
|
|
217
254
|
captureFailed(): boolean {
|
|
218
255
|
return this.lastCaptureFailed;
|
|
219
256
|
}
|
|
@@ -222,7 +259,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
222
259
|
if (this.locked || this.warmUpInFlight !== undefined) return;
|
|
223
260
|
this.warmUpInFlight = (async () => {
|
|
224
261
|
try {
|
|
225
|
-
await this.captureWithWorkspaceLock();
|
|
262
|
+
this.warmUpManifest = await this.captureWithWorkspaceLock();
|
|
226
263
|
} catch {
|
|
227
264
|
// 预热是 best-effort:失败静默,正式 capture 会再次尝试并上报。
|
|
228
265
|
}
|
|
@@ -243,21 +280,49 @@ export class UndoControllerImpl implements UndoController {
|
|
|
243
280
|
if (this.operationInFlight) return { action: "defer" };
|
|
244
281
|
if (context.streaming || text.length === 0) return { action: "continue" };
|
|
245
282
|
try {
|
|
246
|
-
const before = await this.
|
|
247
|
-
this.
|
|
248
|
-
this.lastCaptureFailureMessage = undefined;
|
|
249
|
-
this.historyPaused = false;
|
|
250
|
-
this.staged = { rawPrompt: text, before, sourceLogicalLeaf: this.dependencies.getLogicalLeafId() };
|
|
283
|
+
const before = await this.captureInputBaseline();
|
|
284
|
+
this.stageInput(text, before);
|
|
251
285
|
return { action: "continue" };
|
|
252
286
|
} catch (error) {
|
|
253
287
|
// 无法证明输入前状态:放弃记录本次历史,但绝不吞掉用户输入。
|
|
254
|
-
this.
|
|
255
|
-
this.lastCaptureFailureMessage = truncateReason(error instanceof Error ? error.message : String(error));
|
|
288
|
+
this.recordCaptureFailure(error);
|
|
256
289
|
return { action: "continue" };
|
|
257
290
|
}
|
|
258
291
|
}
|
|
259
292
|
|
|
293
|
+
beginInput(text: string, context: InputContext): InputEventResult {
|
|
294
|
+
if (this.promptDeferralInFlight) return { action: "defer" };
|
|
295
|
+
if (this.locked) return { action: "continue" };
|
|
296
|
+
if (this.operationInFlight) return { action: "defer" };
|
|
297
|
+
if (context.streaming || text.length === 0) return { action: "continue" };
|
|
298
|
+
this.staged = undefined;
|
|
299
|
+
this.lastCaptureFailed = false;
|
|
300
|
+
this.lastCaptureFailureMessage = undefined;
|
|
301
|
+
this.deferAgentStartUntilMessageEnd = true;
|
|
302
|
+
const token = Symbol("input-capture");
|
|
303
|
+
const promise = this.captureInputForToken(text, token);
|
|
304
|
+
this.pendingInputCapture = { token, promise };
|
|
305
|
+
void promise.then(() => {
|
|
306
|
+
if (this.pendingInputCapture?.token === token) this.pendingInputCapture = undefined;
|
|
307
|
+
});
|
|
308
|
+
return { action: "continue" };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async commitInput(): Promise<void> {
|
|
312
|
+
if (!this.deferAgentStartUntilMessageEnd) return;
|
|
313
|
+
const pending = this.pendingInputCapture;
|
|
314
|
+
if (pending !== undefined) await pending.promise;
|
|
315
|
+
this.pendingInputCapture = undefined;
|
|
316
|
+
this.deferAgentStartUntilMessageEnd = false;
|
|
317
|
+
await this.startAgentRun();
|
|
318
|
+
}
|
|
319
|
+
|
|
260
320
|
async beforeAgentStart(): Promise<void> {
|
|
321
|
+
if (this.deferAgentStartUntilMessageEnd) return;
|
|
322
|
+
await this.startAgentRun();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async startAgentRun(): Promise<void> {
|
|
261
326
|
if (this.locked || this.staged === undefined) return;
|
|
262
327
|
try {
|
|
263
328
|
this.staged.startEntryId = await this.dependencies.appendControl("pi-undo:start", {
|
|
@@ -266,13 +331,13 @@ export class UndoControllerImpl implements UndoController {
|
|
|
266
331
|
sourceLogicalLeaf: this.staged.sourceLogicalLeaf,
|
|
267
332
|
});
|
|
268
333
|
if (this.staged.startEntryId === null) {
|
|
269
|
-
this.
|
|
334
|
+
this.lock("start_entry_missing");
|
|
270
335
|
this.staged = undefined;
|
|
271
336
|
await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
|
|
272
337
|
return;
|
|
273
338
|
}
|
|
274
339
|
} catch {
|
|
275
|
-
this.
|
|
340
|
+
this.lock("start_entry_append_failed");
|
|
276
341
|
this.staged = undefined;
|
|
277
342
|
return;
|
|
278
343
|
}
|
|
@@ -280,19 +345,54 @@ export class UndoControllerImpl implements UndoController {
|
|
|
280
345
|
this.redoStack.length = 0;
|
|
281
346
|
}
|
|
282
347
|
|
|
348
|
+
private async captureInputBaseline(): Promise<SnapshotManifest> {
|
|
349
|
+
// warm-up 仍在进行时先等待,确保随后可以消费已完成的 baseline,而不是再次完整 capture。
|
|
350
|
+
const warmUp = this.warmUpInFlight;
|
|
351
|
+
if (warmUp !== undefined) await warmUp;
|
|
352
|
+
const warmUpManifest = this.warmUpManifest;
|
|
353
|
+
this.warmUpManifest = undefined;
|
|
354
|
+
return warmUpManifest !== undefined && this.dependencies.captureBaseline !== undefined
|
|
355
|
+
? this.captureBaselineWithWorkspaceLock(warmUpManifest)
|
|
356
|
+
: this.captureWithWorkspaceLock();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private async captureInputForToken(text: string, token: symbol): Promise<void> {
|
|
360
|
+
try {
|
|
361
|
+
const before = await this.captureInputBaseline();
|
|
362
|
+
if (this.pendingInputCapture?.token !== token) return;
|
|
363
|
+
this.stageInput(text, before);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (this.pendingInputCapture?.token !== token) return;
|
|
366
|
+
// 无法证明输入前状态:放弃记录本次历史,但绝不吞掉用户输入。
|
|
367
|
+
this.recordCaptureFailure(error);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private stageInput(text: string, before: SnapshotManifest): void {
|
|
372
|
+
this.lastCaptureFailed = false;
|
|
373
|
+
this.lastCaptureFailureMessage = undefined;
|
|
374
|
+
this.historyPaused = false;
|
|
375
|
+
this.staged = { rawPrompt: text, before, sourceLogicalLeaf: this.dependencies.getLogicalLeafId() };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private recordCaptureFailure(error: unknown): void {
|
|
379
|
+
this.lastCaptureFailed = true;
|
|
380
|
+
this.lastCaptureFailureMessage = truncateReason(error instanceof Error ? error.message : String(error));
|
|
381
|
+
}
|
|
382
|
+
|
|
283
383
|
async agentSettled(): Promise<void> {
|
|
284
384
|
const staged = this.staged;
|
|
285
385
|
this.staged = undefined;
|
|
286
386
|
if (this.locked || staged === undefined) return;
|
|
287
387
|
if (staged.startEntryId === undefined || staged.startEntryId === null) {
|
|
288
388
|
// Pi 没有提供已落盘的 start entry ID,不能把后续 assistant 输出归属到该 checkpoint。
|
|
289
|
-
this.
|
|
389
|
+
this.lock("start_entry_missing");
|
|
290
390
|
await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
|
|
291
391
|
return;
|
|
292
392
|
}
|
|
293
393
|
const userEntryId = this.dependencies.findUserEntryAfter(staged.startEntryId);
|
|
294
394
|
if (userEntryId === null) {
|
|
295
|
-
this.
|
|
395
|
+
this.lock("user_entry_missing");
|
|
296
396
|
await this.dependencies.appendControl("pi-undo:barrier", { reason: "user_entry_missing" }).catch(() => {});
|
|
297
397
|
return;
|
|
298
398
|
}
|
|
@@ -300,7 +400,8 @@ export class UndoControllerImpl implements UndoController {
|
|
|
300
400
|
const measure = <T>(phase: string, operation: () => Promise<T>): Promise<T> =>
|
|
301
401
|
profiler === undefined ? operation() : profiler.measure(phase, operation);
|
|
302
402
|
try {
|
|
303
|
-
|
|
403
|
+
// settled 复用 run 开始时的 before;helper 在缺少 captureBaseline 时回退完整 capture。
|
|
404
|
+
const after = await measure("settled.capture", () => this.captureBaselineWithWorkspaceLock(staged.before));
|
|
304
405
|
const changedPaths = await measure("settled.changedPaths", () =>
|
|
305
406
|
this.dependencies.changedPaths(staged.before, after));
|
|
306
407
|
if (changedPaths.length > 0 && this.dependencies.prepareDurableRestore !== undefined) {
|
|
@@ -321,7 +422,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
321
422
|
const checkpointEntryId = await measure("settled.checkpoint", () =>
|
|
322
423
|
this.dependencies.appendControl("pi-undo:checkpoint", checkpoint));
|
|
323
424
|
if (checkpointEntryId === null) {
|
|
324
|
-
this.
|
|
425
|
+
this.lock("checkpoint_entry_missing");
|
|
325
426
|
await this.dependencies.appendControl("pi-undo:barrier", { reason: "checkpoint_entry_missing" }).catch(() => {});
|
|
326
427
|
return;
|
|
327
428
|
}
|
|
@@ -335,11 +436,13 @@ export class UndoControllerImpl implements UndoController {
|
|
|
335
436
|
}
|
|
336
437
|
|
|
337
438
|
async undo(): Promise<OperationResult> {
|
|
439
|
+
if (this.locked) return this.recoveryResult();
|
|
338
440
|
if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
|
|
339
441
|
return this.runOperation("undo");
|
|
340
442
|
}
|
|
341
443
|
|
|
342
444
|
async redo(): Promise<OperationResult> {
|
|
445
|
+
if (this.locked) return this.recoveryResult();
|
|
343
446
|
if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
|
|
344
447
|
// 新 run 开始时 redo frontier 已失效;空栈命令不得为了确认 noop 而中断正在运行的 Agent。
|
|
345
448
|
if (this.redoStack.length === 0) return noop();
|
|
@@ -366,7 +469,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
366
469
|
return undefined;
|
|
367
470
|
} catch {
|
|
368
471
|
if (lease !== undefined) {
|
|
369
|
-
await lease.release().catch(() => { this.
|
|
472
|
+
await lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
|
|
370
473
|
}
|
|
371
474
|
this.operationInFlight = false;
|
|
372
475
|
return { cancel: true };
|
|
@@ -379,7 +482,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
379
482
|
this.pendingTree = undefined;
|
|
380
483
|
try {
|
|
381
484
|
if ((event.navigationTargetLeafId ?? event.newLeafId) !== pending.descriptor.toLogicalLeaf) {
|
|
382
|
-
this.
|
|
485
|
+
this.lock("session_navigation_diverged");
|
|
383
486
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
384
487
|
return;
|
|
385
488
|
}
|
|
@@ -393,7 +496,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
393
496
|
{ opId: pending.descriptor.opId },
|
|
394
497
|
);
|
|
395
498
|
if (applied.code !== "ok") {
|
|
396
|
-
this.
|
|
499
|
+
this.lock("restore_failed");
|
|
397
500
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
398
501
|
return;
|
|
399
502
|
}
|
|
@@ -402,7 +505,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
402
505
|
this.createTreeCursor(pending.descriptor, event.newLeafId, pending.undoStack),
|
|
403
506
|
);
|
|
404
507
|
if (cursorResult.kind !== "durable") {
|
|
405
|
-
this.
|
|
508
|
+
this.lock(cursorResult.kind === "recovery_required" ? cursorResult.reason : "cursor_recovery_required");
|
|
406
509
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
407
510
|
return;
|
|
408
511
|
}
|
|
@@ -411,9 +514,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
411
514
|
this.undoStack.splice(0, this.undoStack.length, ...pending.undoStack);
|
|
412
515
|
this.redoStack.length = 0;
|
|
413
516
|
} catch {
|
|
414
|
-
this.
|
|
517
|
+
this.lock("tree_recovery_failed");
|
|
415
518
|
} finally {
|
|
416
|
-
await pending.lease.release().catch(() => { this.
|
|
519
|
+
await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
|
|
417
520
|
this.operationInFlight = false;
|
|
418
521
|
}
|
|
419
522
|
}
|
|
@@ -426,24 +529,37 @@ export class UndoControllerImpl implements UndoController {
|
|
|
426
529
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTING");
|
|
427
530
|
await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTED");
|
|
428
531
|
} catch {
|
|
429
|
-
this.
|
|
532
|
+
this.lock("tree_cancel_failed");
|
|
430
533
|
} finally {
|
|
431
|
-
await pending.lease.release().catch(() => { this.
|
|
534
|
+
await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
|
|
432
535
|
this.operationInFlight = false;
|
|
433
536
|
}
|
|
434
537
|
}
|
|
435
538
|
|
|
436
539
|
async recover(): Promise<void> {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
540
|
+
if (this.recoveryCompleted) return;
|
|
541
|
+
const recoveryInFlight = this.recoveryInFlight;
|
|
542
|
+
if (recoveryInFlight !== undefined) {
|
|
543
|
+
await recoveryInFlight;
|
|
544
|
+
return;
|
|
442
545
|
}
|
|
546
|
+
const recovery = (async (): Promise<void> => {
|
|
547
|
+
try {
|
|
548
|
+
const result = await this.dependencies.recoverPending();
|
|
549
|
+
if (result.kind === "locked") this.lock(result.reason ?? "recovery_failed");
|
|
550
|
+
} catch {
|
|
551
|
+
this.lock("recovery_failed");
|
|
552
|
+
} finally {
|
|
553
|
+
this.recoveryCompleted = true;
|
|
554
|
+
}
|
|
555
|
+
})();
|
|
556
|
+
this.recoveryInFlight = recovery;
|
|
557
|
+
await recovery;
|
|
443
558
|
}
|
|
444
559
|
|
|
445
560
|
private async runOperation(action: "undo" | "redo"): Promise<OperationResult> {
|
|
446
|
-
if (this.locked
|
|
561
|
+
if (this.locked) return this.recoveryResult();
|
|
562
|
+
if (this.operationInFlight) return { code: "busy", changedFiles: 0 };
|
|
447
563
|
const profile = new OperationProfiler();
|
|
448
564
|
const done = (result: OperationResult): OperationResult => profile.attach(result);
|
|
449
565
|
this.operationInFlight = true;
|
|
@@ -513,7 +629,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
513
629
|
return done({ code: "restore_failed_safe", changedFiles: 0 });
|
|
514
630
|
}
|
|
515
631
|
if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
|
|
516
|
-
this.
|
|
632
|
+
this.lock("session_navigation_diverged");
|
|
517
633
|
await profile.measure("journal", () =>
|
|
518
634
|
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
|
|
519
635
|
return done({ code: "recovery_required", changedFiles: 0 });
|
|
@@ -533,7 +649,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
533
649
|
const cursor = this.createCursor(descriptor, action, checkpoint);
|
|
534
650
|
const cursorResult = await profile.measure("cursor", () => this.dependencies.appendCursor(cursor));
|
|
535
651
|
if (cursorResult.kind === "recovery_required") {
|
|
536
|
-
this.
|
|
652
|
+
this.lock(cursorResult.reason);
|
|
537
653
|
await profile.measure("journal", () =>
|
|
538
654
|
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
|
|
539
655
|
return done({ code: "recovery_required", changedFiles: applied.verifiedPaths });
|
|
@@ -552,13 +668,13 @@ export class UndoControllerImpl implements UndoController {
|
|
|
552
668
|
this.lastSafetyManifestId = rollback.manifestId;
|
|
553
669
|
return done(this.advanceHistory(action, checkpoint, { code: "ok", changedFiles: applied.verifiedPaths }));
|
|
554
670
|
} catch {
|
|
555
|
-
this.
|
|
556
|
-
return done(
|
|
671
|
+
this.lock("operation_failed");
|
|
672
|
+
return done(this.recoveryResult());
|
|
557
673
|
} finally {
|
|
558
674
|
const activeLease = lease;
|
|
559
675
|
if (activeLease !== undefined) {
|
|
560
676
|
await profile.measure("unlock", () =>
|
|
561
|
-
activeLease.release().catch(() => { this.
|
|
677
|
+
activeLease.release().catch(() => { this.lock("workspace_lock_release_failed"); }));
|
|
562
678
|
}
|
|
563
679
|
if (this.operationProfiler === profile) this.operationProfiler = undefined;
|
|
564
680
|
this.operationAction = undefined;
|
|
@@ -616,7 +732,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
616
732
|
return { code: "restore_failed_safe", changedFiles: 0 };
|
|
617
733
|
}
|
|
618
734
|
if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
|
|
619
|
-
this.
|
|
735
|
+
this.lock("session_navigation_diverged");
|
|
620
736
|
await profile.measure("journal", () =>
|
|
621
737
|
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
|
|
622
738
|
return { code: "recovery_required", changedFiles: 0 };
|
|
@@ -629,7 +745,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
629
745
|
const cursorResult = await profile.measure("cursor", () =>
|
|
630
746
|
this.dependencies.appendCursor(this.createCursor(descriptor, action, checkpoint)));
|
|
631
747
|
if (cursorResult.kind === "recovery_required") {
|
|
632
|
-
this.
|
|
748
|
+
this.lock(cursorResult.reason);
|
|
633
749
|
await profile.measure("journal", () =>
|
|
634
750
|
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
|
|
635
751
|
return { code: "recovery_required", changedFiles: 0 };
|
|
@@ -654,9 +770,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
654
770
|
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
|
|
655
771
|
return { code: "restore_failed_safe", changedFiles: 0 };
|
|
656
772
|
} catch {
|
|
657
|
-
this.
|
|
773
|
+
this.lock("session_only_recovery_failed");
|
|
658
774
|
await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
|
|
659
|
-
return
|
|
775
|
+
return this.recoveryResult();
|
|
660
776
|
}
|
|
661
777
|
}
|
|
662
778
|
|
|
@@ -703,6 +819,17 @@ export class UndoControllerImpl implements UndoController {
|
|
|
703
819
|
}
|
|
704
820
|
}
|
|
705
821
|
|
|
822
|
+
private async captureBaselineWithWorkspaceLock(baseline: SnapshotManifest): Promise<SnapshotManifest> {
|
|
823
|
+
const captureBaseline = this.dependencies.captureBaseline;
|
|
824
|
+
if (captureBaseline === undefined) return this.captureWithWorkspaceLock();
|
|
825
|
+
const lease = await this.dependencies.acquireWorkspaceLock();
|
|
826
|
+
try {
|
|
827
|
+
return await captureBaseline(baseline);
|
|
828
|
+
} finally {
|
|
829
|
+
await lease.release();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
706
833
|
private async compensate(
|
|
707
834
|
descriptor: OperationDescriptor,
|
|
708
835
|
rollback: SnapshotManifest,
|
|
@@ -727,9 +854,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
727
854
|
} catch {
|
|
728
855
|
// 下面统一进入 recovery lock。
|
|
729
856
|
}
|
|
730
|
-
this.
|
|
857
|
+
this.lock("compensation_failed");
|
|
731
858
|
await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
|
|
732
|
-
return
|
|
859
|
+
return this.recoveryResult(failure.verifiedPaths);
|
|
733
860
|
}
|
|
734
861
|
|
|
735
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
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { finalizeDurablePack, hasDurablePack, loadDurablePack } from "./durable-pack.ts";
|
|
15
15
|
import { assertCursor, canonicalJson, checksum, sameWorkspaceSnapshot } from "./encoding.ts";
|
|
16
16
|
import { JournalStore, finalizeCursorMarker, inspectCursorMarkers } from "./journal.ts";
|
|
17
|
-
import type { CheckpointRecord, ManifestId, SessionFileIdentity } from "./model.ts";
|
|
17
|
+
import type { CheckpointRecord, ManifestId, SessionFileIdentity, SnapshotManifest } from "./model.ts";
|
|
18
18
|
import {
|
|
19
19
|
cleanupPackedMutations,
|
|
20
20
|
materializePackedMutationJournal,
|
|
@@ -50,13 +50,21 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
50
50
|
if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
|
|
51
51
|
throw new Error("workspace identity 已变化");
|
|
52
52
|
}
|
|
53
|
-
return store.capture(topology, scopePaths);
|
|
53
|
+
return store.capture(topology, scopePaths, { topologyAlreadyValidated: true });
|
|
54
|
+
};
|
|
55
|
+
const captureBaseline = async (baseline: SnapshotManifest) => {
|
|
56
|
+
const topology = await discovery.discover(context.cwd);
|
|
57
|
+
if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
|
|
58
|
+
throw new Error("workspace identity 已变化");
|
|
59
|
+
}
|
|
60
|
+
return store.captureBaseline(topology, baseline, undefined, { topologyAlreadyValidated: true });
|
|
54
61
|
};
|
|
55
62
|
const recovery = new JournalRecovery({
|
|
56
63
|
sessionIdentity,
|
|
57
64
|
workspaceIdentity: initialTopology.workspaceIdentity,
|
|
58
65
|
getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
|
|
59
66
|
loadPending: () => journal.loadPending(),
|
|
67
|
+
assessForeignTransaction: (pending) => journal.isInertForeignPrepared(pending),
|
|
60
68
|
inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor),
|
|
61
69
|
finalizeCursor: (pending, inspection) => finalizeCursorMarker(
|
|
62
70
|
pending.descriptor.sessionIdentity.path,
|
|
@@ -214,6 +222,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
214
222
|
appendControl: async (customType, data) => appendControlEntry(pi, manager, customType, data),
|
|
215
223
|
appendCursor: async (cursor) => cursorWriter.appendCursor(cursor, pi, sourceFor(manager)),
|
|
216
224
|
capture,
|
|
225
|
+
captureBaseline,
|
|
217
226
|
captureSafety: async (referenceManifestId, targetManifestId, scopePaths) => {
|
|
218
227
|
const [reference, target] = await Promise.all([
|
|
219
228
|
store.loadManifest(referenceManifestId),
|
|
@@ -251,13 +260,15 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
251
260
|
const controller = new UndoControllerImpl(dependencies, {
|
|
252
261
|
...rebuildControllerState(manager, sessionIdentity),
|
|
253
262
|
locked: startupRecovery.kind === "locked",
|
|
263
|
+
recoveryReason: startupRecovery.kind === "locked" ? startupRecovery.reason : undefined,
|
|
264
|
+
recoveryCompleted: true,
|
|
254
265
|
});
|
|
255
266
|
return {
|
|
256
267
|
controller,
|
|
257
268
|
reporter: new StatusReporter(context),
|
|
258
269
|
diffSource: store,
|
|
259
270
|
recovery: startupRecovery.kind === "locked"
|
|
260
|
-
? { files: startupRecovery.files, opId: startupRecovery.opId }
|
|
271
|
+
? { reason: startupRecovery.reason, files: startupRecovery.files, opId: startupRecovery.opId }
|
|
261
272
|
: undefined,
|
|
262
273
|
setCommandContext(next: ExtensionCommandContext | undefined): void {
|
|
263
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;
|
|
@@ -147,6 +147,8 @@ export interface SnapshotStoreOptions {
|
|
|
147
147
|
|
|
148
148
|
export interface CaptureOptions {
|
|
149
149
|
readonly excludePaths?: readonly string[];
|
|
150
|
+
/** 调用方刚完成 topology discovery 时跳过重复的捕获前校验。捕获后校验仍然执行。 */
|
|
151
|
+
readonly topologyAlreadyValidated?: boolean;
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
export interface SnapshotBlobRequest {
|
|
@@ -176,6 +178,12 @@ export class SnapshotStoreError extends Error {
|
|
|
176
178
|
|
|
177
179
|
export interface SnapshotStore {
|
|
178
180
|
capture(topology: RootTopology, scope?: readonly string[], options?: CaptureOptions): Promise<SnapshotManifest>;
|
|
181
|
+
captureBaseline(
|
|
182
|
+
topology: RootTopology,
|
|
183
|
+
baseline: SnapshotManifest,
|
|
184
|
+
scope?: readonly string[],
|
|
185
|
+
options?: CaptureOptions,
|
|
186
|
+
): Promise<SnapshotManifest>;
|
|
179
187
|
listVisibleLeafPaths(topology: RootTopology, options?: CaptureOptions): Promise<readonly string[]>;
|
|
180
188
|
loadManifest(id: ManifestId): Promise<SnapshotManifest>;
|
|
181
189
|
assertComplete(id: ManifestId, scopePaths?: readonly string[]): Promise<void>;
|
|
@@ -269,7 +277,9 @@ export class SnapshotStore {
|
|
|
269
277
|
}
|
|
270
278
|
const coverage = captureCoverage(topology.workspaceIdentity, scope);
|
|
271
279
|
const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
|
|
272
|
-
|
|
280
|
+
if (options.topologyAlreadyValidated !== true) {
|
|
281
|
+
await this.assertTopology(topology, "捕获前 topology 已变化");
|
|
282
|
+
}
|
|
273
283
|
const brokenRoots = brokenRootPaths(topology);
|
|
274
284
|
if (brokenRoots.length > 0) {
|
|
275
285
|
throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入快照: ${brokenRoots.join(", ")}`);
|
|
@@ -350,6 +360,189 @@ export class SnapshotStore {
|
|
|
350
360
|
}
|
|
351
361
|
}
|
|
352
362
|
|
|
363
|
+
/**
|
|
364
|
+
* 复核 warm-up 生成的 baseline;只有 topology、可见路径、文件 metadata 和 ignored proof
|
|
365
|
+
* 都能由现有证据证明未变化时,才跳过完整 capture。
|
|
366
|
+
*/
|
|
367
|
+
async captureBaseline(
|
|
368
|
+
topology: RootTopology,
|
|
369
|
+
baseline: SnapshotManifest,
|
|
370
|
+
scope?: readonly string[],
|
|
371
|
+
options: CaptureOptions = {},
|
|
372
|
+
): Promise<SnapshotManifest> {
|
|
373
|
+
await this.assertPrivateStore(topology.workspaceIdentity);
|
|
374
|
+
const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
|
|
375
|
+
return this.lock.withLock(lockIdentity, () => this.captureBaselineLocked(topology, baseline, scope, options));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async captureBaselineLocked(
|
|
379
|
+
topology: RootTopology,
|
|
380
|
+
baseline: SnapshotManifest,
|
|
381
|
+
scope: readonly string[] | undefined,
|
|
382
|
+
options: CaptureOptions,
|
|
383
|
+
): Promise<SnapshotManifest> {
|
|
384
|
+
try {
|
|
385
|
+
if (topology.fingerprint !== topologyFingerprint(topology.workspaceIdentity, topology.roots)) {
|
|
386
|
+
throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
|
|
387
|
+
}
|
|
388
|
+
if (options.topologyAlreadyValidated !== true) {
|
|
389
|
+
await this.assertTopology(topology, "捕获前 topology 已变化");
|
|
390
|
+
}
|
|
391
|
+
const brokenRoots = brokenRootPaths(topology);
|
|
392
|
+
if (brokenRoots.length > 0) {
|
|
393
|
+
throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入 baseline 校验: ${brokenRoots.join(", ")}`);
|
|
394
|
+
}
|
|
395
|
+
if (await this.isBaselineFresh(topology, baseline, scope, options)) {
|
|
396
|
+
await this.assertTopology(topology, "捕获期间 topology 已变化");
|
|
397
|
+
await this.touchStore(this.storeDirectory(topology));
|
|
398
|
+
return baseline;
|
|
399
|
+
}
|
|
400
|
+
// 已完成一次捕获前 topology 校验;完整回退仍保留捕获后的校验。
|
|
401
|
+
return this.captureLocked(topology, scope, { ...options, topologyAlreadyValidated: true });
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof SnapshotStoreError) {
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
throw new SnapshotStoreError("capture_failed", errorMessage(error), { cause: error });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async isBaselineFresh(
|
|
411
|
+
topology: RootTopology,
|
|
412
|
+
baseline: SnapshotManifest,
|
|
413
|
+
scope: readonly string[] | undefined,
|
|
414
|
+
options: CaptureOptions,
|
|
415
|
+
): Promise<boolean> {
|
|
416
|
+
try {
|
|
417
|
+
assertManifest(baseline);
|
|
418
|
+
} catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
|
|
422
|
+
if (
|
|
423
|
+
baseline.workspaceIdentity !== topology.workspaceIdentity ||
|
|
424
|
+
baseline.topologyFingerprint !== topology.fingerprint ||
|
|
425
|
+
baseline.coverage !== captureCoverage(topology.workspaceIdentity, scope) ||
|
|
426
|
+
baseline.roots.length !== topology.roots.length
|
|
427
|
+
) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const storeDirectory = this.storeDirectory(topology);
|
|
432
|
+
await this.loadPersistedLeafCache(storeDirectory);
|
|
433
|
+
const transactionsRoot = join(storeDirectory, "transactions");
|
|
434
|
+
await mkdir(transactionsRoot, { recursive: true });
|
|
435
|
+
const transactionDirectory = await mkdtemp(join(transactionsRoot, "baseline-"));
|
|
436
|
+
try {
|
|
437
|
+
const baselineRoots = new Map(baseline.roots.map((root) => [root.relativeRoot, root]));
|
|
438
|
+
for (const root of topology.roots) {
|
|
439
|
+
const baselineRoot = baselineRoots.get(root.relativeRoot);
|
|
440
|
+
if (
|
|
441
|
+
baselineRoot === undefined ||
|
|
442
|
+
baselineRoot.parentRoot !== root.parentRoot ||
|
|
443
|
+
baselineRoot.state !== root.state ||
|
|
444
|
+
baselineRoot.sourceIdentity !== root.sourceIdentity ||
|
|
445
|
+
baselineRoot.privateRepositoryId !== root.privateRepositoryId ||
|
|
446
|
+
(baselineRoot.gitlinkOid ?? null) !== (root.gitlinkOid ?? null) ||
|
|
447
|
+
baselineRoot.coverage !== rootCaptureCoverage(root.relativeRoot, scope, topology.roots) ||
|
|
448
|
+
baselineRoot.ignorePolicy !== IGNORE_POLICY
|
|
449
|
+
) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
if (root.state !== "active") {
|
|
453
|
+
if (
|
|
454
|
+
baselineRoot.treeId !== null ||
|
|
455
|
+
baselineRoot.ignoredPresentPaths.length > 0 ||
|
|
456
|
+
baselineRoot.objectClosure !== inactiveRootClosure(root)
|
|
457
|
+
) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const treeId = baselineRoot.treeId;
|
|
464
|
+
if (treeId === null) return false;
|
|
465
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
466
|
+
await this.ensurePrivateRepository(gitDirectory);
|
|
467
|
+
await this.assertNoAlternates(gitDirectory);
|
|
468
|
+
const absoluteRoot = workspaceRootPath(topology.workspaceIdentity, root.relativeRoot);
|
|
469
|
+
const indexPath = join(transactionDirectory, `${rootStoreId(root)}.index`);
|
|
470
|
+
const environment = privateGitEnvironment(gitDirectory, absoluteRoot, indexPath);
|
|
471
|
+
await this.runGit(["read-tree", "--empty"], { cwd: absoluteRoot, env: environment });
|
|
472
|
+
await this.validateIgnoreQuery(absoluteRoot, environment, root.gitBacked);
|
|
473
|
+
|
|
474
|
+
const requestedInclusions = rootScopePathspecs(root.relativeRoot, scope);
|
|
475
|
+
const exclusions = topology.roots
|
|
476
|
+
.filter((candidate) => isStrictRootAncestor(root.relativeRoot, candidate.relativeRoot))
|
|
477
|
+
.map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
|
|
478
|
+
const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
|
|
479
|
+
const inclusions = ownedRootInclusions(requestedInclusions, exclusions);
|
|
480
|
+
const entries = await this.readTreeEntries(gitDirectory, treeId);
|
|
481
|
+
await this.assertObjectsComplete(gitDirectory, treeId, entries);
|
|
482
|
+
if (baselineRoot.objectClosure !== treeObjectClosure(treeId, entries)) return false;
|
|
483
|
+
|
|
484
|
+
const leaves = await this.collectVisibleLeaves(
|
|
485
|
+
absoluteRoot,
|
|
486
|
+
environment,
|
|
487
|
+
root.gitBacked,
|
|
488
|
+
inclusions,
|
|
489
|
+
exclusions,
|
|
490
|
+
exactExclusions,
|
|
491
|
+
transactionDirectory,
|
|
492
|
+
);
|
|
493
|
+
if (!samePathList(
|
|
494
|
+
leaves.map((leaf) => leaf.relativePath).sort(comparePaths),
|
|
495
|
+
entries.map((entry) => entry.relativePath).sort(comparePaths),
|
|
496
|
+
)) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
const cache = this.visibleLeafCache.get(gitDirectory);
|
|
500
|
+
if (cache === undefined) return false;
|
|
501
|
+
const entriesByPath = new Map(entries.map((entry) => [entry.relativePath, entry]));
|
|
502
|
+
for (const leaf of leaves) {
|
|
503
|
+
const entry = entriesByPath.get(leaf.relativePath);
|
|
504
|
+
const cached = cache.get(leaf.relativePath);
|
|
505
|
+
if (
|
|
506
|
+
entry === undefined ||
|
|
507
|
+
!leaf.cacheable ||
|
|
508
|
+
cached?.cacheable !== true ||
|
|
509
|
+
cached.kind !== leaf.kind ||
|
|
510
|
+
cached.mode !== leaf.mode ||
|
|
511
|
+
cached.fingerprint !== leaf.fingerprint ||
|
|
512
|
+
cached.objectId !== entry.objectId ||
|
|
513
|
+
cached.verifiedAtNs <= cached.changedAtNs + RACY_CLEAN_WINDOW_NS
|
|
514
|
+
) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const ignoredPresentPaths = await this.captureIgnoredPresentPaths(
|
|
520
|
+
absoluteRoot,
|
|
521
|
+
environment,
|
|
522
|
+
root.gitBacked,
|
|
523
|
+
inclusions,
|
|
524
|
+
exclusions,
|
|
525
|
+
exactExclusions,
|
|
526
|
+
transactionDirectory,
|
|
527
|
+
);
|
|
528
|
+
if (!samePathList(ignoredPresentPaths, baselineRoot.ignoredPresentPaths)) return false;
|
|
529
|
+
if (baselineRoot.ignoreClosure !== ignoredPresentClosure({
|
|
530
|
+
coverage: baselineRoot.coverage,
|
|
531
|
+
ignorePolicy: IGNORE_POLICY,
|
|
532
|
+
ignoredPresentPaths,
|
|
533
|
+
})) return false;
|
|
534
|
+
// metadata 初检与最终复核之间若有变化,放弃 baseline,回退完整 capture。
|
|
535
|
+
await this.assertVisibleLeavesUnchanged(absoluteRoot, leaves, transactionDirectory);
|
|
536
|
+
}
|
|
537
|
+
return true;
|
|
538
|
+
} catch {
|
|
539
|
+
// baseline 证据读取失败时不复用旧快照;完整 capture 会重新建立对象和缓存。
|
|
540
|
+
return false;
|
|
541
|
+
} finally {
|
|
542
|
+
await rm(transactionDirectory, { recursive: true, force: true }).catch(() => {});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
353
546
|
async listVisibleLeafPaths(
|
|
354
547
|
topology: RootTopology,
|
|
355
548
|
options: CaptureOptions = {},
|
|
@@ -817,7 +1010,7 @@ export class SnapshotStore {
|
|
|
817
1010
|
}
|
|
818
1011
|
// ignored build/vendor trees 常含数万叶子;复用同一批量 metadata 协议,避免逐路径重复
|
|
819
1012
|
// 遍历父目录。Native 与 fallback 都在叶子扫描前后复核共享父目录。
|
|
820
|
-
const nativeEntries = await this.
|
|
1013
|
+
const nativeEntries = await this.inspectNativeMetadataBatches(cwd, candidates, requestDirectory);
|
|
821
1014
|
const kinds = nativeEntries === undefined
|
|
822
1015
|
? await this.collectIgnoredPresentKindsFallback(cwd, candidates)
|
|
823
1016
|
: nativeEntries.map((entry) => entry.kind);
|
|
@@ -834,18 +1027,20 @@ export class SnapshotStore {
|
|
|
834
1027
|
return result.sort(comparePaths);
|
|
835
1028
|
}
|
|
836
1029
|
|
|
837
|
-
private async
|
|
1030
|
+
private async inspectNativeMetadataBatches(
|
|
838
1031
|
cwd: string,
|
|
839
1032
|
paths: readonly string[],
|
|
840
1033
|
requestDirectory: string,
|
|
841
1034
|
): Promise<readonly NativeMetadataEntry[] | undefined> {
|
|
1035
|
+
// 空路径不得触发 native inspect;首批 unsupported 才整体回退,中途变化必须 fail closed。
|
|
1036
|
+
if (paths.length === 0) return [];
|
|
842
1037
|
const result: NativeMetadataEntry[] = [];
|
|
843
|
-
for (let offset = 0; offset < paths.length; offset +=
|
|
844
|
-
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);
|
|
845
1040
|
const inspected = await this.nativeMetadata.inspect(cwd, batch, requestDirectory);
|
|
846
1041
|
if (inspected === undefined) {
|
|
847
1042
|
if (result.length > 0) {
|
|
848
|
-
throw new SnapshotStoreError("capture_failed", "native
|
|
1043
|
+
throw new SnapshotStoreError("capture_failed", "native metadata 能力在批次间变化");
|
|
849
1044
|
}
|
|
850
1045
|
return undefined;
|
|
851
1046
|
}
|
|
@@ -859,8 +1054,8 @@ export class SnapshotStore {
|
|
|
859
1054
|
paths: readonly string[],
|
|
860
1055
|
): Promise<readonly NativeMetadataEntry["kind"][]> {
|
|
861
1056
|
const result: NativeMetadataEntry["kind"][] = [];
|
|
862
|
-
for (let offset = 0; offset < paths.length; offset +=
|
|
863
|
-
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);
|
|
864
1059
|
await assertNoSymlinkParents(cwd, batch);
|
|
865
1060
|
const kinds = await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
|
|
866
1061
|
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
@@ -1050,12 +1245,12 @@ export class SnapshotStore {
|
|
|
1050
1245
|
leaves: readonly VisibleLeaf[],
|
|
1051
1246
|
requestDirectory?: string,
|
|
1052
1247
|
): Promise<void> {
|
|
1248
|
+
if (leaves.length === 0) return;
|
|
1249
|
+
const paths = leaves.map((leaf) => leaf.relativePath);
|
|
1053
1250
|
if (requestDirectory !== undefined) {
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
requestDirectory,
|
|
1058
|
-
);
|
|
1251
|
+
// 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
|
|
1252
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1253
|
+
const inspected = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
|
|
1059
1254
|
if (inspected !== undefined) {
|
|
1060
1255
|
for (let index = 0; index < leaves.length; index += 1) {
|
|
1061
1256
|
const leaf = leaves[index]!;
|
|
@@ -1064,10 +1259,15 @@ export class SnapshotStore {
|
|
|
1064
1259
|
throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
|
|
1065
1260
|
}
|
|
1066
1261
|
}
|
|
1262
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1067
1263
|
return;
|
|
1068
1264
|
}
|
|
1265
|
+
await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
1266
|
+
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
1267
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1268
|
+
return;
|
|
1069
1269
|
}
|
|
1070
|
-
await assertNoSymlinkParents(cwd,
|
|
1270
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1071
1271
|
await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
1072
1272
|
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
1073
1273
|
}
|
|
@@ -1142,10 +1342,14 @@ export class SnapshotStore {
|
|
|
1142
1342
|
exclusions,
|
|
1143
1343
|
exactExclusions,
|
|
1144
1344
|
);
|
|
1145
|
-
|
|
1345
|
+
if (paths.length === 0) return [];
|
|
1346
|
+
// 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
|
|
1347
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1348
|
+
const nativeEntries = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
|
|
1146
1349
|
const metadataEntries = nativeEntries === undefined
|
|
1147
1350
|
? await this.collectVisibleLeafMetadataFallback(cwd, paths)
|
|
1148
1351
|
: nativeEntries.map((entry) => nativeVisibleLeafMetadata(entry));
|
|
1352
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1149
1353
|
const leaves: VisibleLeaf[] = [];
|
|
1150
1354
|
for (let index = 0; index < paths.length; index += 1) {
|
|
1151
1355
|
const relativePath = paths[index]!;
|
|
@@ -1593,8 +1797,17 @@ function ownedRootInclusions(
|
|
|
1593
1797
|
return owned.length === 0 ? null : owned;
|
|
1594
1798
|
}
|
|
1595
1799
|
|
|
1596
|
-
function rootCaptureCoverage(
|
|
1597
|
-
|
|
1800
|
+
function rootCaptureCoverage(
|
|
1801
|
+
rootPath: string,
|
|
1802
|
+
scope: readonly string[] | undefined,
|
|
1803
|
+
roots?: readonly RootTopologyIdentity[],
|
|
1804
|
+
): string {
|
|
1805
|
+
const requestedInclusions = rootScopePathspecs(rootPath, scope);
|
|
1806
|
+
if (roots === undefined) return rootCoverageFromInclusions(requestedInclusions);
|
|
1807
|
+
const exclusions = roots
|
|
1808
|
+
.filter((root) => isStrictRootAncestor(rootPath, root.relativeRoot))
|
|
1809
|
+
.map((root) => rootRelativePath(rootPath, root.relativeRoot));
|
|
1810
|
+
return rootCoverageFromInclusions(ownedRootInclusions(requestedInclusions, exclusions));
|
|
1598
1811
|
}
|
|
1599
1812
|
|
|
1600
1813
|
function rootCoverageFromInclusions(inclusions: readonly string[] | null): string {
|
|
@@ -2186,6 +2399,10 @@ function gitExitCode(error: unknown): number | null | undefined {
|
|
|
2186
2399
|
: undefined;
|
|
2187
2400
|
}
|
|
2188
2401
|
|
|
2402
|
+
function samePathList(left: readonly string[], right: readonly string[]): boolean {
|
|
2403
|
+
return left.length === right.length && left.every((path, index) => path === right[index]);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2189
2406
|
function comparePaths(left: string, right: string): number {
|
|
2190
2407
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
2191
2408
|
}
|
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 {
|