@davideasden/pi-undo 0.2.0 → 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/README.md +147 -85
- package/extensions/pi-undo.ts +4 -1
- package/package.json +1 -1
- package/src/atomic-fs.ts +7 -2
- package/src/controller.ts +200 -37
- package/src/mutation-journal.ts +180 -40
- package/src/pi-runtime.ts +11 -4
- package/src/quarantine.ts +357 -17
- package/src/recovery.ts +15 -13
- package/src/restore-engine.ts +269 -49
- package/src/snapshot-store.ts +316 -63
- package/src/status-reporter.ts +15 -2
package/src/controller.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
2
3
|
|
|
3
4
|
import { canonicalJson, checksum } from "./encoding.ts";
|
|
4
5
|
import type {
|
|
@@ -35,7 +36,7 @@ export interface ControllerDependencies {
|
|
|
35
36
|
}>;
|
|
36
37
|
readonly appendControl: (customType: string, data?: unknown) => Promise<string | null>;
|
|
37
38
|
readonly appendCursor: (cursor: CursorState) => Promise<CursorAppendResult>;
|
|
38
|
-
readonly capture: () => Promise<SnapshotManifest>;
|
|
39
|
+
readonly capture: (scopePaths?: readonly string[]) => Promise<SnapshotManifest>;
|
|
39
40
|
readonly changedPaths: (before: SnapshotManifest, after: SnapshotManifest) => Promise<readonly string[]>;
|
|
40
41
|
readonly loadManifest: (id: ManifestId) => Promise<SnapshotManifest>;
|
|
41
42
|
readonly planRestore: (
|
|
@@ -73,11 +74,17 @@ export type CursorAppendResult =
|
|
|
73
74
|
| { readonly kind: "volatile"; readonly reason: string }
|
|
74
75
|
| { readonly kind: "recovery_required"; readonly reason: string };
|
|
75
76
|
|
|
77
|
+
export interface OperationTiming {
|
|
78
|
+
readonly phase: string;
|
|
79
|
+
readonly durationMs: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
export interface OperationResult {
|
|
77
83
|
readonly code: ResultCode;
|
|
78
84
|
readonly changedFiles: number;
|
|
79
85
|
readonly message?: string;
|
|
80
86
|
readonly refillPrompt?: string;
|
|
87
|
+
readonly timings?: readonly OperationTiming[];
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
export type InputEventResult =
|
|
@@ -384,85 +391,188 @@ export class UndoControllerImpl implements UndoController {
|
|
|
384
391
|
targetManifestId?: ManifestId,
|
|
385
392
|
): Promise<OperationResult> {
|
|
386
393
|
if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
|
|
394
|
+
const profile = new OperationProfiler();
|
|
395
|
+
const done = (result: OperationResult): OperationResult => profile.attach(result);
|
|
387
396
|
this.operationInFlight = true;
|
|
388
397
|
this.promptDeferralInFlight = true;
|
|
389
398
|
this.lastSafetyManifestId = null;
|
|
390
399
|
let lease: { release(): Promise<void> } | undefined;
|
|
391
400
|
try {
|
|
392
|
-
if (!await this.ensureIdle())
|
|
401
|
+
if (!await profile.measure("idle", () => this.ensureIdle())) {
|
|
402
|
+
return done({ code: "idle_timeout", changedFiles: 0 });
|
|
403
|
+
}
|
|
393
404
|
try {
|
|
394
|
-
lease = await this.dependencies.acquireWorkspaceLock();
|
|
405
|
+
lease = await profile.measure("lock", () => this.dependencies.acquireWorkspaceLock());
|
|
395
406
|
} catch {
|
|
396
|
-
return { code: "busy", changedFiles: 0 };
|
|
407
|
+
return done({ code: "busy", changedFiles: 0 });
|
|
408
|
+
}
|
|
409
|
+
if (checkpoint.changedPaths.length === 0) {
|
|
410
|
+
return done(await this.runSessionOnlyOperation(action, checkpoint, targetManifestId, profile));
|
|
397
411
|
}
|
|
398
412
|
let rollback: SnapshotManifest;
|
|
399
413
|
try {
|
|
400
|
-
rollback = await
|
|
414
|
+
rollback = await profile.measure("capture", () =>
|
|
415
|
+
this.dependencies.capture(checkpoint.changedPaths));
|
|
401
416
|
} catch {
|
|
402
|
-
return { code: "capture_failed", changedFiles: 0 };
|
|
417
|
+
return done({ code: "capture_failed", changedFiles: 0 });
|
|
403
418
|
}
|
|
404
419
|
let target: SnapshotManifest;
|
|
405
420
|
let plan: RestorePlan;
|
|
406
421
|
let targetLogicalLeaf: string | null;
|
|
407
422
|
try {
|
|
408
|
-
target = await this.dependencies.loadManifest(
|
|
423
|
+
target = await profile.measure("load", () => this.dependencies.loadManifest(
|
|
409
424
|
targetManifestId ?? (action === "undo" ? checkpoint.beforeManifestId : checkpoint.afterManifestId),
|
|
410
|
-
);
|
|
411
|
-
plan = await
|
|
425
|
+
));
|
|
426
|
+
plan = await profile.measure("plan", () =>
|
|
427
|
+
this.dependencies.planRestore(rollback, target, checkpoint.changedPaths));
|
|
412
428
|
targetLogicalLeaf = this.dependencies.resolveSessionTarget(action, checkpoint);
|
|
413
429
|
} catch {
|
|
414
|
-
return { code: "restore_failed_safe", changedFiles: 0 };
|
|
430
|
+
return done({ code: "restore_failed_safe", changedFiles: 0 });
|
|
415
431
|
}
|
|
416
432
|
const descriptor = this.createDescriptor(action, rollback, target, plan, targetLogicalLeaf);
|
|
417
|
-
await this.dependencies.journal.prepare(descriptor, plan);
|
|
418
|
-
const navigation = await
|
|
433
|
+
await profile.measure("journal", () => this.dependencies.journal.prepare(descriptor, plan));
|
|
434
|
+
const navigation = await profile.measure("navigate", () =>
|
|
435
|
+
this.dependencies.navigateSession(action, checkpoint));
|
|
419
436
|
if (navigation.cancelled) {
|
|
420
|
-
await
|
|
421
|
-
|
|
422
|
-
|
|
437
|
+
await profile.measure("journal", async () => {
|
|
438
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
|
|
439
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
|
|
440
|
+
});
|
|
441
|
+
return done({ code: "restore_failed_safe", changedFiles: 0 });
|
|
423
442
|
}
|
|
424
443
|
if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
|
|
425
444
|
this.locked = true;
|
|
426
|
-
await
|
|
427
|
-
|
|
445
|
+
await profile.measure("journal", () =>
|
|
446
|
+
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
|
|
447
|
+
return done({ code: "recovery_required", changedFiles: 0 });
|
|
428
448
|
}
|
|
429
|
-
await
|
|
430
|
-
|
|
449
|
+
await profile.measure("journal", async () => {
|
|
450
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "SESSION_MOVED", {
|
|
451
|
+
observedLogicalLeaf: navigation.logicalLeafId,
|
|
452
|
+
});
|
|
453
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "APPLYING");
|
|
431
454
|
});
|
|
432
|
-
await
|
|
433
|
-
|
|
434
|
-
if (applied.code !== "ok")
|
|
435
|
-
|
|
455
|
+
const applied = await profile.measure("apply", () =>
|
|
456
|
+
this.dependencies.applyRestore(plan, target, { opId: descriptor.opId }));
|
|
457
|
+
if (applied.code !== "ok") {
|
|
458
|
+
return done(await profile.measure("compensate", () =>
|
|
459
|
+
this.compensate(descriptor, rollback, target, applied)));
|
|
460
|
+
}
|
|
461
|
+
await profile.measure("journal", () =>
|
|
462
|
+
this.dependencies.journal.setPhase(descriptor.opId, "FILES_VERIFIED"));
|
|
436
463
|
const cursor = this.createCursor(descriptor, action, checkpoint);
|
|
437
|
-
const cursorResult = await this.dependencies.appendCursor(cursor);
|
|
464
|
+
const cursorResult = await profile.measure("cursor", () => this.dependencies.appendCursor(cursor));
|
|
438
465
|
if (cursorResult.kind === "recovery_required") {
|
|
439
466
|
this.locked = true;
|
|
440
|
-
await
|
|
441
|
-
|
|
467
|
+
await profile.measure("journal", () =>
|
|
468
|
+
this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
|
|
469
|
+
return done({ code: "recovery_required", changedFiles: applied.verifiedPaths });
|
|
442
470
|
}
|
|
443
471
|
if (cursorResult.kind === "volatile") {
|
|
444
|
-
return this.compensate(descriptor, rollback, target, {
|
|
472
|
+
return done(await profile.measure("compensate", () => this.compensate(descriptor, rollback, target, {
|
|
445
473
|
code: "recovery_required",
|
|
446
474
|
verifiedPaths: applied.verifiedPaths,
|
|
447
475
|
totalPaths: applied.totalPaths,
|
|
448
|
-
});
|
|
476
|
+
})));
|
|
449
477
|
}
|
|
450
|
-
await
|
|
451
|
-
|
|
478
|
+
await profile.measure("commit", async () => {
|
|
479
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "CURSOR_COMMITTED");
|
|
480
|
+
await this.dependencies.journal.markCommitted(descriptor.opId);
|
|
481
|
+
});
|
|
452
482
|
this.lastSafetyManifestId = rollback.manifestId;
|
|
453
|
-
return { code: "ok", changedFiles: applied.verifiedPaths };
|
|
483
|
+
return done({ code: "ok", changedFiles: applied.verifiedPaths });
|
|
454
484
|
} catch {
|
|
455
485
|
this.locked = true;
|
|
456
|
-
return { code: "recovery_required", changedFiles: 0 };
|
|
486
|
+
return done({ code: "recovery_required", changedFiles: 0 });
|
|
457
487
|
} finally {
|
|
458
|
-
|
|
459
|
-
|
|
488
|
+
const activeLease = lease;
|
|
489
|
+
if (activeLease !== undefined) {
|
|
490
|
+
await profile.measure("unlock", () =>
|
|
491
|
+
activeLease.release().catch(() => { this.locked = true; }));
|
|
460
492
|
}
|
|
461
493
|
this.promptDeferralInFlight = false;
|
|
462
494
|
this.operationInFlight = false;
|
|
463
495
|
}
|
|
464
496
|
}
|
|
465
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
|
+
|
|
466
576
|
private async ensureIdle(): Promise<boolean> {
|
|
467
577
|
if (this.dependencies.isAgentIdle()) return true;
|
|
468
578
|
try {
|
|
@@ -541,7 +651,23 @@ export class UndoControllerImpl implements UndoController {
|
|
|
541
651
|
plan: RestorePlan,
|
|
542
652
|
targetLogicalLeaf: string | null,
|
|
543
653
|
): OperationDescriptor {
|
|
544
|
-
|
|
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,
|
|
669
|
+
): OperationDescriptor {
|
|
670
|
+
const scopePaths = [...(plan.scopePaths ?? [...plan.deletePaths, ...plan.writePaths])].sort();
|
|
545
671
|
const payload = {
|
|
546
672
|
schemaVersion: 1 as const,
|
|
547
673
|
opId: `op-${randomUUID()}`,
|
|
@@ -550,8 +676,8 @@ export class UndoControllerImpl implements UndoController {
|
|
|
550
676
|
action,
|
|
551
677
|
fromLogicalLeaf: this.dependencies.getLogicalLeafId(),
|
|
552
678
|
toLogicalLeaf: targetLogicalLeaf,
|
|
553
|
-
targetManifestId
|
|
554
|
-
rollbackManifestId
|
|
679
|
+
targetManifestId,
|
|
680
|
+
rollbackManifestId,
|
|
555
681
|
coverage: `paths:${checksum(canonicalJson(scopePaths))}`,
|
|
556
682
|
scopePaths,
|
|
557
683
|
planDigest: plan.planDigest,
|
|
@@ -604,6 +730,43 @@ export class UndoControllerImpl implements UndoController {
|
|
|
604
730
|
}
|
|
605
731
|
}
|
|
606
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
|
+
|
|
745
|
+
class OperationProfiler {
|
|
746
|
+
private readonly durations = new Map<string, number>();
|
|
747
|
+
|
|
748
|
+
async measure<T>(phase: string, operation: () => Promise<T>): Promise<T> {
|
|
749
|
+
const started = performance.now();
|
|
750
|
+
try {
|
|
751
|
+
return await operation();
|
|
752
|
+
} finally {
|
|
753
|
+
this.durations.set(phase, (this.durations.get(phase) ?? 0) + performance.now() - started);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
attach(result: OperationResult): OperationResult {
|
|
758
|
+
const total = [...this.durations.values()].reduce((sum, duration) => sum + duration, 0);
|
|
759
|
+
if (total < 1_000) return result;
|
|
760
|
+
return {
|
|
761
|
+
...result,
|
|
762
|
+
timings: [...this.durations].map(([phase, durationMs]) => ({
|
|
763
|
+
phase,
|
|
764
|
+
durationMs: Math.round(durationMs),
|
|
765
|
+
})),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
607
770
|
function noop(): OperationResult {
|
|
608
771
|
return { code: "noop", changedFiles: 0 };
|
|
609
772
|
}
|
package/src/mutation-journal.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { open, readFile } from "node:fs/promises";
|
|
1
|
+
import { open, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { fsyncDirectory } from "./atomic-fs.ts";
|
|
@@ -14,6 +14,24 @@ export interface MutationIntent {
|
|
|
14
14
|
readonly targetFingerprint: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export interface MutationAdvance {
|
|
18
|
+
readonly ordinal: number;
|
|
19
|
+
readonly states: readonly MutationState[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface JournalRecords {
|
|
23
|
+
readonly latest: readonly MutationRecord[];
|
|
24
|
+
readonly tail: MutationRecord | undefined;
|
|
25
|
+
readonly durableEnd: number;
|
|
26
|
+
readonly hasNonDurableTail: boolean;
|
|
27
|
+
readonly fileExisted: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CachedJournalRecords {
|
|
31
|
+
readonly records: JournalRecords;
|
|
32
|
+
readonly fingerprint: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
const stateOrder: readonly MutationState[] = [
|
|
18
36
|
"INTENT",
|
|
19
37
|
"SOURCE_QUARANTINED",
|
|
@@ -27,6 +45,7 @@ export class MutationJournal {
|
|
|
27
45
|
private readonly path: string;
|
|
28
46
|
private readonly opId: string;
|
|
29
47
|
private mutationQueue: Promise<void> = Promise.resolve();
|
|
48
|
+
private cachedRecords: CachedJournalRecords | undefined;
|
|
30
49
|
|
|
31
50
|
constructor(path: string, opId: string) {
|
|
32
51
|
this.path = path;
|
|
@@ -46,33 +65,53 @@ export class MutationJournal {
|
|
|
46
65
|
}
|
|
47
66
|
|
|
48
67
|
begin(intent: MutationIntent): Promise<MutationRecord> {
|
|
49
|
-
return this.enqueueMutation(() => this.
|
|
68
|
+
return this.enqueueMutation(async () => (await this.beginManyMutation([intent]))[0]!);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beginMany(intents: readonly MutationIntent[]): Promise<readonly MutationRecord[]> {
|
|
72
|
+
return this.enqueueMutation(() => this.beginManyMutation(intents));
|
|
50
73
|
}
|
|
51
74
|
|
|
52
|
-
private async
|
|
75
|
+
private async beginManyMutation(intents: readonly MutationIntent[]): Promise<readonly MutationRecord[]> {
|
|
76
|
+
if (intents.length === 0) throw new Error("mutation 批量 intent 不能为空");
|
|
53
77
|
const current = await this.readRecords();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
let tail = current.tail;
|
|
79
|
+
const records: MutationRecord[] = [];
|
|
80
|
+
for (let index = 0; index < intents.length; index += 1) {
|
|
81
|
+
const intent = intents[index]!;
|
|
82
|
+
const content = {
|
|
83
|
+
schemaVersion: 1 as const,
|
|
84
|
+
opId: this.opId,
|
|
85
|
+
ordinal: current.latest.length + index + 1,
|
|
86
|
+
state: "INTENT" as const,
|
|
87
|
+
kind: intent.kind,
|
|
88
|
+
path: intent.path,
|
|
89
|
+
sourceArtifact: intent.sourceArtifact,
|
|
90
|
+
targetArtifact: intent.targetArtifact,
|
|
91
|
+
sourceFingerprint: intent.sourceFingerprint,
|
|
92
|
+
targetFingerprint: intent.targetFingerprint,
|
|
93
|
+
previousChecksum: tail?.checksum ?? null,
|
|
94
|
+
};
|
|
95
|
+
const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
|
|
96
|
+
records.push(record);
|
|
97
|
+
tail = record;
|
|
98
|
+
}
|
|
99
|
+
await this.append(records, current);
|
|
100
|
+
return records;
|
|
70
101
|
}
|
|
71
102
|
|
|
72
103
|
advance(ordinal: number, state: MutationState): Promise<MutationRecord> {
|
|
73
104
|
return this.enqueueMutation(() => this.advanceMutation(ordinal, state));
|
|
74
105
|
}
|
|
75
106
|
|
|
107
|
+
advanceMany(ordinal: number, states: readonly MutationState[]): Promise<readonly MutationRecord[]> {
|
|
108
|
+
return this.enqueueMutation(() => this.advanceManyMutation(ordinal, states));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
advanceBatch(advances: readonly MutationAdvance[]): Promise<readonly MutationRecord[]> {
|
|
112
|
+
return this.enqueueMutation(() => this.advanceBatchMutation(advances));
|
|
113
|
+
}
|
|
114
|
+
|
|
76
115
|
markRollbackCleaned(ordinal: number): Promise<MutationRecord> {
|
|
77
116
|
return this.enqueueMutation(() => this.markRollbackCleanedMutation(ordinal));
|
|
78
117
|
}
|
|
@@ -87,16 +126,58 @@ export class MutationJournal {
|
|
|
87
126
|
}
|
|
88
127
|
|
|
89
128
|
private async advanceMutation(ordinal: number, state: MutationState): Promise<MutationRecord> {
|
|
129
|
+
const [record] = await this.advanceManyMutation(ordinal, [state]);
|
|
130
|
+
return record!;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private advanceManyMutation(
|
|
134
|
+
ordinal: number,
|
|
135
|
+
states: readonly MutationState[],
|
|
136
|
+
): Promise<readonly MutationRecord[]> {
|
|
137
|
+
return this.advanceBatchMutation([{ ordinal, states }]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async advanceBatchMutation(
|
|
141
|
+
advances: readonly MutationAdvance[],
|
|
142
|
+
): Promise<readonly MutationRecord[]> {
|
|
143
|
+
if (advances.length === 0 || advances.some((advance) => advance.states.length === 0)) {
|
|
144
|
+
throw new Error("mutation 批量状态不能为空");
|
|
145
|
+
}
|
|
90
146
|
const current = await this.readRecords();
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
147
|
+
const latest = [...current.latest];
|
|
148
|
+
let tail = current.tail;
|
|
149
|
+
const records: MutationRecord[] = [];
|
|
150
|
+
for (const advance of advances) {
|
|
151
|
+
for (const state of advance.states) {
|
|
152
|
+
const previous = latest[advance.ordinal - 1];
|
|
153
|
+
if (previous === undefined || stateOrder.indexOf(state) !== stateOrder.indexOf(previous.state) + 1) {
|
|
154
|
+
throw new Error(`mutation state 必须严格推进:${previous?.state ?? "missing"} -> ${state}`);
|
|
155
|
+
}
|
|
156
|
+
const content = {
|
|
157
|
+
schemaVersion: previous.schemaVersion,
|
|
158
|
+
opId: previous.opId,
|
|
159
|
+
ordinal: previous.ordinal,
|
|
160
|
+
state,
|
|
161
|
+
kind: previous.kind,
|
|
162
|
+
path: previous.path,
|
|
163
|
+
sourceArtifact: previous.sourceArtifact,
|
|
164
|
+
targetArtifact: previous.targetArtifact,
|
|
165
|
+
sourceFingerprint: previous.sourceFingerprint,
|
|
166
|
+
targetFingerprint: previous.targetFingerprint,
|
|
167
|
+
previousChecksum: tail?.checksum ?? null,
|
|
168
|
+
};
|
|
169
|
+
const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
|
|
170
|
+
records.push(record);
|
|
171
|
+
latest[advance.ordinal - 1] = record;
|
|
172
|
+
tail = record;
|
|
173
|
+
}
|
|
94
174
|
}
|
|
95
|
-
|
|
175
|
+
await this.append(records, current);
|
|
176
|
+
return records;
|
|
96
177
|
}
|
|
97
178
|
|
|
98
179
|
private async appendState(
|
|
99
|
-
current:
|
|
180
|
+
current: JournalRecords,
|
|
100
181
|
previous: MutationRecord,
|
|
101
182
|
state: MutationState,
|
|
102
183
|
): Promise<MutationRecord> {
|
|
@@ -114,7 +195,7 @@ export class MutationJournal {
|
|
|
114
195
|
previousChecksum: current.tail?.checksum ?? null,
|
|
115
196
|
};
|
|
116
197
|
const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
|
|
117
|
-
await this.append(record, current
|
|
198
|
+
await this.append([record], current);
|
|
118
199
|
return record;
|
|
119
200
|
}
|
|
120
201
|
|
|
@@ -140,21 +221,26 @@ export class MutationJournal {
|
|
|
140
221
|
}
|
|
141
222
|
}
|
|
142
223
|
|
|
143
|
-
private async readRecords(): Promise<{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}> {
|
|
224
|
+
private async readRecords(): Promise<JournalRecords> {
|
|
225
|
+
const before = await journalFileFingerprint(this.path);
|
|
226
|
+
if (before !== null && this.cachedRecords?.fingerprint === before) {
|
|
227
|
+
return this.cachedRecords.records;
|
|
228
|
+
}
|
|
149
229
|
let bytes: Buffer;
|
|
150
230
|
try {
|
|
151
231
|
bytes = await readFile(this.path);
|
|
152
232
|
} catch (error) {
|
|
153
233
|
if (hasErrorCode(error, "ENOENT")) {
|
|
154
|
-
|
|
234
|
+
const records = emptyJournalRecords();
|
|
235
|
+
this.cachedRecords = undefined;
|
|
236
|
+
return records;
|
|
155
237
|
}
|
|
156
238
|
throw error;
|
|
157
239
|
}
|
|
240
|
+
const after = await journalFileFingerprint(this.path);
|
|
241
|
+
if (before === null || after === null || before !== after) {
|
|
242
|
+
throw new Error("mutation journal 读取期间发生变化");
|
|
243
|
+
}
|
|
158
244
|
|
|
159
245
|
const durableEnd = bytes.at(-1) === 0x0a ? bytes.length : bytes.lastIndexOf(0x0a) + 1;
|
|
160
246
|
const durable = bytes.subarray(0, durableEnd).toString("utf8");
|
|
@@ -163,7 +249,7 @@ export class MutationJournal {
|
|
|
163
249
|
let tail: MutationRecord | undefined;
|
|
164
250
|
|
|
165
251
|
for (const line of lines) {
|
|
166
|
-
const record = assertMutationRecord(JSON.parse(line));
|
|
252
|
+
const record = Object.freeze(assertMutationRecord(JSON.parse(line)));
|
|
167
253
|
if (record.opId !== this.opId) throw new Error("mutation record opId 与 journal 不匹配");
|
|
168
254
|
if (record.previousChecksum !== (tail?.checksum ?? null)) {
|
|
169
255
|
throw new Error("mutation journal hash chain 断裂");
|
|
@@ -185,24 +271,78 @@ export class MutationJournal {
|
|
|
185
271
|
tail = record;
|
|
186
272
|
}
|
|
187
273
|
|
|
188
|
-
|
|
274
|
+
const records: JournalRecords = {
|
|
275
|
+
latest: Object.freeze(latest),
|
|
276
|
+
tail,
|
|
277
|
+
durableEnd,
|
|
278
|
+
hasNonDurableTail: durableEnd !== bytes.length,
|
|
279
|
+
fileExisted: true,
|
|
280
|
+
};
|
|
281
|
+
this.cachedRecords = { records, fingerprint: after };
|
|
282
|
+
return records;
|
|
189
283
|
}
|
|
190
284
|
|
|
191
|
-
private async append(
|
|
285
|
+
private async append(records: readonly MutationRecord[], current: JournalRecords): Promise<void> {
|
|
192
286
|
const directory = dirname(this.path);
|
|
287
|
+
const lines = records.map((record) => `${canonicalJson(record)}\n`).join("");
|
|
193
288
|
const handle = await open(this.path, "a+", 0o600);
|
|
194
289
|
try {
|
|
195
|
-
if (hasNonDurableTail) {
|
|
196
|
-
await handle.truncate(durableEnd);
|
|
290
|
+
if (current.hasNonDurableTail) {
|
|
291
|
+
await handle.truncate(current.durableEnd);
|
|
197
292
|
await handle.sync();
|
|
198
|
-
await fsyncDirectory(directory);
|
|
199
293
|
}
|
|
200
|
-
await handle.writeFile(
|
|
294
|
+
await handle.writeFile(lines);
|
|
201
295
|
await handle.sync();
|
|
202
296
|
} finally {
|
|
203
297
|
await handle.close();
|
|
204
298
|
}
|
|
205
|
-
await fsyncDirectory(directory);
|
|
299
|
+
if (!current.fileExisted) await fsyncDirectory(directory);
|
|
300
|
+
const fingerprint = await journalFileFingerprint(this.path);
|
|
301
|
+
if (fingerprint === null) throw new Error("mutation journal append 后丢失");
|
|
302
|
+
const latest = [...current.latest];
|
|
303
|
+
let tail = current.tail;
|
|
304
|
+
for (const record of records) {
|
|
305
|
+
const durableRecord = Object.freeze({ ...record });
|
|
306
|
+
latest[record.ordinal - 1] = durableRecord;
|
|
307
|
+
tail = durableRecord;
|
|
308
|
+
}
|
|
309
|
+
this.cachedRecords = {
|
|
310
|
+
records: {
|
|
311
|
+
latest: Object.freeze(latest),
|
|
312
|
+
tail,
|
|
313
|
+
durableEnd: current.durableEnd + Buffer.byteLength(lines),
|
|
314
|
+
hasNonDurableTail: false,
|
|
315
|
+
fileExisted: true,
|
|
316
|
+
},
|
|
317
|
+
fingerprint,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function emptyJournalRecords(): JournalRecords {
|
|
323
|
+
return {
|
|
324
|
+
latest: Object.freeze([]),
|
|
325
|
+
tail: undefined,
|
|
326
|
+
durableEnd: 0,
|
|
327
|
+
hasNonDurableTail: false,
|
|
328
|
+
fileExisted: false,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function journalFileFingerprint(path: string): Promise<string | null> {
|
|
333
|
+
try {
|
|
334
|
+
const metadata = await stat(path, { bigint: true });
|
|
335
|
+
return [
|
|
336
|
+
metadata.dev,
|
|
337
|
+
metadata.ino,
|
|
338
|
+
metadata.mode,
|
|
339
|
+
metadata.size,
|
|
340
|
+
metadata.mtimeNs,
|
|
341
|
+
metadata.ctimeNs,
|
|
342
|
+
].join(":");
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (hasErrorCode(error, "ENOENT")) return null;
|
|
345
|
+
throw error;
|
|
206
346
|
}
|
|
207
347
|
}
|
|
208
348
|
|
package/src/pi-runtime.ts
CHANGED
|
@@ -38,12 +38,12 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
38
38
|
const workspaceLock = new WorkspaceLock();
|
|
39
39
|
let commandContext: ExtensionCommandContext | undefined;
|
|
40
40
|
let internalNavigation = false;
|
|
41
|
-
const capture = async () => {
|
|
41
|
+
const capture = async (scopePaths?: readonly string[]) => {
|
|
42
42
|
const topology = await discovery.discover(context.cwd);
|
|
43
43
|
if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
|
|
44
44
|
throw new Error("workspace identity 已变化");
|
|
45
45
|
}
|
|
46
|
-
return store.capture(topology);
|
|
46
|
+
return store.capture(topology, scopePaths);
|
|
47
47
|
};
|
|
48
48
|
const recovery = new JournalRecovery({
|
|
49
49
|
sessionIdentity,
|
|
@@ -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
|
-
|
|
63
|
-
const
|
|
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") {
|