diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts index da4bab2b..48b735b4 100644 --- a/packages/mcp/src/config.ts +++ b/packages/mcp/src/config.ts @@ -53,12 +53,25 @@ export interface CodebaseInfoIndexing extends CodebaseInfoBase { indexingPercentage: number; // Current progress percentage } +/** Change counts from a successful incremental sync (reindexByChange). */ +export interface CodebaseIncrementalSyncStats { + added: number; + removed: number; + modified: number; +} + // Indexed state - when indexing completed successfully export interface CodebaseInfoIndexed extends CodebaseInfoBase { status: 'indexed'; indexedFiles: number; // Number of files indexed totalChunks: number; // Total number of chunks generated indexStatus: 'completed' | 'limit_reached'; // Status from indexing result + /** ISO timestamp of the last successful full index (index_codebase). */ + lastFullIndexAt?: string; + /** ISO timestamp of the last successful incremental sync (background/trigger). */ + lastIncrementalSyncAt?: string; + /** Stats from the most recent incremental sync, if any. */ + lastSyncStats?: CodebaseIncrementalSyncStats; } // Index failed state - when indexing failed diff --git a/packages/mcp/src/handlers.get-indexing-status.test.ts b/packages/mcp/src/handlers.get-indexing-status.test.ts index 9a7f53d9..8cddc5d8 100644 --- a/packages/mcp/src/handlers.get-indexing-status.test.ts +++ b/packages/mcp/src/handlers.get-indexing-status.test.ts @@ -61,6 +61,8 @@ test("get_indexing_status syncs cloud state before reading the snapshot", async assert.equal(result.isError, undefined); assert.match(result.content[0].text, /fully indexed and ready for search/); assert.match(result.content[0].text, /3 files, 5 chunks/); + assert.match(result.content[0].text, /Last full index:/); + assert.match(result.content[0].text, /Index freshness:/); assert.equal(snapshotManager.getCodebaseStatus(codebasePath), "indexed"); }); }); diff --git a/packages/mcp/src/handlers.ts b/packages/mcp/src/handlers.ts index c9b73299..d76f306a 100644 --- a/packages/mcp/src/handlers.ts +++ b/packages/mcp/src/handlers.ts @@ -1035,7 +1035,23 @@ export class ToolHandlers { statusMessage = `āœ… Codebase '${statusCodebasePath}' is fully indexed and ready for search.`; statusMessage += `\nšŸ“Š Statistics: ${indexedInfo.indexedFiles} files, ${indexedInfo.totalChunks} chunks`; statusMessage += `\nšŸ“… Status: ${indexedInfo.indexStatus}`; - statusMessage += `\nšŸ• Last updated: ${new Date(indexedInfo.lastUpdated).toLocaleString()}`; + const lastFullAt = indexedInfo.lastFullIndexAt ?? indexedInfo.lastUpdated; + statusMessage += `\nšŸ• Last full index: ${new Date(lastFullAt).toLocaleString()}`; + if (indexedInfo.lastIncrementalSyncAt) { + statusMessage += `\nšŸ”„ Last incremental sync: ${new Date(indexedInfo.lastIncrementalSyncAt).toLocaleString()}`; + const syncStats = indexedInfo.lastSyncStats; + if (syncStats) { + const { added, removed, modified } = syncStats; + if (added === 0 && removed === 0 && modified === 0) { + statusMessage += ' (no file changes)'; + } else { + statusMessage += ` (added: ${added}, removed: ${removed}, modified: ${modified})`; + } + } + } else { + statusMessage += `\nšŸ”„ Last incremental sync: not recorded yet (run background/trigger sync to refresh)`; + } + statusMessage += `\nšŸ• Index freshness: ${new Date(indexedInfo.lastUpdated).toLocaleString()}`; } else { statusMessage = `āœ… Codebase '${statusCodebasePath}' is fully indexed and ready for search.`; } diff --git a/packages/mcp/src/snapshot.incremental-sync.test.ts b/packages/mcp/src/snapshot.incremental-sync.test.ts new file mode 100644 index 00000000..d1ed36e1 --- /dev/null +++ b/packages/mcp/src/snapshot.incremental-sync.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { SnapshotManager } from "./snapshot.js"; + +async function withTempHome(run: (homeDir: string) => Promise): Promise { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "claude-context-mcp-snap-inc-")); + const homeDir = path.join(tempRoot, "home"); + await mkdir(homeDir, { recursive: true }); + + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + + try { + await run(homeDir); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + await rm(tempRoot, { recursive: true, force: true }); + } +} + +test("recordIncrementalSyncSuccess updates freshness and persists across reload", async () => { + await withTempHome(async () => { + const codebasePath = path.join(os.tmpdir(), "fake-repo-" + Date.now()); + await mkdir(codebasePath, { recursive: true }); + + const snapshotManager = new SnapshotManager(); + snapshotManager.setCodebaseIndexed(codebasePath, { + indexedFiles: 10, + totalChunks: 40, + status: "completed", + }); + const afterFull = snapshotManager.getCodebaseInfo(codebasePath); + assert.ok(afterFull && afterFull.status === "indexed"); + const fullUpdated = afterFull.lastUpdated; + const fullIndexAt = afterFull.lastFullIndexAt; + assert.ok(fullIndexAt); + + await new Promise((r) => setTimeout(r, 5)); + + const ok = snapshotManager.recordIncrementalSyncSuccess(codebasePath, { + added: 1, + removed: 0, + modified: 2, + }); + assert.equal(ok, true); + snapshotManager.saveCodebaseSnapshot(); + + const afterSync = snapshotManager.getCodebaseInfo(codebasePath); + assert.ok(afterSync && afterSync.status === "indexed"); + assert.equal(afterSync.indexedFiles, 11); + assert.equal(afterSync.lastFullIndexAt, fullIndexAt); + assert.ok(afterSync.lastIncrementalSyncAt); + assert.ok(afterSync.lastUpdated >= fullUpdated); + assert.deepEqual(afterSync.lastSyncStats, { added: 1, removed: 0, modified: 2 }); + + const snapshotPath = path.join(process.env.HOME!, ".context", "mcp-codebase-snapshot.json"); + const disk = JSON.parse(await readFile(snapshotPath, "utf8")); + const diskInfo = disk.codebases[codebasePath]; + assert.equal(diskInfo.lastIncrementalSyncAt, afterSync.lastIncrementalSyncAt); + + const reloaded = new SnapshotManager(); + reloaded.loadCodebaseSnapshot(); + const fromDisk = reloaded.getCodebaseInfo(codebasePath); + assert.ok(fromDisk && fromDisk.status === "indexed"); + assert.equal(fromDisk.lastIncrementalSyncAt, afterSync.lastIncrementalSyncAt); + assert.deepEqual(fromDisk.lastSyncStats, { added: 1, removed: 0, modified: 2 }); + }); +}); + +test("recordIncrementalSyncSuccess records zero-change sync", async () => { + await withTempHome(async () => { + const codebasePath = path.join(os.tmpdir(), "fake-repo-zero-" + Date.now()); + await mkdir(codebasePath, { recursive: true }); + + const snapshotManager = new SnapshotManager(); + snapshotManager.setCodebaseIndexed(codebasePath, { + indexedFiles: 5, + totalChunks: 20, + status: "completed", + }); + + const ok = snapshotManager.recordIncrementalSyncSuccess(codebasePath, { + added: 0, + removed: 0, + modified: 0, + }); + assert.equal(ok, true); + const info = snapshotManager.getCodebaseInfo(codebasePath); + assert.ok(info && info.status === "indexed"); + assert.ok(info.lastIncrementalSyncAt); + assert.deepEqual(info.lastSyncStats, { added: 0, removed: 0, modified: 0 }); + assert.equal(info.indexedFiles, 5); + }); +}); \ No newline at end of file diff --git a/packages/mcp/src/snapshot.ts b/packages/mcp/src/snapshot.ts index 903f6770..9bc1dd04 100644 --- a/packages/mcp/src/snapshot.ts +++ b/packages/mcp/src/snapshot.ts @@ -9,7 +9,8 @@ import { CodebaseIndexOptions, CodebaseInfoIndexing, CodebaseInfoIndexed, - CodebaseInfoIndexFailed + CodebaseInfoIndexFailed, + CodebaseIncrementalSyncStats } from "./config.js"; export class SnapshotManager { @@ -444,15 +445,62 @@ export class SnapshotManager { const resolvedIndexOptions = this.resolveIndexOptions(codebasePath, indexOptions); + const now = new Date().toISOString(); + const existing = this.codebaseInfoMap.get(codebasePath); + const preservedIncremental = + existing && existing.status === 'indexed' + ? { + lastIncrementalSyncAt: existing.lastIncrementalSyncAt, + lastSyncStats: existing.lastSyncStats, + } + : {}; + const info: CodebaseInfoIndexed = { status: 'indexed', indexedFiles: stats.indexedFiles, totalChunks: stats.totalChunks, indexStatus: stats.status, ...resolvedIndexOptions, - lastUpdated: new Date().toISOString() + lastUpdated: now, + lastFullIndexAt: now, + ...preservedIncremental, + }; + this.codebaseInfoMap.set(codebasePath, info); + } + + /** + * Record a successful incremental sync without rewriting full-index metadata. + * Updates freshness timestamps and optional file-count delta from sync stats. + */ + public recordIncrementalSyncSuccess( + codebasePath: string, + syncStats: CodebaseIncrementalSyncStats + ): boolean { + const existing = this.codebaseInfoMap.get(codebasePath); + if (!existing || existing.status !== 'indexed') { + console.warn( + `[SNAPSHOT] Skipping incremental sync metadata for '${codebasePath}': not in indexed state` + ); + return false; + } + + const now = new Date().toISOString(); + const indexedFiles = Math.max( + 0, + existing.indexedFiles + syncStats.added - syncStats.removed + ); + + const info: CodebaseInfoIndexed = { + ...existing, + indexedFiles, + lastUpdated: now, + lastFullIndexAt: existing.lastFullIndexAt ?? existing.lastUpdated, + lastIncrementalSyncAt: now, + lastSyncStats: { ...syncStats }, }; this.codebaseInfoMap.set(codebasePath, info); + this.codebaseFileCount.set(codebasePath, indexedFiles); + return true; } /** diff --git a/packages/mcp/src/sync.incremental-metadata.test.ts b/packages/mcp/src/sync.incremental-metadata.test.ts new file mode 100644 index 00000000..747248a8 --- /dev/null +++ b/packages/mcp/src/sync.incremental-metadata.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { SyncManager } from "./sync.js"; +import { SnapshotManager } from "./snapshot.js"; + +async function withTempHome(run: (tempRoot: string) => Promise): Promise { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "claude-context-mcp-sync-meta-")); + const homeDir = path.join(tempRoot, "home"); + await mkdir(homeDir, { recursive: true }); + + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + + try { + await run(tempRoot); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + await rm(tempRoot, { recursive: true, force: true }); + } +} + +test("handleSyncIndex persists incremental sync metadata after reindexByChange", async () => { + await withTempHome(async (tempRoot) => { + const codebasePath = path.join(tempRoot, "repo"); + await mkdir(codebasePath, { recursive: true }); + + const snapshotManager = new SnapshotManager(); + snapshotManager.setCodebaseIndexed(codebasePath, { + indexedFiles: 4, + totalChunks: 12, + status: "completed", + }); + snapshotManager.saveCodebaseSnapshot(); + + const before = snapshotManager.getCodebaseInfo(codebasePath); + assert.ok(before && before.status === "indexed"); + assert.equal(before.lastIncrementalSyncAt, undefined); + + const mockContext = { + async reindexByChange() { + return { added: 0, removed: 0, modified: 1 }; + }, + }; + + const syncManager = new SyncManager(mockContext as any, snapshotManager); + await syncManager.handleSyncIndex(); + + const after = snapshotManager.getCodebaseInfo(codebasePath); + assert.ok(after && after.status === "indexed"); + assert.ok(after.lastIncrementalSyncAt); + assert.deepEqual(after.lastSyncStats, { added: 0, removed: 0, modified: 1 }); + assert.ok(after.lastUpdated >= before.lastUpdated); + }); +}); \ No newline at end of file diff --git a/packages/mcp/src/sync.ts b/packages/mcp/src/sync.ts index 8f8b68d6..f42902ba 100644 --- a/packages/mcp/src/sync.ts +++ b/packages/mcp/src/sync.ts @@ -227,6 +227,11 @@ export class SyncManager { totalStats.removed += stats.removed; totalStats.modified += stats.modified; + const recorded = this.snapshotManager.recordIncrementalSyncSuccess(codebasePath, stats); + if (recorded) { + this.snapshotManager.saveCodebaseSnapshot(); + } + if (stats.added > 0 || stats.removed > 0 || stats.modified > 0) { console.log(`[SYNC] Sync complete for '${codebasePath}'. Added: ${stats.added}, Removed: ${stats.removed}, Modified: ${stats.modified} (${codebaseElapsed}ms)`); } else {