Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- **MCP clients now know when an index needs a rebuild.** The first successful tool response for a project whose index predates the running extraction engine carries a one-time warning with the available build details and the `codegraph index` command. Each project is tracked independently in each MCP session, including projects selected with `projectPath`; `codegraph_status` always reports the index-build version, running version, and whether rebuilding is recommended. Detection is local and never starts a rebuild automatically. (#1852)

- **Codex and Astra read project guidance from `AGENTS.md`.** The canonical agent guide now lives in `AGENTS.md` (with a nested `docs/AGENTS.md` for long validation notes); `CLAUDE.md` is a thin `@AGENTS.md` wrapper for Claude Code. Codex/Astra no longer miss the old CLAUDE-only instructions.

- **Next.js pages and their navigation are in the graph.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are routes bound to the component they export, and `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` and the middleware's `NextResponse.redirect(…)` are `navigates` edges between them. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, and `pages/api/*` handlers are `ANY /api/…`. Re-index after upgrading.
Expand Down
111 changes: 111 additions & 0 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { CodeGraph } from '../src';
import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
import { CodeGraphPackageVersion } from '../src/mcp/version';
import { once } from 'events';
import { EXTRACTION_VERSION } from '../src/extraction/extraction-version';
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
import { recordSpawns, removeSpawnLog, settleLosingCandidates } from './daemon-candidates';

Expand Down Expand Up @@ -168,6 +169,17 @@ function countListeningLines(root: string): number {
return readDaemonLog(root).split('\n').filter((l) => l.includes('[CodeGraph daemon] Listening on')).length;
}

function stampIndexStale(cg: CodeGraph, version: string): void {
const metadata = cg as unknown as {
queries: { setMetadata(key: string, value: string): void };
};
metadata.queries.setMetadata('indexed_with_version', version);
metadata.queries.setMetadata(
'indexed_with_extraction_version',
String(EXTRACTION_VERSION - 1),
);
}

function killTree(...procs: ChildProcessWithoutNullStreams[]): void {
for (const p of procs) {
if (!p.killed) { try { p.kill('SIGKILL'); } catch { /* gone */ } }
Expand Down Expand Up @@ -314,6 +326,105 @@ describe('Shared MCP daemon (issue #411)', () => {
expect(readLockPid(realRoot)).toBe(daemonPid);
}, 40000);

it('warns once per proxied client when the shared project index is stale (#1852)', async () => {
fs.writeFileSync(path.join(tempDir, 'alpha.ts'), 'export function alpha() { return 1; }\n');
const indexed = await CodeGraph.open(tempDir);
await indexed.indexAll();
stampIndexStale(indexed, '0.1.0');
indexed.close();

const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };
const first = spawnServer(tempDir, env);
const second = spawnServer(tempDir, env);
servers.push(first, second);
sendInitialize(first.child, `file://${tempDir}`, 1);
sendInitialize(second.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(first.stdout, 1), 10000);
await waitFor(() => findResponse(second.stdout, 1), 10000);
await waitFor(() => first.stderr.some((line) => line.includes('Attached to shared daemon')), 8000);
await waitFor(() => second.stderr.some((line) => line.includes('Attached to shared daemon')), 8000);

const callSearch = (server: SpawnedServer, id: number) => {
sendMessage(server.child, {
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'codegraph_search', arguments: { query: 'alpha' } },
});
};
const responseText = (response: any): string => response.result.content[0].text;

callSearch(first, 2);
const firstInitial = await waitFor(() => findResponse(first.stdout, 2), 10000);

callSearch(first, 3);
callSearch(second, 2);
const firstRepeat = await waitFor(() => findResponse(first.stdout, 3), 10000);
const secondInitial = await waitFor(() => findResponse(second.stdout, 2), 10000);

expect(responseText(firstInitial)).toMatch(/index predates the running extraction engine/i);
expect(responseText(firstRepeat)).not.toMatch(/index predates/i);
expect(responseText(secondInitial)).toMatch(/index predates the running extraction engine/i);
expect(countListeningLines(realRoot)).toBe(1);
}, 45000);

it('does not repeat a daemon-delivered warning after proxy failover (#1852)', async () => {
fs.writeFileSync(path.join(tempDir, 'alpha.ts'), 'export function alpha() { return 1; }\n');
const indexed = await CodeGraph.open(tempDir);
await indexed.indexAll();
stampIndexStale(indexed, '0.1.0');
indexed.close();

const otherRoot = path.join(tempDir, 'other');
fs.mkdirSync(otherRoot);
fs.writeFileSync(path.join(otherRoot, 'bravo.ts'), 'export function bravo() { return 2; }\n');
const other = await CodeGraph.init(otherRoot, { index: false });
await other.indexAll();
stampIndexStale(other, '0.2.0');
other.close();

const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '5000' };
const server = spawnServer(tempDir, env);
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(server.stdout, 1), 10000);
await waitFor(() => server.stderr.some((line) => line.includes('Attached to shared daemon')), 8000);
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
const daemonPid = readLockPid(realRoot)!;

sendMessage(server.child, {
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'codegraph_search', arguments: { query: 'alpha' } },
});
const throughDaemon = await waitFor(() => findResponse(server.stdout, 2), 10000);
expect(throughDaemon.result.content[0].text).toMatch(/index predates/i);

process.kill(daemonPid, 'SIGTERM');
expect(await waitProcessExit(daemonPid, 8000)).toBe(true);
await waitFor(
() => server.stderr.some((line) => line.includes('serving this session in-process')),
8000,
);

sendMessage(server.child, {
jsonrpc: '2.0', id: 3, method: 'tools/call',
params: { name: 'codegraph_search', arguments: { query: 'alpha' } },
});
const afterFailover = await waitFor(() => findResponse(server.stdout, 3), 10000);
expect(afterFailover.result.content[0].text).not.toMatch(/index predates/i);

sendMessage(server.child, {
jsonrpc: '2.0', id: 4, method: 'tools/call',
params: {
name: 'codegraph_search',
arguments: { query: 'bravo', projectPath: otherRoot },
},
});
const otherProject = await waitFor(() => findResponse(server.stdout, 4), 10000);
expect(otherProject.result.content[0].text).toMatch(/index predates/i);
expect(otherProject.result.content[0].text).toContain('CodeGraph v0.2.0');
}, 45000);

it('concurrent launchers converge on a single daemon (lockfile race — must-fix 1)', async () => {
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };

Expand Down
Loading