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
@@ -1,12 +1,184 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Project } from "ts-morph";
4
- import { findDeclaration } from "./symbols.js";
4
+ import { InMemorySymbolIndex, SYMBOL_INDEX_SCHEMA_VERSION, } from "./symbol-index.js";
5
+ import { addSymbolIndexRuntimeCount, createInitialSymbolIndexRuntimeObservability, readSymbolIndexPersistencePolicy, symbolIndexCachePath, symbolIndexPolicyKey, } from "./symbol-index-policy.js";
6
+ import { openSQLiteSymbolIndexStore, quarantineSQLiteSymbolIndexFile, } from "./symbol-index-sqlite.js";
7
+ import { findDeclaration, sourceFileIndexSymbols } from "./symbols.js";
8
+ import { createProjectWatcher, selectProjectWatchDirectories, } from "./project-watcher.js";
5
9
  import { createConfigSnapshot, createWorkspaceSnapshot } from "./workspace.js";
6
- import { createInitialProjectStatus, createProjectIdentity, projectStatusToProjection, transitionProjectStatus, } from "./project-status.js";
7
- const MAX_PROJECT_SESSIONS = 8;
10
+ import { createInitialProjectStatus, createProjectIdentity, projectOperationQueueToProjection, projectStatusToProjection, transitionProjectStatus, } from "./project-status.js";
11
+ import { ProjectOperationScheduler, ProjectOperationSchedulerError, } from "./project-operation-scheduler.js";
12
+ import { isCooperativeInterruption, NO_REQUEST_CONTEXT, } from "./request-context.js";
13
+ import { readRuntimePolicy } from "./runtime-policy.js";
14
+ export class ProjectCapacityError extends Error {
15
+ code = "PROJECT_CAPACITY_EXCEEDED";
16
+ constructor() {
17
+ super("Project session capacity exceeded.");
18
+ this.name = "ProjectCapacityError";
19
+ }
20
+ }
8
21
  const projectSessions = new Map();
9
22
  const projectRoots = new WeakMap();
23
+ let projectSessionAccessSequence = 0n;
24
+ let projectRuntimePolicy;
25
+ let projectRuntimeAdmission = "open";
26
+ function nextProjectSessionAccessSequence() {
27
+ projectSessionAccessSequence += 1n;
28
+ return projectSessionAccessSequence;
29
+ }
30
+ function getProjectRuntimePolicy() {
31
+ projectRuntimePolicy ??= readRuntimePolicy();
32
+ return projectRuntimePolicy;
33
+ }
34
+ function closePersistentSymbolIndex(session) {
35
+ session.persistentSymbolIndex?.close();
36
+ session.persistentSymbolIndex = undefined;
37
+ }
38
+ function symbolIndexFailureOperation(reason) {
39
+ if (reason === "corrupt_storage")
40
+ return "corruption";
41
+ if (reason === "migration_failed" || reason === "unsupported_schema")
42
+ return "migration";
43
+ if (reason === "read_failed" || reason === "sqlite_read_failed")
44
+ return "read_failure";
45
+ if (reason === "write_failed" || reason === "sqlite_write_failed" || reason === "contention") {
46
+ return "write_failure";
47
+ }
48
+ return "fallback";
49
+ }
50
+ function fallbackToMemory(session, reason) {
51
+ const persistentSymbolIndex = session.persistentSymbolIndex;
52
+ session.persistentSymbolIndex = undefined;
53
+ const boundedReason = reason.slice(0, 128);
54
+ const operation = symbolIndexFailureOperation(boundedReason);
55
+ const previous = session.context.symbolIndexObservability;
56
+ session.context = {
57
+ ...session.context,
58
+ status: transitionProjectStatus(session.context.status, {
59
+ type: "index_failed",
60
+ error: boundedReason,
61
+ }),
62
+ symbolIndex: new InMemorySymbolIndex(),
63
+ symbolIndexBackend: "memory",
64
+ symbolIndexFallbackReason: boundedReason,
65
+ symbolIndexReady: false,
66
+ symbolIndexObservability: {
67
+ ...session.context.symbolIndexObservability,
68
+ policy: session.symbolIndexPolicy.mode,
69
+ policy_reason: session.symbolIndexPolicy.reason,
70
+ backend: "memory",
71
+ state: "failed",
72
+ operation,
73
+ last_operation: operation,
74
+ rejected_entries: addSymbolIndexRuntimeCount(previous.rejected_entries),
75
+ fallback_count: addSymbolIndexRuntimeCount(previous.fallback_count),
76
+ migration_count: addSymbolIndexRuntimeCount(previous.migration_count, operation === "migration" ? 1 : 0),
77
+ corruption_count: addSymbolIndexRuntimeCount(previous.corruption_count, operation === "corruption" ? 1 : 0),
78
+ write_failure_count: addSymbolIndexRuntimeCount(previous.write_failure_count, operation === "write_failure" ? 1 : 0),
79
+ last_error: boundedReason,
80
+ },
81
+ };
82
+ try {
83
+ persistentSymbolIndex?.close();
84
+ }
85
+ catch {
86
+ // The compiler-backed memory context was already installed; cleanup is best effort.
87
+ }
88
+ }
89
+ export async function reportSymbolIndexFailure(projectRoot, reason) {
90
+ const session = projectSessions.get(resolveTsConfigPath(projectRoot));
91
+ if (!session)
92
+ return undefined;
93
+ const cachePath = symbolIndexCachePath(session.symbolIndexPolicy, session.context.status.project);
94
+ fallbackToMemory(session, reason);
95
+ if (cachePath && reason === "corrupt_storage") {
96
+ try {
97
+ await quarantineSQLiteSymbolIndexFile(cachePath);
98
+ }
99
+ catch {
100
+ // The compiler-backed memory fallback remains authoritative if quarantine fails.
101
+ }
102
+ }
103
+ return session.context;
104
+ }
105
+ async function ensureSessionSymbolIndex(session, requestContext) {
106
+ requestContext.checkpoint();
107
+ const policy = readSymbolIndexPersistencePolicy();
108
+ if (symbolIndexPolicyKey(policy) !== symbolIndexPolicyKey(session.symbolIndexPolicy)) {
109
+ closePersistentSymbolIndex(session);
110
+ session.symbolIndexPolicy = policy;
111
+ session.context = {
112
+ ...session.context,
113
+ status: transitionProjectStatus(session.context.status, { type: "index_disabled" }),
114
+ symbolIndex: new InMemorySymbolIndex(),
115
+ symbolIndexBackend: "memory",
116
+ symbolIndexFallbackReason: null,
117
+ symbolIndexReady: false,
118
+ symbolIndexObservability: createInitialSymbolIndexRuntimeObservability(policy),
119
+ };
120
+ }
121
+ if (policy.backend === "memory")
122
+ return;
123
+ if (session.persistentSymbolIndex)
124
+ return;
125
+ const cachePath = symbolIndexCachePath(policy, session.context.status.project);
126
+ if (!cachePath)
127
+ return;
128
+ try {
129
+ requestContext.checkpoint();
130
+ const store = await openSQLiteSymbolIndexStore(cachePath, {
131
+ busyTimeoutMs: policy.busy_timeout_ms,
132
+ });
133
+ try {
134
+ requestContext.checkpoint();
135
+ }
136
+ catch (error) {
137
+ store.close();
138
+ throw error;
139
+ }
140
+ session.persistentSymbolIndex = store;
141
+ const migrationPerformed = store.migrationPerformed;
142
+ session.context = {
143
+ ...session.context,
144
+ status: session.context.status.causes.includes("index_failure")
145
+ ? transitionProjectStatus(session.context.status, { type: "index_recovered" })
146
+ : transitionProjectStatus(session.context.status, { type: "index_ready" }),
147
+ symbolIndex: store,
148
+ symbolIndexBackend: "sqlite",
149
+ symbolIndexFallbackReason: null,
150
+ symbolIndexReady: false,
151
+ symbolIndexObservability: {
152
+ ...session.context.symbolIndexObservability,
153
+ policy: policy.mode,
154
+ policy_reason: policy.reason,
155
+ backend: "sqlite",
156
+ state: "rebuilding",
157
+ operation: "miss",
158
+ last_operation: migrationPerformed ? "migration" : "miss",
159
+ migration_count: addSymbolIndexRuntimeCount(session.context.symbolIndexObservability.migration_count, migrationPerformed ? 1 : 0),
160
+ last_error: null,
161
+ },
162
+ };
163
+ }
164
+ catch (error) {
165
+ if (isCooperativeInterruption(error))
166
+ throw error;
167
+ const reason = error.code ?? "sqlite_open_failed";
168
+ if (reason === "corrupt_storage" ||
169
+ reason === "unsupported_schema" ||
170
+ reason === "migration_failed" ||
171
+ reason === "read_failed") {
172
+ try {
173
+ await quarantineSQLiteSymbolIndexFile(cachePath);
174
+ }
175
+ catch {
176
+ // Keep the memory fallback even when the corrupt artifact cannot be moved.
177
+ }
178
+ }
179
+ fallbackToMemory(session, String(reason));
180
+ }
181
+ }
10
182
  export function resolveTsConfigPath(projectRoot) {
11
183
  const abs = path.resolve(projectRoot);
12
184
  const stat = fs.existsSync(abs) ? fs.statSync(abs) : null;
@@ -27,70 +199,219 @@ function buildProjectContext(tsConfigFilePath) {
27
199
  const projectRoot = path.dirname(tsConfigFilePath);
28
200
  projectRoots.set(project, projectRoot);
29
201
  const status = createInitialProjectStatus(createProjectIdentity({ projectRoot, configPath: tsConfigFilePath }), { sourceCount: project.getSourceFiles().length });
30
- return { project, projectRoot, tsConfigFilePath, status };
202
+ return {
203
+ project,
204
+ projectRoot,
205
+ tsConfigFilePath,
206
+ status,
207
+ symbolIndex: new InMemorySymbolIndex(),
208
+ symbolIndexReady: false,
209
+ symbolIndexBackend: "memory",
210
+ symbolIndexFallbackReason: null,
211
+ symbolIndexObservability: createInitialSymbolIndexRuntimeObservability(),
212
+ };
31
213
  }
32
214
  export function createFreshProject(projectRoot) {
33
215
  return buildProjectContext(resolveTsConfigPath(projectRoot));
34
216
  }
35
- function evictSessions() {
36
- if (projectSessions.size < MAX_PROJECT_SESSIONS)
217
+ function closeProjectSession(session) {
218
+ session.watcher.close();
219
+ closePersistentSymbolIndex(session);
220
+ }
221
+ function createSessionWatcher(getSession, projectRoot, directories) {
222
+ return createProjectWatcher({
223
+ projectRoot,
224
+ directories,
225
+ onChange: (files) => {
226
+ const session = getSession();
227
+ session.context = {
228
+ ...session.context,
229
+ status: transitionProjectStatus(session.context.status, {
230
+ type: "source_changed",
231
+ files,
232
+ }),
233
+ };
234
+ },
235
+ onError: (error) => {
236
+ const session = getSession();
237
+ session.context = {
238
+ ...session.context,
239
+ status: transitionProjectStatus(session.context.status, {
240
+ type: "watcher_failed",
241
+ error: error instanceof Error ? error.message : String(error),
242
+ }),
243
+ };
244
+ },
245
+ });
246
+ }
247
+ function sameDirectories(left, right) {
248
+ return (left.length === right.length && left.every((directory, index) => directory === right[index]));
249
+ }
250
+ function replaceSessionWatcher(session, directories) {
251
+ if (sameDirectories(session.watchDirectories, directories) &&
252
+ session.watcher.snapshot().state !== "failed") {
37
253
  return;
38
- const candidate = [...projectSessions.entries()]
39
- .filter(([, session]) => session.activeOperations === 0)
40
- .sort((left, right) => left[1].lastAccessedAt - right[1].lastAccessedAt)[0];
41
- if (candidate)
254
+ }
255
+ const previous = session.watcher;
256
+ const replacement = createSessionWatcher(() => session, session.context.projectRoot, directories);
257
+ session.watcher = replacement;
258
+ session.watchDirectories = directories;
259
+ replacement.start();
260
+ previous.close();
261
+ if (replacement.snapshot().state === "ready") {
262
+ session.context = {
263
+ ...session.context,
264
+ status: transitionProjectStatus(session.context.status, { type: "watcher_recovered" }),
265
+ };
266
+ }
267
+ }
268
+ function sessionHasAdmittedOperations(session) {
269
+ const snapshot = session.scheduler.snapshot();
270
+ return snapshot.active_operations + snapshot.queued_operations > 0;
271
+ }
272
+ function ensureProjectSessionCapacity(sessionCapacity) {
273
+ while (projectSessions.size >= sessionCapacity) {
274
+ const candidate = [...projectSessions.entries()]
275
+ .filter(([, session]) => !sessionHasAdmittedOperations(session))
276
+ .sort((left, right) => left[1].lastAccessSequence < right[1].lastAccessSequence
277
+ ? -1
278
+ : left[1].lastAccessSequence > right[1].lastAccessSequence
279
+ ? 1
280
+ : 0)[0];
281
+ if (!candidate)
282
+ throw new ProjectCapacityError();
283
+ closeProjectSession(candidate[1]);
42
284
  projectSessions.delete(candidate[0]);
285
+ }
286
+ }
287
+ function assertProjectRuntimeAdmissionOpen() {
288
+ if (projectRuntimeAdmission === "closed") {
289
+ throw new ProjectOperationSchedulerError("SERVER_SHUTTING_DOWN");
290
+ }
43
291
  }
44
- function getOrCreateSession(projectRoot) {
292
+ function getOrCreateSession(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
293
+ assertProjectRuntimeAdmissionOpen();
294
+ requestContext.checkpoint();
45
295
  const tsConfigFilePath = resolveTsConfigPath(projectRoot);
46
296
  const existing = projectSessions.get(tsConfigFilePath);
47
297
  if (existing)
48
298
  return existing;
49
- evictSessions();
299
+ const runtimePolicy = getProjectRuntimePolicy();
300
+ ensureProjectSessionCapacity(runtimePolicy.maxProjectSessions);
301
+ const symbolIndexPolicy = readSymbolIndexPersistencePolicy();
302
+ requestContext.checkpoint();
303
+ const baseContext = buildProjectContext(tsConfigFilePath);
304
+ const context = {
305
+ ...baseContext,
306
+ symbolIndexObservability: createInitialSymbolIndexRuntimeObservability(symbolIndexPolicy),
307
+ };
308
+ const watchDirectories = selectProjectWatchDirectories(context.projectRoot, context.project.getSourceFiles().map((sourceFile) => sourceFile.getFilePath()));
309
+ const watcher = createSessionWatcher(() => session, context.projectRoot, watchDirectories);
50
310
  const session = {
51
- context: buildProjectContext(tsConfigFilePath),
52
- configDigest: createConfigSnapshot(tsConfigFilePath).digest,
53
- queue: Promise.resolve(),
54
- activeOperations: 0,
55
- runningOperations: 0,
56
- queuedOperations: 0,
57
- lastAccessedAt: Date.now(),
311
+ context,
312
+ watcher,
313
+ watchDirectories,
314
+ configDigest: (() => {
315
+ requestContext.checkpoint();
316
+ return createConfigSnapshot(tsConfigFilePath).digest;
317
+ })(),
318
+ fingerprints: new Map(),
319
+ compilerStateUntrusted: false,
320
+ scheduler: new ProjectOperationScheduler({
321
+ queueCapacity: runtimePolicy.maxQueuedOperationsPerProject,
322
+ queueWaitTimeoutMs: runtimePolicy.queueWaitTimeoutMs,
323
+ operationDeadlineMs: runtimePolicy.operationDeadlineMs,
324
+ }),
325
+ lastAccessSequence: nextProjectSessionAccessSequence(),
326
+ symbolIndexPolicy,
58
327
  };
59
328
  projectSessions.set(tsConfigFilePath, session);
329
+ session.watcher.start();
330
+ if (session.watcher.snapshot().state === "ready") {
331
+ session.context = {
332
+ ...session.context,
333
+ status: transitionProjectStatus(session.context.status, { type: "watcher_recovered" }),
334
+ };
335
+ }
60
336
  return session;
61
337
  }
62
338
  function getQueueStatus(session) {
63
- return {
64
- state: session.runningOperations > 0 ? "running" : session.queuedOperations > 0 ? "queued" : "idle",
65
- active_operations: session.runningOperations,
66
- queued_operations: session.queuedOperations,
67
- };
339
+ const snapshot = session.scheduler.snapshot();
340
+ return projectOperationQueueToProjection(snapshot, snapshot.queue_capacity);
341
+ }
342
+ export function getProjectOperationQueueSnapshot(projectRoot) {
343
+ const session = projectSessions.get(resolveTsConfigPath(projectRoot));
344
+ if (!session)
345
+ throw new Error("Project session not found.");
346
+ return getQueueStatus(session);
68
347
  }
69
- async function synchronizeSession(session) {
348
+ export function getProjectSessionRegistrySnapshot() {
349
+ const activeSessions = [...projectSessions.values()].filter((session) => sessionHasAdmittedOperations(session)).length;
350
+ return Object.freeze({
351
+ session_count: projectSessions.size,
352
+ active_sessions: activeSessions,
353
+ idle_sessions: projectSessions.size - activeSessions,
354
+ session_capacity: getProjectRuntimePolicy().maxProjectSessions,
355
+ });
356
+ }
357
+ async function synchronizeSession(session, requestContext) {
70
358
  let syncFailureCause = "compiler_rebuild";
71
359
  let currentConfigDigest;
360
+ let compilerStateMayHaveAdvanced = false;
72
361
  try {
362
+ requestContext.checkpoint();
73
363
  currentConfigDigest = createConfigSnapshot(session.context.tsConfigFilePath).digest;
74
- if (currentConfigDigest !== session.configDigest) {
75
- let status = transitionProjectStatus(session.context.status, { type: "config_changed" });
364
+ const compilerConfigChanged = currentConfigDigest !== session.configDigest;
365
+ if (compilerConfigChanged || session.compilerStateUntrusted) {
366
+ let status = session.context.status;
367
+ if (compilerConfigChanged) {
368
+ status = transitionProjectStatus(status, { type: "config_changed" });
369
+ }
76
370
  status = transitionProjectStatus(status, { type: "compiler_rebuild_started" });
371
+ const symbolIndexObservability = session.context.symbolIndexObservability;
372
+ closePersistentSymbolIndex(session);
77
373
  const rebuilt = buildProjectContext(session.context.tsConfigFilePath);
78
- session.context = { ...rebuilt, status };
374
+ session.context = { ...rebuilt, status, symbolIndexObservability };
375
+ session.compilerStateUntrusted = false;
79
376
  }
80
377
  const { project, tsConfigFilePath } = session.context;
81
378
  syncFailureCause = "source_change";
82
- const refreshSourceFiles = async () => {
83
- project.addSourceFilesFromTsConfig(tsConfigFilePath);
84
- await Promise.all(project.getSourceFiles().map((sourceFile) => sourceFile.refreshFromFileSystem()));
85
- };
86
- await refreshSourceFiles();
87
- const snapshot = createWorkspaceSnapshot(session.context);
379
+ requestContext.checkpoint();
380
+ compilerStateMayHaveAdvanced = true;
381
+ project.addSourceFilesFromTsConfig(tsConfigFilePath);
382
+ for (const sourceFile of project.getSourceFiles()) {
383
+ requestContext.checkpoint();
384
+ if (!fs.existsSync(sourceFile.getFilePath()))
385
+ project.removeSourceFile(sourceFile);
386
+ }
387
+ requestContext.checkpoint();
388
+ replaceSessionWatcher(session, selectProjectWatchDirectories(session.context.projectRoot, project.getSourceFiles().map((sourceFile) => sourceFile.getFilePath())));
389
+ requestContext.checkpoint();
390
+ const snapshot = createWorkspaceSnapshot(session.context, {
391
+ previousFingerprints: session.fingerprints,
392
+ verifyContentHash: true,
393
+ });
88
394
  if (snapshot.configDigest !== currentConfigDigest) {
89
395
  syncFailureCause = "config_change";
90
396
  throw new Error("Project configuration changed during synchronization. Retry the operation.");
91
397
  }
92
- await refreshSourceFiles();
93
- const verificationSnapshot = createWorkspaceSnapshot(session.context);
398
+ const changedPaths = new Set([
399
+ ...snapshot.fingerprintChanges.added,
400
+ ...snapshot.fingerprintChanges.changed,
401
+ ]);
402
+ requestContext.checkpoint();
403
+ await Promise.all(project.getSourceFiles().map(async (sourceFile) => {
404
+ requestContext.checkpoint();
405
+ const absoluteFilePath = fs.realpathSync(sourceFile.getFilePath());
406
+ if (changedPaths.has(absoluteFilePath))
407
+ await sourceFile.refreshFromFileSystem();
408
+ }));
409
+ requestContext.checkpoint();
410
+ const verificationSnapshot = createWorkspaceSnapshot(session.context, {
411
+ previousFingerprints: snapshot.fingerprints,
412
+ verifyContentHash: true,
413
+ });
414
+ requestContext.checkpoint();
94
415
  if (verificationSnapshot.configDigest !== currentConfigDigest) {
95
416
  syncFailureCause = "config_change";
96
417
  throw new Error("Project configuration changed during synchronization. Retry the operation.");
@@ -99,6 +420,165 @@ async function synchronizeSession(session) {
99
420
  syncFailureCause = "source_change";
100
421
  throw new Error("Project source changed during synchronization. Retry the operation.");
101
422
  }
423
+ requestContext.checkpoint();
424
+ await ensureSessionSymbolIndex(session, requestContext);
425
+ const sourceFiles = session.context.project.getSourceFiles();
426
+ const currentIndexFiles = sourceFiles.map((sourceFile) => {
427
+ requestContext.checkpoint();
428
+ const absoluteFilePath = fs.realpathSync(sourceFile.getFilePath());
429
+ const fingerprint = verificationSnapshot.fingerprints.get(absoluteFilePath);
430
+ if (!fingerprint) {
431
+ throw new Error(`No verified fingerprint found for ${absoluteFilePath}.`);
432
+ }
433
+ return {
434
+ file_path: path
435
+ .relative(session.context.projectRoot, sourceFile.getFilePath())
436
+ .replaceAll(path.sep, "/"),
437
+ content_hash: fingerprint.content_hash,
438
+ };
439
+ });
440
+ let existingIndexEntries;
441
+ try {
442
+ requestContext.checkpoint();
443
+ existingIndexEntries = await session.context.symbolIndex.load(session.context.status.project, SYMBOL_INDEX_SCHEMA_VERSION);
444
+ requestContext.checkpoint();
445
+ }
446
+ catch (error) {
447
+ if (isCooperativeInterruption(error))
448
+ throw error;
449
+ if (session.context.symbolIndexBackend !== "sqlite")
450
+ throw error;
451
+ fallbackToMemory(session, error.code ?? "sqlite_read_failed");
452
+ requestContext.checkpoint();
453
+ existingIndexEntries = await session.context.symbolIndex.load(session.context.status.project, SYMBOL_INDEX_SCHEMA_VERSION);
454
+ requestContext.checkpoint();
455
+ }
456
+ if (session.context.symbolIndexBackend === "sqlite") {
457
+ const cacheWarm = existingIndexEntries.length > 0;
458
+ session.context = {
459
+ ...session.context,
460
+ symbolIndexObservability: {
461
+ ...session.context.symbolIndexObservability,
462
+ state: "rebuilding",
463
+ operation: cacheWarm ? "hit" : "miss",
464
+ last_operation: cacheWarm ? "hit" : "miss",
465
+ loaded_entries: addSymbolIndexRuntimeCount(session.context.symbolIndexObservability.loaded_entries, existingIndexEntries.length),
466
+ accepted_entries: addSymbolIndexRuntimeCount(session.context.symbolIndexObservability.accepted_entries, existingIndexEntries.length),
467
+ cache_hits: addSymbolIndexRuntimeCount(session.context.symbolIndexObservability.cache_hits, cacheWarm ? 1 : 0),
468
+ cache_misses: addSymbolIndexRuntimeCount(session.context.symbolIndexObservability.cache_misses, cacheWarm ? 0 : 1),
469
+ },
470
+ };
471
+ }
472
+ const existingByPath = new Map(existingIndexEntries.map((entry) => [entry.file_path, entry]));
473
+ const fingerprintChangedFiles = new Set([
474
+ ...verificationSnapshot.fingerprintChanges.added,
475
+ ...verificationSnapshot.fingerprintChanges.changed,
476
+ ].map((filePath) => path.relative(session.context.projectRoot, filePath).replaceAll(path.sep, "/")));
477
+ const configChanged = existingIndexEntries.some((entry) => entry.config_digest !== verificationSnapshot.configDigest);
478
+ const filesToRebuild = new Set(currentIndexFiles
479
+ .filter((file) => {
480
+ const previous = existingByPath.get(file.file_path);
481
+ return (configChanged ||
482
+ fingerprintChangedFiles.has(file.file_path) ||
483
+ !previous ||
484
+ previous.content_hash !== file.content_hash ||
485
+ previous.config_digest !== verificationSnapshot.configDigest);
486
+ })
487
+ .map((file) => file.file_path));
488
+ const indexFiles = sourceFiles
489
+ .filter((sourceFile) => {
490
+ requestContext.checkpoint();
491
+ const filePath = path
492
+ .relative(session.context.projectRoot, sourceFile.getFilePath())
493
+ .replaceAll(path.sep, "/");
494
+ return filesToRebuild.has(filePath);
495
+ })
496
+ .map((sourceFile) => {
497
+ requestContext.checkpoint();
498
+ const absoluteFilePath = fs.realpathSync(sourceFile.getFilePath());
499
+ const fingerprint = verificationSnapshot.fingerprints.get(absoluteFilePath);
500
+ if (!fingerprint) {
501
+ throw new Error(`No verified fingerprint found for ${absoluteFilePath}.`);
502
+ }
503
+ return {
504
+ file_path: path
505
+ .relative(session.context.projectRoot, sourceFile.getFilePath())
506
+ .replaceAll(path.sep, "/"),
507
+ content_hash: fingerprint.content_hash,
508
+ symbols: sourceFileIndexSymbols(sourceFile, session.context.projectRoot),
509
+ };
510
+ });
511
+ try {
512
+ requestContext.checkpoint();
513
+ const refreshResult = await session.context.symbolIndex.refresh({
514
+ project: session.context.status.project,
515
+ config_digest: verificationSnapshot.configDigest,
516
+ current_files: currentIndexFiles,
517
+ files: indexFiles,
518
+ last_indexed_at: new Date().toISOString(),
519
+ });
520
+ requestContext.checkpoint();
521
+ await session.context.symbolIndex.flush();
522
+ requestContext.checkpoint();
523
+ session.context = {
524
+ ...session.context,
525
+ symbolIndexReady: true,
526
+ symbolIndexObservability: session.context.symbolIndexBackend === "sqlite"
527
+ ? {
528
+ ...session.context.symbolIndexObservability,
529
+ state: "ready",
530
+ operation: refreshResult.rebuilt_files.length > 0 ? "rebuild" : "hit",
531
+ last_operation: refreshResult.rebuilt_files.length > 0 ? "rebuild" : "hit",
532
+ last_successful_persistence_at: new Date().toISOString(),
533
+ rebuilt_files: addSymbolIndexRuntimeCount(session.context.symbolIndexObservability.rebuilt_files, refreshResult.rebuilt_files.length),
534
+ reused_files: addSymbolIndexRuntimeCount(session.context.symbolIndexObservability.reused_files, refreshResult.reused_files.length),
535
+ removed_files: addSymbolIndexRuntimeCount(session.context.symbolIndexObservability.removed_files, refreshResult.removed_files.length),
536
+ }
537
+ : session.context.symbolIndexObservability,
538
+ };
539
+ }
540
+ catch (error) {
541
+ if (isCooperativeInterruption(error))
542
+ throw error;
543
+ if (session.context.symbolIndexBackend === "sqlite") {
544
+ fallbackToMemory(session, error.code ?? "sqlite_write_failed");
545
+ try {
546
+ const fallbackFiles = sourceFiles.map((sourceFile) => {
547
+ requestContext.checkpoint();
548
+ const absoluteFilePath = fs.realpathSync(sourceFile.getFilePath());
549
+ const fingerprint = verificationSnapshot.fingerprints.get(absoluteFilePath);
550
+ if (!fingerprint) {
551
+ throw new Error(`No verified fingerprint found for ${absoluteFilePath}.`);
552
+ }
553
+ return {
554
+ file_path: path
555
+ .relative(session.context.projectRoot, sourceFile.getFilePath())
556
+ .replaceAll(path.sep, "/"),
557
+ content_hash: fingerprint.content_hash,
558
+ symbols: sourceFileIndexSymbols(sourceFile, session.context.projectRoot),
559
+ };
560
+ });
561
+ requestContext.checkpoint();
562
+ await session.context.symbolIndex.refresh({
563
+ project: session.context.status.project,
564
+ config_digest: verificationSnapshot.configDigest,
565
+ current_files: currentIndexFiles,
566
+ files: fallbackFiles,
567
+ last_indexed_at: new Date().toISOString(),
568
+ });
569
+ requestContext.checkpoint();
570
+ session.context = { ...session.context, symbolIndexReady: true };
571
+ }
572
+ catch (fallbackError) {
573
+ if (isCooperativeInterruption(fallbackError))
574
+ throw fallbackError;
575
+ session.context = { ...session.context, symbolIndexReady: false };
576
+ }
577
+ }
578
+ else {
579
+ session.context = { ...session.context, symbolIndexReady: false };
580
+ }
581
+ }
102
582
  const status = transitionProjectStatus(session.context.status, {
103
583
  type: "sync_succeeded",
104
584
  sourceCount: verificationSnapshot.sourceFileCount,
@@ -110,8 +590,14 @@ async function synchronizeSession(session) {
110
590
  });
111
591
  session.context = { ...session.context, status };
112
592
  session.configDigest = verificationSnapshot.configDigest;
593
+ session.fingerprints = verificationSnapshot.fingerprints;
594
+ session.compilerStateUntrusted = false;
113
595
  }
114
596
  catch (error) {
597
+ if (compilerStateMayHaveAdvanced)
598
+ session.compilerStateUntrusted = true;
599
+ if (isCooperativeInterruption(error))
600
+ throw error;
115
601
  const status = transitionProjectStatus(session.context.status, {
116
602
  type: "sync_failed",
117
603
  cause: syncFailureCause,
@@ -121,43 +607,66 @@ async function synchronizeSession(session) {
121
607
  throw error;
122
608
  }
123
609
  }
124
- async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure) {
125
- session.activeOperations += 1;
126
- session.queuedOperations += 1;
127
- let release;
128
- const previous = session.queue;
129
- session.queue = new Promise((resolve) => {
130
- release = resolve;
131
- });
132
- await previous;
133
- session.queuedOperations -= 1;
134
- session.runningOperations += 1;
135
- try {
610
+ async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure, requestContext) {
611
+ requestContext.checkpoint();
612
+ return session.scheduler.run(async (operationContext) => {
613
+ operationContext.checkpoint();
136
614
  try {
137
- await synchronizeSession(session);
615
+ await synchronizeSession(session, operationContext);
138
616
  }
139
617
  catch (error) {
140
- if (!allowSynchronizationFailure)
618
+ if (!allowSynchronizationFailure || isCooperativeInterruption(error))
141
619
  throw error;
142
620
  }
143
- session.lastAccessedAt = Date.now();
144
- return await operation(session.context);
145
- }
146
- finally {
147
- session.runningOperations -= 1;
148
- session.activeOperations -= 1;
149
- release?.();
150
- }
621
+ operationContext.checkpoint();
622
+ operationContext.markExecuting();
623
+ session.lastAccessSequence = nextProjectSessionAccessSequence();
624
+ return operation(session.context, operationContext);
625
+ }, { signal: requestContext.signal });
626
+ }
627
+ export async function withProject(projectRoot, operation, requestContext = NO_REQUEST_CONTEXT) {
628
+ requestContext.checkpoint();
629
+ return runSessionWithSyncPolicy(getOrCreateSession(projectRoot, requestContext), operation, false, requestContext);
151
630
  }
152
- export async function withProject(projectRoot, operation) {
153
- return runSessionWithSyncPolicy(getOrCreateSession(projectRoot), operation, false);
631
+ export async function withProjectOperation(projectRoot, operation, requestContext = NO_REQUEST_CONTEXT) {
632
+ requestContext.checkpoint();
633
+ const session = getOrCreateSession(projectRoot, requestContext);
634
+ return session.scheduler.run((operationContext) => {
635
+ operationContext.checkpoint();
636
+ operationContext.markExecuting();
637
+ session.lastAccessSequence = nextProjectSessionAccessSequence();
638
+ return operation(operationContext);
639
+ }, { signal: requestContext.signal });
154
640
  }
155
- export async function getProjectStatus(projectRoot) {
156
- const session = getOrCreateSession(projectRoot);
157
- return runSessionWithSyncPolicy(session, (context) => ({
158
- ...projectStatusToProjection(context.status),
159
- operation_queue: getQueueStatus(session),
160
- }), true);
641
+ export async function getProjectStatus(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
642
+ requestContext.checkpoint();
643
+ const session = getOrCreateSession(projectRoot, requestContext);
644
+ return runSessionWithSyncPolicy(session, async (context, operationContext) => {
645
+ let effectiveContext = context;
646
+ let indexedCount = 0;
647
+ if (context.symbolIndexBackend === "sqlite" && context.symbolIndexReady) {
648
+ try {
649
+ operationContext.checkpoint();
650
+ indexedCount = (await context.symbolIndex.load(context.status.project, SYMBOL_INDEX_SCHEMA_VERSION)).length;
651
+ operationContext.checkpoint();
652
+ }
653
+ catch (error) {
654
+ if (isCooperativeInterruption(error))
655
+ throw error;
656
+ fallbackToMemory(session, error.code ?? "sqlite_read_failed");
657
+ effectiveContext = session.context;
658
+ indexedCount = 0;
659
+ }
660
+ }
661
+ return {
662
+ ...projectStatusToProjection(effectiveContext.status),
663
+ index: { state: effectiveContext.symbolIndexObservability.state },
664
+ indexed_count: indexedCount,
665
+ last_successful_index_at: effectiveContext.symbolIndexObservability.last_successful_persistence_at,
666
+ index_observability: { ...effectiveContext.symbolIndexObservability },
667
+ operation_queue: getQueueStatus(session),
668
+ };
669
+ }, true, requestContext);
161
670
  }
162
671
  // Compatibility API for internal scripts. MCP tools should use withProject().
163
672
  export function getProject(projectRoot) {
@@ -165,10 +674,81 @@ export function getProject(projectRoot) {
165
674
  }
166
675
  export function invalidateProject(projectRoot) {
167
676
  const tsConfigFilePath = resolveTsConfigPath(projectRoot);
677
+ const session = projectSessions.get(tsConfigFilePath);
678
+ if (session)
679
+ closeProjectSession(session);
680
+ projectSessions.delete(tsConfigFilePath);
681
+ }
682
+ export function invalidateProjectIfIdle(projectRoot, options = {}) {
683
+ const tsConfigFilePath = resolveTsConfigPath(projectRoot);
684
+ const session = projectSessions.get(tsConfigFilePath);
685
+ if (!session)
686
+ return true;
687
+ const queue = session.scheduler.snapshot();
688
+ if (queue.active_operations > 0 ||
689
+ queue.queued_operations > 0 ||
690
+ (options.preserveCancellationTelemetry === true && queue.cancelled_operations > 0)) {
691
+ return false;
692
+ }
693
+ closeProjectSession(session);
168
694
  projectSessions.delete(tsConfigFilePath);
695
+ return true;
169
696
  }
170
697
  export function clearProjectSessions() {
698
+ for (const session of projectSessions.values()) {
699
+ closeProjectSession(session);
700
+ }
701
+ projectSessions.clear();
702
+ projectSessionAccessSequence = 0n;
703
+ projectRuntimePolicy = undefined;
704
+ projectRuntimeAdmission = "open";
705
+ }
706
+ export function prepareProjectRuntimeForStartup() {
707
+ if (projectSessions.size !== 0) {
708
+ throw new Error("Cannot start a project runtime while sessions remain open.");
709
+ }
710
+ projectRuntimeAdmission = "open";
711
+ }
712
+ export function getProjectRuntimeShutdownSnapshot() {
713
+ let activeOperations = 0;
714
+ let queuedOperations = 0;
715
+ let completionCriticalOperations = 0;
716
+ for (const session of projectSessions.values()) {
717
+ const snapshot = session.scheduler.shutdownSnapshot();
718
+ activeOperations += snapshot.active_operations;
719
+ queuedOperations += snapshot.queued_operations;
720
+ completionCriticalOperations += snapshot.completion_critical_operations;
721
+ }
722
+ return Object.freeze({
723
+ admission: projectRuntimeAdmission,
724
+ session_count: projectSessions.size,
725
+ active_operations: activeOperations,
726
+ queued_operations: queuedOperations,
727
+ completion_critical_operations: completionCriticalOperations,
728
+ });
729
+ }
730
+ export function beginProjectShutdown() {
731
+ projectRuntimeAdmission = "closed";
732
+ for (const session of projectSessions.values())
733
+ session.scheduler.beginShutdown();
734
+ return getProjectRuntimeShutdownSnapshot();
735
+ }
736
+ export async function waitForProjectOperationsToDrain() {
737
+ await Promise.all([...projectSessions.values()].map((session) => session.scheduler.waitForIdle()));
738
+ }
739
+ export async function waitForProjectCompletionCriticalOperationsToDrain() {
740
+ await Promise.all([...projectSessions.values()].map((session) => session.scheduler.waitForCompletionCriticalOperationsToDrain()));
741
+ }
742
+ export function closeDrainedProjectSessions() {
743
+ const snapshot = getProjectRuntimeShutdownSnapshot();
744
+ if (snapshot.active_operations !== 0 || snapshot.queued_operations !== 0) {
745
+ throw new Error("Cannot close project sessions while operations are active.");
746
+ }
747
+ for (const session of projectSessions.values())
748
+ closeProjectSession(session);
171
749
  projectSessions.clear();
750
+ projectSessionAccessSequence = 0n;
751
+ projectRuntimePolicy = undefined;
172
752
  }
173
753
  export function getSourceFileOrThrow(project, filePath) {
174
754
  const projectRoot = projectRoots.get(project) ?? process.cwd();