forked from colbymchenry/codegraph
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcp-catchup-gate.test.ts
More file actions
122 lines (105 loc) · 5.02 KB
/
Copy pathmcp-catchup-gate.test.ts
File metadata and controls
122 lines (105 loc) · 5.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
/**
* MCP catch-up gate — first tool call blocks on the engine's post-open
* filesystem reconcile so it never serves rows for files that were
* deleted (or edited) while no MCP server was running.
*
* Background: `MCPEngine.catchUpSync()` fires `cg.sync()` in the background.
* Before this fix it was fire-and-forget — a tool call could race past it
* and return rows for files that no longer exist on disk. The per-file
* staleness banner (`withStalenessNotice`) couldn't help, because
* `getPendingFiles()` is populated by the watcher, not by catch-up.
*
* The fix: `catchUpSync()` pushes its promise into the `ToolHandler` via
* `setCatchUpGate(p)`; the first `execute()` call awaits the gate and then
* clears it. These tests exercise the gate directly (deterministic) and
* the engine-driven path (proves the engine actually pokes the gate).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
describe('MCP catch-up gate', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-catchup-gate-'));
fs.mkdirSync(path.join(testDir, 'src'));
fs.writeFileSync(
path.join(testDir, 'src', 'survivor.ts'),
'export function survivor() { return 1; }\n',
);
fs.writeFileSync(
path.join(testDir, 'src', 'deleted-later.ts'),
'export function deletedLater() { return 2; }\n',
);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
try { cg.unwatch(); } catch { /* ignore */ }
try { cg.close(); } catch { /* ignore */ }
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('awaits the gate before serving the first tool call', async () => {
let gateResolved = false;
const gate = new Promise<void>((resolve) => {
setTimeout(() => { gateResolved = true; resolve(); }, 80);
});
handler.setCatchUpGate(gate);
const res = await handler.execute('codegraph_search', { query: 'survivor' });
expect(gateResolved).toBe(true);
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toMatch(/survivor/);
});
it('drops the gate after first await — second call does not re-wait', async () => {
let awaitCount = 0;
const gate = new Promise<void>((resolve) => {
awaitCount++;
setTimeout(resolve, 20);
});
handler.setCatchUpGate(gate);
await handler.execute('codegraph_search', { query: 'survivor' });
const before = awaitCount;
await handler.execute('codegraph_search', { query: 'survivor' });
// The promise body runs once when constructed; second execute never
// resubscribes to a fresh promise because the gate field was nulled.
expect(awaitCount).toBe(before);
});
it('catch-up reconciles a deleted file before the first tool call sees it', async () => {
// Simulate the empty-project / deleted-files startup case: file is in
// the DB (we indexed it above) but vanishes from disk before the MCP
// server's first query. The catch-up sync, awaited via the gate,
// must remove the row so the first tool call returns no hit.
fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts'));
// Push the actual catch-up sync as the gate — same flow the MCP engine
// uses (`cg.sync()` returns a Promise<SyncResult>, the wrapper voids it).
handler.setCatchUpGate(cg.sync().then(() => undefined));
const res = await handler.execute('codegraph_search', { query: 'deletedLater' });
expect(res.isError).toBeFalsy();
const text = res.content[0].text;
expect(text).not.toMatch(/src\/deleted-later\.ts/);
});
it('catch-up that converges the project to 0 files clears all rows', async () => {
// Worst case: every source file is gone between sessions. Without the
// gate, the first tool call serves whatever was in the DB. With the
// gate + the orchestrator's filesystem reconcile, the DB drains.
fs.unlinkSync(path.join(testDir, 'src', 'survivor.ts'));
fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts'));
handler.setCatchUpGate(cg.sync().then(() => undefined));
const res = await handler.execute('codegraph_search', { query: 'survivor' });
expect(res.isError).toBeFalsy();
expect(cg.getStats().fileCount).toBe(0);
});
it('gate that rejects does not break the tool call', async () => {
// A catch-up sync failure (lock contention, transient FS error) must
// not poison tool dispatch — the engine logs it, the handler proceeds.
handler.setCatchUpGate(Promise.reject(new Error('simulated sync failure')));
const res = await handler.execute('codegraph_search', { query: 'survivor' });
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toMatch(/survivor/);
});
});