Skip to content

Commit f598bf6

Browse files
committed
refactor(mcp): make snapshot persistence async and workspace-scoped
1 parent 2484ae2 commit f598bf6

File tree

2 files changed

+634
-189
lines changed

2 files changed

+634
-189
lines changed

packages/mcp/src/handlers.ts

Lines changed: 121 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,12 @@ export class ToolHandlers {
1919
}
2020

2121
/**
22-
* Sync indexed codebases from Zilliz Cloud collections
23-
* This method fetches all collections from the vector database,
24-
* gets the first document from each collection to extract codebasePath from metadata,
25-
* and updates the snapshot with discovered codebases.
26-
*
27-
* Logic: Compare mcp-codebase-snapshot.json with zilliz cloud collections
28-
* - If local snapshot has extra directories (not in cloud), remove them
29-
* - If local snapshot is missing directories (exist in cloud), ignore them
22+
* Best-effort cloud sync for diagnostics.
23+
*
24+
* IMPORTANT SAFETY RULE:
25+
* Never remove local snapshot entries based only on cloud list/query results.
26+
* Different CLI sessions may run with different credentials/clusters, and
27+
* transient cloud visibility issues can cause false negatives.
3028
*/
3129
private async syncIndexedCodebasesFromCloud(): Promise<void> {
3230
try {
@@ -41,18 +39,7 @@ export class ToolHandlers {
4139
console.log(`[SYNC-CLOUD] 📋 Found ${collections.length} collections in Zilliz Cloud`);
4240

4341
if (collections.length === 0) {
44-
console.log(`[SYNC-CLOUD] ✅ No collections found in cloud`);
45-
// If no collections in cloud, remove all local codebases
46-
const localCodebases = this.snapshotManager.getIndexedCodebases();
47-
if (localCodebases.length > 0) {
48-
console.log(`[SYNC-CLOUD] 🧹 Removing ${localCodebases.length} local codebases as cloud has no collections`);
49-
for (const codebasePath of localCodebases) {
50-
this.snapshotManager.removeIndexedCodebase(codebasePath);
51-
console.log(`[SYNC-CLOUD] ➖ Removed local codebase: ${codebasePath}`);
52-
}
53-
this.snapshotManager.saveCodebaseSnapshot();
54-
console.log(`[SYNC-CLOUD] 💾 Updated snapshot to match empty cloud state`);
55-
}
42+
console.warn(`[SYNC-CLOUD] ⚠️ Cloud returned zero collections. Skipping local snapshot cleanup to avoid false negatives.`);
5643
return;
5744
}
5845

@@ -110,30 +97,19 @@ export class ToolHandlers {
11097
console.log(`[SYNC-CLOUD] 📊 Found ${cloudCodebases.size} valid codebases in cloud`);
11198

11299
// Get current local codebases
113-
const localCodebases = new Set(this.snapshotManager.getIndexedCodebases());
114-
console.log(`[SYNC-CLOUD] 📊 Found ${localCodebases.size} local codebases in snapshot`);
115-
116-
let hasChanges = false;
100+
const localCodebases = this.snapshotManager.getIndexedCodebases();
101+
console.log(`[SYNC-CLOUD] 📊 Found ${localCodebases.length} local codebases in snapshot`);
117102

118-
// Remove local codebases that don't exist in cloud
119-
for (const localCodebase of localCodebases) {
120-
if (!cloudCodebases.has(localCodebase)) {
121-
this.snapshotManager.removeIndexedCodebase(localCodebase);
122-
hasChanges = true;
123-
console.log(`[SYNC-CLOUD] ➖ Removed local codebase (not in cloud): ${localCodebase}`);
124-
}
125-
}
126-
127-
// Note: We don't add cloud codebases that are missing locally (as per user requirement)
128-
console.log(`[SYNC-CLOUD] ℹ️ Skipping addition of cloud codebases not present locally (per sync policy)`);
129-
130-
if (hasChanges) {
131-
this.snapshotManager.saveCodebaseSnapshot();
132-
console.log(`[SYNC-CLOUD] 💾 Updated snapshot to match cloud state`);
133-
} else {
134-
console.log(`[SYNC-CLOUD] ✅ Local snapshot already matches cloud state`);
103+
const missingInCloud = localCodebases.filter((localCodebase) => !cloudCodebases.has(localCodebase));
104+
if (missingInCloud.length > 0) {
105+
console.warn(
106+
`[SYNC-CLOUD] ⚠️ ${missingInCloud.length} local codebase(s) were not found in cloud metadata. ` +
107+
`Keeping local snapshot unchanged for safety.`
108+
);
135109
}
136110

111+
// Note: intentionally no snapshot mutation here.
112+
console.log(`[SYNC-CLOUD] ℹ️ Cloud sync is non-destructive; local snapshot was not modified.`);
137113
console.log(`[SYNC-CLOUD] ✅ Cloud sync completed successfully`);
138114
} catch (error: any) {
139115
console.error(`[SYNC-CLOUD] ❌ Error syncing codebases from cloud:`, error.message || error);
@@ -199,13 +175,29 @@ export class ToolHandlers {
199175
};
200176
}
201177

202-
//Check if the snapshot and cloud index are in sync
203-
if (this.snapshotManager.getIndexedCodebases().includes(absolutePath) !== await this.context.hasIndex(absolutePath)) {
178+
const snapshotHasIndex = this.snapshotManager.getIndexedCodebases().includes(absolutePath);
179+
const cloudHasIndex = await this.context.hasIndex(absolutePath);
180+
181+
// Reconcile local snapshot with cloud truth for this specific codebase
182+
if (snapshotHasIndex !== cloudHasIndex) {
204183
console.warn(`[INDEX-VALIDATION] ❌ Snapshot and cloud index mismatch: ${absolutePath}`);
184+
if (cloudHasIndex && !snapshotHasIndex) {
185+
this.snapshotManager.setCodebaseIndexed(absolutePath, {
186+
indexedFiles: 0,
187+
totalChunks: 0,
188+
status: 'completed'
189+
});
190+
await this.snapshotManager.saveCodebaseSnapshot('index-reconcile-cloud-present');
191+
console.log(`[INDEX-VALIDATION] 🛠️ Recovered missing snapshot entry from cloud index: ${absolutePath}`);
192+
} else if (!cloudHasIndex && snapshotHasIndex) {
193+
this.snapshotManager.removeCodebaseCompletely(absolutePath);
194+
await this.snapshotManager.saveCodebaseSnapshot('index-reconcile-cloud-missing');
195+
console.log(`[INDEX-VALIDATION] 🧹 Removed stale snapshot entry without cloud index: ${absolutePath}`);
196+
}
205197
}
206198

207-
// Check if already indexed (unless force is true)
208-
if (!forceReindex && this.snapshotManager.getIndexedCodebases().includes(absolutePath)) {
199+
// Check if already indexed in cloud (unless force is true)
200+
if (!forceReindex && cloudHasIndex) {
209201
return {
210202
content: [{
211203
type: "text",
@@ -219,9 +211,9 @@ export class ToolHandlers {
219211
if (forceReindex) {
220212
if (this.snapshotManager.getIndexedCodebases().includes(absolutePath)) {
221213
console.log(`[FORCE-REINDEX] 🔄 Removing '${absolutePath}' from indexed list for re-indexing`);
222-
this.snapshotManager.removeIndexedCodebase(absolutePath);
214+
this.snapshotManager.removeCodebaseCompletely(absolutePath);
223215
}
224-
if (await this.context.hasIndex(absolutePath)) {
216+
if (cloudHasIndex) {
225217
console.log(`[FORCE-REINDEX] 🔄 Clearing index for '${absolutePath}'`);
226218
await this.context.clearIndex(absolutePath);
227219
}
@@ -279,7 +271,7 @@ export class ToolHandlers {
279271

280272
// Set to indexing status and save snapshot immediately
281273
this.snapshotManager.setCodebaseIndexing(absolutePath, 0);
282-
this.snapshotManager.saveCodebaseSnapshot();
274+
await this.snapshotManager.saveCodebaseSnapshot('index-started');
283275

284276
// Track the codebase path for syncing
285277
trackCodebasePath(absolutePath);
@@ -323,7 +315,7 @@ export class ToolHandlers {
323315

324316
private async startBackgroundIndexing(codebasePath: string, forceReindex: boolean, splitterType: string) {
325317
const absolutePath = codebasePath;
326-
let lastSaveTime = 0; // Track last save timestamp
318+
let lastPersistedProgress = -1;
327319

328320
try {
329321
console.log(`[BACKGROUND-INDEX] Starting background indexing for: ${absolutePath}`);
@@ -369,12 +361,16 @@ export class ToolHandlers {
369361
// Update progress in snapshot manager using new method
370362
this.snapshotManager.setCodebaseIndexing(absolutePath, progress.percentage);
371363

372-
// Save snapshot periodically (every 2 seconds to avoid too frequent saves)
373-
const currentTime = Date.now();
374-
if (currentTime - lastSaveTime >= 2000) { // 2 seconds = 2000ms
375-
this.snapshotManager.saveCodebaseSnapshot();
376-
lastSaveTime = currentTime;
377-
console.log(`[BACKGROUND-INDEX] 💾 Saved progress snapshot at ${progress.percentage.toFixed(1)}%`);
364+
// Coalesce disk writes: persist only meaningful progress jumps.
365+
const shouldPersistProgress =
366+
lastPersistedProgress < 0 ||
367+
progress.percentage >= 100 ||
368+
Math.abs(progress.percentage - lastPersistedProgress) >= 2;
369+
370+
if (shouldPersistProgress) {
371+
this.snapshotManager.scheduleSaveCodebaseSnapshot('index-progress');
372+
lastPersistedProgress = progress.percentage;
373+
console.log(`[BACKGROUND-INDEX] 💾 Scheduled progress snapshot at ${progress.percentage.toFixed(1)}%`);
378374
}
379375

380376
console.log(`[BACKGROUND-INDEX] Progress: ${progress.phase} - ${progress.percentage}% (${progress.current}/${progress.total})`);
@@ -386,7 +382,7 @@ export class ToolHandlers {
386382
this.indexingStats = { indexedFiles: stats.indexedFiles, totalChunks: stats.totalChunks };
387383

388384
// Save snapshot after updating codebase lists
389-
this.snapshotManager.saveCodebaseSnapshot();
385+
await this.snapshotManager.saveCodebaseSnapshot('index-completed');
390386

391387
let message = `Background indexing completed for '${absolutePath}' using ${splitterType.toUpperCase()} splitter.\nIndexed ${stats.indexedFiles} files, ${stats.totalChunks} chunks.`;
392388
if (stats.status === 'limit_reached') {
@@ -404,7 +400,7 @@ export class ToolHandlers {
404400
// Set codebase to failed status with error information
405401
const errorMessage = error.message || String(error);
406402
this.snapshotManager.setCodebaseIndexFailed(absolutePath, errorMessage, lastProgress);
407-
this.snapshotManager.saveCodebaseSnapshot();
403+
await this.snapshotManager.saveCodebaseSnapshot('index-failed');
408404

409405
// Log error but don't crash MCP service - indexing errors are handled gracefully
410406
console.error(`[BACKGROUND-INDEX] Indexing failed for ${absolutePath}: ${errorMessage}`);
@@ -447,11 +443,23 @@ export class ToolHandlers {
447443

448444
trackCodebasePath(absolutePath);
449445

450-
// Check if this codebase is indexed or being indexed
451-
const isIndexed = this.snapshotManager.getIndexedCodebases().includes(absolutePath);
446+
// Check status with cloud as source of truth and snapshot as progress source
447+
const isIndexedInSnapshot = this.snapshotManager.getIndexedCodebases().includes(absolutePath);
452448
const isIndexing = this.snapshotManager.getIndexingCodebases().includes(absolutePath);
449+
const hasCloudIndex = await this.context.hasIndex(absolutePath);
450+
451+
// Self-heal snapshot if index exists in cloud but local snapshot is missing
452+
if (hasCloudIndex && !isIndexedInSnapshot && !isIndexing) {
453+
this.snapshotManager.setCodebaseIndexed(absolutePath, {
454+
indexedFiles: 0,
455+
totalChunks: 0,
456+
status: 'completed'
457+
});
458+
await this.snapshotManager.saveCodebaseSnapshot('search-reconcile-cloud-present');
459+
console.log(`[SEARCH] 🛠️ Restored missing snapshot entry from cloud index for: ${absolutePath}`);
460+
}
453461

454-
if (!isIndexed && !isIndexing) {
462+
if (!hasCloudIndex && !isIndexing) {
455463
return {
456464
content: [{
457465
type: "text",
@@ -469,7 +477,7 @@ export class ToolHandlers {
469477

470478
console.log(`[SEARCH] Searching in codebase: ${absolutePath}`);
471479
console.log(`[SEARCH] Query: "${query}"`);
472-
console.log(`[SEARCH] Indexing status: ${isIndexing ? 'In Progress' : 'Completed'}`);
480+
console.log(`[SEARCH] Indexing status: ${isIndexing ? 'In Progress' : (hasCloudIndex ? 'Completed' : 'No collection yet')}`);
473481

474482
// Log embedding provider information before search
475483
const embeddingProvider = this.context.getEmbedding();
@@ -571,15 +579,6 @@ export class ToolHandlers {
571579
public async handleClearIndex(args: any) {
572580
const { path: codebasePath } = args;
573581

574-
if (this.snapshotManager.getIndexedCodebases().length === 0 && this.snapshotManager.getIndexingCodebases().length === 0) {
575-
return {
576-
content: [{
577-
type: "text",
578-
text: "No codebases are currently indexed or being indexed."
579-
}]
580-
};
581-
}
582-
583582
try {
584583
// Force absolute path resolution - warn if relative path provided
585584
const absolutePath = ensureAbsolutePath(codebasePath);
@@ -610,8 +609,9 @@ export class ToolHandlers {
610609
// Check if this codebase is indexed or being indexed
611610
const isIndexed = this.snapshotManager.getIndexedCodebases().includes(absolutePath);
612611
const isIndexing = this.snapshotManager.getIndexingCodebases().includes(absolutePath);
612+
const hasCloudIndex = await this.context.hasIndex(absolutePath);
613613

614-
if (!isIndexed && !isIndexing) {
614+
if (!isIndexed && !isIndexing && !hasCloudIndex) {
615615
return {
616616
content: [{
617617
type: "text",
@@ -623,19 +623,23 @@ export class ToolHandlers {
623623

624624
console.log(`[CLEAR] Clearing codebase: ${absolutePath}`);
625625

626-
try {
627-
await this.context.clearIndex(absolutePath);
628-
console.log(`[CLEAR] Successfully cleared index for: ${absolutePath}`);
629-
} catch (error: any) {
630-
const errorMsg = `Failed to clear ${absolutePath}: ${error.message}`;
631-
console.error(`[CLEAR] ${errorMsg}`);
632-
return {
633-
content: [{
634-
type: "text",
635-
text: errorMsg
636-
}],
637-
isError: true
638-
};
626+
if (hasCloudIndex) {
627+
try {
628+
await this.context.clearIndex(absolutePath);
629+
console.log(`[CLEAR] Successfully cleared index for: ${absolutePath}`);
630+
} catch (error: any) {
631+
const errorMsg = `Failed to clear ${absolutePath}: ${error.message}`;
632+
console.error(`[CLEAR] ${errorMsg}`);
633+
return {
634+
content: [{
635+
type: "text",
636+
text: errorMsg
637+
}],
638+
isError: true
639+
};
640+
}
641+
} else {
642+
console.log(`[CLEAR] ℹ️ No cloud collection found for ${absolutePath}, cleaning snapshot only`);
639643
}
640644

641645
// Completely remove the cleared codebase from snapshot
@@ -645,7 +649,7 @@ export class ToolHandlers {
645649
this.indexingStats = null;
646650

647651
// Save snapshot after clearing index
648-
this.snapshotManager.saveCodebaseSnapshot();
652+
await this.snapshotManager.saveCodebaseSnapshot('clear-index');
649653

650654
let resultText = `Successfully cleared codebase '${absolutePath}'`;
651655

@@ -718,9 +722,34 @@ export class ToolHandlers {
718722
};
719723
}
720724

721-
// Check indexing status using new status system
722-
const status = this.snapshotManager.getCodebaseStatus(absolutePath);
723-
const info = this.snapshotManager.getCodebaseInfo(absolutePath);
725+
// Check indexing status using snapshot plus cloud truth
726+
let status = this.snapshotManager.getCodebaseStatus(absolutePath);
727+
let info = this.snapshotManager.getCodebaseInfo(absolutePath);
728+
let recoveredFromCloud = false;
729+
const hasCloudIndex = await this.context.hasIndex(absolutePath);
730+
731+
// Self-heal snapshot if cloud has index but local status is missing
732+
if (status === 'not_found' && hasCloudIndex) {
733+
this.snapshotManager.setCodebaseIndexed(absolutePath, {
734+
indexedFiles: 0,
735+
totalChunks: 0,
736+
status: 'completed'
737+
});
738+
await this.snapshotManager.saveCodebaseSnapshot('status-reconcile-cloud-present');
739+
status = 'indexed';
740+
info = this.snapshotManager.getCodebaseInfo(absolutePath);
741+
recoveredFromCloud = true;
742+
console.log(`[STATUS] 🛠️ Restored missing snapshot entry from cloud index for: ${absolutePath}`);
743+
}
744+
745+
// Cleanup stale snapshot entries if cloud index no longer exists
746+
if (status === 'indexed' && !hasCloudIndex) {
747+
this.snapshotManager.removeCodebaseCompletely(absolutePath);
748+
await this.snapshotManager.saveCodebaseSnapshot('status-reconcile-cloud-missing');
749+
status = 'not_found';
750+
info = undefined;
751+
console.log(`[STATUS] 🧹 Removed stale indexed snapshot entry without cloud index for: ${absolutePath}`);
752+
}
724753

725754
let statusMessage = '';
726755

@@ -735,6 +764,9 @@ export class ToolHandlers {
735764
} else {
736765
statusMessage = `✅ Codebase '${absolutePath}' is fully indexed and ready for search.`;
737766
}
767+
if (recoveredFromCloud) {
768+
statusMessage += `\nℹ️ Index was detected directly in vector database and local snapshot state was restored.`;
769+
}
738770
break;
739771

740772
case 'indexing':
@@ -797,4 +829,4 @@ export class ToolHandlers {
797829
};
798830
}
799831
}
800-
}
832+
}

0 commit comments

Comments
 (0)