@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/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 async function createPiUndoRuntime(context: ExtensionContext, pi: ExtensionAPI) {
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 || logicalLeafId === null) return false;
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(logicalLeafId, { summarize: false });
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 Promise.all([
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: new StatusReporter(context),
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) checkpoints = checkpointFrontierById(manager, identity, exact.checkpointId);
427
- const checkpoint = exact ?? checkpoints.at(-1);
428
- if (checkpoint === undefined) throw new Error("tree target 缺少完整 checkpoint");
429
- return { logicalLeafId, targetManifestId: checkpoint.afterManifestId, undoStack: checkpoints };
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
- const remaining = Math.max(0, deadlineMs - Date.now());
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 expectedLeaf = inspection.kind === "match"
98
- ? journal.descriptor.toLogicalLeaf
99
- : journal.descriptor.fromLogicalLeaf;
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
  }