@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.
@@ -0,0 +1,415 @@
1
+ import { join, resolve } from "node:path";
2
+
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ ReadonlySessionManager,
8
+ } from "@earendil-works/pi-coding-agent";
9
+
10
+ import {
11
+ UndoControllerImpl,
12
+ type ControllerDependencies,
13
+ type ControllerInitialState,
14
+ } from "./controller.ts";
15
+ import { assertCursor, canonicalJson, checksum } from "./encoding.ts";
16
+ import { JournalStore, finalizeCursorMarker, inspectCursorMarkers } from "./journal.ts";
17
+ import type { CheckpointRecord, ManifestId, SessionFileIdentity } from "./model.ts";
18
+ import { JournalRecovery } from "./recovery.ts";
19
+ import { QuarantineManager } from "./quarantine.ts";
20
+ import { RestoreEngine } from "./restore-engine.ts";
21
+ import { RootDiscovery } from "./root-discovery.ts";
22
+ import { DurableCursorWriter, SessionState, type SessionEntrySource } from "./session-state.ts";
23
+ import { SnapshotStore } from "./snapshot-store.ts";
24
+ import { StatusReporter } from "./status-reporter.ts";
25
+ import { WorkspaceLock } from "./workspace-lock.ts";
26
+
27
+ export async function createPiUndoRuntime(context: ExtensionContext, pi: ExtensionAPI) {
28
+ const manager = context.sessionManager;
29
+ const sessionState = sessionStateFor(manager);
30
+ const sessionIdentity = await sessionState.getSessionIdentity() ?? volatileSessionIdentity(manager, context.cwd);
31
+ const privateRoot = join(manager.getSessionDir(), ".pi-undo");
32
+ const discovery = new RootDiscovery();
33
+ const initialTopology = await discovery.discover(context.cwd);
34
+ const store = new SnapshotStore({ storeRoot: privateRoot, discovery });
35
+ const restore = new RestoreEngine({ workspaceRoot: context.cwd, store, discovery });
36
+ const journal = new JournalStore({ transactionsRoot: join(privateRoot, "transactions") });
37
+ const cursorWriter = new DurableCursorWriter();
38
+ const workspaceLock = new WorkspaceLock();
39
+ let commandContext: ExtensionCommandContext | undefined;
40
+ let internalNavigation = false;
41
+ const capture = async () => {
42
+ const topology = await discovery.discover(context.cwd);
43
+ if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
44
+ throw new Error("workspace identity 已变化");
45
+ }
46
+ return store.capture(topology);
47
+ };
48
+ const recovery = new JournalRecovery({
49
+ sessionIdentity,
50
+ workspaceIdentity: initialTopology.workspaceIdentity,
51
+ getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
52
+ loadPending: () => journal.loadPending(),
53
+ inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor),
54
+ finalizeCursor: (pending, inspection) => finalizeCursorMarker(
55
+ pending.descriptor.sessionIdentity.path,
56
+ pending.descriptor,
57
+ inspection,
58
+ ),
59
+ recoverMutations: async (pending, decision) => {
60
+ const mutationJournal = journal.mutationJournal(pending.descriptor.opId);
61
+ const quarantine = new QuarantineManager({ workspaceRoot: context.cwd, journal: mutationJournal });
62
+ try {
63
+ const records = (await mutationJournal.load()).filter((record) => record.state !== "CLEANED");
64
+ if (decision === "rollback") records.reverse();
65
+ for (const record of records) {
66
+ if (decision === "rollback") {
67
+ await quarantine.restoreMutation(record);
68
+ continue;
69
+ }
70
+ await quarantine.rollForwardMutation(record);
71
+ const latest = (await mutationJournal.load()).find((candidate) => candidate.ordinal === record.ordinal);
72
+ if (latest === undefined) throw new Error("mutation ordinal 在恢复期间丢失");
73
+ await quarantine.cleanupMutation(latest);
74
+ }
75
+ await mutationJournal.assertCleaned();
76
+ return { kind: "clean" } as const;
77
+ } catch {
78
+ const active = await mutationJournal.load().catch(() => []);
79
+ return {
80
+ kind: "conflict" as const,
81
+ paths: Math.max(1, active.filter((record) => record.state !== "CLEANED").length),
82
+ };
83
+ }
84
+ },
85
+ capture,
86
+ loadManifest: (manifestId) => store.loadManifest(manifestId),
87
+ planRestore: (current, target, scopePaths) => restore.plan(current, target, scopePaths),
88
+ applyRestore: (plan, target, operation) => restore.apply(plan, target, {
89
+ opId: operation.opId,
90
+ mutationJournal: journal.mutationJournal(operation.opId),
91
+ }),
92
+ settle: (opId, phase) => journal.settleRecovery(opId, phase),
93
+ });
94
+
95
+ const dependencies: ControllerDependencies = {
96
+ workspaceIdentity: initialTopology.workspaceIdentity,
97
+ sessionIdentity,
98
+ isAgentIdle: () => context.isIdle(),
99
+ abortAgent: async () => { context.abort(); },
100
+ waitForIdle: async (deadlineMs) => waitForIdle(commandContext, deadlineMs),
101
+ getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
102
+ acquireWorkspaceLock: () => workspaceLock.acquire(initialTopology.workspaceIdentity),
103
+ findUserEntryAfter: (startEntryId) => findUserEntryAfter(manager, startEntryId),
104
+ resolveSessionTarget: (action, checkpoint) => action === "undo"
105
+ ? logicalLeafAt(manager, entryParent(manager, checkpoint.userEntryId))
106
+ : checkpoint.endLeafId,
107
+ navigateSession: async (action, checkpoint) => {
108
+ if (commandContext === undefined) throw new Error("command context 不可用");
109
+ const targetId = action === "undo" ? checkpoint.userEntryId : checkpoint.endLeafId;
110
+ internalNavigation = true;
111
+ try {
112
+ const result = await commandContext.navigateTree(targetId, { summarize: false });
113
+ return { cancelled: result.cancelled, logicalLeafId: sessionStateFor(manager).getLogicalLeafId() };
114
+ } finally {
115
+ internalNavigation = false;
116
+ }
117
+ },
118
+ restoreSessionLeaf: async (logicalLeafId) => {
119
+ if (commandContext === undefined || logicalLeafId === null) return false;
120
+ internalNavigation = true;
121
+ try {
122
+ const result = await commandContext.navigateTree(logicalLeafId, { summarize: false });
123
+ return !result.cancelled && sessionStateFor(manager).getLogicalLeafId() === logicalLeafId;
124
+ } finally {
125
+ internalNavigation = false;
126
+ }
127
+ },
128
+ resolveTreeTarget: async (targetEntryId) => resolveTreeTarget(manager, sessionIdentity, targetEntryId),
129
+ appendControl: async (customType, data) => appendControlEntry(pi, manager, customType, data),
130
+ appendCursor: async (cursor) => cursorWriter.appendCursor(cursor, pi, sourceFor(manager)),
131
+ capture,
132
+ changedPaths: async (before, after) => {
133
+ const plan = await restore.plan(before, after);
134
+ return [...new Set([...plan.deletePaths, ...plan.writePaths])].sort();
135
+ },
136
+ loadManifest: (manifestId) => store.loadManifest(manifestId),
137
+ planRestore: (current, target, scopePaths) => restore.plan(current, target, scopePaths),
138
+ applyRestore: (plan, target, operation) => restore.apply(plan, target, {
139
+ opId: operation.opId,
140
+ mutationJournal: journal.mutationJournal(operation.opId),
141
+ }),
142
+ recoverPending: () => workspaceLock.withLock(initialTopology.workspaceIdentity, () => recovery.recover()),
143
+ journal,
144
+ clock: Date.now,
145
+ };
146
+ const startupRecovery = await workspaceLock.withLock(
147
+ initialTopology.workspaceIdentity,
148
+ () => recovery.recover(),
149
+ );
150
+ const controller = new UndoControllerImpl(dependencies, {
151
+ ...rebuildControllerState(manager, sessionIdentity),
152
+ locked: startupRecovery.kind === "locked",
153
+ });
154
+ return {
155
+ controller,
156
+ reporter: new StatusReporter(context),
157
+ recovery: startupRecovery.kind === "locked"
158
+ ? { files: startupRecovery.files, opId: startupRecovery.opId }
159
+ : undefined,
160
+ setCommandContext(next: ExtensionCommandContext | undefined): void {
161
+ commandContext = next;
162
+ },
163
+ isInternalNavigation(): boolean {
164
+ return internalNavigation;
165
+ },
166
+ };
167
+ }
168
+
169
+ function rebuildControllerState(
170
+ manager: ReadonlySessionManager,
171
+ identity: SessionFileIdentity,
172
+ ): ControllerInitialState {
173
+ const state = sessionStateFor(manager);
174
+ const checkpoints = state.getCheckpoints(identity);
175
+ const cursor = state.getCursor(identity);
176
+ let undoStack = [...checkpoints];
177
+ if (cursor !== null) {
178
+ if (cursor.undoHead === null) {
179
+ undoStack = [];
180
+ } else {
181
+ undoStack = checkpointFrontierById(manager, identity, cursor.undoHead);
182
+ }
183
+ }
184
+ const redoStack = cursor === null ? [] : cursor.redoStack.map((checkpointId, index) => {
185
+ const checkpoint = findCheckpointById(manager, identity, checkpointId);
186
+ if (checkpoint === undefined) throw new Error("cursor redo checkpoint 不可信");
187
+ const prefix = cursor.redoStack.slice(0, index + 1);
188
+ const sourceCursor = validCursors(manager, identity)
189
+ .filter((candidate) => candidate.action === "undo" && sameStrings(candidate.redoStack, prefix))
190
+ .at(-1);
191
+ if (sourceCursor === undefined) throw new Error("cursor redo safety manifest 缺失");
192
+ return { checkpoint, targetManifestId: sourceCursor.rollbackManifestId };
193
+ });
194
+ const branch = physicalBranch(manager, manager.getLeafId());
195
+ const lastBarrier = findLastIndex(branch, (entry) => entry.type === "custom" && entry.customType === "pi-undo:barrier");
196
+ const lastCheckpoint = findLastIndex(branch, (entry) => entry.type === "custom" && entry.customType === "pi-undo:checkpoint");
197
+ return { undoStack, redoStack, historyPaused: lastBarrier > lastCheckpoint };
198
+ }
199
+
200
+ function checkpointFrontierById(
201
+ manager: ReadonlySessionManager,
202
+ identity: SessionFileIdentity,
203
+ checkpointId: string,
204
+ ): CheckpointRecord[] {
205
+ for (const entry of manager.getEntries() as unknown[]) {
206
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== "pi-undo:checkpoint" || typeof entry.id !== "string") continue;
207
+ const frontier = sessionStateFor(manager, entry.id).getCheckpoints(identity);
208
+ const index = frontier.findIndex((checkpoint) => checkpoint.checkpointId === checkpointId);
209
+ if (index >= 0) return frontier.slice(0, index + 1);
210
+ }
211
+ throw new Error("cursor undoHead 不在可信 checkpoint branch");
212
+ }
213
+
214
+ async function appendControlEntry(
215
+ pi: ExtensionAPI,
216
+ manager: ReadonlySessionManager,
217
+ customType: string,
218
+ data?: unknown,
219
+ ): Promise<string | null> {
220
+ pi.appendEntry(customType, data);
221
+ const leafId = manager.getLeafId();
222
+ if (leafId === null) return null;
223
+ const entry = manager.getEntry(leafId) as unknown;
224
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== customType) return null;
225
+ if (canonicalJson(entry.data) !== canonicalJson(data ?? null) && entry.data !== data) return null;
226
+ return leafId;
227
+ }
228
+
229
+ function sessionStateFor(manager: ReadonlySessionManager, leafId = manager.getLeafId()): SessionState {
230
+ return new SessionState(sourceFor(manager, leafId));
231
+ }
232
+
233
+ function sourceFor(manager: ReadonlySessionManager, leafId?: string | null): SessionEntrySource {
234
+ return {
235
+ getEntries: () => manager.getEntries(),
236
+ getLeafId: () => leafId === undefined ? manager.getLeafId() : leafId,
237
+ getSessionFile: () => manager.getSessionFile(),
238
+ };
239
+ }
240
+
241
+ function findUserEntryAfter(manager: ReadonlySessionManager, startEntryId: string): string | null {
242
+ const entries = manager.getEntries() as unknown[];
243
+ for (const value of entries) {
244
+ if (!isRecord(value) || value.parentId !== startEntryId || value.type !== "message" || !isRecord(value.message)) continue;
245
+ if (value.message.role === "user" && typeof value.id === "string") return value.id;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ function entryParent(manager: ReadonlySessionManager, entryId: string): string | null {
251
+ const entry = manager.getEntry(entryId) as unknown;
252
+ if (!isRecord(entry) || (entry.parentId !== null && typeof entry.parentId !== "string")) {
253
+ throw new Error("session target entry 无效");
254
+ }
255
+ return entry.parentId;
256
+ }
257
+
258
+ function logicalLeafAt(manager: ReadonlySessionManager, leafId: string | null): string | null {
259
+ return sessionStateFor(manager, leafId).getLogicalLeafId();
260
+ }
261
+
262
+ async function resolveTreeTarget(
263
+ manager: ReadonlySessionManager,
264
+ identity: SessionFileIdentity,
265
+ targetEntryId: string | null,
266
+ ): Promise<{ logicalLeafId: string | null; targetManifestId: ManifestId; undoStack: readonly CheckpointRecord[] }> {
267
+ if (targetEntryId === null) throw new Error("tree target 缺失");
268
+ const target = manager.getEntry(targetEntryId) as unknown;
269
+ if (!isRecord(target)) throw new Error("tree target 不存在");
270
+ const isUser = target.type === "message" && isRecord(target.message) && target.message.role === "user";
271
+ const physicalLeaf = isUser || target.type === "custom_message" ? entryParent(manager, targetEntryId) : targetEntryId;
272
+ const logicalLeafId = logicalLeafAt(manager, physicalLeaf);
273
+ if (isUser) {
274
+ const checkpoint = findCheckpointByUserEntry(manager, identity, targetEntryId);
275
+ if (checkpoint === undefined) throw new Error("tree target 缺少 before checkpoint");
276
+ return {
277
+ logicalLeafId,
278
+ targetManifestId: checkpoint.beforeManifestId,
279
+ undoStack: sessionStateFor(manager, physicalLeaf).getCheckpoints(identity),
280
+ };
281
+ }
282
+ let checkpoints = [...sessionStateFor(manager, physicalLeaf).getCheckpoints(identity)];
283
+ const exact = findCheckpointByEndLeaf(manager, identity, logicalLeafId);
284
+ if (exact !== undefined) checkpoints = checkpointFrontierById(manager, identity, exact.checkpointId);
285
+ const checkpoint = exact ?? checkpoints.at(-1);
286
+ if (checkpoint === undefined) throw new Error("tree target 缺少完整 checkpoint");
287
+ return { logicalLeafId, targetManifestId: checkpoint.afterManifestId, undoStack: checkpoints };
288
+ }
289
+
290
+ function findCheckpointByEndLeaf(
291
+ manager: ReadonlySessionManager,
292
+ identity: SessionFileIdentity,
293
+ endLeafId: string | null,
294
+ ): CheckpointRecord | undefined {
295
+ if (endLeafId === null) return undefined;
296
+ const matches: CheckpointRecord[] = [];
297
+ for (const entry of manager.getEntries() as unknown[]) {
298
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== "pi-undo:checkpoint" || typeof entry.id !== "string") continue;
299
+ const checkpoint = sessionStateFor(manager, entry.id).getCheckpoints(identity)
300
+ .find((candidate) => candidate.endLeafId === endLeafId);
301
+ if (checkpoint !== undefined) matches.push(checkpoint);
302
+ }
303
+ return matches.length === 1 ? matches[0] : undefined;
304
+ }
305
+
306
+ function findCheckpointByUserEntry(
307
+ manager: ReadonlySessionManager,
308
+ identity: SessionFileIdentity,
309
+ userEntryId: string,
310
+ ): CheckpointRecord | undefined {
311
+ for (const entry of manager.getEntries() as unknown[]) {
312
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== "pi-undo:checkpoint" || typeof entry.id !== "string") continue;
313
+ const checkpoint = sessionStateFor(manager, entry.id).getCheckpoints(identity)
314
+ .find((candidate) => candidate.userEntryId === userEntryId);
315
+ if (checkpoint !== undefined) return checkpoint;
316
+ }
317
+ return undefined;
318
+ }
319
+
320
+ function findCheckpointById(
321
+ manager: ReadonlySessionManager,
322
+ identity: SessionFileIdentity,
323
+ checkpointId: string,
324
+ ): CheckpointRecord | undefined {
325
+ for (const entry of manager.getEntries() as unknown[]) {
326
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== "pi-undo:checkpoint" || typeof entry.id !== "string") continue;
327
+ const checkpoint = sessionStateFor(manager, entry.id).getCheckpoints(identity)
328
+ .find((candidate) => candidate.checkpointId === checkpointId);
329
+ if (checkpoint !== undefined) return checkpoint;
330
+ }
331
+ return undefined;
332
+ }
333
+
334
+ function validCursors(manager: ReadonlySessionManager, identity: SessionFileIdentity) {
335
+ const cursors = [];
336
+ for (const entry of manager.getEntries() as unknown[]) {
337
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== "pi-undo:cursor") continue;
338
+ try {
339
+ const cursor = assertCursor(entry.data);
340
+ if (sameIdentity(cursor.sessionIdentity, identity)) cursors.push(cursor);
341
+ } catch {
342
+ // 旧分支中的损坏 cursor 不参与 frontier 重建。
343
+ }
344
+ }
345
+ return cursors;
346
+ }
347
+
348
+ function physicalBranch(manager: ReadonlySessionManager, leafId: string | null): Record<string, unknown>[] {
349
+ const entries = new Map<string, Record<string, unknown>>();
350
+ for (const entry of manager.getEntries() as unknown[]) {
351
+ if (isRecord(entry) && typeof entry.id === "string") entries.set(entry.id, entry);
352
+ }
353
+ const branch: Record<string, unknown>[] = [];
354
+ const visited = new Set<string>();
355
+ let current = leafId;
356
+ while (current !== null) {
357
+ if (visited.has(current)) throw new Error("session parent cycle");
358
+ visited.add(current);
359
+ const entry = entries.get(current);
360
+ if (entry === undefined) throw new Error("session parent 缺失");
361
+ branch.push(entry);
362
+ if (entry.parentId !== null && typeof entry.parentId !== "string") throw new Error("session parent 无效");
363
+ current = entry.parentId;
364
+ }
365
+ return branch.reverse();
366
+ }
367
+
368
+ function findLastIndex<T>(values: readonly T[], predicate: (value: T) => boolean): number {
369
+ for (let index = values.length - 1; index >= 0; index -= 1) {
370
+ if (predicate(values[index]!)) return index;
371
+ }
372
+ return -1;
373
+ }
374
+
375
+ function sameStrings(left: readonly string[], right: readonly string[]): boolean {
376
+ return left.length === right.length && left.every((value, index) => value === right[index]);
377
+ }
378
+
379
+ function sameIdentity(left: SessionFileIdentity, right: SessionFileIdentity): boolean {
380
+ return resolve(left.path) === resolve(right.path) && left.headerChecksum === right.headerChecksum;
381
+ }
382
+
383
+ function volatileSessionIdentity(manager: ReadonlySessionManager, cwd: string): SessionFileIdentity {
384
+ const header = manager.getHeader() as unknown;
385
+ const record = isRecord(header) ? header : {};
386
+ const content = {
387
+ id: typeof record.id === "string" ? record.id : manager.getSessionId(),
388
+ timestamp: typeof record.timestamp === "string" ? record.timestamp : "volatile",
389
+ cwd: typeof record.cwd === "string" ? record.cwd : cwd,
390
+ };
391
+ return {
392
+ path: resolve(manager.getSessionFile() ?? join(manager.getSessionDir(), `.pi-undo/volatile-${manager.getSessionId()}.jsonl`)),
393
+ headerChecksum: checksum(canonicalJson(content)),
394
+ };
395
+ }
396
+
397
+ async function waitForIdle(context: ExtensionCommandContext | undefined, deadlineMs: number): Promise<boolean> {
398
+ if (context === undefined) return false;
399
+ const remaining = Math.max(0, deadlineMs - Date.now());
400
+ let timeout: ReturnType<typeof setTimeout> | undefined;
401
+ try {
402
+ return await Promise.race([
403
+ context.waitForIdle().then(() => true, () => false),
404
+ new Promise<boolean>((resolveTimeout) => {
405
+ timeout = setTimeout(() => resolveTimeout(false), remaining);
406
+ }),
407
+ ]);
408
+ } finally {
409
+ if (timeout !== undefined) clearTimeout(timeout);
410
+ }
411
+ }
412
+
413
+ function isRecord(value: unknown): value is Record<string, unknown> {
414
+ return typeof value === "object" && value !== null && !Array.isArray(value);
415
+ }