ast-mcp-server 0.6.0 → 0.7.1

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +47 -15
  3. package/SECURITY.md +31 -0
  4. package/dist/cli.js +0 -0
  5. package/dist/index.js +129 -10
  6. package/dist/server.js +21 -2
  7. package/dist/services/agent-setup.js +36 -117
  8. package/dist/services/agent-targets.js +122 -0
  9. package/dist/services/context-builder.js +52 -24
  10. package/dist/services/diagnostics.js +10 -5
  11. package/dist/services/file-fingerprints.js +104 -0
  12. package/dist/services/file-snapshot.js +8 -1
  13. package/dist/services/impact.js +334 -0
  14. package/dist/services/operations.js +343 -254
  15. package/dist/services/outline.js +94 -21
  16. package/dist/services/pagination.js +10 -4
  17. package/dist/services/project-operation-scheduler.js +475 -0
  18. package/dist/services/project-status.js +134 -52
  19. package/dist/services/project-watcher.js +230 -0
  20. package/dist/services/project.js +646 -66
  21. package/dist/services/public-errors.js +255 -0
  22. package/dist/services/references.js +7 -1
  23. package/dist/services/relationships.js +1198 -0
  24. package/dist/services/request-context.js +42 -0
  25. package/dist/services/runtime-activity.js +65 -0
  26. package/dist/services/runtime-logger.js +50 -0
  27. package/dist/services/runtime-policy.js +47 -0
  28. package/dist/services/setup-wizard.js +4 -7
  29. package/dist/services/shutdown.js +93 -0
  30. package/dist/services/symbol-index-limits.js +5 -0
  31. package/dist/services/symbol-index-order.js +13 -0
  32. package/dist/services/symbol-index-policy.js +91 -0
  33. package/dist/services/symbol-index-sqlite.js +850 -0
  34. package/dist/services/symbol-index.js +447 -0
  35. package/dist/services/symbols.js +403 -79
  36. package/dist/services/test-candidates.js +209 -0
  37. package/dist/services/workspace.js +18 -7
  38. package/dist/tools/apply_operation.js +8 -4
  39. package/dist/tools/explore.js +8 -6
  40. package/dist/tools/find_references.js +12 -13
  41. package/dist/tools/get_diagnostics.js +10 -7
  42. package/dist/tools/get_file.js +23 -11
  43. package/dist/tools/get_impact.js +134 -0
  44. package/dist/tools/get_operation_preview.js +7 -4
  45. package/dist/tools/get_outline.js +8 -6
  46. package/dist/tools/get_project_status.js +77 -8
  47. package/dist/tools/get_symbol_source.js +8 -6
  48. package/dist/tools/list_files.js +11 -6
  49. package/dist/tools/rename_symbol.js +6 -4
  50. package/dist/tools/replace_symbol_body.js +6 -4
  51. package/dist/tools/result.js +27 -17
  52. package/dist/tools/scaffold_class.js +6 -4
  53. package/dist/tools/search_symbols.js +15 -14
  54. package/docs/support.md +80 -0
  55. package/package.json +16 -5
  56. package/skills/structural-code-editing/SKILL.md +37 -10
@@ -5,7 +5,8 @@ import path from "node:path";
5
5
  import { createTwoFilesPatch } from "diff";
6
6
  import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
7
7
  import { compareDiagnostics, normalizeDiagnostic, } from "./diagnostics.js";
8
- import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProject, } from "./project.js";
8
+ import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProjectIfIdle, withProjectOperation, } from "./project.js";
9
+ import { NO_REQUEST_CONTEXT } from "./request-context.js";
9
10
  import { executableDeclaration } from "./symbols.js";
10
11
  import { buildClassScaffold } from "./scaffold.js";
11
12
  import { withWorkspaceFileLock } from "./runtime-state.js";
@@ -61,8 +62,8 @@ function pruneOperations() {
61
62
  operations.delete(oldest);
62
63
  }
63
64
  }
64
- function normalizeProjectDiagnostics(project, projectRoot) {
65
- return [...project.getConfigFileParsingDiagnostics(), ...project.getPreEmitDiagnostics()].map((diagnostic) => normalizeDiagnostic(diagnostic, projectRoot));
65
+ function normalizeProjectDiagnostics(project, projectRoot, requestContext) {
66
+ return [...project.getConfigFileParsingDiagnostics(), ...project.getPreEmitDiagnostics()].map((diagnostic) => normalizeDiagnostic(diagnostic, projectRoot, requestContext));
66
67
  }
67
68
  function decodeSource(bytes, filePath) {
68
69
  if ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) {
@@ -154,127 +155,149 @@ function assertSafeCreationState(projectRoot, absolutePath, displayPath) {
154
155
  assertSafeNewOperationFile(projectRoot, absolutePath, displayPath);
155
156
  }
156
157
  }
157
- async function createPlan(projectRootInput, kind, allowNewErrors, mutate) {
158
- pruneOperations();
159
- const context = createFreshProject(projectRootInput);
160
- const workspaceBefore = createWorkspaceSnapshot(context);
161
- const beforeDiagnostics = normalizeProjectDiagnostics(context.project, context.projectRoot);
162
- const originals = new Map(context.project
163
- .getSourceFiles()
164
- .map((sourceFile) => [sourceFile.getFilePath(), sourceFile.getFullText()]));
165
- for (const [filePath, sourceText] of originals) {
166
- assertSafeOperationFile(context.projectRoot, filePath, path.relative(context.projectRoot, filePath));
167
- const diskText = decodeSource(await readFile(filePath), filePath);
168
- if (diskText !== sourceText) {
169
- throw new Error(`Workspace changed while the operation was being prepared (${path.relative(context.projectRoot, filePath)}). Retry preparation.`);
158
+ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, requestContext) {
159
+ requestContext.checkpoint();
160
+ let retainedOperationId;
161
+ return withProjectOperation(projectRootInput, async (operationContext) => {
162
+ operationContext.checkpoint();
163
+ pruneOperations();
164
+ const context = createFreshProject(projectRootInput);
165
+ const workspaceBefore = createWorkspaceSnapshot(context);
166
+ operationContext.checkpoint();
167
+ const beforeDiagnostics = normalizeProjectDiagnostics(context.project, context.projectRoot, operationContext);
168
+ const originals = new Map(context.project
169
+ .getSourceFiles()
170
+ .map((sourceFile) => [sourceFile.getFilePath(), sourceFile.getFullText()]));
171
+ for (const [filePath, sourceText] of originals) {
172
+ operationContext.checkpoint();
173
+ assertSafeOperationFile(context.projectRoot, filePath, path.relative(context.projectRoot, filePath));
174
+ const diskText = decodeSource(await readFile(filePath), filePath);
175
+ operationContext.checkpoint();
176
+ if (diskText !== sourceText) {
177
+ throw new Error(`Workspace changed while the operation was being prepared (${path.relative(context.projectRoot, filePath)}). Retry preparation.`);
178
+ }
170
179
  }
171
- }
172
- const referenceCount = mutate(context.project, context.projectRoot);
173
- const afterDiagnostics = normalizeProjectDiagnostics(context.project, context.projectRoot);
174
- const diagnosticDelta = compareDiagnostics(beforeDiagnostics, afterDiagnostics);
175
- const files = [];
176
- for (const sourceFile of context.project.getSourceFiles()) {
177
- const absolutePath = sourceFile.getFilePath();
178
- const originalText = originals.get(absolutePath);
179
- const updatedText = sourceFile.getFullText();
180
- if (originalText === undefined) {
180
+ operationContext.checkpoint();
181
+ const referenceCount = mutate(context.project, context.projectRoot);
182
+ operationContext.checkpoint();
183
+ const afterDiagnostics = normalizeProjectDiagnostics(context.project, context.projectRoot, operationContext);
184
+ const diagnosticDelta = compareDiagnostics(beforeDiagnostics, afterDiagnostics, operationContext);
185
+ const files = [];
186
+ for (const sourceFile of context.project.getSourceFiles()) {
187
+ operationContext.checkpoint();
188
+ const absolutePath = sourceFile.getFilePath();
189
+ const originalText = originals.get(absolutePath);
190
+ const updatedText = sourceFile.getFullText();
191
+ if (originalText === undefined) {
192
+ const file = path.relative(context.projectRoot, absolutePath);
193
+ assertSafeNewOperationFile(context.projectRoot, absolutePath, file);
194
+ const originalBytes = Buffer.alloc(0);
195
+ const updatedBytes = Buffer.from(updatedText, "utf8");
196
+ files.push({
197
+ absolutePath,
198
+ file,
199
+ originalHash: ABSENT_FILE_HASH,
200
+ updatedHash: hashBytes(updatedBytes),
201
+ originalText: "",
202
+ updatedText,
203
+ originalBytes,
204
+ updatedBytes,
205
+ diff: createTwoFilesPatch("/dev/null", file, "", updatedText, "before", "after", {
206
+ context: 3,
207
+ }),
208
+ mode: DEFAULT_NEW_FILE_MODE,
209
+ });
210
+ continue;
211
+ }
212
+ if (updatedText === originalText)
213
+ continue;
214
+ const originalBytes = await readFile(absolutePath);
215
+ operationContext.checkpoint();
216
+ if (decodeSource(originalBytes, absolutePath) !== originalText) {
217
+ throw new Error(`Workspace changed while the operation was being prepared (${path.relative(context.projectRoot, absolutePath)}). Retry preparation.`);
218
+ }
219
+ const updatedBytes = encodeUpdatedSource(originalBytes, updatedText);
220
+ const fileStat = await stat(absolutePath);
221
+ operationContext.checkpoint();
181
222
  const file = path.relative(context.projectRoot, absolutePath);
182
- assertSafeNewOperationFile(context.projectRoot, absolutePath, file);
183
- const originalBytes = Buffer.alloc(0);
184
- const updatedBytes = Buffer.from(updatedText, "utf8");
185
223
  files.push({
186
224
  absolutePath,
187
225
  file,
188
- originalHash: ABSENT_FILE_HASH,
226
+ originalHash: hashBytes(originalBytes),
189
227
  updatedHash: hashBytes(updatedBytes),
190
- originalText: "",
228
+ originalText,
191
229
  updatedText,
192
230
  originalBytes,
193
231
  updatedBytes,
194
- diff: createTwoFilesPatch("/dev/null", file, "", updatedText, "before", "after", {
232
+ diff: createTwoFilesPatch(file, file, originalText, updatedText, "before", "after", {
195
233
  context: 3,
196
234
  }),
197
- mode: DEFAULT_NEW_FILE_MODE,
235
+ mode: fileStat.mode,
198
236
  });
199
- continue;
200
237
  }
201
- if (updatedText === originalText)
202
- continue;
203
- const originalBytes = await readFile(absolutePath);
204
- if (decodeSource(originalBytes, absolutePath) !== originalText) {
205
- throw new Error(`Workspace changed while the operation was being prepared (${path.relative(context.projectRoot, absolutePath)}). Retry preparation.`);
238
+ files.sort((left, right) => left.file.localeCompare(right.file));
239
+ if (files.length === 0) {
240
+ throw new Error("The structural operation produced no file changes.");
206
241
  }
207
- const updatedBytes = encodeUpdatedSource(originalBytes, updatedText);
208
- const fileStat = await stat(absolutePath);
209
- const file = path.relative(context.projectRoot, absolutePath);
210
- files.push({
211
- absolutePath,
212
- file,
213
- originalHash: hashBytes(originalBytes),
214
- updatedHash: hashBytes(updatedBytes),
215
- originalText,
216
- updatedText,
217
- originalBytes,
218
- updatedBytes,
219
- diff: createTwoFilesPatch(file, file, originalText, updatedText, "before", "after", {
220
- context: 3,
221
- }),
222
- mode: fileStat.mode,
223
- });
224
- }
225
- files.sort((left, right) => left.file.localeCompare(right.file));
226
- if (files.length === 0) {
227
- throw new Error("The structural operation produced no file changes.");
228
- }
229
- const postWorkspaceFiles = new Map(workspaceBefore.files);
230
- for (const file of files) {
231
- if (!isCreatedFile(file) && !postWorkspaceFiles.has(file.absolutePath)) {
232
- throw new Error(`Affected file is missing from the workspace snapshot: ${file.file}`);
242
+ const postWorkspaceFiles = new Map(workspaceBefore.files);
243
+ for (const file of files) {
244
+ operationContext.checkpoint();
245
+ if (!isCreatedFile(file) && !postWorkspaceFiles.has(file.absolutePath)) {
246
+ throw new Error(`Affected file is missing from the workspace snapshot: ${file.file}`);
247
+ }
248
+ postWorkspaceFiles.set(file.absolutePath, file.updatedHash);
233
249
  }
234
- postWorkspaceFiles.set(file.absolutePath, file.updatedHash);
235
- }
236
- const postWorkspaceHash = hashWorkspaceFiles(postWorkspaceFiles);
237
- const workspaceAfter = createWorkspaceSnapshot(createFreshProject(context.tsConfigFilePath));
238
- if (workspaceAfter.digest !== workspaceBefore.digest) {
239
- throw new Error("Workspace changed while the operation was being prepared. Retry preparation.");
240
- }
241
- const fullPreview = files.map((file) => file.diff).join("\n");
242
- const blocked = !allowNewErrors && diagnosticDelta.addedErrors.length > 0;
243
- const now = operationNow();
244
- const operationId = randomUUID();
245
- const recordWithoutHash = {
246
- operation_id: operationId,
247
- kind,
248
- status: "prepared",
249
- project_root: context.projectRoot,
250
- tsConfigFilePath: context.tsConfigFilePath,
251
- created_at: new Date(now).toISOString(),
252
- expires_at: new Date(now + operationTtlMs).toISOString(),
253
- affected_files: files.map((file) => ({
254
- file: file.file,
255
- original_hash: file.originalHash,
256
- updated_hash: file.updatedHash,
257
- })),
258
- reference_count: referenceCount,
259
- workspace_hash: workspaceBefore.digest,
260
- post_workspace_hash: postWorkspaceHash,
261
- workspace_file_count: workspaceBefore.fileCount,
262
- diagnostics: diagnosticDelta,
263
- allow_new_errors: allowNewErrors,
264
- blocked,
265
- block_reason: blocked
266
- ? `${diagnosticDelta.addedErrors.length} new TypeScript error(s) would be introduced. Prepare again with allow_new_errors=true only after explicit review.`
267
- : null,
268
- preview: fullPreview.length <= MAX_INLINE_PREVIEW_CHARS ? fullPreview : null,
269
- preview_truncated: fullPreview.length > MAX_INLINE_PREVIEW_CHARS,
270
- files,
271
- };
272
- const record = {
273
- ...recordWithoutHash,
274
- plan_hash: planHashFor(recordWithoutHash),
275
- };
276
- operations.set(operationId, record);
277
- return publicOperation(record);
250
+ const postWorkspaceHash = hashWorkspaceFiles(postWorkspaceFiles);
251
+ const workspaceAfter = createWorkspaceSnapshot(createFreshProject(context.tsConfigFilePath));
252
+ operationContext.checkpoint();
253
+ if (workspaceAfter.digest !== workspaceBefore.digest) {
254
+ throw new Error("Workspace changed while the operation was being prepared. Retry preparation.");
255
+ }
256
+ const fullPreview = files.map((file) => file.diff).join("\n");
257
+ const blocked = !allowNewErrors && diagnosticDelta.addedErrors.length > 0;
258
+ const now = operationNow();
259
+ const operationId = randomUUID();
260
+ const recordWithoutHash = {
261
+ operation_id: operationId,
262
+ kind,
263
+ status: "prepared",
264
+ project_root: context.projectRoot,
265
+ tsConfigFilePath: context.tsConfigFilePath,
266
+ created_at: new Date(now).toISOString(),
267
+ expires_at: new Date(now + operationTtlMs).toISOString(),
268
+ affected_files: files.map((file) => ({
269
+ file: file.file,
270
+ original_hash: file.originalHash,
271
+ updated_hash: file.updatedHash,
272
+ })),
273
+ reference_count: referenceCount,
274
+ workspace_hash: workspaceBefore.digest,
275
+ post_workspace_hash: postWorkspaceHash,
276
+ workspace_file_count: workspaceBefore.fileCount,
277
+ diagnostics: diagnosticDelta,
278
+ allow_new_errors: allowNewErrors,
279
+ blocked,
280
+ block_reason: blocked
281
+ ? `${diagnosticDelta.addedErrors.length} new TypeScript error(s) would be introduced. Prepare again with allow_new_errors=true only after explicit review.`
282
+ : null,
283
+ preview: fullPreview.length <= MAX_INLINE_PREVIEW_CHARS ? fullPreview : null,
284
+ preview_truncated: fullPreview.length > MAX_INLINE_PREVIEW_CHARS,
285
+ files,
286
+ };
287
+ const record = {
288
+ ...recordWithoutHash,
289
+ plan_hash: planHashFor(recordWithoutHash),
290
+ };
291
+ operationContext.checkpoint();
292
+ operations.set(operationId, record);
293
+ retainedOperationId = operationId;
294
+ await operationTestHooks.afterRetain?.(operationId);
295
+ return publicOperation(record);
296
+ }, requestContext).catch((error) => {
297
+ if (retainedOperationId)
298
+ operations.delete(retainedOperationId);
299
+ throw error;
300
+ });
278
301
  }
279
302
  function publicOperation(record) {
280
303
  return {
@@ -298,7 +321,8 @@ function publicOperation(record) {
298
321
  preview_truncated: record.preview_truncated,
299
322
  };
300
323
  }
301
- export async function prepareRename(args) {
324
+ export async function prepareRename(args, requestContext = NO_REQUEST_CONTEXT) {
325
+ requestContext.checkpoint();
302
326
  const scanner = ts.createScanner(ScriptTarget.Latest, false, LanguageVariant.Standard, args.newName);
303
327
  const validIdentifier = scanner.scan() === SyntaxKind.Identifier && scanner.scan() === SyntaxKind.EndOfFileToken;
304
328
  if (!validIdentifier) {
@@ -310,12 +334,14 @@ export async function prepareRename(args) {
310
334
  if (!Node.isRenameable(node)) {
311
335
  throw new Error(`Symbol "${args.symbolPath}" (${node.getKindName()}) does not support structural rename.`);
312
336
  }
313
- const referenceCount = Node.isReferenceFindable(node) ? node.findReferencesAsNodes().length : 0;
337
+ const referenceCount = Node.isReferenceFindable(node)
338
+ ? node.findReferencesAsNodes().length
339
+ : 0;
314
340
  node.rename(args.newName);
315
341
  return referenceCount;
316
- });
342
+ }, requestContext);
317
343
  }
318
- export async function prepareReplaceBody(args) {
344
+ export async function prepareReplaceBody(args, requestContext = NO_REQUEST_CONTEXT) {
319
345
  return createPlan(args.projectRoot, "replace_symbol_body", args.allowNewErrors ?? false, (project) => {
320
346
  const sourceFile = getSourceFileOrThrow(project, args.filePath);
321
347
  const declaration = findDeclarationByName(sourceFile, args.symbolPath);
@@ -328,9 +354,10 @@ export async function prepareReplaceBody(args) {
328
354
  }
329
355
  executable.setBodyText(args.newBody);
330
356
  return 0;
331
- });
357
+ }, requestContext);
332
358
  }
333
- export async function prepareScaffoldClass(args) {
359
+ export async function prepareScaffoldClass(args, requestContext = NO_REQUEST_CONTEXT) {
360
+ requestContext.checkpoint();
334
361
  if (path.isAbsolute(args.filePath)) {
335
362
  throw new Error("Scaffold file_path must be project-relative.");
336
363
  }
@@ -354,7 +381,7 @@ export async function prepareScaffoldClass(args) {
354
381
  pendingMethods: scaffold.pendingMethods,
355
382
  };
356
383
  return 0;
357
- });
384
+ }, requestContext);
358
385
  if (!metadata)
359
386
  throw new Error("Scaffold generation did not produce metadata.");
360
387
  return {
@@ -365,15 +392,18 @@ export async function prepareScaffoldClass(args) {
365
392
  pendingMethods: metadata.pendingMethods,
366
393
  };
367
394
  }
368
- async function withWriteLock(key, callback) {
395
+ async function withWriteLock(key, requestContext, callback) {
396
+ requestContext.checkpoint();
369
397
  const previous = writeQueues.get(key) ?? Promise.resolve();
370
398
  let release;
371
399
  const next = new Promise((resolve) => {
372
400
  release = resolve;
373
401
  });
374
402
  writeQueues.set(key, next);
375
- await previous;
376
403
  try {
404
+ await operationTestHooks.afterWriteLockEnqueue?.();
405
+ await previous;
406
+ requestContext.checkpoint();
377
407
  return await callback();
378
408
  }
379
409
  finally {
@@ -429,15 +459,18 @@ async function syncDirectories(files) {
429
459
  }
430
460
  }
431
461
  }
432
- async function currentWorkspaceHash(operation) {
462
+ async function currentWorkspaceHash(operation, requestContext) {
463
+ requestContext.checkpoint();
433
464
  const context = createFreshProject(operation.tsConfigFilePath);
434
465
  const snapshot = createWorkspaceSnapshot(context);
435
466
  const files = new Map(snapshot.files);
436
467
  for (const file of operation.files) {
468
+ requestContext.checkpoint();
437
469
  if (!isCreatedFile(file))
438
470
  continue;
439
471
  try {
440
472
  files.set(file.absolutePath, hashBytes(await readFile(file.absolutePath)));
473
+ requestContext.checkpoint();
441
474
  }
442
475
  catch (error) {
443
476
  if (error.code !== "ENOENT")
@@ -445,6 +478,7 @@ async function currentWorkspaceHash(operation) {
445
478
  files.delete(file.absolutePath);
446
479
  }
447
480
  }
481
+ requestContext.checkpoint();
448
482
  return hashWorkspaceFiles(files);
449
483
  }
450
484
  function appliedResult(operation, idempotentReplay) {
@@ -465,7 +499,8 @@ export function configureOperationApply(operationId, options) {
465
499
  operation.receiptWriter = options.receiptWriter;
466
500
  operation.lockStateDirectory = options.stateDirectory;
467
501
  }
468
- export async function applyOperation(operationId, planHash) {
502
+ export async function applyOperation(operationId, planHash, requestContext = NO_REQUEST_CONTEXT) {
503
+ requestContext.checkpoint();
469
504
  const operation = operations.get(operationId);
470
505
  if (!operation) {
471
506
  throw new Error(`Prepared operation "${operationId}" was not found or has expired.`);
@@ -473,6 +508,8 @@ export async function applyOperation(operationId, planHash) {
473
508
  if (planHash !== operation.plan_hash) {
474
509
  throw new Error(`Plan hash mismatch for operation "${operationId}". No files were written.`);
475
510
  }
511
+ requestContext.checkpoint();
512
+ let invalidateAfterCompletion = false;
476
513
  const persistReceipt = async () => {
477
514
  if (!operation.receiptWriter)
478
515
  return;
@@ -483,149 +520,201 @@ export async function applyOperation(operationId, planHash) {
483
520
  throw new Error("Operation sources are at the verified postimages, but receipt persistence failed. Retry apply with the same reviewed plan after resolving the storage error.", { cause: error });
484
521
  }
485
522
  };
486
- return withWriteLock(operation.tsConfigFilePath, async () => withWorkspaceFileLock(operation.tsConfigFilePath, { stateDirectory: operation.lockStateDirectory }, async () => {
487
- if (operation.status === "applied") {
488
- for (const file of operation.files) {
489
- assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
490
- const currentBytes = await readFile(file.absolutePath);
491
- if (hashBytes(currentBytes) !== file.updatedHash) {
492
- throw new Error(`Applied receipt conflict: ${file.file} no longer matches the recorded postimage.`);
493
- }
494
- }
495
- await persistReceipt();
496
- return appliedResult(operation, true);
497
- }
498
- if (Date.parse(operation.expires_at) <= operationNow()) {
499
- operations.delete(operationId);
500
- throw new Error(`Prepared operation "${operationId}" has expired.`);
501
- }
502
- if (operation.blocked) {
503
- throw new Error(operation.block_reason ?? "The operation is blocked by validation errors.");
504
- }
505
- for (const file of operation.files) {
506
- if (isCreatedFile(file)) {
507
- assertSafeCreationState(operation.project_root, file.absolutePath, file.file);
508
- }
509
- else {
510
- assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
511
- }
512
- }
513
- const workspaceHash = await currentWorkspaceHash(operation);
514
- if (workspaceHash === operation.post_workspace_hash) {
515
- for (const file of operation.files) {
516
- assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
517
- const currentBytes = await readFile(file.absolutePath);
518
- if (hashBytes(currentBytes) !== file.updatedHash) {
519
- throw new Error(`Postimage recovery conflict: ${file.file} does not match the reviewed postimage.`);
520
- }
521
- }
522
- operation.status = "applied";
523
- operation.applied_at ??= new Date().toISOString();
524
- invalidateProject(operation.tsConfigFilePath);
525
- await persistReceipt();
526
- return appliedResult(operation, true);
527
- }
528
- if (workspaceHash !== operation.workspace_hash) {
529
- throw new Error("Conflict: the project source/config workspace changed after preparation. No files were written.");
530
- }
531
- for (const file of operation.files) {
532
- if (isCreatedFile(file))
533
- continue;
534
- const currentBytes = await readFile(file.absolutePath);
535
- if (hashBytes(currentBytes) !== file.originalHash) {
536
- throw new Error(`Conflict: ${file.file} changed after the operation was prepared. No files were written.`);
537
- }
538
- }
539
- const staged = new Map();
540
- const applied = [];
541
- try {
542
- for (const file of operation.files) {
543
- staged.set(file, await stageFile(file, operationId));
544
- }
545
- for (const file of operation.files) {
546
- if (isCreatedFile(file)) {
547
- assertSafeNewOperationFile(operation.project_root, file.absolutePath, file.file);
548
- continue;
549
- }
550
- const currentBytes = await readFile(file.absolutePath);
551
- if (hashBytes(currentBytes) !== file.originalHash) {
552
- throw new Error(`Conflict: ${file.file} changed while the operation was being staged. No files were written.`);
553
- }
554
- }
555
- for (const [index, file] of operation.files.entries()) {
556
- await operationTestHooks.beforeReplace?.(file.file, index);
557
- if (isCreatedFile(file)) {
558
- await link(staged.get(file), file.absolutePath);
559
- applied.push(file);
560
- await safeUnlink(staged.get(file));
561
- staged.delete(file);
562
- }
563
- else {
564
- await rename(staged.get(file), file.absolutePath);
565
- staged.delete(file);
566
- applied.push(file);
567
- }
568
- await operationTestHooks.afterReplace?.(file.file, index);
569
- const writtenBytes = await readFile(file.absolutePath);
570
- if (hashBytes(writtenBytes) !== file.updatedHash) {
571
- throw new Error(`Post-write verification failed for ${file.file}.`);
572
- }
573
- }
574
- await syncDirectories(operation.files);
575
- }
576
- catch (error) {
577
- const rollbackErrors = [];
578
- for (const file of [...applied].reverse()) {
579
- try {
580
- const currentBytes = await readFile(file.absolutePath);
581
- if (hashBytes(currentBytes) !== file.updatedHash) {
582
- rollbackErrors.push(`${file.file}: current bytes no longer match this operation's postimage`);
583
- continue;
523
+ try {
524
+ return await withProjectOperation(operation.tsConfigFilePath, async (operationContext) => {
525
+ operationContext.checkpoint();
526
+ return withWriteLock(operation.tsConfigFilePath, operationContext, async () => {
527
+ operationContext.checkpoint();
528
+ return withWorkspaceFileLock(operation.tsConfigFilePath, { stateDirectory: operation.lockStateDirectory }, async () => {
529
+ operationContext.checkpoint();
530
+ if (operation.status === "applied") {
531
+ for (const file of operation.files) {
532
+ operationContext.checkpoint();
533
+ assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
534
+ const currentBytes = await readFile(file.absolutePath);
535
+ operationContext.checkpoint();
536
+ if (hashBytes(currentBytes) !== file.updatedHash) {
537
+ throw new Error(`Applied receipt conflict: ${file.file} no longer matches the recorded postimage.`);
538
+ }
539
+ }
540
+ operationContext.enterCompletionCritical();
541
+ invalidateAfterCompletion = true;
542
+ await persistReceipt();
543
+ return appliedResult(operation, true);
584
544
  }
585
- if (isCreatedFile(file)) {
586
- assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
587
- await safeUnlink(file.absolutePath);
545
+ if (Date.parse(operation.expires_at) <= operationNow()) {
546
+ operations.delete(operationId);
547
+ throw new Error(`Prepared operation "${operationId}" has expired.`);
588
548
  }
589
- else {
590
- await restoreFile(file, operationId);
549
+ if (operation.blocked) {
550
+ throw new Error(operation.block_reason ?? "The operation is blocked by validation errors.");
591
551
  }
592
- }
593
- catch (rollbackError) {
594
- rollbackErrors.push(`${file.file}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
595
- }
596
- }
597
- for (const temporaryPath of staged.values())
598
- await safeUnlink(temporaryPath);
599
- if (applied.length > 0 && rollbackErrors.length === 0) {
600
- await syncDirectories(applied);
601
- throw new Error(`Operation failed after replacing ${applied.length} file(s); rollback succeeded and original bytes were restored. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
602
- }
603
- if (rollbackErrors.length > 0) {
604
- throw new Error(`Operation failed and rollback was incomplete: ${rollbackErrors.join("; ")}. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
605
- }
606
- throw error;
552
+ for (const file of operation.files) {
553
+ operationContext.checkpoint();
554
+ if (isCreatedFile(file)) {
555
+ assertSafeCreationState(operation.project_root, file.absolutePath, file.file);
556
+ }
557
+ else {
558
+ assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
559
+ }
560
+ }
561
+ const workspaceHash = await currentWorkspaceHash(operation, operationContext);
562
+ if (workspaceHash === operation.post_workspace_hash) {
563
+ for (const file of operation.files) {
564
+ operationContext.checkpoint();
565
+ assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
566
+ const currentBytes = await readFile(file.absolutePath);
567
+ operationContext.checkpoint();
568
+ if (hashBytes(currentBytes) !== file.updatedHash) {
569
+ throw new Error(`Postimage recovery conflict: ${file.file} does not match the reviewed postimage.`);
570
+ }
571
+ }
572
+ operationContext.enterCompletionCritical();
573
+ invalidateAfterCompletion = true;
574
+ operation.status = "applied";
575
+ operation.applied_at ??= new Date().toISOString();
576
+ await persistReceipt();
577
+ return appliedResult(operation, true);
578
+ }
579
+ if (workspaceHash !== operation.workspace_hash) {
580
+ throw new Error("Conflict: the project source/config workspace changed after preparation. No files were written.");
581
+ }
582
+ for (const file of operation.files) {
583
+ operationContext.checkpoint();
584
+ if (isCreatedFile(file))
585
+ continue;
586
+ const currentBytes = await readFile(file.absolutePath);
587
+ operationContext.checkpoint();
588
+ if (hashBytes(currentBytes) !== file.originalHash) {
589
+ throw new Error(`Conflict: ${file.file} changed after the operation was prepared. No files were written.`);
590
+ }
591
+ }
592
+ const staged = new Map();
593
+ const applied = [];
594
+ let completionCritical = false;
595
+ try {
596
+ for (const file of operation.files) {
597
+ staged.set(file, await stageFile(file, operationId));
598
+ operationContext.checkpoint();
599
+ }
600
+ for (const file of operation.files) {
601
+ operationContext.checkpoint();
602
+ if (isCreatedFile(file)) {
603
+ assertSafeNewOperationFile(operation.project_root, file.absolutePath, file.file);
604
+ continue;
605
+ }
606
+ const currentBytes = await readFile(file.absolutePath);
607
+ operationContext.checkpoint();
608
+ if (hashBytes(currentBytes) !== file.originalHash) {
609
+ throw new Error(`Conflict: ${file.file} changed while the operation was being staged. No files were written.`);
610
+ }
611
+ }
612
+ for (const [index, file] of operation.files.entries()) {
613
+ operationContext.checkpoint();
614
+ await operationTestHooks.beforeReplace?.(file.file, index);
615
+ if (!completionCritical) {
616
+ operationContext.enterCompletionCritical();
617
+ completionCritical = true;
618
+ invalidateAfterCompletion = true;
619
+ }
620
+ if (isCreatedFile(file)) {
621
+ await link(staged.get(file), file.absolutePath);
622
+ applied.push(file);
623
+ await safeUnlink(staged.get(file));
624
+ staged.delete(file);
625
+ }
626
+ else {
627
+ await rename(staged.get(file), file.absolutePath);
628
+ staged.delete(file);
629
+ applied.push(file);
630
+ }
631
+ await operationTestHooks.afterReplace?.(file.file, index);
632
+ const writtenBytes = await readFile(file.absolutePath);
633
+ if (hashBytes(writtenBytes) !== file.updatedHash) {
634
+ throw new Error(`Post-write verification failed for ${file.file}.`);
635
+ }
636
+ }
637
+ await syncDirectories(operation.files);
638
+ }
639
+ catch (error) {
640
+ const rollbackErrors = [];
641
+ for (const file of [...applied].reverse()) {
642
+ try {
643
+ const currentBytes = await readFile(file.absolutePath);
644
+ if (hashBytes(currentBytes) !== file.updatedHash) {
645
+ rollbackErrors.push(`${file.file}: current bytes no longer match this operation's postimage`);
646
+ continue;
647
+ }
648
+ if (isCreatedFile(file)) {
649
+ assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
650
+ await safeUnlink(file.absolutePath);
651
+ }
652
+ else {
653
+ await restoreFile(file, operationId);
654
+ }
655
+ }
656
+ catch (rollbackError) {
657
+ rollbackErrors.push(`${file.file}: ${rollbackError instanceof Error
658
+ ? rollbackError.message
659
+ : String(rollbackError)}`);
660
+ }
661
+ }
662
+ for (const temporaryPath of staged.values())
663
+ await safeUnlink(temporaryPath);
664
+ if (applied.length > 0 && rollbackErrors.length === 0) {
665
+ await syncDirectories(applied);
666
+ throw new Error(`Operation failed after replacing ${applied.length} file(s); rollback succeeded and original bytes were restored. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
667
+ }
668
+ if (rollbackErrors.length > 0) {
669
+ throw new Error(`Operation failed and rollback was incomplete: ${rollbackErrors.join("; ")}. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
670
+ }
671
+ throw error;
672
+ }
673
+ operation.status = "applied";
674
+ operation.applied_at = new Date().toISOString();
675
+ await persistReceipt();
676
+ return appliedResult(operation, false);
677
+ });
678
+ });
679
+ }, requestContext);
680
+ }
681
+ finally {
682
+ if (invalidateAfterCompletion) {
683
+ invalidateProjectIfIdle(operation.tsConfigFilePath, {
684
+ preserveCancellationTelemetry: true,
685
+ });
607
686
  }
608
- operation.status = "applied";
609
- operation.applied_at = new Date().toISOString();
610
- invalidateProject(operation.tsConfigFilePath);
611
- await persistReceipt();
612
- return appliedResult(operation, false);
613
- }));
687
+ }
614
688
  }
615
- export function getOperationPreview(operationId, file) {
689
+ export async function getOperationPreview(operationId, file, requestContext = NO_REQUEST_CONTEXT) {
690
+ requestContext.checkpoint();
616
691
  const operation = operations.get(operationId);
617
692
  if (!operation) {
618
693
  throw new Error(`Prepared operation "${operationId}" was not found or has expired.`);
619
694
  }
620
- const files = file ? operation.files.filter((item) => item.file === file) : operation.files;
621
- if (file && files.length === 0) {
622
- throw new Error(`File "${file}" is not part of operation "${operationId}".`);
623
- }
624
- return {
625
- operation_id: operationId,
626
- plan_hash: operation.plan_hash,
627
- files: files.map((item) => ({ file: item.file, diff: item.diff })),
628
- };
695
+ const tsConfigFilePath = operation.tsConfigFilePath;
696
+ return withProjectOperation(tsConfigFilePath, (operationContext) => {
697
+ operationContext.checkpoint();
698
+ const retainedOperation = operations.get(operationId);
699
+ if (!retainedOperation) {
700
+ throw new Error(`Prepared operation "${operationId}" was not found or has expired.`);
701
+ }
702
+ const files = file
703
+ ? retainedOperation.files.filter((item) => item.file === file)
704
+ : retainedOperation.files;
705
+ if (file && files.length === 0) {
706
+ throw new Error(`File "${file}" is not part of operation "${operationId}".`);
707
+ }
708
+ operationContext.checkpoint();
709
+ return {
710
+ operation_id: operationId,
711
+ plan_hash: retainedOperation.plan_hash,
712
+ files: files.map((item) => {
713
+ operationContext.checkpoint();
714
+ return { file: item.file, diff: item.diff };
715
+ }),
716
+ };
717
+ }, requestContext);
629
718
  }
630
719
  function decodePersistedBytes(value, label) {
631
720
  const bytes = Buffer.from(value, "base64");