feat(codegraph): codegraph_explore 改为自有 stdio JSON-RPC 客户端直连 codegraph serve

一次性 `codegraph explore` CLI 不会同步索引,新增符号在手动 sync 前
无法被搜到。改为按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,
由组合包自有的逐行 JSON-RPC 客户端(extensions/codegraph-client.ts,
不依赖 MCP adapter/SDK)收发,serve 内置文件监听自动同步索引,删除
mtime 结果缓存。子进程闲置 10 分钟或会话结束关闭,子进程死亡时透明
重试一次;用户拒绝初始化在会话内记住;用户中止调用不关闭子进程;
所有失败以文本打回引导改用 find/grep/read/LSP。新增模拟服务与真实
CLI 集成测试。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-20 02:02:47 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent da9908fb6e
commit dc4fe92e27
6 changed files with 990 additions and 104 deletions
+378
View File
@@ -0,0 +1,378 @@
/**
* Minimal stdio JSON-RPC 2.0 client for `codegraph serve --mcp -p <root>`.
*
* Same approach as pi-web-search/clients/exa.ts (plain JSON-RPC, no MCP
* adapter or SDK): `codegraph serve --mcp` speaks one JSON object per line on
* stdio. Unlike the one-shot `codegraph explore` CLI, the serve process runs a
* file watcher that keeps the index synced, so newly written symbols become
* searchable without a manual `codegraph sync`.
*
* One client exists per index root; `extensions/codegraph.ts` owns the map,
* idle closing, and the one-shot respawn retry.
*/
import {
spawn as nodeSpawn,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
const PROTOCOL_VERSION = "2025-06-18";
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
const STDERR_TAIL_LINES = 20;
const CLOSE_GRACE_MS = 1_000;
export type CodegraphSpawn = (
command: string,
args: string[],
options: SpawnOptions,
) => ChildProcess;
export interface CodegraphRpcClientOptions {
/** Index root — passed to `codegraph serve --mcp -p <root>` and used as cwd. */
root: string;
/** Test seam: replace the child_process spawn. */
spawn?: CodegraphSpawn;
/** Default "codegraph". */
command?: string;
/** Default ["serve", "--mcp", "-p", root]. */
args?: string[];
/** Per-request timeout; default 120 s. */
requestTimeoutMs?: number;
}
interface PendingRequest {
resolve: (result: unknown) => void;
reject: (error: Error) => void;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function contentText(result: Record<string, unknown>): string {
const content = Array.isArray(result.content)
? (result.content as Record<string, unknown>[])
: [];
return content
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text as string)
.join("\n")
.trim();
}
export class CodegraphRpcClient {
private readonly spawnFn: CodegraphSpawn;
private readonly command: string;
private readonly args: string[];
private readonly timeoutMs: number;
private child: ChildProcess | undefined;
private stdoutBuffer = "";
private stderrLines: string[] = [];
private pending = new Map<number, PendingRequest>();
private nextId = 1;
private startPromise: Promise<void> | undefined;
/** null = upstream tool declares no inputSchema.properties → forward all args. */
private schemaProps: ReadonlySet<string> | null = null;
private deadFlag = false;
private closedFlag = false;
private deathFinalized = false;
private exitDetail = "";
private deathReason = "codegraph serve 子进程已退出";
private readonly options: CodegraphRpcClientOptions;
constructor(options: CodegraphRpcClientOptions) {
this.options = options;
this.spawnFn = options.spawn ?? nodeSpawn;
this.command = options.command ?? "codegraph";
this.args =
options.args ?? ["serve", "--mcp", "-p", options.root];
this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
/** True once the child has exited or errored — the handler respawns instead. */
get dead(): boolean {
return this.deadFlag;
}
/** Property names of the upstream codegraph_explore inputSchema. */
schemaPropertyNames(): readonly string[] {
return this.schemaProps === null ? [] : [...this.schemaProps];
}
/** Spawn + initialize + tools/list. Memoized: concurrent callers share it. */
start(): Promise<void> {
if (!this.startPromise) {
this.startPromise = this.doStart();
// A dead child during startup rejects this promise; attach a no-op
// branch so it never surfaces as an unhandled rejection when a
// racing close() kills the child with nobody awaiting start().
this.startPromise.catch(() => {});
}
return this.startPromise;
}
private async doStart(): Promise<void> {
let child: ChildProcess;
try {
child = this.spawnFn(this.command, this.args, {
cwd: this.options.root,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (error) {
this.markDead(
`无法启动 codegraph serve:${error instanceof Error ? error.message : String(error)}`,
);
throw this.deathError();
}
this.child = child;
this.attach(child);
await this.request("initialize", {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "my-pi-codegraph", version: "1.0.0" },
});
this.notify("notifications/initialized");
const list = (await this.request("tools/list", {})) as Record<
string,
unknown
>;
const tools = Array.isArray(list.tools) ? list.tools : [];
const tool = tools.find(
(entry) => isRecord(entry) && entry.name === "codegraph_explore",
);
if (!tool) {
throw new Error("codegraph serve 未提供 codegraph_explore 工具");
}
const properties =
isRecord(tool) && isRecord(tool.inputSchema)
? tool.inputSchema.properties
: undefined;
this.schemaProps = isRecord(properties)
? new Set(Object.keys(properties))
: null;
}
/**
* `tools/call codegraph_explore`. Forwards only the argument properties the
* upstream inputSchema declares (e.g. `projectPath` is forwarded only when
* upstream lists it). Returns the raw result object; the handler passes
* `result.content` through verbatim.
*/
async callExplore(
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<Record<string, unknown>> {
await this.start();
const forwarded: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (this.schemaProps === null || this.schemaProps.has(key)) {
forwarded[key] = value;
}
}
const result = await this.request(
"tools/call",
{ name: "codegraph_explore", arguments: forwarded },
signal,
);
if (isRecord(result) && result.isError) {
throw new Error(
`codegraph_explore 返回错误:${contentText(result) || "unknown"}`,
);
}
return isRecord(result) ? result : {};
}
/** End stdin, then SIGTERM after a 1 s grace. Idempotent. */
async close(): Promise<void> {
if (this.closedFlag) return;
this.closedFlag = true;
const child = this.child;
this.failAll(new Error("codegraph serve 客户端已关闭"));
if (!child) return;
try {
child.stdin?.end();
} catch {
// stdin already closed.
}
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
try {
child.kill("SIGTERM");
} catch {
// Already gone.
}
resolve();
}, CLOSE_GRACE_MS);
timer.unref();
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
child.once("error", () => {
clearTimeout(timer);
resolve();
});
});
}
// ── internals ────────────────────────────────────────────────────────────
private attach(child: ChildProcess): void {
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
this.stdoutBuffer += chunk;
for (;;) {
const newline = this.stdoutBuffer.indexOf("\n");
if (newline < 0) break;
const line = this.stdoutBuffer.slice(0, newline).trim();
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
if (line) this.handleLine(line);
}
});
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
for (const line of chunk.split(/\r?\n/u)) {
if (!line.trim()) continue;
this.stderrLines.push(line);
if (this.stderrLines.length > STDERR_TAIL_LINES) {
this.stderrLines.shift();
}
}
});
child.once("error", (error) => {
// Spawn failure (e.g. ENOENT) — the message carries the detail.
this.markDead(`无法启动 codegraph serve:${error.message}`);
});
child.once("exit", (code, signal) => {
this.deadFlag = true;
this.exitDetail =
signal !== null ? `signal ${signal}` : `code ${code}`;
});
// Reject pending on "close" (stdio drained), not "exit" — otherwise the
// final stderr lines may not have been delivered yet.
child.once("close", () => {
this.markDead(`codegraph serve 子进程退出(${this.exitDetail})`);
});
}
private handleLine(line: string): void {
let message: unknown;
try {
message = JSON.parse(line);
} catch {
return; // Not JSON — server chatter; ignore.
}
if (!isRecord(message)) return;
// Server → client request: we implement no methods; reply -32601.
if (typeof message.method === "string" && message.id !== undefined) {
this.sendRaw({
jsonrpc: "2.0",
id: message.id,
error: { code: -32601, message: "Method not found" },
});
return;
}
// Response to one of our requests.
if (message.id !== undefined) {
const entry = this.pending.get(Number(message.id));
if (!entry) return;
if (message.error !== undefined) {
const detail = isRecord(message.error)
? (message.error.message ?? JSON.stringify(message.error))
: String(message.error);
entry.reject(new Error(`codegraph serve ${String(detail)}`));
} else {
entry.resolve(message.result);
}
}
// Anything else (server notifications) is ignored.
}
private request(
method: string,
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<unknown> {
if (this.deadFlag || this.closedFlag) {
return Promise.reject(this.deathError());
}
const stdin = this.child?.stdin;
if (!this.child || !stdin || stdin.destroyed) {
return Promise.reject(new Error("codegraph serve 子进程不可用"));
}
if (signal?.aborted) {
return Promise.reject(new Error("codegraph serve 请求已中止"));
}
const id = this.nextId++;
return new Promise<unknown>((resolve, reject) => {
let onAbort: (() => void) | undefined;
const finish = (action: () => void): void => {
clearTimeout(timer);
if (onAbort) signal?.removeEventListener("abort", onAbort);
this.pending.delete(id);
action();
};
const timer = setTimeout(() => {
finish(() =>
reject(new Error(`codegraph serve 请求 ${method} 超时`)),
);
}, this.timeoutMs);
timer.unref();
onAbort = () => {
this.notify("notifications/cancelled", {
requestId: id,
reason: "aborted",
});
finish(() => reject(new Error("codegraph serve 请求已中止")));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
this.pending.set(id, {
resolve: (result) => finish(() => resolve(result)),
reject: (error) => finish(() => reject(error)),
});
this.sendRaw({ jsonrpc: "2.0", id, method, params });
});
}
private notify(method: string, params?: Record<string, unknown>): void {
this.sendRaw({ jsonrpc: "2.0", method, params });
}
private sendRaw(message: Record<string, unknown>): void {
try {
this.child?.stdin?.write(`${JSON.stringify(message)}\n`);
} catch {
// stdin already closed — the exit handler will fail pending calls.
}
}
private markDead(reason: string): void {
if (this.deathFinalized) return;
this.deathFinalized = true;
this.deadFlag = true;
const tail = this.stderrLines.join("\n").trim();
this.deathReason = tail ? `${reason};stderr: ${tail}` : reason;
this.failAll(this.deathError());
}
private deathError(): Error {
return new Error(
this.closedFlag && !this.deadFlag
? "codegraph serve 客户端已关闭"
: this.deathReason,
);
}
private failAll(error: Error): void {
const pending = [...this.pending.values()];
this.pending.clear();
for (const entry of pending) entry.reject(error);
}
}