@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
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { canonicalJson, checksum } from "./encoding.ts";
|
|
4
|
+
import type {
|
|
5
|
+
CheckpointRecord,
|
|
6
|
+
CursorState,
|
|
7
|
+
ManifestId,
|
|
8
|
+
OperationDescriptor,
|
|
9
|
+
ResultCode,
|
|
10
|
+
SessionFileIdentity,
|
|
11
|
+
SnapshotManifest,
|
|
12
|
+
} from "./model.ts";
|
|
13
|
+
import type { RestorePlan, RestoreResult } from "./restore-engine.ts";
|
|
14
|
+
|
|
15
|
+
/** 控制器所需的最小运行时适配层;Pi 绑定在 extension 中完成。 */
|
|
16
|
+
export interface ControllerDependencies {
|
|
17
|
+
readonly workspaceIdentity: string;
|
|
18
|
+
readonly sessionIdentity: SessionFileIdentity;
|
|
19
|
+
readonly isAgentIdle: () => boolean;
|
|
20
|
+
readonly abortAgent: () => Promise<void>;
|
|
21
|
+
readonly waitForIdle: (deadlineMs: number) => Promise<boolean>;
|
|
22
|
+
readonly getLogicalLeafId: () => string | null;
|
|
23
|
+
readonly acquireWorkspaceLock: () => Promise<{ release(): Promise<void> }>;
|
|
24
|
+
readonly findUserEntryAfter: (startEntryId: string) => string | null;
|
|
25
|
+
readonly resolveSessionTarget: (action: "undo" | "redo", checkpoint: CheckpointRecord) => string | null;
|
|
26
|
+
readonly navigateSession: (action: "undo" | "redo", checkpoint: CheckpointRecord) => Promise<{
|
|
27
|
+
readonly cancelled: boolean;
|
|
28
|
+
readonly logicalLeafId: string | null;
|
|
29
|
+
}>;
|
|
30
|
+
readonly restoreSessionLeaf: (logicalLeafId: string | null) => Promise<boolean>;
|
|
31
|
+
readonly resolveTreeTarget: (targetEntryId: string | null) => Promise<{
|
|
32
|
+
readonly logicalLeafId: string | null;
|
|
33
|
+
readonly targetManifestId: ManifestId;
|
|
34
|
+
readonly undoStack: readonly CheckpointRecord[];
|
|
35
|
+
}>;
|
|
36
|
+
readonly appendControl: (customType: string, data?: unknown) => Promise<string | null>;
|
|
37
|
+
readonly appendCursor: (cursor: CursorState) => Promise<CursorAppendResult>;
|
|
38
|
+
readonly capture: () => Promise<SnapshotManifest>;
|
|
39
|
+
readonly changedPaths: (before: SnapshotManifest, after: SnapshotManifest) => Promise<readonly string[]>;
|
|
40
|
+
readonly loadManifest: (id: ManifestId) => Promise<SnapshotManifest>;
|
|
41
|
+
readonly planRestore: (
|
|
42
|
+
current: SnapshotManifest,
|
|
43
|
+
target: SnapshotManifest,
|
|
44
|
+
scopePaths?: readonly string[],
|
|
45
|
+
) => Promise<RestorePlan>;
|
|
46
|
+
readonly applyRestore: (
|
|
47
|
+
plan: RestorePlan,
|
|
48
|
+
target: SnapshotManifest,
|
|
49
|
+
operation: { readonly opId: string },
|
|
50
|
+
) => Promise<RestoreResult>;
|
|
51
|
+
readonly recoverPending: () => Promise<{
|
|
52
|
+
readonly kind: "clean" | "recovered" | "locked";
|
|
53
|
+
readonly operations: number;
|
|
54
|
+
readonly reason?: string;
|
|
55
|
+
}>;
|
|
56
|
+
readonly journal: JournalPort;
|
|
57
|
+
readonly clock: () => number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface JournalPort {
|
|
61
|
+
prepare(descriptor: OperationDescriptor, plan: unknown): Promise<void>;
|
|
62
|
+
setPhase(
|
|
63
|
+
opId: string,
|
|
64
|
+
phase: "SESSION_MOVED" | "APPLYING" | "FILES_VERIFIED" | "CURSOR_COMMITTED" | "ABORTING" | "ABORTED" | "RECOVERY_REQUIRED",
|
|
65
|
+
options?: { readonly observedLogicalLeaf?: string | null },
|
|
66
|
+
): Promise<void>;
|
|
67
|
+
markCommitted(opId: string): Promise<void>;
|
|
68
|
+
loadPending(): Promise<readonly unknown[]>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type CursorAppendResult =
|
|
72
|
+
| { readonly kind: "durable"; readonly logicalLeafId: string | null }
|
|
73
|
+
| { readonly kind: "volatile"; readonly reason: string }
|
|
74
|
+
| { readonly kind: "recovery_required"; readonly reason: string };
|
|
75
|
+
|
|
76
|
+
export interface OperationResult {
|
|
77
|
+
readonly code: ResultCode;
|
|
78
|
+
readonly changedFiles: number;
|
|
79
|
+
readonly message?: string;
|
|
80
|
+
readonly refillPrompt?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface InputEventResult {
|
|
84
|
+
readonly action: "continue" | "handled";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface InputContext {
|
|
88
|
+
readonly streaming: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SessionBeforeTreeEvent {
|
|
92
|
+
readonly targetLeafId: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SessionBeforeTreeResult {
|
|
96
|
+
readonly cancel: true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SessionTreeEvent {
|
|
100
|
+
readonly newLeafId: string | null;
|
|
101
|
+
readonly navigationTargetLeafId?: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface HistoryState {
|
|
105
|
+
readonly undoCount: number;
|
|
106
|
+
readonly redoCount: number;
|
|
107
|
+
readonly locked: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface UndoController {
|
|
111
|
+
prepareInput(text: string, context: InputContext): Promise<InputEventResult>;
|
|
112
|
+
beforeAgentStart(): Promise<void>;
|
|
113
|
+
agentSettled(): Promise<void>;
|
|
114
|
+
undo(): Promise<OperationResult>;
|
|
115
|
+
redo(): Promise<OperationResult>;
|
|
116
|
+
beforeTree(event: SessionBeforeTreeEvent): Promise<SessionBeforeTreeResult | undefined>;
|
|
117
|
+
afterTree(event: SessionTreeEvent): Promise<void>;
|
|
118
|
+
cancelTree?(): Promise<void>;
|
|
119
|
+
recover(): Promise<void>;
|
|
120
|
+
history(): HistoryState;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface StagedRun {
|
|
124
|
+
readonly rawPrompt: string;
|
|
125
|
+
readonly before: SnapshotManifest;
|
|
126
|
+
readonly sourceLogicalLeaf: string | null;
|
|
127
|
+
startEntryId?: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ControllerRedoEntry {
|
|
131
|
+
readonly checkpoint: CheckpointRecord;
|
|
132
|
+
readonly targetManifestId: ManifestId;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ControllerInitialState {
|
|
136
|
+
readonly undoStack?: readonly CheckpointRecord[];
|
|
137
|
+
readonly redoStack?: readonly ControllerRedoEntry[];
|
|
138
|
+
readonly historyPaused?: boolean;
|
|
139
|
+
readonly locked?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface PendingTree {
|
|
143
|
+
readonly descriptor: OperationDescriptor;
|
|
144
|
+
readonly rollback: SnapshotManifest;
|
|
145
|
+
readonly target: SnapshotManifest;
|
|
146
|
+
readonly plan: RestorePlan;
|
|
147
|
+
readonly undoStack: readonly CheckpointRecord[];
|
|
148
|
+
readonly lease: { release(): Promise<void> };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 将 Pi 生命周期事件转换为可恢复的文件系统事务。
|
|
153
|
+
* 此类不直接调用 Pi API,便于单测和避免 session runtime 替换后的陈旧引用。
|
|
154
|
+
*/
|
|
155
|
+
export class UndoControllerImpl implements UndoController {
|
|
156
|
+
private readonly dependencies: ControllerDependencies;
|
|
157
|
+
private readonly undoStack: CheckpointRecord[] = [];
|
|
158
|
+
private readonly redoStack: ControllerRedoEntry[] = [];
|
|
159
|
+
private staged: StagedRun | undefined;
|
|
160
|
+
private pendingTree: PendingTree | undefined;
|
|
161
|
+
private locked = false;
|
|
162
|
+
private historyPaused = false;
|
|
163
|
+
private operationInFlight = false;
|
|
164
|
+
private lastSafetyManifestId: ManifestId | null = null;
|
|
165
|
+
|
|
166
|
+
constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
|
|
167
|
+
this.dependencies = dependencies;
|
|
168
|
+
this.undoStack.push(...(initialState.undoStack ?? []));
|
|
169
|
+
this.redoStack.push(...(initialState.redoStack ?? []));
|
|
170
|
+
this.historyPaused = initialState.historyPaused ?? false;
|
|
171
|
+
this.locked = initialState.locked ?? false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
history(): HistoryState {
|
|
175
|
+
return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async prepareInput(text: string, context: InputContext): Promise<InputEventResult> {
|
|
179
|
+
if (this.locked || this.operationInFlight) return { action: "handled" };
|
|
180
|
+
if (context.streaming || text.length === 0) return { action: "continue" };
|
|
181
|
+
try {
|
|
182
|
+
const before = await this.captureWithWorkspaceLock();
|
|
183
|
+
this.historyPaused = false;
|
|
184
|
+
this.staged = { rawPrompt: text, before, sourceLogicalLeaf: this.dependencies.getLogicalLeafId() };
|
|
185
|
+
return { action: "continue" };
|
|
186
|
+
} catch {
|
|
187
|
+
// 无法证明输入前状态时保留编辑器输入;本轮尚未开始,可由用户直接重试。
|
|
188
|
+
return { action: "handled" };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async beforeAgentStart(): Promise<void> {
|
|
193
|
+
if (this.locked || this.staged === undefined) return;
|
|
194
|
+
try {
|
|
195
|
+
this.staged.startEntryId = await this.dependencies.appendControl("pi-undo:start", {
|
|
196
|
+
schemaVersion: 1,
|
|
197
|
+
beforeManifestId: this.staged.before.manifestId,
|
|
198
|
+
sourceLogicalLeaf: this.staged.sourceLogicalLeaf,
|
|
199
|
+
});
|
|
200
|
+
if (this.staged.startEntryId === null) {
|
|
201
|
+
this.locked = true;
|
|
202
|
+
this.staged = undefined;
|
|
203
|
+
await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
this.locked = true;
|
|
208
|
+
this.staged = undefined;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// 只有实际开始一个新 run 才会令 redo 分支失效;未启动的输入不会改变历史。
|
|
212
|
+
this.redoStack.length = 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async agentSettled(): Promise<void> {
|
|
216
|
+
const staged = this.staged;
|
|
217
|
+
this.staged = undefined;
|
|
218
|
+
if (this.locked || staged === undefined) return;
|
|
219
|
+
if (staged.startEntryId === undefined || staged.startEntryId === null) {
|
|
220
|
+
// Pi 没有提供已落盘的 start entry ID,不能把后续 assistant 输出归属到该 checkpoint。
|
|
221
|
+
this.locked = true;
|
|
222
|
+
await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const userEntryId = this.dependencies.findUserEntryAfter(staged.startEntryId);
|
|
226
|
+
if (userEntryId === null) {
|
|
227
|
+
this.locked = true;
|
|
228
|
+
await this.dependencies.appendControl("pi-undo:barrier", { reason: "user_entry_missing" }).catch(() => {});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const after = await this.captureWithWorkspaceLock();
|
|
233
|
+
const changedPaths = await this.dependencies.changedPaths(staged.before, after);
|
|
234
|
+
const endLeafId = this.dependencies.getLogicalLeafId() ?? staged.startEntryId;
|
|
235
|
+
const checkpoint = this.createCheckpoint(staged, after, changedPaths, userEntryId, endLeafId);
|
|
236
|
+
const checkpointEntryId = await this.dependencies.appendControl("pi-undo:checkpoint", checkpoint);
|
|
237
|
+
if (checkpointEntryId === null) {
|
|
238
|
+
this.locked = true;
|
|
239
|
+
await this.dependencies.appendControl("pi-undo:barrier", { reason: "checkpoint_entry_missing" }).catch(() => {});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.undoStack.push(checkpoint);
|
|
243
|
+
} catch {
|
|
244
|
+
this.historyPaused = true;
|
|
245
|
+
this.undoStack.length = 0;
|
|
246
|
+
this.redoStack.length = 0;
|
|
247
|
+
await this.dependencies.appendControl("pi-undo:barrier", { reason: "settled_capture_failed" }).catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async undo(): Promise<OperationResult> {
|
|
252
|
+
if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
|
|
253
|
+
const checkpoint = this.undoStack.at(-1);
|
|
254
|
+
if (checkpoint === undefined) return noop();
|
|
255
|
+
const result = await this.runOperation("undo", checkpoint);
|
|
256
|
+
if (result.code === "ok" && this.lastSafetyManifestId !== null) {
|
|
257
|
+
this.undoStack.pop();
|
|
258
|
+
this.redoStack.push({ checkpoint, targetManifestId: this.lastSafetyManifestId });
|
|
259
|
+
return { ...result, refillPrompt: checkpoint.rawPrompt };
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async redo(): Promise<OperationResult> {
|
|
265
|
+
if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
|
|
266
|
+
const redo = this.redoStack.at(-1);
|
|
267
|
+
if (redo === undefined) return noop();
|
|
268
|
+
const result = await this.runOperation("redo", redo.checkpoint, redo.targetManifestId);
|
|
269
|
+
if (result.code === "ok") {
|
|
270
|
+
this.redoStack.pop();
|
|
271
|
+
this.undoStack.push(redo.checkpoint);
|
|
272
|
+
}
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async beforeTree(event: SessionBeforeTreeEvent): Promise<SessionBeforeTreeResult | undefined> {
|
|
277
|
+
if (!this.dependencies.isAgentIdle()) {
|
|
278
|
+
await this.dependencies.abortAgent();
|
|
279
|
+
return { cancel: true };
|
|
280
|
+
}
|
|
281
|
+
if (this.locked || this.historyPaused || this.operationInFlight) return { cancel: true };
|
|
282
|
+
this.operationInFlight = true;
|
|
283
|
+
let lease: { release(): Promise<void> } | undefined;
|
|
284
|
+
try {
|
|
285
|
+
lease = await this.dependencies.acquireWorkspaceLock();
|
|
286
|
+
const rollback = await this.dependencies.capture();
|
|
287
|
+
const targetState = await this.dependencies.resolveTreeTarget(event.targetLeafId);
|
|
288
|
+
const target = await this.dependencies.loadManifest(targetState.targetManifestId);
|
|
289
|
+
const plan = await this.dependencies.planRestore(rollback, target);
|
|
290
|
+
const descriptor = this.createDescriptor("tree", rollback, target, plan, targetState.logicalLeafId);
|
|
291
|
+
await this.dependencies.journal.prepare(descriptor, plan);
|
|
292
|
+
this.pendingTree = { descriptor, rollback, target, plan, undoStack: targetState.undoStack, lease };
|
|
293
|
+
return undefined;
|
|
294
|
+
} catch {
|
|
295
|
+
if (lease !== undefined) {
|
|
296
|
+
await lease.release().catch(() => { this.locked = true; });
|
|
297
|
+
}
|
|
298
|
+
this.operationInFlight = false;
|
|
299
|
+
return { cancel: true };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async afterTree(event: SessionTreeEvent): Promise<void> {
|
|
304
|
+
const pending = this.pendingTree;
|
|
305
|
+
if (pending === undefined) return;
|
|
306
|
+
this.pendingTree = undefined;
|
|
307
|
+
try {
|
|
308
|
+
if ((event.navigationTargetLeafId ?? event.newLeafId) !== pending.descriptor.toLogicalLeaf) {
|
|
309
|
+
this.locked = true;
|
|
310
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "SESSION_MOVED", {
|
|
314
|
+
observedLogicalLeaf: event.newLeafId,
|
|
315
|
+
});
|
|
316
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "APPLYING");
|
|
317
|
+
const applied = await this.dependencies.applyRestore(
|
|
318
|
+
pending.plan,
|
|
319
|
+
pending.target,
|
|
320
|
+
{ opId: pending.descriptor.opId },
|
|
321
|
+
);
|
|
322
|
+
if (applied.code !== "ok") {
|
|
323
|
+
this.locked = true;
|
|
324
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "FILES_VERIFIED");
|
|
328
|
+
const cursorResult = await this.dependencies.appendCursor(
|
|
329
|
+
this.createTreeCursor(pending.descriptor, event.newLeafId, pending.undoStack),
|
|
330
|
+
);
|
|
331
|
+
if (cursorResult.kind !== "durable") {
|
|
332
|
+
this.locked = true;
|
|
333
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "CURSOR_COMMITTED");
|
|
337
|
+
await this.dependencies.journal.markCommitted(pending.descriptor.opId);
|
|
338
|
+
this.undoStack.splice(0, this.undoStack.length, ...pending.undoStack);
|
|
339
|
+
this.redoStack.length = 0;
|
|
340
|
+
} catch {
|
|
341
|
+
this.locked = true;
|
|
342
|
+
} finally {
|
|
343
|
+
await pending.lease.release().catch(() => { this.locked = true; });
|
|
344
|
+
this.operationInFlight = false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async cancelTree(): Promise<void> {
|
|
349
|
+
const pending = this.pendingTree;
|
|
350
|
+
if (pending === undefined) return;
|
|
351
|
+
this.pendingTree = undefined;
|
|
352
|
+
try {
|
|
353
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTING");
|
|
354
|
+
await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTED");
|
|
355
|
+
} catch {
|
|
356
|
+
this.locked = true;
|
|
357
|
+
} finally {
|
|
358
|
+
await pending.lease.release().catch(() => { this.locked = true; });
|
|
359
|
+
this.operationInFlight = false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async recover(): Promise<void> {
|
|
364
|
+
try {
|
|
365
|
+
const result = await this.dependencies.recoverPending();
|
|
366
|
+
if (result.kind === "locked") this.locked = true;
|
|
367
|
+
} catch {
|
|
368
|
+
this.locked = true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async runOperation(
|
|
373
|
+
action: "undo" | "redo",
|
|
374
|
+
checkpoint: CheckpointRecord,
|
|
375
|
+
targetManifestId?: ManifestId,
|
|
376
|
+
): Promise<OperationResult> {
|
|
377
|
+
if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
|
|
378
|
+
this.operationInFlight = true;
|
|
379
|
+
this.lastSafetyManifestId = null;
|
|
380
|
+
let lease: { release(): Promise<void> } | undefined;
|
|
381
|
+
try {
|
|
382
|
+
if (!await this.ensureIdle()) return { code: "idle_timeout", changedFiles: 0 };
|
|
383
|
+
try {
|
|
384
|
+
lease = await this.dependencies.acquireWorkspaceLock();
|
|
385
|
+
} catch {
|
|
386
|
+
return { code: "busy", changedFiles: 0 };
|
|
387
|
+
}
|
|
388
|
+
let rollback: SnapshotManifest;
|
|
389
|
+
try {
|
|
390
|
+
rollback = await this.dependencies.capture();
|
|
391
|
+
} catch {
|
|
392
|
+
return { code: "capture_failed", changedFiles: 0 };
|
|
393
|
+
}
|
|
394
|
+
let target: SnapshotManifest;
|
|
395
|
+
let plan: RestorePlan;
|
|
396
|
+
let targetLogicalLeaf: string | null;
|
|
397
|
+
try {
|
|
398
|
+
target = await this.dependencies.loadManifest(
|
|
399
|
+
targetManifestId ?? (action === "undo" ? checkpoint.beforeManifestId : checkpoint.afterManifestId),
|
|
400
|
+
);
|
|
401
|
+
plan = await this.dependencies.planRestore(rollback, target, checkpoint.changedPaths);
|
|
402
|
+
targetLogicalLeaf = this.dependencies.resolveSessionTarget(action, checkpoint);
|
|
403
|
+
} catch {
|
|
404
|
+
return { code: "restore_failed_safe", changedFiles: 0 };
|
|
405
|
+
}
|
|
406
|
+
const descriptor = this.createDescriptor(action, rollback, target, plan, targetLogicalLeaf);
|
|
407
|
+
await this.dependencies.journal.prepare(descriptor, plan);
|
|
408
|
+
const navigation = await this.dependencies.navigateSession(action, checkpoint);
|
|
409
|
+
if (navigation.cancelled) {
|
|
410
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
|
|
411
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
|
|
412
|
+
return { code: "restore_failed_safe", changedFiles: 0 };
|
|
413
|
+
}
|
|
414
|
+
if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
|
|
415
|
+
this.locked = true;
|
|
416
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED");
|
|
417
|
+
return { code: "recovery_required", changedFiles: 0 };
|
|
418
|
+
}
|
|
419
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "SESSION_MOVED", {
|
|
420
|
+
observedLogicalLeaf: navigation.logicalLeafId,
|
|
421
|
+
});
|
|
422
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "APPLYING");
|
|
423
|
+
const applied = await this.dependencies.applyRestore(plan, target, { opId: descriptor.opId });
|
|
424
|
+
if (applied.code !== "ok") return this.compensate(descriptor, rollback, target, applied);
|
|
425
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "FILES_VERIFIED");
|
|
426
|
+
const cursor = this.createCursor(descriptor, action, checkpoint);
|
|
427
|
+
const cursorResult = await this.dependencies.appendCursor(cursor);
|
|
428
|
+
if (cursorResult.kind === "recovery_required") {
|
|
429
|
+
this.locked = true;
|
|
430
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
|
|
431
|
+
return { code: "recovery_required", changedFiles: applied.verifiedPaths };
|
|
432
|
+
}
|
|
433
|
+
if (cursorResult.kind === "volatile") {
|
|
434
|
+
return this.compensate(descriptor, rollback, target, {
|
|
435
|
+
code: "recovery_required",
|
|
436
|
+
verifiedPaths: applied.verifiedPaths,
|
|
437
|
+
totalPaths: applied.totalPaths,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "CURSOR_COMMITTED");
|
|
441
|
+
await this.dependencies.journal.markCommitted(descriptor.opId);
|
|
442
|
+
this.lastSafetyManifestId = rollback.manifestId;
|
|
443
|
+
return { code: "ok", changedFiles: applied.verifiedPaths };
|
|
444
|
+
} catch {
|
|
445
|
+
this.locked = true;
|
|
446
|
+
return { code: "recovery_required", changedFiles: 0 };
|
|
447
|
+
} finally {
|
|
448
|
+
if (lease !== undefined) {
|
|
449
|
+
await lease.release().catch(() => { this.locked = true; });
|
|
450
|
+
}
|
|
451
|
+
this.operationInFlight = false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private async ensureIdle(): Promise<boolean> {
|
|
456
|
+
if (this.dependencies.isAgentIdle()) return true;
|
|
457
|
+
try {
|
|
458
|
+
await this.dependencies.abortAgent();
|
|
459
|
+
return await this.dependencies.waitForIdle(this.dependencies.clock() + 30_000);
|
|
460
|
+
} catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async captureWithWorkspaceLock(): Promise<SnapshotManifest> {
|
|
466
|
+
const lease = await this.dependencies.acquireWorkspaceLock();
|
|
467
|
+
try {
|
|
468
|
+
return await this.dependencies.capture();
|
|
469
|
+
} finally {
|
|
470
|
+
await lease.release();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private async compensate(
|
|
475
|
+
descriptor: OperationDescriptor,
|
|
476
|
+
rollback: SnapshotManifest,
|
|
477
|
+
target: SnapshotManifest,
|
|
478
|
+
failure: RestoreResult,
|
|
479
|
+
): Promise<OperationResult> {
|
|
480
|
+
try {
|
|
481
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
|
|
482
|
+
if (!await this.dependencies.restoreSessionLeaf(descriptor.fromLogicalLeaf)) {
|
|
483
|
+
throw new Error("session rollback failed");
|
|
484
|
+
}
|
|
485
|
+
const rollbackPlan = await this.dependencies.planRestore(target, rollback, descriptor.scopePaths);
|
|
486
|
+
const reverted = await this.dependencies.applyRestore(
|
|
487
|
+
rollbackPlan,
|
|
488
|
+
rollback,
|
|
489
|
+
{ opId: descriptor.opId },
|
|
490
|
+
);
|
|
491
|
+
if (reverted.code === "ok") {
|
|
492
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
|
|
493
|
+
return { code: "restore_failed_safe", changedFiles: failure.verifiedPaths };
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
// 下面统一进入 recovery lock。
|
|
497
|
+
}
|
|
498
|
+
this.locked = true;
|
|
499
|
+
await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
|
|
500
|
+
return { code: "recovery_required", changedFiles: failure.verifiedPaths };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private createCheckpoint(
|
|
504
|
+
staged: StagedRun,
|
|
505
|
+
after: SnapshotManifest,
|
|
506
|
+
changedPaths: readonly string[],
|
|
507
|
+
userEntryId: string,
|
|
508
|
+
endLeafId: string,
|
|
509
|
+
): CheckpointRecord {
|
|
510
|
+
const payload = {
|
|
511
|
+
schemaVersion: 1 as const,
|
|
512
|
+
checkpointId: randomUUID(),
|
|
513
|
+
runId: randomUUID(),
|
|
514
|
+
sessionIdentity: this.dependencies.sessionIdentity,
|
|
515
|
+
startEntryId: staged.startEntryId ?? endLeafId,
|
|
516
|
+
userEntryId,
|
|
517
|
+
endLeafId,
|
|
518
|
+
rawPrompt: staged.rawPrompt,
|
|
519
|
+
beforeManifestId: staged.before.manifestId,
|
|
520
|
+
afterManifestId: after.manifestId,
|
|
521
|
+
changedPaths: [...changedPaths].sort(),
|
|
522
|
+
};
|
|
523
|
+
return { ...payload, checksum: checksum(canonicalJson(payload)) };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private createDescriptor(
|
|
527
|
+
action: "undo" | "redo" | "tree",
|
|
528
|
+
rollback: SnapshotManifest,
|
|
529
|
+
target: SnapshotManifest,
|
|
530
|
+
plan: RestorePlan,
|
|
531
|
+
targetLogicalLeaf: string | null,
|
|
532
|
+
): OperationDescriptor {
|
|
533
|
+
const scopePaths = [...plan.deletePaths, ...plan.writePaths].sort();
|
|
534
|
+
const payload = {
|
|
535
|
+
schemaVersion: 1 as const,
|
|
536
|
+
opId: `op-${randomUUID()}`,
|
|
537
|
+
sessionIdentity: this.dependencies.sessionIdentity,
|
|
538
|
+
workspaceIdentity: this.dependencies.workspaceIdentity,
|
|
539
|
+
action,
|
|
540
|
+
fromLogicalLeaf: this.dependencies.getLogicalLeafId(),
|
|
541
|
+
toLogicalLeaf: targetLogicalLeaf,
|
|
542
|
+
targetManifestId: target.manifestId,
|
|
543
|
+
rollbackManifestId: rollback.manifestId,
|
|
544
|
+
coverage: `paths:${checksum(canonicalJson(scopePaths))}`,
|
|
545
|
+
scopePaths,
|
|
546
|
+
planDigest: plan.planDigest,
|
|
547
|
+
};
|
|
548
|
+
return { ...payload, checksum: checksum(canonicalJson(payload)) };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private createCursor(descriptor: OperationDescriptor, action: "undo" | "redo", checkpoint: CheckpointRecord): CursorState {
|
|
552
|
+
const redoStack = action === "undo"
|
|
553
|
+
? [...this.redoStack.map((entry) => entry.checkpoint.checkpointId), checkpoint.checkpointId]
|
|
554
|
+
: this.redoStack.slice(0, -1).map((entry) => entry.checkpoint.checkpointId);
|
|
555
|
+
const undoHead = action === "undo"
|
|
556
|
+
? this.undoStack.at(-2)?.checkpointId ?? null
|
|
557
|
+
: checkpoint.checkpointId;
|
|
558
|
+
const payload = {
|
|
559
|
+
schemaVersion: 1 as const,
|
|
560
|
+
opId: descriptor.opId,
|
|
561
|
+
action,
|
|
562
|
+
sessionIdentity: descriptor.sessionIdentity,
|
|
563
|
+
fromLogicalLeaf: descriptor.fromLogicalLeaf,
|
|
564
|
+
toLogicalLeaf: descriptor.toLogicalLeaf,
|
|
565
|
+
targetManifestId: descriptor.targetManifestId,
|
|
566
|
+
rollbackManifestId: descriptor.rollbackManifestId,
|
|
567
|
+
undoHead,
|
|
568
|
+
redoStack,
|
|
569
|
+
descriptorChecksum: descriptor.checksum,
|
|
570
|
+
};
|
|
571
|
+
return { ...payload, checksum: checksum(canonicalJson(payload)) };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private createTreeCursor(
|
|
575
|
+
descriptor: OperationDescriptor,
|
|
576
|
+
observedLogicalLeaf: string | null,
|
|
577
|
+
undoStack: readonly CheckpointRecord[],
|
|
578
|
+
): CursorState {
|
|
579
|
+
const payload = {
|
|
580
|
+
schemaVersion: 1 as const,
|
|
581
|
+
opId: descriptor.opId,
|
|
582
|
+
action: "tree" as const,
|
|
583
|
+
sessionIdentity: descriptor.sessionIdentity,
|
|
584
|
+
fromLogicalLeaf: descriptor.fromLogicalLeaf,
|
|
585
|
+
toLogicalLeaf: observedLogicalLeaf,
|
|
586
|
+
targetManifestId: descriptor.targetManifestId,
|
|
587
|
+
rollbackManifestId: descriptor.rollbackManifestId,
|
|
588
|
+
undoHead: undoStack.at(-1)?.checkpointId ?? null,
|
|
589
|
+
redoStack: [],
|
|
590
|
+
descriptorChecksum: descriptor.checksum,
|
|
591
|
+
};
|
|
592
|
+
return { ...payload, checksum: checksum(canonicalJson(payload)) };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function noop(): OperationResult {
|
|
597
|
+
return { code: "noop", changedFiles: 0 };
|
|
598
|
+
}
|