@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,308 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+
3
+ export const DEFAULT_STDERR_LIMIT = 64 * 1024;
4
+ const TERMINATION_GRACE_MS = 50;
5
+ const PROCESS_TREE_EXIT_TIMEOUT_MS = 1_000;
6
+ const PROCESS_TREE_POLL_MS = 10;
7
+ const activeProcessGroups = new Set<number>();
8
+
9
+ process.once("exit", () => {
10
+ for (const processGroup of activeProcessGroups) {
11
+ try {
12
+ process.kill(-processGroup, "SIGKILL");
13
+ } catch {
14
+ // 进程组已经结束时无需处理。
15
+ }
16
+ }
17
+ });
18
+
19
+ export interface GitRunOptions {
20
+ readonly cwd?: string;
21
+ readonly env?: Readonly<Record<string, string | undefined>>;
22
+ readonly stdin?: string | Uint8Array;
23
+ readonly signal?: AbortSignal;
24
+ readonly timeoutMs?: number;
25
+ readonly stderrLimit?: number;
26
+ }
27
+
28
+ export interface GitRunResult {
29
+ readonly stdout: string;
30
+ readonly stdoutBytes: Uint8Array;
31
+ readonly stderr: string;
32
+ readonly code: number | null;
33
+ readonly killed: boolean;
34
+ readonly timedOut: boolean;
35
+ readonly aborted: boolean;
36
+ }
37
+
38
+ export type GitRunErrorCode = "git_failed" | "git_spawn_failed" | "git_termination_failed";
39
+
40
+ export class GitRunError extends Error {
41
+ readonly code: GitRunErrorCode;
42
+ readonly result?: GitRunResult;
43
+
44
+ constructor(code: GitRunErrorCode, message: string, result?: GitRunResult) {
45
+ super(message);
46
+ this.name = "GitRunError";
47
+ this.code = code;
48
+ this.result = result;
49
+ }
50
+ }
51
+
52
+ export interface GitRunner {
53
+ run(args: readonly string[], options?: GitRunOptions): Promise<GitRunResult>;
54
+ }
55
+
56
+ export class GitRunner {
57
+ async run(args: readonly string[], options: GitRunOptions = {}): Promise<GitRunResult> {
58
+ const stderrLimit = options.stderrLimit ?? DEFAULT_STDERR_LIMIT;
59
+ if (!Number.isInteger(stderrLimit) || stderrLimit < 0) {
60
+ throw new RangeError("stderrLimit 必须是非负整数");
61
+ }
62
+ if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0)) {
63
+ throw new RangeError("timeoutMs 必须是非负有限数字");
64
+ }
65
+ if (options.signal?.aborted) {
66
+ return {
67
+ stdout: "",
68
+ stdoutBytes: new Uint8Array(),
69
+ stderr: "",
70
+ code: null,
71
+ killed: true,
72
+ timedOut: false,
73
+ aborted: true,
74
+ };
75
+ }
76
+
77
+ return new Promise<GitRunResult>((resolve, reject) => {
78
+ const environment = mergeEnvironment(options.env);
79
+ let child;
80
+ try {
81
+ child = spawn("git", [...args], {
82
+ cwd: options.cwd,
83
+ detached: process.platform !== "win32",
84
+ env: environment,
85
+ shell: false,
86
+ stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
87
+ });
88
+ } catch (error) {
89
+ reject(new GitRunError("git_spawn_failed", errorMessage(error)));
90
+ return;
91
+ }
92
+ trackProcessGroup(child);
93
+
94
+ const stdout: Buffer[] = [];
95
+ const stderr: Buffer[] = [];
96
+ let stderrBytes = 0;
97
+ let killed = false;
98
+ let timedOut = false;
99
+ let aborted = false;
100
+ let settled = false;
101
+ let timeout: NodeJS.Timeout | undefined;
102
+ let forceKill: NodeJS.Timeout | undefined;
103
+ let closeResult: { code: number | null; signal: NodeJS.Signals | null } | undefined;
104
+ let terminationFinalized = false;
105
+ let terminationFailed = false;
106
+
107
+ child.stdout?.on("data", (chunk: Buffer | string) => {
108
+ stdout.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
109
+ });
110
+ child.stderr?.on("data", (chunk: Buffer | string) => {
111
+ if (stderrBytes >= stderrLimit) {
112
+ return;
113
+ }
114
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
115
+ const remaining = stderrLimit - stderrBytes;
116
+ const captured = bytes.length > remaining ? bytes.subarray(0, remaining) : bytes;
117
+ stderr.push(captured);
118
+ stderrBytes += captured.length;
119
+ });
120
+ if (options.stdin !== undefined) {
121
+ child.stdin?.end(typeof options.stdin === "string" ? options.stdin : Buffer.from(options.stdin));
122
+ }
123
+
124
+ const terminate = (reason: "timeout" | "abort"): void => {
125
+ if (settled || killed) {
126
+ return;
127
+ }
128
+ killed = true;
129
+ timedOut = reason === "timeout";
130
+ aborted = reason === "abort";
131
+ signalProcessTree(child, "SIGTERM");
132
+ forceKill = setTimeout(() => {
133
+ void forceTerminateProcessTree(child).then((stopped) => {
134
+ terminationFailed = !stopped;
135
+ terminationFinalized = true;
136
+ finish();
137
+ });
138
+ }, TERMINATION_GRACE_MS);
139
+ };
140
+
141
+ const onAbort = (): void => terminate("abort");
142
+ options.signal?.addEventListener("abort", onAbort, { once: true });
143
+ if (options.timeoutMs !== undefined) {
144
+ timeout = setTimeout(() => terminate("timeout"), options.timeoutMs);
145
+ }
146
+
147
+ const cleanUp = (): void => {
148
+ settled = true;
149
+ if (timeout) {
150
+ clearTimeout(timeout);
151
+ }
152
+ if (forceKill) {
153
+ clearTimeout(forceKill);
154
+ }
155
+ untrackProcessGroup(child);
156
+ options.signal?.removeEventListener("abort", onAbort);
157
+ };
158
+
159
+ const finish = (): void => {
160
+ if (settled || closeResult === undefined || (killed && !terminationFinalized)) {
161
+ return;
162
+ }
163
+ cleanUp();
164
+ const stdoutBuffer = Buffer.concat(stdout);
165
+ const result: GitRunResult = {
166
+ stdout: stdoutBuffer.toString("utf8"),
167
+ stdoutBytes: new Uint8Array(stdoutBuffer),
168
+ stderr: Buffer.concat(stderr).toString("utf8"),
169
+ code: closeResult.code,
170
+ killed: killed || closeResult.signal !== null,
171
+ timedOut,
172
+ aborted,
173
+ };
174
+ if (terminationFailed) {
175
+ reject(new GitRunError("git_termination_failed", "Git 进程组未能完全终止", result));
176
+ return;
177
+ }
178
+ if (!killed && closeResult.code !== 0) {
179
+ reject(new GitRunError("git_failed", `git 退出码为 ${String(closeResult.code)}`, result));
180
+ return;
181
+ }
182
+ resolve(result);
183
+ };
184
+
185
+ child.once("error", (error) => {
186
+ if (settled) {
187
+ return;
188
+ }
189
+ cleanUp();
190
+ reject(new GitRunError("git_spawn_failed", error.message));
191
+ });
192
+
193
+ child.once("close", (code, signal) => {
194
+ if (settled) {
195
+ return;
196
+ }
197
+ closeResult = { code, signal };
198
+ if (!killed) {
199
+ terminationFinalized = true;
200
+ }
201
+ finish();
202
+ });
203
+ });
204
+ }
205
+ }
206
+
207
+ async function forceTerminateProcessTree(child: ChildProcess): Promise<boolean> {
208
+ if (process.platform === "win32" && child.pid !== undefined) {
209
+ await runTaskkill(child.pid, child);
210
+ return true;
211
+ }
212
+ signalProcessTree(child, "SIGKILL");
213
+ if (child.pid === undefined) {
214
+ return true;
215
+ }
216
+ const deadline = Date.now() + PROCESS_TREE_EXIT_TIMEOUT_MS;
217
+ while (processGroupExists(child.pid)) {
218
+ if (Date.now() >= deadline) {
219
+ return false;
220
+ }
221
+ await delay(PROCESS_TREE_POLL_MS);
222
+ }
223
+ return true;
224
+ }
225
+
226
+ function runTaskkill(pid: number, child: ChildProcess): Promise<void> {
227
+ return new Promise((resolve) => {
228
+ const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
229
+ stdio: "ignore",
230
+ windowsHide: true,
231
+ });
232
+ let settled = false;
233
+ const finish = (): void => {
234
+ if (settled) return;
235
+ settled = true;
236
+ resolve();
237
+ };
238
+ killer.once("error", () => {
239
+ child.kill("SIGKILL");
240
+ finish();
241
+ });
242
+ killer.once("close", finish);
243
+ });
244
+ }
245
+
246
+ function processGroupExists(processGroup: number): boolean {
247
+ try {
248
+ process.kill(-processGroup, 0);
249
+ return true;
250
+ } catch (error) {
251
+ return !hasErrorCode(error, "ESRCH");
252
+ }
253
+ }
254
+
255
+ function trackProcessGroup(child: ChildProcess): void {
256
+ if (process.platform !== "win32" && child.pid !== undefined) {
257
+ activeProcessGroups.add(child.pid);
258
+ }
259
+ }
260
+
261
+ function untrackProcessGroup(child: ChildProcess): void {
262
+ if (child.pid !== undefined) {
263
+ activeProcessGroups.delete(child.pid);
264
+ }
265
+ }
266
+
267
+ function signalProcessTree(child: ChildProcess, signal: NodeJS.Signals): void {
268
+ if (process.platform !== "win32" && child.pid !== undefined) {
269
+ try {
270
+ process.kill(-child.pid, signal);
271
+ return;
272
+ } catch (error) {
273
+ if (!hasErrorCode(error, "ESRCH")) {
274
+ child.kill(signal);
275
+ }
276
+ return;
277
+ }
278
+ }
279
+ child.kill(signal);
280
+ }
281
+
282
+ export function createGitRunner(): GitRunner {
283
+ return new GitRunner();
284
+ }
285
+
286
+ function mergeEnvironment(overrides: Readonly<Record<string, string | undefined>> | undefined): NodeJS.ProcessEnv {
287
+ const environment: NodeJS.ProcessEnv = { ...process.env };
288
+ for (const [key, value] of Object.entries(overrides ?? {})) {
289
+ if (value === undefined) {
290
+ delete environment[key];
291
+ } else {
292
+ environment[key] = value;
293
+ }
294
+ }
295
+ return environment;
296
+ }
297
+
298
+ function errorMessage(error: unknown): string {
299
+ return error instanceof Error ? error.message : String(error);
300
+ }
301
+
302
+ function delay(milliseconds: number): Promise<void> {
303
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
304
+ }
305
+
306
+ function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
307
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
308
+ }
package/src/journal.ts ADDED
@@ -0,0 +1,297 @@
1
+ import { appendFile, lstat, readFile, readdir, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { fsyncDirectory, fsyncFile, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
5
+ import {
6
+ assertCursor,
7
+ assertJournalState,
8
+ assertOperationDescriptor,
9
+ canonicalJson,
10
+ checksum,
11
+ } from "./encoding.ts";
12
+ import type {
13
+ CursorState,
14
+ JournalPhase,
15
+ JournalState,
16
+ OperationDescriptor,
17
+ } from "./model.ts";
18
+ import { MutationJournal } from "./mutation-journal.ts";
19
+
20
+ export interface PendingJournal {
21
+ readonly descriptor: OperationDescriptor;
22
+ readonly plan: unknown;
23
+ readonly state: JournalState;
24
+ }
25
+
26
+ export interface JournalStoreOptions {
27
+ readonly transactionsRoot: string;
28
+ }
29
+
30
+ export interface JournalPhaseOptions {
31
+ readonly observedLogicalLeaf?: string | null;
32
+ }
33
+
34
+ export type CursorMarkerInspection =
35
+ | { readonly kind: "absent" }
36
+ | { readonly kind: "match"; readonly needsTrailingNewline: boolean }
37
+ | { readonly kind: "conflict" };
38
+
39
+ export interface RecoveryDecision {
40
+ readonly action: "rollback" | "roll_forward" | "lock" | "discard";
41
+ readonly reason: string;
42
+ }
43
+
44
+ export class JournalStore {
45
+ private readonly transactionsRoot: string;
46
+
47
+ constructor(options: JournalStoreOptions) {
48
+ this.transactionsRoot = options.transactionsRoot;
49
+ }
50
+
51
+ mutationJournal(opId: string): MutationJournal {
52
+ return new MutationJournal(join(this.operationDirectory(opId), "mutations.jsonl"), opId);
53
+ }
54
+
55
+ async prepare(descriptor: OperationDescriptor, plan: unknown): Promise<void> {
56
+ assertOperationDescriptor(descriptor);
57
+ if (!isPlanForDescriptor(plan, descriptor)) {
58
+ throw new Error("restore plan 与 descriptor 不匹配");
59
+ }
60
+ const directory = this.operationDirectory(descriptor.opId);
61
+ await writeContentAddressed(join(directory, "descriptor.json"), Buffer.from(canonicalJson(descriptor), "utf8"));
62
+ await writeContentAddressed(join(directory, "restore-plan.json"), Buffer.from(canonicalJson(plan), "utf8"));
63
+ const statePath = join(directory, "state.json");
64
+ try {
65
+ const existing = assertJournalState(JSON.parse(await readFile(statePath, "utf8")));
66
+ if (existing.opId !== descriptor.opId || existing.descriptorChecksum !== descriptor.checksum) {
67
+ throw new Error("已存在 journal state 与 descriptor 不匹配");
68
+ }
69
+ } catch (error) {
70
+ if (!hasErrorCode(error, "ENOENT")) {
71
+ throw error;
72
+ }
73
+ await writeJsonAtomic(statePath, makeState(descriptor, "PREPARED", 1));
74
+ }
75
+ }
76
+
77
+ async setPhase(opId: string, phase: JournalPhase, options: JournalPhaseOptions = {}): Promise<void> {
78
+ const pending = await this.load(opId);
79
+ if (!canTransition(pending.state.phase, phase)) {
80
+ throw new Error(`journal phase 不能回退或跳跃:${pending.state.phase} -> ${phase}`);
81
+ }
82
+ const observedLogicalLeaf = options.observedLogicalLeaf === undefined
83
+ ? pending.state.observedLogicalLeaf
84
+ : options.observedLogicalLeaf;
85
+ await writeJsonAtomic(
86
+ join(this.operationDirectory(opId), "state.json"),
87
+ makeState(pending.descriptor, phase, pending.state.revision + 1, observedLogicalLeaf),
88
+ );
89
+ }
90
+
91
+ async loadPending(): Promise<readonly PendingJournal[]> {
92
+ let entries;
93
+ try {
94
+ entries = await readdir(this.transactionsRoot, { withFileTypes: true });
95
+ } catch (error) {
96
+ if (hasErrorCode(error, "ENOENT")) return [];
97
+ throw error;
98
+ }
99
+ const result: PendingJournal[] = [];
100
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
101
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
102
+ try {
103
+ const state = await lstat(join(this.transactionsRoot, entry.name, "state.json"));
104
+ if (!state.isFile() || state.isSymbolicLink()) throw new Error("journal state 文件类型无效");
105
+ } catch (error) {
106
+ if (hasErrorCode(error, "ENOENT")) continue;
107
+ throw error;
108
+ }
109
+ const journal = await this.load(entry.name);
110
+ if (journal.state.phase !== "COMMITTED" && journal.state.phase !== "ABORTED") {
111
+ result.push(journal);
112
+ }
113
+ }
114
+ return result;
115
+ }
116
+
117
+ async markCommitted(opId: string): Promise<void> {
118
+ await this.mutationJournal(opId).assertCleaned();
119
+ await this.setPhase(opId, "COMMITTED");
120
+ }
121
+
122
+ /** 仅在恢复器已经重新验证 workspace 与 cursor 后,允许中间 phase 收敛到终态。 */
123
+ async settleRecovery(opId: string, phase: "COMMITTED" | "ABORTED"): Promise<void> {
124
+ await this.mutationJournal(opId).assertCleaned();
125
+ const pending = await this.load(opId);
126
+ if (pending.state.phase === phase) return;
127
+ if (pending.state.phase === "COMMITTED" || pending.state.phase === "ABORTED") {
128
+ throw new Error("journal 已处于不同终态");
129
+ }
130
+ await writeJsonAtomic(
131
+ join(this.operationDirectory(opId), "state.json"),
132
+ makeState(
133
+ pending.descriptor,
134
+ phase,
135
+ pending.state.revision + 1,
136
+ pending.state.observedLogicalLeaf,
137
+ ),
138
+ );
139
+ }
140
+
141
+ async removeIfSettled(opId: string): Promise<void> {
142
+ const pending = await this.load(opId);
143
+ if (pending.state.phase !== "COMMITTED" && pending.state.phase !== "ABORTED") {
144
+ return;
145
+ }
146
+ await rm(this.operationDirectory(opId), { recursive: true, force: true });
147
+ }
148
+
149
+ private async load(opId: string): Promise<PendingJournal> {
150
+ const directory = this.operationDirectory(opId);
151
+ const [descriptorBytes, planBytes, stateBytes] = await Promise.all([
152
+ readFile(join(directory, "descriptor.json"), "utf8"),
153
+ readFile(join(directory, "restore-plan.json"), "utf8"),
154
+ readFile(join(directory, "state.json"), "utf8"),
155
+ ]);
156
+ const descriptor = assertOperationDescriptor(JSON.parse(descriptorBytes));
157
+ if (descriptor.opId !== opId) {
158
+ throw new Error("journal directory 与 descriptor opId 不匹配");
159
+ }
160
+ const plan: unknown = JSON.parse(planBytes);
161
+ if (!isPlanForDescriptor(plan, descriptor)) {
162
+ throw new Error("journal restore plan 与 descriptor 不匹配");
163
+ }
164
+ const state = assertJournalState(JSON.parse(stateBytes));
165
+ if (state.opId !== descriptor.opId || state.descriptorChecksum !== descriptor.checksum) {
166
+ throw new Error("journal state 与 descriptor 不匹配");
167
+ }
168
+ return { descriptor, plan, state };
169
+ }
170
+
171
+ private operationDirectory(opId: string): string {
172
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(opId)) {
173
+ throw new Error("journal opId 无效");
174
+ }
175
+ return join(this.transactionsRoot, opId);
176
+ }
177
+ }
178
+
179
+ export async function inspectCursorMarkers(
180
+ sessionFile: string,
181
+ descriptor: OperationDescriptor,
182
+ ): Promise<CursorMarkerInspection> {
183
+ assertOperationDescriptor(descriptor);
184
+ let content: string;
185
+ try {
186
+ content = await readFile(sessionFile, "utf8");
187
+ } catch (error) {
188
+ if (hasErrorCode(error, "ENOENT")) return { kind: "absent" };
189
+ throw error;
190
+ }
191
+ const lines = content.split("\n");
192
+ const finalLine = lines.length - 1;
193
+ let matched: string | undefined;
194
+ let needsTrailingNewline = false;
195
+ for (let index = 0; index < lines.length; index += 1) {
196
+ const line = lines[index];
197
+ if (line === undefined || line.trim() === "") continue;
198
+ let entry: unknown;
199
+ try {
200
+ entry = JSON.parse(line);
201
+ } catch {
202
+ continue;
203
+ }
204
+ if (!isCursorEntry(entry)) continue;
205
+ const rawData = entry.data;
206
+ const rawOpId = isRecord(rawData) && typeof rawData.opId === "string" ? rawData.opId : undefined;
207
+ let cursor: CursorState;
208
+ try {
209
+ cursor = assertCursor(rawData);
210
+ } catch {
211
+ if (rawOpId === descriptor.opId) return { kind: "conflict" };
212
+ continue;
213
+ }
214
+ if (cursor.opId !== descriptor.opId) continue;
215
+ if (!matchesDescriptor(cursor, descriptor)) return { kind: "conflict" };
216
+ const encoded = canonicalJson(cursor);
217
+ if (matched !== undefined && matched !== encoded) return { kind: "conflict" };
218
+ matched = encoded;
219
+ needsTrailingNewline = index === finalLine && !content.endsWith("\n");
220
+ }
221
+ return matched === undefined ? { kind: "absent" } : { kind: "match", needsTrailingNewline };
222
+ }
223
+
224
+ export function decideRecovery(inspection: CursorMarkerInspection): RecoveryDecision {
225
+ if (inspection.kind === "match") return { action: "roll_forward", reason: "durable_cursor" };
226
+ if (inspection.kind === "absent") return { action: "rollback", reason: "cursor_absent" };
227
+ return { action: "lock", reason: "cursor_conflict" };
228
+ }
229
+
230
+ export async function finalizeCursorMarker(
231
+ sessionFile: string,
232
+ descriptor: OperationDescriptor,
233
+ inspection: Extract<CursorMarkerInspection, { kind: "match" }>,
234
+ ): Promise<void> {
235
+ if (inspection.needsTrailingNewline) {
236
+ await appendFile(sessionFile, "\n");
237
+ }
238
+ await fsyncFile(sessionFile);
239
+ await fsyncDirectory(dirname(sessionFile));
240
+ const verified = await inspectCursorMarkers(sessionFile, descriptor);
241
+ if (verified.kind !== "match" || verified.needsTrailingNewline) {
242
+ throw new Error("cursor marker 耐久化校验失败");
243
+ }
244
+ }
245
+
246
+ function makeState(
247
+ descriptor: OperationDescriptor,
248
+ phase: JournalPhase,
249
+ revision: number,
250
+ observedLogicalLeaf?: string | null,
251
+ ): JournalState {
252
+ const content = {
253
+ schemaVersion: 1 as const,
254
+ opId: descriptor.opId,
255
+ phase,
256
+ revision,
257
+ descriptorChecksum: descriptor.checksum,
258
+ ...(observedLogicalLeaf === undefined ? {} : { observedLogicalLeaf }),
259
+ };
260
+ return { ...content, checksum: checksum(canonicalJson(content)) };
261
+ }
262
+
263
+ function canTransition(current: JournalPhase, next: JournalPhase): boolean {
264
+ if (current === next || current === "COMMITTED" || current === "ABORTED") return false;
265
+ if (next === "RECOVERY_REQUIRED") return true;
266
+ if (next === "ABORTING") return current !== "ABORTING";
267
+ if (next === "ABORTED") return current === "ABORTING";
268
+ const order: JournalPhase[] = [
269
+ "PREPARED", "SESSION_MOVED", "APPLYING", "FILES_VERIFIED", "CURSOR_COMMITTED", "COMMITTED",
270
+ ];
271
+ return order.indexOf(next) === order.indexOf(current) + 1;
272
+ }
273
+
274
+ function matchesDescriptor(cursor: CursorState, descriptor: OperationDescriptor): boolean {
275
+ return cursor.descriptorChecksum === descriptor.checksum &&
276
+ cursor.action === descriptor.action &&
277
+ cursor.targetManifestId === descriptor.targetManifestId &&
278
+ cursor.rollbackManifestId === descriptor.rollbackManifestId &&
279
+ cursor.sessionIdentity.path === descriptor.sessionIdentity.path &&
280
+ cursor.sessionIdentity.headerChecksum === descriptor.sessionIdentity.headerChecksum;
281
+ }
282
+
283
+ function isPlanForDescriptor(value: unknown, descriptor: OperationDescriptor): boolean {
284
+ return isRecord(value) && value.planDigest === descriptor.planDigest;
285
+ }
286
+
287
+ function isCursorEntry(value: unknown): value is { type: "custom"; customType: "pi-undo:cursor"; data: unknown } {
288
+ return isRecord(value) && value.type === "custom" && value.customType === "pi-undo:cursor";
289
+ }
290
+
291
+ function isRecord(value: unknown): value is Record<string, unknown> {
292
+ return typeof value === "object" && value !== null && !Array.isArray(value);
293
+ }
294
+
295
+ function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
296
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
297
+ }