@ian-pascoe/pi-lsp 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,872 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ readFile,
7
+ readlink,
8
+ realpath,
9
+ rename,
10
+ rm,
11
+ stat,
12
+ symlink,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { dirname, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
18
+ import type {
19
+ Position,
20
+ PositionEncodingKind,
21
+ Range,
22
+ TextEdit,
23
+ WorkspaceEdit,
24
+ LSPAny,
25
+ } from "vscode-languageserver-protocol";
26
+ import {
27
+ convertLspProtocolPosition,
28
+ normalizeLspPositionEncoding,
29
+ } from "./lsp-position-encoding.js";
30
+
31
+ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
32
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
33
+
34
+ /** A canonical file operation exposed to Pi permission hooks before an LSP apply. */
35
+ export interface LspMutationManifestEntry {
36
+ /** Resource operation performed by the guarded batch. */
37
+ readonly operation: "create" | "modify" | "delete" | "rename";
38
+ /** Canonical content target, or named resource source for delete and rename. */
39
+ readonly path: string;
40
+ /** Named directory entry referenced by the language server. */
41
+ readonly named_path: string;
42
+ /** Canonical rename destination when `operation` is `rename`. */
43
+ readonly destination_path?: string;
44
+ }
45
+
46
+ /** Exact canonical paths and operations for one Validated Workspace Edit. */
47
+ export interface LspMutationManifest {
48
+ readonly entries: readonly LspMutationManifestEntry[];
49
+ }
50
+
51
+ type FileSnapshot =
52
+ | { readonly kind: "missing" }
53
+ | {
54
+ readonly kind: "file";
55
+ readonly content_base64: string;
56
+ readonly hash: string;
57
+ readonly mode: number;
58
+ }
59
+ | { readonly kind: "symlink"; readonly link_target: string; readonly mode: number };
60
+
61
+ type ModifyOperation = {
62
+ readonly kind: "modify";
63
+ readonly named_path: string;
64
+ readonly path: string;
65
+ readonly named_before: FileSnapshot;
66
+ readonly before: FileSnapshot;
67
+ after_base64: string;
68
+ readonly mode: number;
69
+ };
70
+
71
+ type CreateOperation = {
72
+ readonly kind: "create";
73
+ readonly named_path: string;
74
+ readonly before: FileSnapshot;
75
+ after_base64: string;
76
+ readonly mode: number;
77
+ };
78
+
79
+ type DeleteOperation = {
80
+ readonly kind: "delete";
81
+ readonly named_path: string;
82
+ readonly before: Exclude<FileSnapshot, { readonly kind: "missing" }>;
83
+ };
84
+
85
+ type RenameOperation = {
86
+ readonly kind: "rename";
87
+ readonly named_path: string;
88
+ readonly destination_path: string;
89
+ readonly before: Exclude<FileSnapshot, { readonly kind: "missing" }>;
90
+ readonly destination_before: FileSnapshot;
91
+ };
92
+
93
+ type NormalizedWorkspaceOperation =
94
+ | ModifyOperation
95
+ | CreateOperation
96
+ | DeleteOperation
97
+ | RenameOperation;
98
+
99
+ /** Schema-friendly preview record persisted in LSP tool result details. */
100
+ export interface LspWorkspaceEditPreview {
101
+ readonly kind: "workspace_edit_preview";
102
+ readonly preview_id: string;
103
+ readonly server_id: string;
104
+ readonly summary: string;
105
+ readonly state: "available" | "applied";
106
+ readonly operations: readonly NormalizedWorkspaceOperation[];
107
+ }
108
+
109
+ /** Result of one guarded Workspace Edit application. */
110
+ export interface LspWorkspaceEditApplyResult {
111
+ readonly preview_id: string;
112
+ readonly state: "applied";
113
+ readonly changed_files: readonly string[];
114
+ readonly created_files: readonly string[];
115
+ readonly deleted_files: readonly string[];
116
+ readonly moved_files: readonly { readonly from: string; readonly to: string }[];
117
+ }
118
+
119
+ /** Narrow filesystem mutation seam used to inject deterministic rollback failures in tests. */
120
+ export interface LspWorkspaceEditFileOperations {
121
+ replaceFile(path: string, contents: Buffer, mode: number): Promise<void>;
122
+ removePath(path: string): Promise<void>;
123
+ renamePath(source: string, destination: string): Promise<void>;
124
+ }
125
+
126
+ async function replaceFile(path: string, contents: Buffer, mode: number): Promise<void> {
127
+ await mkdir(dirname(path), { recursive: true });
128
+ const temporaryPath = `${path}.pi-lsp-${randomUUID()}.tmp`;
129
+ try {
130
+ await writeFile(temporaryPath, contents, { mode: 0o600 });
131
+ await chmod(temporaryPath, mode);
132
+ await rename(temporaryPath, path);
133
+ } finally {
134
+ await rm(temporaryPath, { force: true });
135
+ }
136
+ }
137
+
138
+ /** Production file operations for temporary replacement and named resource changes. */
139
+ export const nodeLspWorkspaceEditFileOperations: LspWorkspaceEditFileOperations = {
140
+ replaceFile,
141
+ removePath: (path) => rm(path, { force: true }),
142
+ renamePath: async (source, destination) => {
143
+ await mkdir(dirname(destination), { recursive: true });
144
+ await rename(source, destination);
145
+ },
146
+ };
147
+
148
+ type WorkspaceEditErrorCode =
149
+ | "contradictory_resource_operations"
150
+ | "directory_operation"
151
+ | "duplicate_destination"
152
+ | "invalid_destination"
153
+ | "invalid_position"
154
+ | "invalid_utf8"
155
+ | "mutation_manifest_mismatch"
156
+ | "non_file_uri"
157
+ | "overlapping_text_edits"
158
+ | "preview_already_applied"
159
+ | "preview_not_found"
160
+ | "stale_workspace_edit"
161
+ | "workspace_edit_apply_failed"
162
+ | "workspace_edit_cancelled"
163
+ | "workspace_edit_recovery_failed";
164
+
165
+ /** Expected preview normalization, validation, apply, or rollback failure. */
166
+ export class LspWorkspaceEditError extends Error {
167
+ readonly _tag = "LspWorkspaceEditError" as const;
168
+
169
+ /** Construct a stable Workspace Edit failure with optional unrecovered paths. */
170
+ constructor(
171
+ readonly code: WorkspaceEditErrorCode,
172
+ message: string,
173
+ readonly recoveryFailures: readonly string[] = [],
174
+ options?: ErrorOptions,
175
+ ) {
176
+ super(`Pi LSP: ${message}`, options);
177
+ }
178
+ }
179
+
180
+ interface LspWorkspaceEditStoreOptions {
181
+ readonly createPreviewId?: () => string;
182
+ readonly fileOperations?: LspWorkspaceEditFileOperations;
183
+ readonly queueMutation?: <T>(path: string, operation: () => Promise<T>) => Promise<T>;
184
+ }
185
+
186
+ interface CreateWorkspaceEditPreviewInput {
187
+ readonly edit: WorkspaceEdit;
188
+ readonly serverId: string;
189
+ readonly positionEncoding?: PositionEncodingKind;
190
+ }
191
+
192
+ interface DecodedUtf8Document {
193
+ readonly bom: boolean;
194
+ readonly text: string;
195
+ }
196
+
197
+ /** Counts accepted and rejected persisted Workspace Edit Preview records. */
198
+ export interface LspWorkspaceEditReplayResult {
199
+ readonly accepted: number;
200
+ readonly rejected: number;
201
+ }
202
+
203
+ function hashContents(contents: Buffer): string {
204
+ return createHash("sha256").update(contents).digest("hex");
205
+ }
206
+
207
+ function contentsFromSnapshot(snapshot: FileSnapshot): Buffer {
208
+ if (snapshot.kind !== "file") {
209
+ throw new LspWorkspaceEditError("invalid_destination", "expected a regular file");
210
+ }
211
+ return Buffer.from(snapshot.content_base64, "base64");
212
+ }
213
+
214
+ async function snapshotNamedPath(path: string): Promise<FileSnapshot> {
215
+ let metadata;
216
+ try {
217
+ metadata = await lstat(path);
218
+ } catch (cause) {
219
+ if (cause instanceof Error && isMissingPathError(cause)) return { kind: "missing" };
220
+ throw cause;
221
+ }
222
+ const mode = metadata.mode & 0o7777;
223
+ if (metadata.isDirectory()) {
224
+ throw new LspWorkspaceEditError(
225
+ "directory_operation",
226
+ `directory-tree operation is not supported: ${path}`,
227
+ );
228
+ }
229
+ if (metadata.isSymbolicLink()) {
230
+ return { kind: "symlink", link_target: await readlink(path), mode };
231
+ }
232
+ if (!metadata.isFile()) {
233
+ throw new LspWorkspaceEditError("invalid_destination", `path is not a regular file: ${path}`);
234
+ }
235
+ const contents = await readFile(path);
236
+ return {
237
+ kind: "file",
238
+ content_base64: contents.toString("base64"),
239
+ hash: hashContents(contents),
240
+ mode,
241
+ };
242
+ }
243
+
244
+ function isMissingPathError(cause: Error): boolean {
245
+ return "code" in cause && (cause.code === "ENOENT" || cause.code === "ENOTDIR");
246
+ }
247
+
248
+ function filePathFromUri(uri: string): string {
249
+ try {
250
+ const url = new URL(uri);
251
+ if (url.protocol !== "file:") throw new Error("not file");
252
+ return resolve(fileURLToPath(url));
253
+ } catch (cause) {
254
+ throw new LspWorkspaceEditError("non_file_uri", `Workspace Edit URI is not file: ${uri}`, [], {
255
+ cause,
256
+ });
257
+ }
258
+ }
259
+
260
+ function decodeUtf8(contents: Buffer, path: string): DecodedUtf8Document {
261
+ try {
262
+ const decoded = UTF8_DECODER.decode(contents);
263
+ return {
264
+ bom: contents.subarray(0, 3).equals(UTF8_BOM),
265
+ text: decoded.startsWith("\uFEFF") ? decoded.slice(1) : decoded,
266
+ };
267
+ } catch (cause) {
268
+ throw new LspWorkspaceEditError("invalid_utf8", `file is not valid UTF-8: ${path}`, [], {
269
+ cause,
270
+ });
271
+ }
272
+ }
273
+
274
+ function encodeUtf8(text: string, bom: boolean): Buffer {
275
+ const contents = Buffer.from(text, "utf8");
276
+ return bom ? Buffer.concat([UTF8_BOM, contents]) : contents;
277
+ }
278
+
279
+ function textOffsetAtPosition(
280
+ text: string,
281
+ position: Position,
282
+ encoding: PositionEncodingKind,
283
+ ): number {
284
+ try {
285
+ const codePointPosition = convertLspProtocolPosition(
286
+ text,
287
+ position,
288
+ normalizeLspPositionEncoding(encoding),
289
+ );
290
+ const lineStarts = [0];
291
+ for (let index = 0; index < text.length; index++) {
292
+ if (text[index] === "\r" && text[index + 1] === "\n") index++;
293
+ if (text[index] === "\r" || text[index] === "\n") lineStarts.push(index + 1);
294
+ }
295
+ const lineStart = lineStarts[position.line];
296
+ if (lineStart === undefined) throw new Error("line exceeds document length");
297
+ const lineEnd = lineStarts[position.line + 1] ?? text.length;
298
+ const line = text.slice(lineStart, lineEnd).replace(/\r?\n$|\r$/u, "");
299
+ const utf16Offset = Array.from(line)
300
+ .slice(0, codePointPosition.character - 1)
301
+ .join("").length;
302
+ return lineStart + utf16Offset;
303
+ } catch (cause) {
304
+ throw new LspWorkspaceEditError("invalid_position", "Workspace Edit position is invalid", [], {
305
+ cause,
306
+ });
307
+ }
308
+ }
309
+
310
+ function applyTextEdits(
311
+ text: string,
312
+ edits: readonly TextEdit[],
313
+ encoding: PositionEncodingKind,
314
+ ): string {
315
+ const replacements = edits.map((edit) => ({
316
+ start: textOffsetAtPosition(text, edit.range.start, encoding),
317
+ end: textOffsetAtPosition(text, edit.range.end, encoding),
318
+ text: edit.newText,
319
+ }));
320
+ replacements.sort((left, right) => left.start - right.start || left.end - right.end);
321
+ for (let index = 1; index < replacements.length; index++) {
322
+ const previous = replacements[index - 1];
323
+ const current = replacements[index];
324
+ if (previous !== undefined && current !== undefined && current.start < previous.end) {
325
+ throw new LspWorkspaceEditError(
326
+ "overlapping_text_edits",
327
+ "Workspace Edit contains overlapping text edits",
328
+ );
329
+ }
330
+ }
331
+ let result = text;
332
+ for (const replacement of replacements.reverse()) {
333
+ result = result.slice(0, replacement.start) + replacement.text + result.slice(replacement.end);
334
+ }
335
+ return result;
336
+ }
337
+
338
+ function regularTextEdits(
339
+ edits: readonly { readonly range: Range; readonly newText?: string }[],
340
+ ): TextEdit[] {
341
+ return edits.map((edit) => {
342
+ if (edit.newText === undefined) {
343
+ throw new LspWorkspaceEditError(
344
+ "invalid_destination",
345
+ "Workspace Edit snippet edits are not supported",
346
+ );
347
+ }
348
+ return { range: edit.range, newText: edit.newText };
349
+ });
350
+ }
351
+
352
+ function fileSummary(path: string, before: Buffer, after: Buffer): string {
353
+ const beforeText = decodeUtf8(before, path).text;
354
+ const afterText = decodeUtf8(after, path).text;
355
+ return generateUnifiedPatch(path, beforeText, afterText);
356
+ }
357
+
358
+ function snapshotMatches(left: FileSnapshot, right: FileSnapshot): boolean {
359
+ return JSON.stringify(left) === JSON.stringify(right);
360
+ }
361
+
362
+ function manifestForOperations(
363
+ operations: readonly NormalizedWorkspaceOperation[],
364
+ ): LspMutationManifest {
365
+ return {
366
+ entries: operations
367
+ .map((operation): LspMutationManifestEntry => {
368
+ if (operation.kind === "modify") {
369
+ return {
370
+ named_path: operation.named_path,
371
+ operation: "modify",
372
+ path: operation.path,
373
+ };
374
+ }
375
+ if (operation.kind === "create") {
376
+ return {
377
+ named_path: operation.named_path,
378
+ operation: "create",
379
+ path: operation.named_path,
380
+ };
381
+ }
382
+ if (operation.kind === "delete") {
383
+ return {
384
+ named_path: operation.named_path,
385
+ operation: "delete",
386
+ path: operation.named_path,
387
+ };
388
+ }
389
+ return {
390
+ destination_path: operation.destination_path,
391
+ named_path: operation.named_path,
392
+ operation: "rename",
393
+ path: operation.named_path,
394
+ };
395
+ })
396
+ .sort((left, right) => left.path.localeCompare(right.path)),
397
+ };
398
+ }
399
+
400
+ function manifestQueuePaths(manifest: LspMutationManifest): string[] {
401
+ const paths = new Set<string>();
402
+ for (const entry of manifest.entries) {
403
+ paths.add(entry.path);
404
+ if (entry.destination_path !== undefined) paths.add(entry.destination_path);
405
+ }
406
+ return [...paths].sort((left, right) => left.localeCompare(right));
407
+ }
408
+
409
+ async function restorePath(
410
+ path: string,
411
+ snapshot: FileSnapshot,
412
+ files: LspWorkspaceEditFileOperations,
413
+ ): Promise<void> {
414
+ if (snapshot.kind === "missing") {
415
+ await files.removePath(path);
416
+ return;
417
+ }
418
+ if (snapshot.kind === "file") {
419
+ await files.replaceFile(path, contentsFromSnapshot(snapshot), snapshot.mode);
420
+ return;
421
+ }
422
+ await files.removePath(path);
423
+ await mkdir(dirname(path), { recursive: true });
424
+ await symlink(snapshot.link_target, path);
425
+ }
426
+
427
+ function mutablePreview(preview: LspWorkspaceEditPreview): LspWorkspaceEditPreview {
428
+ return structuredClone(preview);
429
+ }
430
+
431
+ /** Own persisted Workspace Edit Preview state and guarded one-use application. */
432
+ export class LspWorkspaceEditStore {
433
+ private readonly previews = new Map<string, LspWorkspaceEditPreview>();
434
+ private readonly unreportedPreviews = new Map<string, LspWorkspaceEditPreview>();
435
+ private readonly createPreviewId: () => string;
436
+ private readonly files: LspWorkspaceEditFileOperations;
437
+ private readonly queueMutation: <T>(path: string, operation: () => Promise<T>) => Promise<T>;
438
+
439
+ /** Construct a preview store with optional deterministic IDs, queues, and failure points. */
440
+ constructor(options: LspWorkspaceEditStoreOptions = {}) {
441
+ this.createPreviewId = options.createPreviewId ?? randomUUID;
442
+ this.files = options.fileOperations ?? nodeLspWorkspaceEditFileOperations;
443
+ this.queueMutation = options.queueMutation ?? withFileMutationQueue;
444
+ }
445
+
446
+ /** Normalize and persist one language-server Workspace Edit without mutating files. */
447
+ async createPreview(input: CreateWorkspaceEditPreviewInput): Promise<LspWorkspaceEditPreview> {
448
+ const operations: NormalizedWorkspaceOperation[] = [];
449
+ const editableOperations = new Map<string, ModifyOperation | CreateOperation>();
450
+ const resourceActions = new Map<string, string>();
451
+ const destinations = new Set<string>();
452
+ const encoding = input.positionEncoding ?? "utf-16";
453
+
454
+ const rememberResourceAction = (path: string, action: string): void => {
455
+ const previous = resourceActions.get(path);
456
+ if (previous !== undefined && previous !== action) {
457
+ throw new LspWorkspaceEditError(
458
+ "contradictory_resource_operations",
459
+ `contradictory resource operations target ${path}`,
460
+ );
461
+ }
462
+ resourceActions.set(path, action);
463
+ };
464
+
465
+ const applyDocumentEdits = async (uri: string, edits: readonly TextEdit[]): Promise<void> => {
466
+ const namedPath = filePathFromUri(uri);
467
+ const editable = editableOperations.get(namedPath);
468
+ if (editable !== undefined) {
469
+ const current = Buffer.from(editable.after_base64, "base64");
470
+ const decoded = decodeUtf8(current, namedPath);
471
+ editable.after_base64 = encodeUtf8(
472
+ applyTextEdits(decoded.text, edits, encoding),
473
+ decoded.bom,
474
+ ).toString("base64");
475
+ return;
476
+ }
477
+
478
+ const namedBefore = await snapshotNamedPath(namedPath);
479
+ if (namedBefore.kind === "missing") {
480
+ throw new LspWorkspaceEditError(
481
+ "invalid_destination",
482
+ `text edit file is missing: ${namedPath}`,
483
+ );
484
+ }
485
+ const targetPath = await realpath(namedPath);
486
+ const targetMetadata = await stat(targetPath);
487
+ if (!targetMetadata.isFile()) {
488
+ throw new LspWorkspaceEditError(
489
+ "directory_operation",
490
+ `text edit target is not a file: ${namedPath}`,
491
+ );
492
+ }
493
+ const before = await snapshotNamedPath(targetPath);
494
+ if (before.kind !== "file") {
495
+ throw new LspWorkspaceEditError(
496
+ "invalid_destination",
497
+ `text edit target is not a file: ${targetPath}`,
498
+ );
499
+ }
500
+ const current = contentsFromSnapshot(before);
501
+ const decoded = decodeUtf8(current, targetPath);
502
+ const after = encodeUtf8(applyTextEdits(decoded.text, edits, encoding), decoded.bom);
503
+ const operation: ModifyOperation = {
504
+ kind: "modify",
505
+ named_path: namedPath,
506
+ path: targetPath,
507
+ named_before: namedBefore,
508
+ before,
509
+ after_base64: after.toString("base64"),
510
+ mode: before.mode,
511
+ };
512
+ operations.push(operation);
513
+ editableOperations.set(namedPath, operation);
514
+ };
515
+
516
+ for (const [uri, edits] of Object.entries(input.edit.changes ?? {})) {
517
+ await applyDocumentEdits(uri, edits);
518
+ }
519
+
520
+ for (const change of input.edit.documentChanges ?? []) {
521
+ if (!("kind" in change)) {
522
+ await applyDocumentEdits(change.textDocument.uri, regularTextEdits(change.edits));
523
+ continue;
524
+ }
525
+ if (change.kind === "create") {
526
+ const path = filePathFromUri(change.uri);
527
+ if (destinations.has(path)) {
528
+ throw new LspWorkspaceEditError(
529
+ "duplicate_destination",
530
+ `duplicate destination: ${path}`,
531
+ );
532
+ }
533
+ destinations.add(path);
534
+ rememberResourceAction(path, "create");
535
+ const before = await snapshotNamedPath(path);
536
+ if (before.kind !== "missing" && change.options?.ignoreIfExists === true) continue;
537
+ if (before.kind !== "missing" && change.options?.overwrite !== true) {
538
+ throw new LspWorkspaceEditError(
539
+ "invalid_destination",
540
+ `create destination exists: ${path}`,
541
+ );
542
+ }
543
+ const operation: CreateOperation = {
544
+ kind: "create",
545
+ named_path: path,
546
+ before,
547
+ after_base64: "",
548
+ mode: before.kind === "file" ? before.mode : 0o600,
549
+ };
550
+ operations.push(operation);
551
+ editableOperations.set(path, operation);
552
+ continue;
553
+ }
554
+ if (change.kind === "delete") {
555
+ const path = filePathFromUri(change.uri);
556
+ rememberResourceAction(path, "delete");
557
+ const before = await snapshotNamedPath(path);
558
+ if (before.kind === "missing" && change.options?.ignoreIfNotExists === true) continue;
559
+ if (before.kind === "missing") {
560
+ throw new LspWorkspaceEditError(
561
+ "invalid_destination",
562
+ `delete source is missing: ${path}`,
563
+ );
564
+ }
565
+ operations.push({ kind: "delete", named_path: path, before });
566
+ continue;
567
+ }
568
+
569
+ const source = filePathFromUri(change.oldUri);
570
+ const destination = filePathFromUri(change.newUri);
571
+ if (destinations.has(destination)) {
572
+ throw new LspWorkspaceEditError(
573
+ "duplicate_destination",
574
+ `duplicate destination: ${destination}`,
575
+ );
576
+ }
577
+ destinations.add(destination);
578
+ rememberResourceAction(source, "rename-source");
579
+ rememberResourceAction(destination, "rename-destination");
580
+ const before = await snapshotNamedPath(source);
581
+ if (before.kind === "missing") {
582
+ throw new LspWorkspaceEditError(
583
+ "invalid_destination",
584
+ `rename source is missing: ${source}`,
585
+ );
586
+ }
587
+ const destinationBefore = await snapshotNamedPath(destination);
588
+ if (destinationBefore.kind !== "missing" && change.options?.ignoreIfExists === true) continue;
589
+ if (destinationBefore.kind !== "missing" && change.options?.overwrite !== true) {
590
+ throw new LspWorkspaceEditError(
591
+ "invalid_destination",
592
+ `rename destination exists: ${destination}`,
593
+ );
594
+ }
595
+ operations.push({
596
+ kind: "rename",
597
+ named_path: source,
598
+ destination_path: destination,
599
+ before,
600
+ destination_before: destinationBefore,
601
+ });
602
+ }
603
+
604
+ const summaries = operations
605
+ .flatMap((operation) => {
606
+ if (operation.kind === "modify") {
607
+ return [
608
+ fileSummary(
609
+ operation.named_path,
610
+ contentsFromSnapshot(operation.before),
611
+ Buffer.from(operation.after_base64, "base64"),
612
+ ),
613
+ ];
614
+ }
615
+ return [];
616
+ })
617
+ .sort((left, right) => left.localeCompare(right));
618
+ const preview: LspWorkspaceEditPreview = {
619
+ kind: "workspace_edit_preview",
620
+ preview_id: this.createPreviewId(),
621
+ server_id: input.serverId,
622
+ summary: summaries.join("\n"),
623
+ state: "available",
624
+ operations,
625
+ };
626
+ this.previews.set(preview.preview_id, preview);
627
+ this.unreportedPreviews.set(preview.preview_id, preview);
628
+ return structuredClone(preview);
629
+ }
630
+
631
+ /** Mark a tool-created preview as already included in its originating LSP result. */
632
+ markPreviewReported(previewId: string): void {
633
+ this.unreportedPreviews.delete(previewId);
634
+ }
635
+
636
+ /** Take server-initiated previews that must be exposed through the active LSP result. */
637
+ takeUnreportedPreviewRecords(): LspWorkspaceEditPreview[] {
638
+ const records = [...this.unreportedPreviews.values()].map((preview) =>
639
+ structuredClone(preview),
640
+ );
641
+ this.unreportedPreviews.clear();
642
+ return records;
643
+ }
644
+
645
+ /** Return the canonical Mutation Manifest prepared before Pi's `tool_call` hooks run. */
646
+ prepareMutationManifest(previewId: string): LspMutationManifest {
647
+ const preview = this.requireAvailablePreview(previewId);
648
+ return structuredClone(manifestForOperations(preview.operations));
649
+ }
650
+
651
+ /** Rebuild branch-local available/applied preview state from persisted tool result records. */
652
+ replayPreviewRecords(records: readonly LSPAny[]): LspWorkspaceEditReplayResult {
653
+ let accepted = 0;
654
+ let rejected = 0;
655
+ for (const record of records) {
656
+ if (!isWorkspaceEditPreview(record)) {
657
+ rejected++;
658
+ continue;
659
+ }
660
+ this.previews.set(record.preview_id, mutablePreview(record));
661
+ accepted++;
662
+ }
663
+ return { accepted, rejected };
664
+ }
665
+
666
+ /** Revalidate and apply one preview inside every sorted canonical mutation queue. */
667
+ async applyPreview(
668
+ previewId: string,
669
+ manifest: LspMutationManifest,
670
+ signal?: AbortSignal,
671
+ ): Promise<LspWorkspaceEditApplyResult> {
672
+ const preview = this.requireAvailablePreview(previewId);
673
+ const canonical = manifestForOperations(preview.operations);
674
+ if (JSON.stringify(manifest) !== JSON.stringify(canonical)) {
675
+ throw new LspWorkspaceEditError(
676
+ "mutation_manifest_mismatch",
677
+ "Mutation Manifest no longer matches its preview",
678
+ );
679
+ }
680
+ const queuePaths = manifestQueuePaths(canonical);
681
+ const acquire = async (index: number): Promise<LspWorkspaceEditApplyResult> => {
682
+ const path = queuePaths[index];
683
+ if (path === undefined) return this.applyInsideQueues(preview, canonical, signal);
684
+ return this.queueMutation(path, () => acquire(index + 1));
685
+ };
686
+ return acquire(0);
687
+ }
688
+
689
+ private requireAvailablePreview(previewId: string): LspWorkspaceEditPreview {
690
+ const preview = this.previews.get(previewId);
691
+ if (preview === undefined) {
692
+ throw new LspWorkspaceEditError(
693
+ "preview_not_found",
694
+ `Workspace Edit Preview not found: ${previewId}`,
695
+ );
696
+ }
697
+ if (preview.state === "applied") {
698
+ throw new LspWorkspaceEditError(
699
+ "preview_already_applied",
700
+ `Workspace Edit Preview was already applied: ${previewId}`,
701
+ );
702
+ }
703
+ return preview;
704
+ }
705
+
706
+ private async applyInsideQueues(
707
+ preview: LspWorkspaceEditPreview,
708
+ manifest: LspMutationManifest,
709
+ signal?: AbortSignal,
710
+ ): Promise<LspWorkspaceEditApplyResult> {
711
+ if (JSON.stringify(manifest) !== JSON.stringify(manifestForOperations(preview.operations))) {
712
+ throw new LspWorkspaceEditError(
713
+ "mutation_manifest_mismatch",
714
+ "Mutation Manifest changed while waiting for file queues",
715
+ );
716
+ }
717
+ await this.assertPreviewFresh(preview);
718
+ if (signal?.aborted === true) {
719
+ throw new LspWorkspaceEditError(
720
+ "workspace_edit_cancelled",
721
+ "Workspace Edit cancelled before its first mutation",
722
+ );
723
+ }
724
+
725
+ const rollback: Array<{ readonly path: string; readonly snapshot: FileSnapshot }> = [];
726
+ const changedFiles: string[] = [];
727
+ const createdFiles: string[] = [];
728
+ const deletedFiles: string[] = [];
729
+ const movedFiles: Array<{ readonly from: string; readonly to: string }> = [];
730
+ try {
731
+ for (const operation of preview.operations) {
732
+ if (operation.kind === "modify") {
733
+ await this.files.replaceFile(
734
+ operation.path,
735
+ Buffer.from(operation.after_base64, "base64"),
736
+ operation.mode,
737
+ );
738
+ rollback.push({ path: operation.path, snapshot: operation.before });
739
+ changedFiles.push(operation.named_path);
740
+ continue;
741
+ }
742
+ if (operation.kind === "create") {
743
+ await this.files.replaceFile(
744
+ operation.named_path,
745
+ Buffer.from(operation.after_base64, "base64"),
746
+ operation.mode,
747
+ );
748
+ rollback.push({ path: operation.named_path, snapshot: operation.before });
749
+ if (operation.before.kind === "missing") createdFiles.push(operation.named_path);
750
+ else changedFiles.push(operation.named_path);
751
+ continue;
752
+ }
753
+ if (operation.kind === "delete") {
754
+ await this.files.removePath(operation.named_path);
755
+ rollback.push({ path: operation.named_path, snapshot: operation.before });
756
+ deletedFiles.push(operation.named_path);
757
+ continue;
758
+ }
759
+ if (operation.destination_before.kind !== "missing") {
760
+ await this.files.removePath(operation.destination_path);
761
+ rollback.push({
762
+ path: operation.destination_path,
763
+ snapshot: operation.destination_before,
764
+ });
765
+ }
766
+ await this.files.renamePath(operation.named_path, operation.destination_path);
767
+ rollback.push({ path: operation.named_path, snapshot: operation.before });
768
+ if (operation.destination_before.kind === "missing") {
769
+ rollback.push({
770
+ path: operation.destination_path,
771
+ snapshot: operation.destination_before,
772
+ });
773
+ }
774
+ movedFiles.push({ from: operation.named_path, to: operation.destination_path });
775
+ }
776
+ } catch (cause) {
777
+ const recoveryFailures: string[] = [];
778
+ for (const entry of rollback.reverse()) {
779
+ try {
780
+ await restorePath(entry.path, entry.snapshot, this.files);
781
+ } catch {
782
+ recoveryFailures.push(entry.path);
783
+ }
784
+ }
785
+ if (recoveryFailures.length > 0) {
786
+ throw new LspWorkspaceEditError(
787
+ "workspace_edit_recovery_failed",
788
+ `Workspace Edit rollback failed for: ${recoveryFailures.join(", ")}`,
789
+ recoveryFailures,
790
+ { cause },
791
+ );
792
+ }
793
+ throw new LspWorkspaceEditError(
794
+ "workspace_edit_apply_failed",
795
+ "Workspace Edit failed and was rolled back",
796
+ [],
797
+ { cause },
798
+ );
799
+ }
800
+
801
+ this.previews.set(preview.preview_id, { ...preview, state: "applied" });
802
+ return {
803
+ preview_id: preview.preview_id,
804
+ state: "applied",
805
+ changed_files: changedFiles.sort(),
806
+ created_files: createdFiles.sort(),
807
+ deleted_files: deletedFiles.sort(),
808
+ moved_files: movedFiles,
809
+ };
810
+ }
811
+
812
+ private async assertPreviewFresh(preview: LspWorkspaceEditPreview): Promise<void> {
813
+ for (const operation of preview.operations) {
814
+ if (operation.kind === "modify") {
815
+ if (
816
+ !snapshotMatches(await snapshotNamedPath(operation.named_path), operation.named_before) ||
817
+ !snapshotMatches(await snapshotNamedPath(operation.path), operation.before)
818
+ ) {
819
+ throw new LspWorkspaceEditError(
820
+ "stale_workspace_edit",
821
+ `file changed: ${operation.named_path}`,
822
+ );
823
+ }
824
+ continue;
825
+ }
826
+ if (operation.kind === "rename") {
827
+ if (
828
+ !snapshotMatches(await snapshotNamedPath(operation.named_path), operation.before) ||
829
+ !snapshotMatches(
830
+ await snapshotNamedPath(operation.destination_path),
831
+ operation.destination_before,
832
+ )
833
+ ) {
834
+ throw new LspWorkspaceEditError(
835
+ "stale_workspace_edit",
836
+ `rename source or destination changed: ${operation.named_path}`,
837
+ );
838
+ }
839
+ continue;
840
+ }
841
+ if (!snapshotMatches(await snapshotNamedPath(operation.named_path), operation.before)) {
842
+ throw new LspWorkspaceEditError(
843
+ "stale_workspace_edit",
844
+ `file changed: ${operation.named_path}`,
845
+ );
846
+ }
847
+ }
848
+ }
849
+ }
850
+
851
+ function isWorkspaceEditPreview(value: LSPAny): value is LspWorkspaceEditPreview {
852
+ return (
853
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- This is the persisted-preview parser boundary; every field is refined before replay.
854
+ typeof value === "object" &&
855
+ value !== null &&
856
+ "kind" in value &&
857
+ value.kind === "workspace_edit_preview" &&
858
+ "preview_id" in value &&
859
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Persisted preview field refinement.
860
+ typeof value.preview_id === "string" &&
861
+ "server_id" in value &&
862
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Persisted preview field refinement.
863
+ typeof value.server_id === "string" &&
864
+ "summary" in value &&
865
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Persisted preview field refinement.
866
+ typeof value.summary === "string" &&
867
+ "state" in value &&
868
+ (value.state === "available" || value.state === "applied") &&
869
+ "operations" in value &&
870
+ Array.isArray(value.operations)
871
+ );
872
+ }