@davideasden/pi-undo 0.2.24 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/docs/native-performance.md +52 -0
- package/extensions/pi-undo.ts +75 -15
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +10 -8
- package/src/controller.ts +377 -45
- package/src/git-runner.ts +356 -51
- package/src/journal.ts +5 -2
- package/src/model.ts +2 -0
- package/src/native-capabilities.ts +34 -0
- package/src/native-directory-scan.ts +114 -0
- package/src/native-metadata.ts +47 -87
- package/src/native-restore.ts +61 -56
- package/src/operation-context.ts +266 -0
- package/src/pi-runtime.ts +119 -15
- package/src/quarantine.ts +3 -0
- package/src/recovery.ts +3 -3
- package/src/restore-engine.ts +115 -88
- package/src/root-discovery.ts +99 -18
- package/src/snapshot-store.ts +39 -16
- package/src/status-reporter.ts +41 -1
- package/src/workspace-lock.ts +4 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
export type OperationErrorCode = "operation_cancelled" | "operation_timeout";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 一次可取消操作的共享作用域。调用方(撤回/捕获/恢复入口)创建 context,
|
|
7
|
+
* 低层只读取当前继承的 context:deadline 是硬上限,signal 是取消通道。
|
|
8
|
+
*/
|
|
9
|
+
export interface ProcessDiagnostic {
|
|
10
|
+
readonly command: string;
|
|
11
|
+
readonly durationMs: number;
|
|
12
|
+
readonly outcome: string;
|
|
13
|
+
readonly exitCode: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OperationContext {
|
|
17
|
+
readonly signal: AbortSignal;
|
|
18
|
+
readonly deadline: number;
|
|
19
|
+
readonly onProgress?: (phase: string) => void;
|
|
20
|
+
readonly onProcess?: (diagnostic: ProcessDiagnostic) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class OperationError extends Error {
|
|
24
|
+
readonly code: OperationErrorCode;
|
|
25
|
+
|
|
26
|
+
constructor(code: OperationErrorCode, message: string, options?: ErrorOptions) {
|
|
27
|
+
super(message, options);
|
|
28
|
+
this.name = "OperationError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 超时与主动取消都走 AbortSignal;用固定 reason 区分两者。 */
|
|
34
|
+
const OPERATION_TIMEOUT_REASON = "operation_timeout";
|
|
35
|
+
|
|
36
|
+
const storage = new AsyncLocalStorage<OperationContext | undefined>();
|
|
37
|
+
const unsafeContexts = new WeakSet<OperationContext>();
|
|
38
|
+
|
|
39
|
+
export function markProcessExitUnconfirmed(): void {
|
|
40
|
+
const context = storage.getStore();
|
|
41
|
+
if (context !== undefined) unsafeContexts.add(context);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function operationHasUnconfirmedExit(): boolean {
|
|
45
|
+
const context = storage.getStore();
|
|
46
|
+
return context !== undefined && unsafeContexts.has(context);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 在 context 下运行 body;context 为 undefined 时清除继承的 context,
|
|
51
|
+
* 保证嵌套作用域之间不会泄漏取消状态。
|
|
52
|
+
*/
|
|
53
|
+
export function runWithOperationContext<T>(context: OperationContext | undefined, body: () => T): T {
|
|
54
|
+
return storage.run(context, body);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function currentOperationContext(): OperationContext | undefined {
|
|
58
|
+
return storage.getStore();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 取消或超时检查:可在任意 await 边界调用。没有 context 时是空操作。
|
|
63
|
+
* 已取消/超时抛 OperationError,code 区分 operation_cancelled 与 operation_timeout。
|
|
64
|
+
*/
|
|
65
|
+
export function checkOperation(): void {
|
|
66
|
+
const context = storage.getStore();
|
|
67
|
+
if (context === undefined) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (unsafeContexts.has(context)) throw Object.assign(new Error("子进程未确认退出"), { code: "process_exit_unconfirmed" });
|
|
71
|
+
if (context.signal.aborted) {
|
|
72
|
+
throw operationErrorFromAbortReason(context.signal.reason);
|
|
73
|
+
}
|
|
74
|
+
if (Date.now() >= context.deadline) {
|
|
75
|
+
throw new OperationError("operation_timeout", "操作超过截止时间");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 报告可读阶段(供状态显示使用);没有 context 或没有 onProgress 时是空操作。 */
|
|
80
|
+
export function reportOperationProgress(phase: string): void {
|
|
81
|
+
storage.getStore()?.onProgress?.(phase);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function reportProcessDiagnostic(diagnostic: ProcessDiagnostic): void {
|
|
85
|
+
try {
|
|
86
|
+
storage.getStore()?.onProcess?.(diagnostic);
|
|
87
|
+
} catch {
|
|
88
|
+
// 诊断失败不能改变事务结果。
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface OperationProcessOptions {
|
|
93
|
+
readonly signal?: AbortSignal;
|
|
94
|
+
readonly timeoutMs: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 低层子进程包装器使用的预算:取「剩余 deadline」与 defaultTimeoutMs 的较小值。
|
|
99
|
+
* 已取消或已超时直接抛出,避免取消后继续启动新工作。
|
|
100
|
+
*/
|
|
101
|
+
export function operationProcessOptions(defaultTimeoutMs = 120_000): OperationProcessOptions {
|
|
102
|
+
if (!Number.isFinite(defaultTimeoutMs) || defaultTimeoutMs < 0) {
|
|
103
|
+
throw new RangeError("defaultTimeoutMs 必须是非负有限数字");
|
|
104
|
+
}
|
|
105
|
+
const context = storage.getStore();
|
|
106
|
+
if (context === undefined) {
|
|
107
|
+
return { timeoutMs: defaultTimeoutMs };
|
|
108
|
+
}
|
|
109
|
+
checkOperation();
|
|
110
|
+
const remaining = context.deadline - Date.now();
|
|
111
|
+
return {
|
|
112
|
+
signal: context.signal,
|
|
113
|
+
timeoutMs: Math.min(defaultTimeoutMs, remaining),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface OperationScopeOptions {
|
|
118
|
+
/** 不传表示没有 deadline(只受 signal 控制)。 */
|
|
119
|
+
readonly timeoutMs?: number;
|
|
120
|
+
/** 上层取消(例如 /undo-cancel 或父作用域)会传播到本作用域。 */
|
|
121
|
+
readonly signal?: AbortSignal;
|
|
122
|
+
readonly onProgress?: (phase: string) => void;
|
|
123
|
+
readonly onProcess?: (diagnostic: ProcessDiagnostic) => void;
|
|
124
|
+
readonly now?: () => number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface OperationScope {
|
|
128
|
+
readonly context: OperationContext;
|
|
129
|
+
/** 请求主动取消。 */
|
|
130
|
+
cancel(): void;
|
|
131
|
+
/** 清除超时计时器与父 signal 监听。 */
|
|
132
|
+
dispose(): void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 创建一次可取消操作的 context。超时通过计时器把 signal 置为 timeout reason,
|
|
137
|
+
* 使 checkOperation/operationProcessOptions 能区分超时与主动取消。
|
|
138
|
+
*/
|
|
139
|
+
export function createOperationScope(options: OperationScopeOptions = {}): OperationScope {
|
|
140
|
+
const now = options.now ?? Date.now;
|
|
141
|
+
const controller = new AbortController();
|
|
142
|
+
const deadline = options.timeoutMs === undefined
|
|
143
|
+
? Number.POSITIVE_INFINITY
|
|
144
|
+
: now() + options.timeoutMs;
|
|
145
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
146
|
+
if (options.timeoutMs !== undefined) {
|
|
147
|
+
timeout = setTimeout(() => controller.abort(OPERATION_TIMEOUT_REASON), Math.max(0, options.timeoutMs));
|
|
148
|
+
timeout.unref();
|
|
149
|
+
}
|
|
150
|
+
const onParentAbort = (): void => controller.abort(options.signal?.reason ?? "operation_cancelled");
|
|
151
|
+
if (options.signal !== undefined) {
|
|
152
|
+
if (options.signal.aborted) {
|
|
153
|
+
onParentAbort();
|
|
154
|
+
} else {
|
|
155
|
+
options.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const context: OperationContext = {
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
deadline,
|
|
161
|
+
...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
|
|
162
|
+
...(options.onProcess === undefined ? {} : { onProcess: options.onProcess }),
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
context,
|
|
166
|
+
cancel: () => controller.abort("operation_cancelled"),
|
|
167
|
+
dispose: () => {
|
|
168
|
+
if (timeout !== undefined) {
|
|
169
|
+
clearTimeout(timeout);
|
|
170
|
+
}
|
|
171
|
+
options.signal?.removeEventListener("abort", onParentAbort);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function allCompleted<T extends readonly unknown[]>(tasks: T): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }> {
|
|
177
|
+
const outcomes = await Promise.allSettled(tasks);
|
|
178
|
+
const failure = outcomes.find((outcome) => outcome.status === "rejected");
|
|
179
|
+
if (failure?.status === "rejected") throw failure.reason;
|
|
180
|
+
return outcomes.map((outcome) => (outcome as PromiseFulfilledResult<unknown>).value) as { -readonly [K in keyof T]: Awaited<T[K]> };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function configuredTimeout(name: string, fallback: number): number {
|
|
184
|
+
const raw = process.env[name];
|
|
185
|
+
if (raw === undefined || raw === "") return fallback;
|
|
186
|
+
const value = Number(raw);
|
|
187
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) throw new Error(`${name} 必须是正整数毫秒`);
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function withRecoveryBudget<T>(body: () => Promise<T>, options: OperationScopeOptions = {}): Promise<T> {
|
|
192
|
+
if (operationHasUnconfirmedExit()) checkOperation();
|
|
193
|
+
const scope = createOperationScope({
|
|
194
|
+
timeoutMs: configuredTimeout("PI_UNDO_OPERATION_TIMEOUT_MS", 300_000),
|
|
195
|
+
onProcess: currentOperationContext()?.onProcess,
|
|
196
|
+
...options,
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
return await runWithOperationContext(scope.context, async () => {
|
|
200
|
+
try {
|
|
201
|
+
return await body();
|
|
202
|
+
} finally {
|
|
203
|
+
if (operationHasUnconfirmedExit()) checkOperation();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (isUnconfirmedExit(error)) markProcessExitUnconfirmed();
|
|
208
|
+
throw error;
|
|
209
|
+
} finally {
|
|
210
|
+
scope.dispose();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isUnconfirmedExit(error: unknown): boolean {
|
|
215
|
+
return errorChain(error).some((value) => value.code === "git_termination_failed" || value.code === "process_exit_unconfirmed");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 包装层保留 cause 时仍能识别取消,避免被误报为普通 capture 失败。 */
|
|
219
|
+
export function operationFailure(error: unknown): OperationErrorCode | undefined {
|
|
220
|
+
for (const value of errorChain(error)) {
|
|
221
|
+
if (value.code === "operation_cancelled" || value.code === "operation_timeout") return value.code;
|
|
222
|
+
const result = value.result as { timedOut?: boolean; aborted?: boolean } | undefined;
|
|
223
|
+
if (result?.timedOut) return "operation_timeout";
|
|
224
|
+
if (result?.aborted) return "operation_cancelled";
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function rethrowOperationFailure(error: unknown): void {
|
|
230
|
+
if (isUnconfirmedExit(error) || operationFailure(error) !== undefined) throw error;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function errorChain(error: unknown): Array<Record<string, unknown>> {
|
|
234
|
+
const chain: Array<Record<string, unknown>> = [];
|
|
235
|
+
const seen = new Set<unknown>();
|
|
236
|
+
while (typeof error === "object" && error !== null && !seen.has(error)) {
|
|
237
|
+
seen.add(error);
|
|
238
|
+
const value = error as Record<string, unknown>;
|
|
239
|
+
chain.push(value);
|
|
240
|
+
error = value.cause;
|
|
241
|
+
}
|
|
242
|
+
return chain;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function operationErrorFromAbortReason(reason: unknown): OperationError {
|
|
246
|
+
if (isTimeoutReason(reason)) {
|
|
247
|
+
return new OperationError("operation_timeout", "操作超过截止时间");
|
|
248
|
+
}
|
|
249
|
+
return new OperationError("operation_cancelled", "操作已被取消");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 判断 AbortSignal 的 reason 是否代表超时(而不是主动取消)。 */
|
|
253
|
+
export function isOperationTimeoutReason(reason: unknown): boolean {
|
|
254
|
+
return isTimeoutReason(reason);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isTimeoutReason(reason: unknown): boolean {
|
|
258
|
+
if (reason === OPERATION_TIMEOUT_REASON) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (typeof reason !== "object" || reason === null) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
const value = reason as { readonly code?: unknown; readonly name?: unknown };
|
|
265
|
+
return value.code === OPERATION_TIMEOUT_REASON || value.name === "TimeoutError";
|
|
266
|
+
}
|
package/src/pi-runtime.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
+
import { writeJsonAtomic } from "./atomic-fs.ts";
|
|
2
3
|
|
|
3
4
|
import type {
|
|
4
5
|
ExtensionAPI,
|
|
@@ -28,11 +29,17 @@ import { DurableCursorWriter, SessionState, type SessionEntrySource } from "./se
|
|
|
28
29
|
import { SnapshotStore } from "./snapshot-store.ts";
|
|
29
30
|
import { StatusReporter } from "./status-reporter.ts";
|
|
30
31
|
import { WorkspaceLock } from "./workspace-lock.ts";
|
|
32
|
+
import { allCompleted, checkOperation, configuredTimeout, currentOperationContext, isUnconfirmedExit, operationHasUnconfirmedExit, runWithOperationContext, withRecoveryBudget, type ProcessDiagnostic } from "./operation-context.ts";
|
|
31
33
|
|
|
32
34
|
type ReadonlySessionManager = ExtensionContext["sessionManager"];
|
|
33
35
|
|
|
34
|
-
export
|
|
36
|
+
export function createPiUndoRuntime(context: ExtensionContext, pi: ExtensionAPI) {
|
|
37
|
+
return withRecoveryBudget(() => initializePiUndoRuntime(context, pi));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function initializePiUndoRuntime(context: ExtensionContext, pi: ExtensionAPI) {
|
|
35
41
|
const manager = context.sessionManager;
|
|
42
|
+
const reporter = new StatusReporter(context);
|
|
36
43
|
const sessionState = sessionStateFor(manager);
|
|
37
44
|
const sessionIdentity = await sessionState.getSessionIdentity() ?? volatileSessionIdentity(manager, context.cwd);
|
|
38
45
|
const privateRoot = join(manager.getSessionDir(), ".pi-undo");
|
|
@@ -66,11 +73,15 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
66
73
|
loadPending: () => journal.loadPending(),
|
|
67
74
|
assessForeignTransaction: (pending) => journal.isInertForeignPrepared(pending),
|
|
68
75
|
assessCompensatedTransaction: (pending) => journal.isFullyCompensated(pending),
|
|
69
|
-
inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor
|
|
76
|
+
inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor,
|
|
77
|
+
pending.descriptor.action === "tree" && pending.state.observedLogicalLeaf !== undefined
|
|
78
|
+
? pending.state.observedLogicalLeaf : pending.descriptor.toLogicalLeaf),
|
|
70
79
|
finalizeCursor: (pending, inspection) => finalizeCursorMarker(
|
|
71
80
|
pending.descriptor.sessionIdentity.path,
|
|
72
81
|
pending.descriptor,
|
|
73
82
|
inspection,
|
|
83
|
+
pending.descriptor.action === "tree" && pending.state.observedLogicalLeaf !== undefined
|
|
84
|
+
? pending.state.observedLogicalLeaf : pending.descriptor.toLogicalLeaf,
|
|
74
85
|
),
|
|
75
86
|
recoverMutations: async (pending, decision) => {
|
|
76
87
|
const mutationJournal = journal.mutationJournal(pending.descriptor.opId);
|
|
@@ -141,7 +152,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
141
152
|
if (finalizationFailure !== undefined) throw finalizationFailure;
|
|
142
153
|
};
|
|
143
154
|
const scheduleFinalization = (opId: string): void => {
|
|
144
|
-
finalizationQueue = finalizationQueue.then(async () => {
|
|
155
|
+
finalizationQueue = runWithOperationContext(undefined, () => finalizationQueue.then(() => withRecoveryBudget(async () => {
|
|
145
156
|
const lease = await workspaceLock.acquire(initialTopology.workspaceIdentity);
|
|
146
157
|
try {
|
|
147
158
|
const mutationJournal = journal.mutationJournal(opId);
|
|
@@ -161,9 +172,9 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
161
172
|
});
|
|
162
173
|
await journal.markCommitted(opId);
|
|
163
174
|
} finally {
|
|
164
|
-
await lease.release();
|
|
175
|
+
if (!operationHasUnconfirmedExit()) await lease.release();
|
|
165
176
|
}
|
|
166
|
-
}).catch((error: unknown) => {
|
|
177
|
+
}))).catch((error: unknown) => {
|
|
167
178
|
finalizationFailure = error;
|
|
168
179
|
});
|
|
169
180
|
};
|
|
@@ -183,6 +194,9 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
183
194
|
loadPending: () => journal.loadPending(),
|
|
184
195
|
};
|
|
185
196
|
|
|
197
|
+
const diagnostics: ProcessDiagnostic[] = [];
|
|
198
|
+
const phases: Array<{ phase: string; elapsedMs: number }> = [];
|
|
199
|
+
let operationStarted = 0;
|
|
186
200
|
const dependencies: ControllerDependencies = {
|
|
187
201
|
workspaceIdentity: initialTopology.workspaceIdentity,
|
|
188
202
|
sessionIdentity,
|
|
@@ -210,10 +224,12 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
210
224
|
}
|
|
211
225
|
},
|
|
212
226
|
restoreSessionLeaf: async (logicalLeafId) => {
|
|
213
|
-
if (commandContext === undefined
|
|
227
|
+
if (commandContext === undefined) return false;
|
|
228
|
+
const targetId = sessionNavigationEntry(manager, logicalLeafId);
|
|
229
|
+
if (targetId === undefined) return false;
|
|
214
230
|
internalNavigation = true;
|
|
215
231
|
try {
|
|
216
|
-
const result = await commandContext.navigateTree(
|
|
232
|
+
const result = await commandContext.navigateTree(targetId, { summarize: false });
|
|
217
233
|
return !result.cancelled && sessionStateFor(manager).getLogicalLeafId() === logicalLeafId;
|
|
218
234
|
} finally {
|
|
219
235
|
internalNavigation = false;
|
|
@@ -225,7 +241,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
225
241
|
capture,
|
|
226
242
|
captureBaseline,
|
|
227
243
|
captureSafety: async (referenceManifestId, targetManifestId, scopePaths) => {
|
|
228
|
-
const [reference, target] = await
|
|
244
|
+
const [reference, target] = await allCompleted([
|
|
229
245
|
store.loadManifest(referenceManifestId),
|
|
230
246
|
store.loadManifest(targetManifestId),
|
|
231
247
|
]);
|
|
@@ -253,6 +269,30 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
253
269
|
},
|
|
254
270
|
journal: transactionJournal,
|
|
255
271
|
clock: Date.now,
|
|
272
|
+
operationTimeoutMs: configuredTimeout("PI_UNDO_OPERATION_TIMEOUT_MS", 300_000),
|
|
273
|
+
onOperationStart: (opId) => {
|
|
274
|
+
operationStarted = performance.now();
|
|
275
|
+
diagnostics.length = 0;
|
|
276
|
+
phases.length = 0;
|
|
277
|
+
reporter.startOperation(opId);
|
|
278
|
+
},
|
|
279
|
+
onProgress: (phase) => {
|
|
280
|
+
reporter.setOperationPhase(phase);
|
|
281
|
+
if (phases.at(-1)?.phase === phase) return;
|
|
282
|
+
if (phases.length === 128) phases.shift();
|
|
283
|
+
phases.push({ phase, elapsedMs: Math.round(performance.now() - operationStarted) });
|
|
284
|
+
},
|
|
285
|
+
onProcess: (diagnostic) => {
|
|
286
|
+
if (diagnostics.length === 128) diagnostics.shift();
|
|
287
|
+
diagnostics.push(diagnostic);
|
|
288
|
+
},
|
|
289
|
+
onOperationEnd: async (opId, result) => {
|
|
290
|
+
const totalMs = Math.round(performance.now() - operationStarted);
|
|
291
|
+
if (totalMs < 1_000 && (result.code === "ok" || result.code === "noop")) return;
|
|
292
|
+
await writeJsonAtomic(join(privateRoot, "diagnostics", `${manager.getSessionId()}-latest.json`), {
|
|
293
|
+
schemaVersion: 1, opId, code: result.code, totalMs, phases, processes: diagnostics,
|
|
294
|
+
});
|
|
295
|
+
},
|
|
256
296
|
};
|
|
257
297
|
const startupRecovery = await workspaceLock.withLock(
|
|
258
298
|
initialTopology.workspaceIdentity,
|
|
@@ -266,17 +306,30 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
266
306
|
});
|
|
267
307
|
return {
|
|
268
308
|
controller,
|
|
269
|
-
reporter
|
|
309
|
+
reporter,
|
|
270
310
|
diffSource: store,
|
|
271
311
|
recovery: startupRecovery.kind === "locked"
|
|
272
312
|
? { reason: startupRecovery.reason, files: startupRecovery.files, opId: startupRecovery.opId }
|
|
273
313
|
: undefined,
|
|
314
|
+
async dispose(): Promise<void> {
|
|
315
|
+
await controller.dispose();
|
|
316
|
+
await finalizationQueue;
|
|
317
|
+
if (isUnconfirmedExit(finalizationFailure)) throw finalizationFailure;
|
|
318
|
+
reporter.endOperation();
|
|
319
|
+
},
|
|
274
320
|
setCommandContext(next: ExtensionCommandContext | undefined): void {
|
|
275
321
|
commandContext = next;
|
|
276
322
|
},
|
|
277
323
|
isInternalNavigation(): boolean {
|
|
278
324
|
return internalNavigation;
|
|
279
325
|
},
|
|
326
|
+
normalizeTreeEvent(event: { newLeafId: string | null; summaryEntry?: { parentId: string | null } }) {
|
|
327
|
+
return {
|
|
328
|
+
newLeafId: logicalLeafAt(manager, event.newLeafId),
|
|
329
|
+
navigationTargetLeafId: logicalLeafAt(manager,
|
|
330
|
+
event.summaryEntry === undefined ? event.newLeafId : event.summaryEntry.parentId),
|
|
331
|
+
};
|
|
332
|
+
},
|
|
280
333
|
};
|
|
281
334
|
}
|
|
282
335
|
|
|
@@ -401,6 +454,21 @@ function logicalLeafAt(manager: ReadonlySessionManager, leafId: string | null):
|
|
|
401
454
|
return sessionStateFor(manager, leafId).getLogicalLeafId();
|
|
402
455
|
}
|
|
403
456
|
|
|
457
|
+
function sessionNavigationEntry(manager: ReadonlySessionManager, logicalLeafId: string | null): string | undefined {
|
|
458
|
+
if (logicalLeafId !== null) {
|
|
459
|
+
const entry = manager.getEntry(logicalLeafId) as unknown;
|
|
460
|
+
if (isRecord(entry) && entry.type !== "custom_message" &&
|
|
461
|
+
!(entry.type === "message" && isRecord(entry.message) && entry.message.role === "user")) return logicalLeafId;
|
|
462
|
+
}
|
|
463
|
+
// Pi 的公开导航 API 不接受 null;选择 parent 对应逻辑位置的用户消息。
|
|
464
|
+
for (const entry of manager.getEntries() as unknown[]) {
|
|
465
|
+
if (!isRecord(entry) || typeof entry.id !== "string" || entry.type !== "message" ||
|
|
466
|
+
!isRecord(entry.message) || entry.message.role !== "user") continue;
|
|
467
|
+
if (logicalLeafAt(manager, entryParent(manager, entry.id)) === logicalLeafId) return entry.id;
|
|
468
|
+
}
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
404
472
|
async function resolveTreeTarget(
|
|
405
473
|
manager: ReadonlySessionManager,
|
|
406
474
|
identity: SessionFileIdentity,
|
|
@@ -421,12 +489,42 @@ async function resolveTreeTarget(
|
|
|
421
489
|
undoStack: sessionStateFor(manager, physicalLeaf).getCheckpoints(identity),
|
|
422
490
|
};
|
|
423
491
|
}
|
|
424
|
-
let checkpoints = [...sessionStateFor(manager, physicalLeaf).getCheckpoints(identity)];
|
|
425
492
|
const exact = findCheckpointByEndLeaf(manager, identity, logicalLeafId);
|
|
426
|
-
if (exact !== undefined)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
493
|
+
if (exact !== undefined) {
|
|
494
|
+
return {
|
|
495
|
+
logicalLeafId,
|
|
496
|
+
targetManifestId: exact.afterManifestId,
|
|
497
|
+
undoStack: checkpointFrontierById(manager, identity, exact.checkpointId),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
// cursor 可以证明 summary/撤回后逻辑边界对应的文件状态;不能退回任意旧栈顶。
|
|
501
|
+
const cursor = sessionStateFor(manager, physicalLeaf).getCursor(identity);
|
|
502
|
+
if (cursor !== null && cursor.toLogicalLeaf === logicalLeafId) {
|
|
503
|
+
return {
|
|
504
|
+
logicalLeafId,
|
|
505
|
+
targetManifestId: cursor.targetManifestId,
|
|
506
|
+
undoStack: cursor.undoHead === null ? [] : checkpointFrontierById(manager, identity, cursor.undoHead),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// 用户输入前的控制条目/根叶可由可信 before checkpoint 证明。
|
|
510
|
+
const boundaries = new Map<string, { checkpoint: CheckpointRecord; physicalLeaf: string | null }>();
|
|
511
|
+
for (const value of manager.getEntries() as unknown[]) {
|
|
512
|
+
if (!isRecord(value) || value.type !== "custom" || value.customType !== "pi-undo:checkpoint" || typeof value.id !== "string") continue;
|
|
513
|
+
for (const checkpoint of sessionStateFor(manager, value.id).getCheckpoints(identity)) {
|
|
514
|
+
const beforeLeaf = entryParent(manager, checkpoint.userEntryId);
|
|
515
|
+
if (logicalLeafAt(manager, beforeLeaf) === logicalLeafId) boundaries.set(checkpoint.checkpointId, { checkpoint, physicalLeaf: beforeLeaf });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const beforeStates = [...boundaries.values()];
|
|
519
|
+
if (beforeStates.length > 0 && new Set(beforeStates.map(({ checkpoint }) => checkpoint.beforeManifestId)).size === 1) {
|
|
520
|
+
const boundary = beforeStates[0]!;
|
|
521
|
+
return {
|
|
522
|
+
logicalLeafId,
|
|
523
|
+
targetManifestId: boundary.checkpoint.beforeManifestId,
|
|
524
|
+
undoStack: sessionStateFor(manager, boundary.physicalLeaf).getCheckpoints(identity),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
throw new Error("tree target 缺少精确的 checkpoint 边界");
|
|
430
528
|
}
|
|
431
529
|
|
|
432
530
|
function findCheckpointByEndLeaf(
|
|
@@ -538,17 +636,23 @@ function volatileSessionIdentity(manager: ReadonlySessionManager, cwd: string):
|
|
|
538
636
|
|
|
539
637
|
async function waitForIdle(context: ExtensionCommandContext | undefined, deadlineMs: number): Promise<boolean> {
|
|
540
638
|
if (context === undefined) return false;
|
|
541
|
-
|
|
639
|
+
checkOperation();
|
|
640
|
+
const operation = currentOperationContext();
|
|
641
|
+
const remaining = Math.max(0, Math.min(deadlineMs, operation?.deadline ?? Infinity) - Date.now());
|
|
542
642
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
643
|
+
let onAbort: (() => void) | undefined;
|
|
543
644
|
try {
|
|
544
645
|
return await Promise.race([
|
|
545
646
|
context.waitForIdle().then(() => true, () => false),
|
|
546
647
|
new Promise<boolean>((resolveTimeout) => {
|
|
547
648
|
timeout = setTimeout(() => resolveTimeout(false), remaining);
|
|
649
|
+
onAbort = () => resolveTimeout(false);
|
|
650
|
+
operation?.signal.addEventListener("abort", onAbort, { once: true });
|
|
548
651
|
}),
|
|
549
652
|
]);
|
|
550
653
|
} finally {
|
|
551
654
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
655
|
+
if (onAbort !== undefined) operation?.signal.removeEventListener("abort", onAbort);
|
|
552
656
|
}
|
|
553
657
|
}
|
|
554
658
|
|
package/src/quarantine.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { fsyncDirectory, writeBytesExclusive } from "./atomic-fs.ts";
|
|
|
7
7
|
import { canonicalJson, checksum } from "./encoding.ts";
|
|
8
8
|
import type { MutationJournal } from "./mutation-journal.ts";
|
|
9
9
|
import type { MutationRecord } from "./model.ts";
|
|
10
|
+
import { checkOperation } from "./operation-context.ts";
|
|
10
11
|
import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
|
|
11
12
|
|
|
12
13
|
const BATCH_FILE_IO_CONCURRENCY = 32;
|
|
@@ -828,6 +829,7 @@ export class QuarantineManager {
|
|
|
828
829
|
}
|
|
829
830
|
|
|
830
831
|
private async assertWorkspaceIdentity(): Promise<void> {
|
|
832
|
+
checkOperation();
|
|
831
833
|
const identity = await realpath(this.requestedWorkspaceRoot);
|
|
832
834
|
if (identity !== this.workspaceRoot) {
|
|
833
835
|
throw new QuarantineError("unsafe_artifact", "workspace root identity 已变化");
|
|
@@ -925,6 +927,7 @@ async function mapConcurrentFailClosed<T>(
|
|
|
925
927
|
const index = nextIndex;
|
|
926
928
|
nextIndex += 1;
|
|
927
929
|
try {
|
|
930
|
+
checkOperation();
|
|
928
931
|
await operation(values[index]!);
|
|
929
932
|
} catch (error) {
|
|
930
933
|
if (!failed) failure = error;
|
package/src/recovery.ts
CHANGED
|
@@ -94,9 +94,9 @@ export class JournalRecovery {
|
|
|
94
94
|
if (inspection.kind === "conflict") {
|
|
95
95
|
return { kind: "locked", reason: "cursor_conflict", operations: recovered };
|
|
96
96
|
}
|
|
97
|
-
const
|
|
98
|
-
? journal.descriptor.toLogicalLeaf
|
|
99
|
-
|
|
97
|
+
const committedLeaf = journal.descriptor.action === "tree" && journal.state.observedLogicalLeaf !== undefined
|
|
98
|
+
? journal.state.observedLogicalLeaf : journal.descriptor.toLogicalLeaf;
|
|
99
|
+
const expectedLeaf = inspection.kind === "match" ? committedLeaf : journal.descriptor.fromLogicalLeaf;
|
|
100
100
|
if (this.dependencies.getLogicalLeafId() !== expectedLeaf) {
|
|
101
101
|
return { kind: "locked", reason: "session_leaf_mismatch", operations: recovered };
|
|
102
102
|
}
|