@davideasden/pi-undo 0.2.24 → 0.2.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/extensions/pi-undo.ts +75 -15
- package/package.json +7 -6
- package/src/controller.ts +377 -45
- package/src/git-runner.ts +356 -51
- package/src/journal.ts +5 -2
- package/src/model.ts +2 -0
- package/src/native-metadata.ts +47 -87
- package/src/native-restore.ts +49 -56
- package/src/operation-context.ts +266 -0
- package/src/pi-runtime.ts +119 -15
- package/src/quarantine.ts +3 -0
- package/src/recovery.ts +3 -3
- package/src/restore-engine.ts +102 -76
- package/src/root-discovery.ts +46 -5
- package/src/snapshot-store.ts +39 -16
- package/src/status-reporter.ts +41 -1
- package/src/workspace-lock.ts +4 -1
package/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
|
}
|