ast-mcp-server 0.6.0 → 0.7.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.
- package/CHANGELOG.md +27 -0
- package/README.md +47 -15
- package/SECURITY.md +31 -0
- package/dist/cli.js +0 -0
- package/dist/index.js +129 -10
- package/dist/server.js +21 -2
- package/dist/services/agent-setup.js +36 -117
- package/dist/services/agent-targets.js +122 -0
- package/dist/services/context-builder.js +52 -24
- package/dist/services/diagnostics.js +10 -5
- package/dist/services/file-fingerprints.js +104 -0
- package/dist/services/file-snapshot.js +8 -1
- package/dist/services/impact.js +334 -0
- package/dist/services/operations.js +343 -254
- package/dist/services/outline.js +94 -21
- package/dist/services/pagination.js +10 -4
- package/dist/services/project-operation-scheduler.js +475 -0
- package/dist/services/project-status.js +134 -52
- package/dist/services/project-watcher.js +230 -0
- package/dist/services/project.js +646 -66
- package/dist/services/public-errors.js +255 -0
- package/dist/services/references.js +7 -1
- package/dist/services/relationships.js +1198 -0
- package/dist/services/request-context.js +42 -0
- package/dist/services/runtime-activity.js +65 -0
- package/dist/services/runtime-logger.js +50 -0
- package/dist/services/runtime-policy.js +47 -0
- package/dist/services/setup-wizard.js +4 -7
- package/dist/services/shutdown.js +93 -0
- package/dist/services/symbol-index-limits.js +5 -0
- package/dist/services/symbol-index-order.js +13 -0
- package/dist/services/symbol-index-policy.js +91 -0
- package/dist/services/symbol-index-sqlite.js +850 -0
- package/dist/services/symbol-index.js +447 -0
- package/dist/services/symbols.js +403 -79
- package/dist/services/test-candidates.js +209 -0
- package/dist/services/workspace.js +18 -7
- package/dist/tools/apply_operation.js +8 -4
- package/dist/tools/explore.js +8 -6
- package/dist/tools/find_references.js +12 -13
- package/dist/tools/get_diagnostics.js +10 -7
- package/dist/tools/get_file.js +23 -11
- package/dist/tools/get_impact.js +134 -0
- package/dist/tools/get_operation_preview.js +7 -4
- package/dist/tools/get_outline.js +8 -6
- package/dist/tools/get_project_status.js +77 -8
- package/dist/tools/get_symbol_source.js +8 -6
- package/dist/tools/list_files.js +11 -6
- package/dist/tools/rename_symbol.js +6 -4
- package/dist/tools/replace_symbol_body.js +6 -4
- package/dist/tools/result.js +27 -17
- package/dist/tools/scaffold_class.js +6 -4
- package/dist/tools/search_symbols.js +15 -14
- package/docs/support.md +80 -0
- package/package.json +16 -5
- 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,
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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:
|
|
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(
|
|
232
|
+
diff: createTwoFilesPatch(file, file, originalText, updatedText, "before", "after", {
|
|
195
233
|
context: 3,
|
|
196
234
|
}),
|
|
197
|
-
mode:
|
|
235
|
+
mode: fileStat.mode,
|
|
198
236
|
});
|
|
199
|
-
continue;
|
|
200
237
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
: null,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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)
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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 (
|
|
586
|
-
|
|
587
|
-
|
|
545
|
+
if (Date.parse(operation.expires_at) <= operationNow()) {
|
|
546
|
+
operations.delete(operationId);
|
|
547
|
+
throw new Error(`Prepared operation "${operationId}" has expired.`);
|
|
588
548
|
}
|
|
589
|
-
|
|
590
|
-
|
|
549
|
+
if (operation.blocked) {
|
|
550
|
+
throw new Error(operation.block_reason ?? "The operation is blocked by validation errors.");
|
|
591
551
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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");
|