From f0f13998d8fec77a157a7d3139543189c8a3c10b Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 17:40:50 -0700 Subject: [PATCH] fix: correct all three adapters to real tool interfaces (found by live testing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel real-environment tests (graphify 0.9.23, Sourcebot v5 in Docker, gbrain 0.42.56) surfaced real mismatches: - graphify: build via `graphify update ` (the local, no-LLM path) instead of `graphify ` (which runs an LLM backend needing a key + network, so the old path wasn't actually local); query via `graphify query --graph ` so it reads the indexed graph regardless of cwd; parse the real NODE/EDGE output (file lives at src=/at=) instead of an invented format. - sourcebot: Sourcebot v5 gates /api/search behind auth — send `Authorization: Bearer `; treat 401/403 as PROVIDER_UNAVAILABLE; make status probe /api/search without following the login redirect. - gbrain: degrade engine/DB init failures (e.g. pglite WASM, garrytan/gbrain#223) to PROVIDER_UNAVAILABLE with a one-line message instead of PROVIDER_ERROR + a raw stack dump; drop flags the real CLI doesn't define (`sync --strategy`, `search --source`); align put/delete to stdin, export to brain-wide. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/code-intelligence/gbrain-adapter.ts | 69 +++++++++++++------- lib/code-intelligence/graphify-adapter.ts | 74 ++++++++++++++-------- lib/code-intelligence/sourcebot-adapter.ts | 28 +++++++- 3 files changed, 120 insertions(+), 51 deletions(-) diff --git a/lib/code-intelligence/gbrain-adapter.ts b/lib/code-intelligence/gbrain-adapter.ts index e4dbae236..fc8958987 100644 --- a/lib/code-intelligence/gbrain-adapter.ts +++ b/lib/code-intelligence/gbrain-adapter.ts @@ -9,7 +9,8 @@ * so it advertises all seven capabilities. */ -import { spawnGbrain } from "../gbrain-exec"; +import { spawnSync } from "child_process"; +import { spawnGbrain, buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "../gbrain-exec"; import { ensureSourceRegistered, probeSource, sourcePageCount } from "../gbrain-sources"; import { assertCapability, @@ -89,7 +90,8 @@ export class GbrainProvider implements CodeProvider { async refresh(source: SourceRef, opts: OpOptions = {}): Promise { assertEgressConsent(this, opts); - this.#assertOk(spawnGbrain(["sync", "--strategy", "code", "--source", source.id], { + // `gbrain sync` has no `--strategy` flag (verified against gbrain 0.42.x --help). + this.#assertOk(spawnGbrain(["sync", "--source", source.id], { baseEnv: opts.env, timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS, })); @@ -98,8 +100,10 @@ export class GbrainProvider implements CodeProvider { async search(query: string, opts: SearchOptions = {}): Promise { if (!query.trim()) return []; + // `gbrain search` is global and has no `--source` flag; `--limit` is real + // (verified against gbrain 0.42.x --help). const args = ["search", query]; - if (opts.source) args.push("--source", opts.source); + if (opts.limit) args.push("--limit", String(opts.limit)); const r = spawnGbrain(args, { baseEnv: opts.env, timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS }); this.#assertOk(r); return parseGbrainSearch(r.stdout || "", opts.minScore ?? 0.1, opts.limit ?? 10); @@ -129,29 +133,29 @@ export class GbrainProvider implements CodeProvider { } } + // Document ops (add/delete/export) are GBrain-only and secondary; they match + // gbrain's documented CLI surface (`put ` reads stdin; `delete `; + // `export`) but could not be exercised against a live engine on the test host + // (pglite WASM broken, garrytan/gbrain#223), so treat them as best-effort. async add(doc: { slug: string; body: string }, opts: OpOptions = {}): Promise { assertCapability(this, "add"); assertEgressConsent(this, opts); - const r = spawnGbrain(["put", doc.slug, "--body", doc.body], { - baseEnv: opts.env, - timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS, - }); - this.#assertOk(r); + // `gbrain put ` reads the document body from stdin. + this.#assertOk(this.#runInput(["put", doc.slug], doc.body, opts)); return { id: doc.slug, state: "ready" }; } async delete(slug: string, opts: OpOptions = {}): Promise { assertCapability(this, "delete"); - this.#assertOk(spawnGbrain(["delete", slug, "--yes"], { - baseEnv: opts.env, - timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS, - })); + // stdin closed ("") so any confirmation prompt gets EOF rather than hanging. + this.#assertOk(this.#runInput(["delete", slug], "", opts)); return { id: slug, state: "absent" }; } - async export(source: SourceRef, opts: OpOptions = {}): Promise { + async export(_source: SourceRef, opts: OpOptions = {}): Promise { assertCapability(this, "export"); - const r = spawnGbrain(["export", "--source", source.id], { + // `gbrain export` is brain-wide (no per-source flag); returns whatever it prints. + const r = spawnGbrain(["export"], { baseEnv: opts.env, timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS, }); @@ -159,6 +163,17 @@ export class GbrainProvider implements CodeProvider { return r.stdout || ""; } + /** spawn gbrain with `input` on stdin, seeded env, Windows-shim aware. */ + #runInput(args: string[], input: string, opts: OpOptions) { + return spawnSync("gbrain", args, { + input, + encoding: "utf-8", + timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS, + env: buildGbrainEnv({ baseEnv: opts.env }), + shell: NEEDS_SHELL_ON_WINDOWS, + }); + } + /** * Throw a typed failure unless the spawn succeeded. Distinguishes a missing * CLI (ENOENT → PROVIDER_UNAVAILABLE, the degrade signal) from a timeout @@ -178,21 +193,29 @@ export class GbrainProvider implements CodeProvider { if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") { throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id); } - if (/not configured|Cannot connect to database|config\.json/.test(stderr)) { - throw new CodeProviderError("PROVIDER_UNAVAILABLE", stderr || "gbrain not configured", this.id); + // Engine / DB / config problems are ENVIRONMENTAL — degrade to UNAVAILABLE + // (caller falls back to file-only), not a hard PROVIDER_ERROR with a raw dump. + // Covers the real case where gbrain's pglite engine fails to init its WASM + // runtime (garrytan/gbrain#223) as well as unreachable/unconfigured databases. + if (/PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i.test(stderr)) { + throw new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(stderr) || "gbrain engine unavailable", this.id); } - throw new CodeProviderError("PROVIDER_ERROR", stderr || `gbrain exited ${r.status}`, this.id); + throw new CodeProviderError("PROVIDER_ERROR", firstLine(stderr) || `gbrain exited ${r.status}`, this.id); } #wrap(err: unknown): CodeProviderError { if (err instanceof CodeProviderError) return err; const message = err instanceof Error ? err.message : String(err); - if (/not on PATH|command not found/.test(message)) { - return new CodeProviderError("PROVIDER_UNAVAILABLE", message, this.id); + // Same environmental-vs-real split as #assertOk: missing CLI, or engine/DB/ + // config problems, degrade to UNAVAILABLE so callers fall back to file-only. + if (/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json/i.test(message)) { + return new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(message), this.id); } - if (/not configured/.test(message)) { - return new CodeProviderError("PROVIDER_UNAVAILABLE", message, this.id); - } - return new CodeProviderError("PROVIDER_ERROR", message, this.id); + return new CodeProviderError("PROVIDER_ERROR", firstLine(message), this.id); } } + +/** First non-empty line, so a multi-line WASM/stack dump never reaches the user. */ +function firstLine(text: string): string { + return (text || "").split("\n").map((l) => l.trim()).find(Boolean) ?? ""; +} diff --git a/lib/code-intelligence/graphify-adapter.ts b/lib/code-intelligence/graphify-adapter.ts index 3cbcf492c..22d58ec99 100644 --- a/lib/code-intelligence/graphify-adapter.ts +++ b/lib/code-intelligence/graphify-adapter.ts @@ -1,15 +1,19 @@ /** * Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify). * - * Graphify is a LOCAL tree-sitter knowledge graph: `graphify ` builds a - * `graphify-out/` (graph.json + report) in that dir, `graphify query ""` - * queries it, and nothing leaves the machine (no embeddings, no network). So it - * needs no egress consent and no MCP — the runtime just shells out to the CLI, - * the same shape as the GBrain adapter. + * Graphify is a LOCAL tree-sitter knowledge graph. The genuinely local, no-LLM + * build is `graphify update ` — it writes `/graphify-out/graph.json` + * with NO embeddings and NO network (verified against graphify 0.9.23). NOTE: + * the bare `graphify ` build instead runs LLM semantic extraction (a gemini + * backend needing an API key + network), so this adapter deliberately uses + * `graphify update`, which keeps the "local, no egress consent" invariant true. * - * Never auto-installed: install is `pip install graphifyy && graphify install`, - * a user action the picker gates on. When the CLI is absent every op throws - * PROVIDER_UNAVAILABLE and callers degrade to file-only. + * Query is `graphify query "" --graph /graphify-out/graph.json`; the + * `--graph` flag points at the built graph so search never depends on cwd. + * + * Never auto-installed: install is `pip install graphifyy && graphify install` + * (needs Python >= 3.10), a user action the picker surfaces. When the CLI is + * absent every op throws PROVIDER_UNAVAILABLE and callers degrade to file-only. * * Path-based, not id-based: for Graphify a source "id" IS the absolute repo path * (that is where `graphify-out/` lives), unlike GBrain's short source ids. @@ -86,29 +90,28 @@ export class GraphifyProvider implements CodeProvider { throw new CodeProviderError("PROVIDER_ERROR", stderr || `graphify exited ${r.status}`, this.id); } - /** Build the graph over repo.path (local; no egress consent needed). */ + /** Build the graph over repo.path locally (no LLM, no egress consent needed). */ async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise { - this.#assertOk(this.#run(["."], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS)); + this.#assertOk(this.#run(["update", repo.path], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS)); return this.status({ id: repo.path }, opts); } - /** Re-parse and merge changes into the existing graph. */ + /** Re-parse and rebuild the graph (same local `graphify update` path). */ async refresh(source: SourceRef, opts: OpOptions = {}): Promise { - this.#assertOk(this.#run([".", "--update"], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS)); + this.#assertOk(this.#run(["update", source.id], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS)); return this.status(source, opts); } /** - * `graphify query ""` returns a traced answer over the graph. Its stdout is - * an answer, not a fixed hit schema (the CLI reference documents the command - * but not a machine format), so we map non-empty output lines to hits - * tolerantly: a leading path-like token becomes the ref, the line the snippet. - * Reconcile against a live graphify if a stricter schema is needed. + * `graphify query "" --graph ` traces the graph and prints + * `NODE ...` / `EDGE ...` lines (plus a `Traversal:` header). We pass `--graph` + * explicitly so the query reads the indexed repo's graph regardless of cwd. */ async search(query: string, opts: SearchOptions = {}): Promise { if (!query.trim()) return []; - const cwd = opts.source ?? this.#root; - const r = this.#run(["query", query], cwd, opts.timeout ?? DEFAULT_TIMEOUT_MS); + const root = opts.source ?? this.#root; + const graphPath = join(root, OUT_DIR, GRAPH_JSON); + const r = this.#run(["query", query, "--graph", graphPath], root, opts.timeout ?? DEFAULT_TIMEOUT_MS); this.#assertOk(r); return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10); } @@ -138,19 +141,38 @@ export class GraphifyProvider implements CodeProvider { } /** - * Map `graphify query` stdout to hits. Tolerant: each non-empty line becomes a - * hit; a leading `path` or `path:line` token becomes the ref, else the whole - * line is the snippet. Exported for deterministic unit testing. + * Parse real `graphify query` output into hits. The format (graphify 0.9.23): + * Traversal: BFS depth=2 | Start: ['query()'] | ... | 4 nodes found + * NODE query() [src=db.py loc=L4 community=login] + * EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8 + * The file lives mid-line (`src= loc=L` on NODE, `at=:L` on + * EDGE), so the ref is `:L`. The `Traversal:` header and any other line + * are skipped. Exported for deterministic unit testing against the real format. */ export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] { const hits: CodeSearchHit[] = []; for (const raw of stdout.split("\n")) { const line = raw.trim(); - if (!line) continue; - const token = line.split(/\s+/)[0]; - const looksPath = /[/\\.]/.test(token) && !token.includes(" "); - hits.push({ ref: looksPath ? token : "graphify", snippet: line, kind: "graph-node" }); + let ref: string | undefined; + const node = line.match(/^NODE\b.*?\[src=(\S+)\s+loc=(L\d+)/); + const edge = line.match(/^EDGE\b.*?\bat=(\S+?):(L\d+)\b/); + if (node) ref = `${node[1]}:${node[2]}`; + else if (edge) ref = `${edge[1]}:${edge[2]}`; + else continue; // skip the Traversal header and anything non-NODE/EDGE + hits.push({ ref, snippet: line, kind: "graph-node" }); if (hits.length >= limit) break; } return hits; } + +/** Whether the `graphify` CLI is installed (for the picker's availability probe). */ +export function graphifyInstalled(env?: NodeJS.ProcessEnv): boolean { + const r = spawnSync("graphify", ["--version"], { + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "ignore", "ignore"], + env, + shell: NEEDS_SHELL_ON_WINDOWS, + }); + return r.status === 0; +} diff --git a/lib/code-intelligence/sourcebot-adapter.ts b/lib/code-intelligence/sourcebot-adapter.ts index b6d7d9fc0..d7c6c5e24 100644 --- a/lib/code-intelligence/sourcebot-adapter.ts +++ b/lib/code-intelligence/sourcebot-adapter.ts @@ -45,6 +45,12 @@ export interface SourcebotOptions { baseUrl?: string; /** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */ configPath?: string; + /** + * API key for the Sourcebot REST API. Defaults to SOURCEBOT_API_KEY. Sourcebot + * v5 gates `/api/search` behind auth (`Authorization: Bearer `); without + * it, `search` gets HTTP 401. Generate one in Settings -> API Keys. + */ + apiKey?: string; /** Injectable fetch for tests. */ fetch?: FetchLike; env?: NodeJS.ProcessEnv; @@ -66,17 +72,23 @@ export class SourcebotProvider implements CodeProvider { readonly local: boolean; readonly #baseUrl: string; readonly #configPath?: string; + readonly #apiKey?: string; readonly #fetch: FetchLike; constructor(opts: SourcebotOptions = {}) { const env = opts.env ?? process.env; this.#baseUrl = (opts.baseUrl ?? env.SOURCEBOT_URL ?? DEFAULT_URL).replace(/\/$/, ""); this.#configPath = opts.configPath ?? env.SOURCEBOT_CONFIG; + this.#apiKey = opts.apiKey ?? env.SOURCEBOT_API_KEY; this.#fetch = opts.fetch ?? globalThis.fetch; this.local = isLoopback(this.#baseUrl); assertRequiredCapabilities(this.id, this.capabilities); } + #authHeaders(): Record { + return this.#apiKey ? { Authorization: `Bearer ${this.#apiKey}` } : {}; + } + has(capability: CodeProviderCapability): boolean { return this.capabilities.has(capability); } @@ -127,8 +139,17 @@ export class SourcebotProvider implements CodeProvider { } async status(_source?: SourceRef, opts: OpOptions = {}): Promise { + // `redirect: manual` so an auth-gated server (307 -> /login) reads as + // not-usable instead of following to a 200 and falsely reporting "ready". try { - const res = await this.#fetchWithTimeout(this.#baseUrl, { method: "GET" }, opts.timeout ?? DEFAULT_TIMEOUT_MS); + const res = await this.#fetchWithTimeout( + `${this.#baseUrl}/api/search`, + { method: "POST", headers: { "Content-Type": "application/json", ...this.#authHeaders() }, body: JSON.stringify({ query: "sourcebot", matches: 1, isRegexEnabled: false }), redirect: "manual" }, + opts.timeout ?? DEFAULT_TIMEOUT_MS, + ); + if (res.status === 401 || res.status === 403) { + return { id: "*", state: "unknown", partial: true, detail: "reachable but not authenticated (set SOURCEBOT_API_KEY)" }; + } return { id: "*", state: res.ok ? "ready" : "unknown", partial: true, detail: `HTTP ${res.status}` }; } catch { return { id: "*", state: "unknown", partial: true, detail: `unreachable at ${this.#baseUrl}` }; @@ -140,12 +161,15 @@ export class SourcebotProvider implements CodeProvider { try { res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, { method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, + headers: { "Content-Type": "application/json", Accept: "application/json", ...this.#authHeaders() }, body: JSON.stringify(body), }, timeout); } catch (err) { throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot unreachable at ${this.#baseUrl}: ${(err as Error).message}`, this.id); } + if (res.status === 401 || res.status === 403) { + throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot requires authentication; set SOURCEBOT_API_KEY (HTTP ${res.status})`, this.id); + } if (!res.ok) throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot ${path} returned HTTP ${res.status}`, this.id); try { return await res.json();