@davideasden/pi-undo 0.1.0
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/LICENSE +21 -0
- package/README.md +84 -0
- package/extensions/pi-undo.ts +133 -0
- package/package.json +54 -0
- package/src/atomic-fs.ts +156 -0
- package/src/controller.ts +598 -0
- package/src/encoding.ts +620 -0
- package/src/git-runner.ts +308 -0
- package/src/journal.ts +297 -0
- package/src/model.ts +160 -0
- package/src/mutation-journal.ts +229 -0
- package/src/path-safety.ts +121 -0
- package/src/pi-runtime.ts +415 -0
- package/src/quarantine.ts +591 -0
- package/src/recovery.ts +143 -0
- package/src/restore-engine.ts +1184 -0
- package/src/root-discovery.ts +388 -0
- package/src/session-state.ts +448 -0
- package/src/snapshot-store.ts +1279 -0
- package/src/status-reporter.ts +80 -0
- package/src/workspace-lock.ts +407 -0
package/src/recovery.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { CursorMarkerInspection, PendingJournal } from "./journal.ts";
|
|
4
|
+
import type { JournalPhase, ManifestId, SessionFileIdentity, SnapshotManifest } from "./model.ts";
|
|
5
|
+
import type { RestorePlan, RestoreResult } from "./restore-engine.ts";
|
|
6
|
+
|
|
7
|
+
export interface JournalRecoveryDependencies {
|
|
8
|
+
readonly sessionIdentity: SessionFileIdentity;
|
|
9
|
+
readonly workspaceIdentity: string;
|
|
10
|
+
readonly getLogicalLeafId: () => string | null;
|
|
11
|
+
readonly loadPending: () => Promise<readonly PendingJournal[]>;
|
|
12
|
+
readonly inspectCursor: (journal: PendingJournal) => Promise<CursorMarkerInspection>;
|
|
13
|
+
readonly finalizeCursor: (journal: PendingJournal, inspection: Extract<CursorMarkerInspection, { kind: "match" }>) => Promise<void>;
|
|
14
|
+
readonly recoverMutations: (
|
|
15
|
+
journal: PendingJournal,
|
|
16
|
+
decision: "rollback" | "roll_forward",
|
|
17
|
+
) => Promise<{ readonly kind: "clean" } | { readonly kind: "conflict"; readonly paths: number }>;
|
|
18
|
+
readonly capture: () => Promise<SnapshotManifest>;
|
|
19
|
+
readonly loadManifest: (id: ManifestId) => Promise<SnapshotManifest>;
|
|
20
|
+
readonly planRestore: (
|
|
21
|
+
current: SnapshotManifest,
|
|
22
|
+
target: SnapshotManifest,
|
|
23
|
+
scopePaths?: readonly string[],
|
|
24
|
+
) => Promise<RestorePlan>;
|
|
25
|
+
readonly applyRestore: (
|
|
26
|
+
plan: RestorePlan,
|
|
27
|
+
target: SnapshotManifest,
|
|
28
|
+
operation: { readonly opId: string },
|
|
29
|
+
) => Promise<RestoreResult>;
|
|
30
|
+
readonly settle: (opId: string, phase: Extract<JournalPhase, "COMMITTED" | "ABORTED">) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type JournalRecoveryResult =
|
|
34
|
+
| { readonly kind: "clean"; readonly operations: 0 }
|
|
35
|
+
| { readonly kind: "recovered"; readonly operations: number }
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: "locked";
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
readonly operations: number;
|
|
40
|
+
readonly files?: number;
|
|
41
|
+
readonly opId?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 根据 durable cursor marker 决定向前补完或回滚。
|
|
46
|
+
* 恢复操作是 set-state 且可重复执行;任何身份或 leaf 证据不一致都 fail closed。
|
|
47
|
+
*/
|
|
48
|
+
export class JournalRecovery {
|
|
49
|
+
private readonly dependencies: JournalRecoveryDependencies;
|
|
50
|
+
|
|
51
|
+
constructor(dependencies: JournalRecoveryDependencies) {
|
|
52
|
+
this.dependencies = dependencies;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async recover(): Promise<JournalRecoveryResult> {
|
|
56
|
+
let recovered = 0;
|
|
57
|
+
let pending: readonly PendingJournal[];
|
|
58
|
+
try {
|
|
59
|
+
pending = await this.dependencies.loadPending();
|
|
60
|
+
} catch {
|
|
61
|
+
return { kind: "locked", reason: "journal_invalid", operations: recovered };
|
|
62
|
+
}
|
|
63
|
+
if (pending.length === 0) return { kind: "clean", operations: 0 };
|
|
64
|
+
|
|
65
|
+
for (const journal of pending) {
|
|
66
|
+
const identityError = this.identityError(journal);
|
|
67
|
+
if (identityError !== null) {
|
|
68
|
+
return { kind: "locked", reason: identityError, operations: recovered };
|
|
69
|
+
}
|
|
70
|
+
let inspection: CursorMarkerInspection;
|
|
71
|
+
try {
|
|
72
|
+
inspection = await this.dependencies.inspectCursor(journal);
|
|
73
|
+
} catch {
|
|
74
|
+
return { kind: "locked", reason: "cursor_inspection_failed", operations: recovered };
|
|
75
|
+
}
|
|
76
|
+
if (inspection.kind === "conflict") {
|
|
77
|
+
return { kind: "locked", reason: "cursor_conflict", operations: recovered };
|
|
78
|
+
}
|
|
79
|
+
const expectedLeaf = inspection.kind === "match"
|
|
80
|
+
? journal.descriptor.toLogicalLeaf
|
|
81
|
+
: journal.descriptor.fromLogicalLeaf;
|
|
82
|
+
if (this.dependencies.getLogicalLeafId() !== expectedLeaf) {
|
|
83
|
+
return { kind: "locked", reason: "session_leaf_mismatch", operations: recovered };
|
|
84
|
+
}
|
|
85
|
+
const mutationDecision = inspection.kind === "match" ? "roll_forward" : "rollback";
|
|
86
|
+
let mutationResult: Awaited<ReturnType<JournalRecoveryDependencies["recoverMutations"]>>;
|
|
87
|
+
try {
|
|
88
|
+
mutationResult = await this.dependencies.recoverMutations(journal, mutationDecision);
|
|
89
|
+
} catch {
|
|
90
|
+
return { kind: "locked", reason: "mutation_recovery_failed", operations: recovered };
|
|
91
|
+
}
|
|
92
|
+
if (mutationResult.kind === "conflict") {
|
|
93
|
+
return {
|
|
94
|
+
kind: "locked",
|
|
95
|
+
reason: "mutation_conflict",
|
|
96
|
+
operations: recovered,
|
|
97
|
+
files: mutationResult.paths,
|
|
98
|
+
opId: journal.descriptor.opId,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
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 };
|
|
116
|
+
}
|
|
117
|
+
if (inspection.kind === "match") {
|
|
118
|
+
await this.dependencies.finalizeCursor(journal, inspection);
|
|
119
|
+
}
|
|
120
|
+
await this.dependencies.settle(
|
|
121
|
+
journal.descriptor.opId,
|
|
122
|
+
inspection.kind === "match" ? "COMMITTED" : "ABORTED",
|
|
123
|
+
);
|
|
124
|
+
recovered += 1;
|
|
125
|
+
} catch {
|
|
126
|
+
return { kind: "locked", reason: "recovery_failed", operations: recovered };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { kind: "recovered", operations: recovered };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private identityError(journal: PendingJournal): string | null {
|
|
133
|
+
if (journal.descriptor.workspaceIdentity !== this.dependencies.workspaceIdentity) {
|
|
134
|
+
return "workspace_identity_mismatch";
|
|
135
|
+
}
|
|
136
|
+
const observed = journal.descriptor.sessionIdentity;
|
|
137
|
+
const expected = this.dependencies.sessionIdentity;
|
|
138
|
+
if (resolve(observed.path) !== resolve(expected.path) || observed.headerChecksum !== expected.headerChecksum) {
|
|
139
|
+
return "session_identity_mismatch";
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|