@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,448 @@
1
+ import { appendFile, lstat, readFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ import { fsyncDirectory, fsyncFile } from "./atomic-fs.ts";
5
+ import { assertCursor, canonicalJson, checksum } from "./encoding.ts";
6
+ import type { CheckpointRecord, CursorState, ManifestId, SessionFileIdentity } from "./model.ts";
7
+
8
+ export interface SessionEntry {
9
+ readonly type: string;
10
+ readonly id: string;
11
+ readonly parentId: string | null;
12
+ readonly customType?: string;
13
+ readonly data?: unknown;
14
+ }
15
+
16
+ export interface SessionEntrySource {
17
+ getEntries(): readonly unknown[];
18
+ getLeafId(): string | null;
19
+ getSessionFile(): string | undefined;
20
+ }
21
+
22
+ export interface CursorEntryAppender {
23
+ appendEntry(customType: string, data?: unknown): void | Promise<void>;
24
+ }
25
+
26
+ export type DurableCursorResult =
27
+ | { readonly kind: "durable"; readonly logicalLeafId: string | null }
28
+ | { readonly kind: "volatile"; readonly reason: "session_file_unavailable" }
29
+ | { readonly kind: "recovery_required"; readonly reason: "append_ambiguous" | "cursor_missing" | "cursor_conflict" | "session_identity_mismatch" };
30
+
31
+ export class SessionState {
32
+ private readonly source: SessionEntrySource;
33
+
34
+ constructor(source: SessionEntrySource) {
35
+ this.source = source;
36
+ }
37
+
38
+ getActiveBranch(): readonly SessionEntry[] {
39
+ return this.physicalBranch().filter((entry) => !isUndoControlEntry(entry));
40
+ }
41
+
42
+ /**
43
+ * 只投影属于已核验 session identity 的可信 checkpoint。
44
+ *
45
+ * 损坏、半截、旧会话或链路不完整的记录会被安全排除,而不是进入 undo 栈;
46
+ * 调用方必须传入由 getSessionIdentity() 读取并核验的当前 identity。
47
+ */
48
+ getCheckpoints(sessionIdentity: SessionFileIdentity): readonly CheckpointRecord[] {
49
+ assertSessionFileIdentity(sessionIdentity);
50
+ const branch = this.physicalBranch();
51
+ const positions = new Map(branch.map((entry, index) => [entry.id, index]));
52
+ const entries = new Map(branch.map((entry) => [entry.id, entry]));
53
+ const candidates: CheckpointRecord[] = [];
54
+ for (const entry of branch) {
55
+ if (entry.type !== "custom" || entry.customType !== "pi-undo:checkpoint") continue;
56
+ try {
57
+ const checkpoint = checkpointFromEntry(entry, sessionIdentity);
58
+ if (isCheckpointChainTrusted(checkpoint, entry.id, positions, entries)) candidates.push(checkpoint);
59
+ } catch {
60
+ // Session 历史是外部持久化输入;无效 checkpoint 只能被排除。
61
+ }
62
+ }
63
+ const counts = new Map<string, number>();
64
+ for (const checkpoint of candidates) {
65
+ counts.set(checkpoint.checkpointId, (counts.get(checkpoint.checkpointId) ?? 0) + 1);
66
+ }
67
+ return candidates.filter((checkpoint) => counts.get(checkpoint.checkpointId) === 1);
68
+ }
69
+
70
+ async getSessionIdentity(): Promise<SessionFileIdentity | null> {
71
+ const sessionFile = this.source.getSessionFile();
72
+ if (sessionFile === undefined) return null;
73
+ return readSessionFileIdentity(sessionFile);
74
+ }
75
+
76
+ getCursor(sessionIdentity: SessionFileIdentity): CursorState | null {
77
+ for (const entry of [...this.physicalBranch()].reverse()) {
78
+ if (entry.type !== "custom") continue;
79
+ if (
80
+ entry.customType === "pi-undo:start" ||
81
+ entry.customType === "pi-undo:checkpoint" ||
82
+ entry.customType === "pi-undo:barrier"
83
+ ) return null;
84
+ if (entry.customType !== "pi-undo:cursor") continue;
85
+ const cursor = assertCursor(entry.data);
86
+ return sameSessionIdentity(cursor.sessionIdentity, sessionIdentity) ? cursor : null;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ verifyCursorOnCurrentBranch(expected: CursorState): "match" | "absent" | "conflict" {
92
+ const entries = this.entryIndex();
93
+ const matches: SessionEntry[] = [];
94
+ for (const entry of this.physicalBranch()) {
95
+ if (entry.type !== "custom" || entry.customType !== "pi-undo:cursor") continue;
96
+ const rawOpId = isRecord(entry.data) && typeof entry.data.opId === "string" ? entry.data.opId : undefined;
97
+ let cursor: CursorState;
98
+ try {
99
+ cursor = assertCursor(entry.data);
100
+ } catch {
101
+ if (rawOpId === expected.opId) return "conflict";
102
+ continue;
103
+ }
104
+ if (cursor.opId !== expected.opId) continue;
105
+ if (cursor.checksum !== expected.checksum || canonicalJson(cursor) !== canonicalJson(expected)) return "conflict";
106
+ matches.push(entry);
107
+ }
108
+ if (matches.length === 0) return "absent";
109
+ if (matches.length !== 1) return "conflict";
110
+ return this.logicalLeafFrom(entries, matches[0]!.parentId) === expected.toLogicalLeaf ? "match" : "conflict";
111
+ }
112
+
113
+ getLogicalLeafId(): string | null {
114
+ const entries = this.entryIndex();
115
+ let current = this.source.getLeafId();
116
+ const visited = new Set<string>();
117
+ while (current !== null) {
118
+ if (visited.has(current)) throw new Error("session parent cycle");
119
+ visited.add(current);
120
+ const entry = entries.get(current);
121
+ if (entry === undefined) throw new Error("session leaf parent 缺失");
122
+ if (!isUndoControlEntry(entry)) return entry.id;
123
+ current = entry.parentId;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ findUserEntry(checkpointId: string, sessionIdentity: SessionFileIdentity): string {
129
+ const checkpoint = this.getCheckpoints(sessionIdentity).find((candidate) => candidate.checkpointId === checkpointId);
130
+ if (checkpoint === undefined) throw new Error("checkpoint 不在 active branch");
131
+ return checkpoint.userEntryId;
132
+ }
133
+
134
+ async findTargetManifest(targetId: string, sessionIdentity: SessionFileIdentity): Promise<ManifestId> {
135
+ const checkpoint = this.getCheckpoints(sessionIdentity).find((candidate) => candidate.checkpointId === targetId);
136
+ if (checkpoint === undefined) throw new Error("checkpoint 不在 active branch");
137
+ return checkpoint.afterManifestId;
138
+ }
139
+
140
+ private physicalBranch(): readonly SessionEntry[] {
141
+ const entries = this.entryIndex();
142
+ const result: SessionEntry[] = [];
143
+ let current = this.source.getLeafId();
144
+ const visited = new Set<string>();
145
+ while (current !== null) {
146
+ if (visited.has(current)) throw new Error("session parent cycle");
147
+ visited.add(current);
148
+ const entry = entries.get(current);
149
+ if (entry === undefined) throw new Error("session parent 缺失");
150
+ result.push(entry);
151
+ current = entry.parentId;
152
+ }
153
+ return result.reverse();
154
+ }
155
+
156
+ private entryIndex(): Map<string, SessionEntry> {
157
+ const entries = new Map<string, SessionEntry>();
158
+ for (const value of this.source.getEntries()) {
159
+ const entry = assertSessionEntry(value);
160
+ if (entries.has(entry.id)) throw new Error("session entry ID 重复");
161
+ entries.set(entry.id, entry);
162
+ }
163
+ return entries;
164
+ }
165
+
166
+ private logicalLeafFrom(entries: ReadonlyMap<string, SessionEntry>, start: string | null): string | null {
167
+ let current = start;
168
+ const visited = new Set<string>();
169
+ while (current !== null) {
170
+ if (visited.has(current)) throw new Error("session parent cycle");
171
+ visited.add(current);
172
+ const entry = entries.get(current);
173
+ if (entry === undefined) throw new Error("session parent 缺失");
174
+ if (!isUndoControlEntry(entry)) return entry.id;
175
+ current = entry.parentId;
176
+ }
177
+ return null;
178
+ }
179
+ }
180
+
181
+ export class DurableCursorWriter {
182
+ async appendCursor(
183
+ state: CursorState,
184
+ pi: CursorEntryAppender,
185
+ session: SessionEntrySource,
186
+ ): Promise<DurableCursorResult> {
187
+ assertCursor(state);
188
+ const sessionFile = session.getSessionFile();
189
+ if (sessionFile === undefined || !await isRegularFile(sessionFile)) {
190
+ return { kind: "volatile", reason: "session_file_unavailable" };
191
+ }
192
+ if (!await sessionIdentityMatches(sessionFile, state.sessionIdentity)) {
193
+ return { kind: "recovery_required", reason: "session_identity_mismatch" };
194
+ }
195
+ try {
196
+ await pi.appendEntry("pi-undo:cursor", state);
197
+ } catch {
198
+ const inspection = await inspectCursorState(sessionFile, state);
199
+ return inspection.kind === "match"
200
+ ? this.finishDurable(sessionFile, state, session, inspection.needsTrailingNewline)
201
+ : { kind: "recovery_required", reason: "append_ambiguous" };
202
+ }
203
+ const inspection = await inspectCursorState(sessionFile, state);
204
+ if (inspection.kind === "conflict") return { kind: "recovery_required", reason: "cursor_conflict" };
205
+ if (inspection.kind === "absent") return { kind: "recovery_required", reason: "cursor_missing" };
206
+ return this.finishDurable(sessionFile, state, session, inspection.needsTrailingNewline);
207
+ }
208
+
209
+ private async finishDurable(
210
+ sessionFile: string,
211
+ state: CursorState,
212
+ session: SessionEntrySource,
213
+ needsTrailingNewline: boolean,
214
+ ): Promise<DurableCursorResult> {
215
+ if (needsTrailingNewline) {
216
+ await appendFile(sessionFile, "\n");
217
+ }
218
+ await fsyncFile(sessionFile);
219
+ await fsyncDirectory(dirname(sessionFile));
220
+ if (!await sessionIdentityMatches(sessionFile, state.sessionIdentity)) {
221
+ return { kind: "recovery_required", reason: "session_identity_mismatch" };
222
+ }
223
+ const verification = await inspectCursorState(sessionFile, state);
224
+ if (verification.kind !== "match") return { kind: "recovery_required", reason: "cursor_conflict" };
225
+ const branch = new SessionState(session).verifyCursorOnCurrentBranch(state);
226
+ if (branch === "absent") return { kind: "recovery_required", reason: "cursor_missing" };
227
+ if (branch === "conflict") return { kind: "recovery_required", reason: "cursor_conflict" };
228
+ return { kind: "durable", logicalLeafId: new SessionState(session).getLogicalLeafId() };
229
+ }
230
+ }
231
+
232
+ type CursorInspection =
233
+ | { readonly kind: "absent" }
234
+ | { readonly kind: "match"; readonly needsTrailingNewline: boolean }
235
+ | { readonly kind: "conflict" };
236
+
237
+ async function inspectCursorState(sessionFile: string, expected: CursorState): Promise<CursorInspection> {
238
+ const content = await readFile(sessionFile, "utf8");
239
+ const lines = content.split("\n");
240
+ let found: string | undefined;
241
+ let needsTrailingNewline = false;
242
+ for (let index = 0; index < lines.length; index += 1) {
243
+ const line = lines[index];
244
+ if (line === undefined || line.trim() === "") continue;
245
+ let entry: unknown;
246
+ try {
247
+ entry = JSON.parse(line);
248
+ } catch {
249
+ continue;
250
+ }
251
+ if (!isCursorEntry(entry)) continue;
252
+ const rawOpId = isRecord(entry.data) && typeof entry.data.opId === "string" ? entry.data.opId : undefined;
253
+ let candidate: CursorState;
254
+ try {
255
+ candidate = assertCursor(entry.data);
256
+ } catch {
257
+ if (rawOpId === expected.opId) return { kind: "conflict" };
258
+ continue;
259
+ }
260
+ if (candidate.opId !== expected.opId) continue;
261
+ const encoded = canonicalJson(candidate);
262
+ if (encoded !== canonicalJson(expected)) return { kind: "conflict" };
263
+ if (found !== undefined && found !== encoded) return { kind: "conflict" };
264
+ found = encoded;
265
+ needsTrailingNewline = index === lines.length - 1 && !content.endsWith("\n");
266
+ }
267
+ return found === undefined ? { kind: "absent" } : { kind: "match", needsTrailingNewline };
268
+ }
269
+
270
+ async function sessionIdentityMatches(sessionFile: string, identity: SessionFileIdentity): Promise<boolean> {
271
+ const observed = await readSessionFileIdentity(sessionFile);
272
+ return observed !== null && sameSessionIdentity(observed, identity);
273
+ }
274
+
275
+ async function readSessionFileIdentity(sessionFile: string): Promise<SessionFileIdentity | null> {
276
+ if (!await isRegularFile(sessionFile)) return null;
277
+ const content = await readFile(sessionFile, "utf8");
278
+ const firstLine = content.split("\n")[0];
279
+ if (firstLine === undefined || firstLine.length === 0) return null;
280
+ try {
281
+ const header = JSON.parse(firstLine) as Record<string, unknown>;
282
+ if (header.type !== "session" || typeof header.id !== "string" || typeof header.timestamp !== "string" || typeof header.cwd !== "string") {
283
+ return null;
284
+ }
285
+ return {
286
+ path: resolve(sessionFile),
287
+ headerChecksum: checksum(canonicalJson({ id: header.id, timestamp: header.timestamp, cwd: header.cwd })),
288
+ };
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+
294
+ function sameSessionIdentity(left: SessionFileIdentity, right: SessionFileIdentity): boolean {
295
+ return resolve(left.path) === resolve(right.path) && left.headerChecksum === right.headerChecksum;
296
+ }
297
+
298
+ async function isRegularFile(path: string): Promise<boolean> {
299
+ try {
300
+ const metadata = await lstat(path);
301
+ return metadata.isFile() && !metadata.isSymbolicLink();
302
+ } catch (error) {
303
+ if (hasErrorCode(error, "ENOENT")) return false;
304
+ throw error;
305
+ }
306
+ }
307
+
308
+ function assertSessionEntry(value: unknown): SessionEntry {
309
+ if (!isRecord(value) || typeof value.type !== "string" || typeof value.id !== "string" || value.id.length === 0 ||
310
+ (value.parentId !== null && typeof value.parentId !== "string")) {
311
+ throw new Error("session entry 无效");
312
+ }
313
+ if (value.customType !== undefined && typeof value.customType !== "string") {
314
+ throw new Error("session customType 无效");
315
+ }
316
+ return value as unknown as SessionEntry;
317
+ }
318
+
319
+ function checkpointFromEntry(entry: SessionEntry, expectedIdentity: SessionFileIdentity): CheckpointRecord {
320
+ if (!isRecord(entry.data) || !hasOnlyEnumerableDataProperties(entry.data)) {
321
+ throw new Error("checkpoint entry 无效");
322
+ }
323
+ const record = entry.data;
324
+ const fields = [
325
+ "afterManifestId", "beforeManifestId", "changedPaths", "checkpointId", "checksum", "endLeafId",
326
+ "rawPrompt", "runId", "schemaVersion", "sessionIdentity", "startEntryId", "userEntryId",
327
+ ];
328
+ if (Object.keys(record).sort().join("\0") !== fields.join("\0") || record.schemaVersion !== 1 ||
329
+ !isEntryId(record.checkpointId) || !isEntryId(record.runId) || !isEntryId(record.startEntryId) ||
330
+ !isEntryId(record.userEntryId) || !isEntryId(record.endLeafId) || typeof record.rawPrompt !== "string" ||
331
+ !isManifestId(record.beforeManifestId) || !isManifestId(record.afterManifestId) ||
332
+ !isCanonicalChangedPaths(record.changedPaths) || !isChecksum(record.checksum)) {
333
+ throw new Error("checkpoint entry 无效");
334
+ }
335
+ assertSessionFileIdentity(record.sessionIdentity);
336
+ if (!sameSessionIdentity(record.sessionIdentity, expectedIdentity)) {
337
+ throw new Error("checkpoint session identity 不匹配");
338
+ }
339
+ const { checksum: recordChecksum, ...content } = record;
340
+ if (recordChecksum !== checksum(canonicalJson(content))) {
341
+ throw new Error("checkpoint checksum 不匹配");
342
+ }
343
+ return record as unknown as CheckpointRecord;
344
+ }
345
+
346
+ function isCheckpointChainTrusted(
347
+ checkpoint: CheckpointRecord,
348
+ checkpointEntryId: string,
349
+ positions: ReadonlyMap<string, number>,
350
+ entries: ReadonlyMap<string, SessionEntry>,
351
+ ): boolean {
352
+ const start = positions.get(checkpoint.startEntryId);
353
+ const user = positions.get(checkpoint.userEntryId);
354
+ const end = positions.get(checkpoint.endLeafId);
355
+ const checkpointEntry = positions.get(checkpointEntryId);
356
+ const startEntry = entries.get(checkpoint.startEntryId);
357
+ const userEntry = entries.get(checkpoint.userEntryId);
358
+ const endEntry = entries.get(checkpoint.endLeafId);
359
+ const checkpointRecord = entries.get(checkpointEntryId);
360
+ return start !== undefined && user !== undefined && end !== undefined && checkpointEntry !== undefined &&
361
+ start < user && user <= end && end < checkpointEntry &&
362
+ startEntry?.type === "custom" && startEntry.customType === "pi-undo:start" &&
363
+ userEntry?.parentId === checkpoint.startEntryId && isMessageRole(userEntry, "user") &&
364
+ endEntry !== undefined && !isUndoControlEntry(endEntry) && checkpointRecord !== undefined &&
365
+ logicalLeafFrom(entries, checkpointRecord.parentId) === checkpoint.endLeafId;
366
+ }
367
+
368
+ function logicalLeafFrom(entries: ReadonlyMap<string, SessionEntry>, start: string | null): string | null {
369
+ let current = start;
370
+ const visited = new Set<string>();
371
+ while (current !== null) {
372
+ if (visited.has(current)) throw new Error("session parent cycle");
373
+ visited.add(current);
374
+ const entry = entries.get(current);
375
+ if (entry === undefined) throw new Error("session parent 缺失");
376
+ if (!isUndoControlEntry(entry)) return entry.id;
377
+ current = entry.parentId;
378
+ }
379
+ return null;
380
+ }
381
+
382
+ function isMessageRole(entry: SessionEntry, role: string): boolean {
383
+ return entry.type === "message" && isRecord(entry) && isRecord(entry.message) && entry.message.role === role;
384
+ }
385
+
386
+ function assertSessionFileIdentity(value: unknown): asserts value is SessionFileIdentity {
387
+ if (!isRecord(value) || !hasOnlyEnumerableDataProperties(value) || Object.keys(value).sort().join("\0") !== "headerChecksum\0path" ||
388
+ typeof value.path !== "string" || value.path.length === 0 || value.path.includes("\0") || !isChecksum(value.headerChecksum)) {
389
+ throw new Error("session identity 无效");
390
+ }
391
+ }
392
+
393
+ function isEntryId(value: unknown): value is string {
394
+ return typeof value === "string" && value.length > 0 && !value.includes("\0");
395
+ }
396
+
397
+ function isManifestId(value: unknown): value is ManifestId {
398
+ return isChecksum(value);
399
+ }
400
+
401
+ function isChecksum(value: unknown): value is string {
402
+ return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
403
+ }
404
+
405
+ function isCanonicalChangedPaths(value: unknown): value is readonly string[] {
406
+ if (!Array.isArray(value)) return false;
407
+ let previous: string | undefined;
408
+ for (let index = 0; index < value.length; index += 1) {
409
+ const path = value[index];
410
+ if (!Object.prototype.hasOwnProperty.call(value, index) || typeof path !== "string" || !isCanonicalRelativePath(path) ||
411
+ (previous !== undefined && previous >= path)) return false;
412
+ previous = path;
413
+ }
414
+ return true;
415
+ }
416
+
417
+ function isCanonicalRelativePath(path: string): boolean {
418
+ if (path === ".") return true;
419
+ if (path.length === 0 || path.startsWith("/") || path.endsWith("/") || path.includes("\\") || path.includes("\0") || /^[A-Za-z]:/.test(path)) {
420
+ return false;
421
+ }
422
+ return path.split("/").every((part) => part.length > 0 && part !== "." && part !== ".." && part.toLowerCase() !== ".git");
423
+ }
424
+
425
+ function hasOnlyEnumerableDataProperties(value: object): boolean {
426
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null || Object.getOwnPropertySymbols(value).length > 0) {
427
+ return false;
428
+ }
429
+ return Object.values(Object.getOwnPropertyDescriptors(value)).every(
430
+ (descriptor) => descriptor.enumerable && Object.hasOwn(descriptor, "value"),
431
+ );
432
+ }
433
+
434
+ function isUndoControlEntry(entry: SessionEntry): boolean {
435
+ return entry.type === "custom" && entry.customType?.startsWith("pi-undo:") === true;
436
+ }
437
+
438
+ function isCursorEntry(value: unknown): value is { type: "custom"; customType: "pi-undo:cursor"; data: unknown } {
439
+ return isRecord(value) && value.type === "custom" && value.customType === "pi-undo:cursor";
440
+ }
441
+
442
+ function isRecord(value: unknown): value is Record<string, unknown> {
443
+ return typeof value === "object" && value !== null && !Array.isArray(value);
444
+ }
445
+
446
+ function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
447
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
448
+ }