@davideasden/pi-undo 0.2.1 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/controller.ts CHANGED
@@ -406,6 +406,9 @@ export class UndoControllerImpl implements UndoController {
406
406
  } catch {
407
407
  return done({ code: "busy", changedFiles: 0 });
408
408
  }
409
+ if (checkpoint.changedPaths.length === 0) {
410
+ return done(await this.runSessionOnlyOperation(action, checkpoint, targetManifestId, profile));
411
+ }
409
412
  let rollback: SnapshotManifest;
410
413
  try {
411
414
  rollback = await profile.measure("capture", () =>
@@ -492,6 +495,84 @@ export class UndoControllerImpl implements UndoController {
492
495
  }
493
496
  }
494
497
 
498
+ private async runSessionOnlyOperation(
499
+ action: "undo" | "redo",
500
+ checkpoint: CheckpointRecord,
501
+ targetManifestId: ManifestId | undefined,
502
+ profile: OperationProfiler,
503
+ ): Promise<OperationResult> {
504
+ const rollbackManifestId = action === "undo"
505
+ ? checkpoint.afterManifestId
506
+ : checkpoint.beforeManifestId;
507
+ const targetId = targetManifestId ?? (
508
+ action === "undo" ? checkpoint.beforeManifestId : checkpoint.afterManifestId
509
+ );
510
+ const plan = emptyRestorePlan(rollbackManifestId, targetId);
511
+ const targetLogicalLeaf = this.dependencies.resolveSessionTarget(action, checkpoint);
512
+ const descriptor = this.createDescriptorFromManifestIds(
513
+ action,
514
+ rollbackManifestId,
515
+ targetId,
516
+ plan,
517
+ targetLogicalLeaf,
518
+ );
519
+ await profile.measure("journal", () => this.dependencies.journal.prepare(descriptor, plan));
520
+ const navigation = await profile.measure("navigate", () =>
521
+ this.dependencies.navigateSession(action, checkpoint));
522
+ if (navigation.cancelled) {
523
+ await profile.measure("journal", async () => {
524
+ await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
525
+ await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
526
+ });
527
+ return { code: "restore_failed_safe", changedFiles: 0 };
528
+ }
529
+ if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
530
+ this.locked = true;
531
+ await profile.measure("journal", () =>
532
+ this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
533
+ return { code: "recovery_required", changedFiles: 0 };
534
+ }
535
+ await profile.measure("journal", async () => {
536
+ await this.dependencies.journal.setPhase(descriptor.opId, "SESSION_MOVED", {
537
+ observedLogicalLeaf: navigation.logicalLeafId,
538
+ });
539
+ await this.dependencies.journal.setPhase(descriptor.opId, "APPLYING");
540
+ await this.dependencies.journal.setPhase(descriptor.opId, "FILES_VERIFIED");
541
+ });
542
+ const cursorResult = await profile.measure("cursor", () =>
543
+ this.dependencies.appendCursor(this.createCursor(descriptor, action, checkpoint)));
544
+ if (cursorResult.kind === "recovery_required") {
545
+ this.locked = true;
546
+ await profile.measure("journal", () =>
547
+ this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
548
+ return { code: "recovery_required", changedFiles: 0 };
549
+ }
550
+ if (cursorResult.kind === "volatile") {
551
+ return profile.measure("compensate", () => this.compensateSessionOnly(descriptor));
552
+ }
553
+ await profile.measure("commit", async () => {
554
+ await this.dependencies.journal.setPhase(descriptor.opId, "CURSOR_COMMITTED");
555
+ await this.dependencies.journal.markCommitted(descriptor.opId);
556
+ });
557
+ this.lastSafetyManifestId = rollbackManifestId;
558
+ return { code: "ok", changedFiles: 0 };
559
+ }
560
+
561
+ private async compensateSessionOnly(descriptor: OperationDescriptor): Promise<OperationResult> {
562
+ try {
563
+ await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
564
+ if (!await this.dependencies.restoreSessionLeaf(descriptor.fromLogicalLeaf)) {
565
+ throw new Error("session rollback failed");
566
+ }
567
+ await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
568
+ return { code: "restore_failed_safe", changedFiles: 0 };
569
+ } catch {
570
+ this.locked = true;
571
+ await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
572
+ return { code: "recovery_required", changedFiles: 0 };
573
+ }
574
+ }
575
+
495
576
  private async ensureIdle(): Promise<boolean> {
496
577
  if (this.dependencies.isAgentIdle()) return true;
497
578
  try {
@@ -569,6 +650,22 @@ export class UndoControllerImpl implements UndoController {
569
650
  target: SnapshotManifest,
570
651
  plan: RestorePlan,
571
652
  targetLogicalLeaf: string | null,
653
+ ): OperationDescriptor {
654
+ return this.createDescriptorFromManifestIds(
655
+ action,
656
+ rollback.manifestId,
657
+ target.manifestId,
658
+ plan,
659
+ targetLogicalLeaf,
660
+ );
661
+ }
662
+
663
+ private createDescriptorFromManifestIds(
664
+ action: "undo" | "redo" | "tree",
665
+ rollbackManifestId: ManifestId,
666
+ targetManifestId: ManifestId,
667
+ plan: RestorePlan,
668
+ targetLogicalLeaf: string | null,
572
669
  ): OperationDescriptor {
573
670
  const scopePaths = [...(plan.scopePaths ?? [...plan.deletePaths, ...plan.writePaths])].sort();
574
671
  const payload = {
@@ -579,8 +676,8 @@ export class UndoControllerImpl implements UndoController {
579
676
  action,
580
677
  fromLogicalLeaf: this.dependencies.getLogicalLeafId(),
581
678
  toLogicalLeaf: targetLogicalLeaf,
582
- targetManifestId: target.manifestId,
583
- rollbackManifestId: rollback.manifestId,
679
+ targetManifestId,
680
+ rollbackManifestId,
584
681
  coverage: `paths:${checksum(canonicalJson(scopePaths))}`,
585
682
  scopePaths,
586
683
  planDigest: plan.planDigest,
@@ -633,6 +730,18 @@ export class UndoControllerImpl implements UndoController {
633
730
  }
634
731
  }
635
732
 
733
+ function emptyRestorePlan(currentManifestId: ManifestId, targetManifestId: ManifestId): RestorePlan {
734
+ const payload = {
735
+ currentManifestId,
736
+ targetManifestId,
737
+ boundaryRoots: [],
738
+ deletePaths: [],
739
+ writePaths: [],
740
+ scopePaths: [],
741
+ };
742
+ return { ...payload, planDigest: checksum(canonicalJson(payload)) };
743
+ }
744
+
636
745
  class OperationProfiler {
637
746
  private readonly durations = new Map<string, number>();
638
747
 
package/src/pi-runtime.ts CHANGED
@@ -59,8 +59,15 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
59
59
  recoverMutations: async (pending, decision) => {
60
60
  const mutationJournal = journal.mutationJournal(pending.descriptor.opId);
61
61
  const quarantine = new QuarantineManager({ workspaceRoot: context.cwd, journal: mutationJournal });
62
- try {
63
- const records = (await mutationJournal.load()).filter((record) => record.state !== "CLEANED");
62
+ try {
63
+ const loaded = await mutationJournal.load();
64
+ const scopePaths = pending.descriptor.scopePaths;
65
+ if (loaded.some((record) => !scopePaths.some((scope) =>
66
+ scope === "." || record.path === scope || record.path.startsWith(`${scope}/`)
67
+ ))) {
68
+ throw new Error("mutation journal path 超出 descriptor scope");
69
+ }
70
+ const records = loaded.filter((record) => record.state !== "CLEANED");
64
71
  if (decision === "rollback") records.reverse();
65
72
  for (const record of records) {
66
73
  if (decision === "rollback") {
package/src/recovery.ts CHANGED
@@ -100,19 +100,21 @@ export class JournalRecovery {
100
100
  }
101
101
 
102
102
  try {
103
- const current = await this.dependencies.capture();
104
- const targetId = inspection.kind === "match"
105
- ? journal.descriptor.targetManifestId
106
- : journal.descriptor.rollbackManifestId;
107
- const target = await this.dependencies.loadManifest(targetId);
108
- const plan = await this.dependencies.planRestore(current, target, journal.descriptor.scopePaths);
109
- const applied = await this.dependencies.applyRestore(
110
- plan,
111
- target,
112
- { opId: journal.descriptor.opId },
113
- );
114
- if (applied.code !== "ok") {
115
- return { kind: "locked", reason: "restore_failed", operations: recovered };
103
+ if (journal.descriptor.scopePaths.length > 0) {
104
+ const current = await this.dependencies.capture();
105
+ const targetId = inspection.kind === "match"
106
+ ? journal.descriptor.targetManifestId
107
+ : journal.descriptor.rollbackManifestId;
108
+ const target = await this.dependencies.loadManifest(targetId);
109
+ const plan = await this.dependencies.planRestore(current, target, journal.descriptor.scopePaths);
110
+ const applied = await this.dependencies.applyRestore(
111
+ plan,
112
+ target,
113
+ { opId: journal.descriptor.opId },
114
+ );
115
+ if (applied.code !== "ok") {
116
+ return { kind: "locked", reason: "restore_failed", operations: recovered };
117
+ }
116
118
  }
117
119
  if (inspection.kind === "match") {
118
120
  await this.dependencies.finalizeCursor(journal, inspection);