mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 20:30:47 +02:00
fix: correct all three adapters to real tool interfaces (found by live testing)
Parallel real-environment tests (graphify 0.9.23, Sourcebot v5 in Docker, gbrain 0.42.56) surfaced real mismatches: - graphify: build via `graphify update <dir>` (the local, no-LLM path) instead of `graphify <dir>` (which runs an LLM backend needing a key + network, so the old path wasn't actually local); query via `graphify query --graph <graph.json>` 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 <SOURCEBOT_API_KEY>`; 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9650e8b056
commit
f0f13998d8
@@ -9,7 +9,8 @@
|
|||||||
* so it advertises all seven capabilities.
|
* 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 { ensureSourceRegistered, probeSource, sourcePageCount } from "../gbrain-sources";
|
||||||
import {
|
import {
|
||||||
assertCapability,
|
assertCapability,
|
||||||
@@ -89,7 +90,8 @@ export class GbrainProvider implements CodeProvider {
|
|||||||
|
|
||||||
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
assertEgressConsent(this, opts);
|
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,
|
baseEnv: opts.env,
|
||||||
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||||
}));
|
}));
|
||||||
@@ -98,8 +100,10 @@ export class GbrainProvider implements CodeProvider {
|
|||||||
|
|
||||||
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||||
if (!query.trim()) return [];
|
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];
|
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 });
|
const r = spawnGbrain(args, { baseEnv: opts.env, timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS });
|
||||||
this.#assertOk(r);
|
this.#assertOk(r);
|
||||||
return parseGbrainSearch(r.stdout || "", opts.minScore ?? 0.1, opts.limit ?? 10);
|
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 <slug>` reads stdin; `delete <slug>`;
|
||||||
|
// `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<SourceStatus> {
|
async add(doc: { slug: string; body: string }, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
assertCapability(this, "add");
|
assertCapability(this, "add");
|
||||||
assertEgressConsent(this, opts);
|
assertEgressConsent(this, opts);
|
||||||
const r = spawnGbrain(["put", doc.slug, "--body", doc.body], {
|
// `gbrain put <slug>` reads the document body from stdin.
|
||||||
baseEnv: opts.env,
|
this.#assertOk(this.#runInput(["put", doc.slug], doc.body, opts));
|
||||||
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
this.#assertOk(r);
|
|
||||||
return { id: doc.slug, state: "ready" };
|
return { id: doc.slug, state: "ready" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(slug: string, opts: OpOptions = {}): Promise<SourceStatus> {
|
async delete(slug: string, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
assertCapability(this, "delete");
|
assertCapability(this, "delete");
|
||||||
this.#assertOk(spawnGbrain(["delete", slug, "--yes"], {
|
// stdin closed ("") so any confirmation prompt gets EOF rather than hanging.
|
||||||
baseEnv: opts.env,
|
this.#assertOk(this.#runInput(["delete", slug], "", opts));
|
||||||
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
|
||||||
}));
|
|
||||||
return { id: slug, state: "absent" };
|
return { id: slug, state: "absent" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async export(source: SourceRef, opts: OpOptions = {}): Promise<string> {
|
async export(_source: SourceRef, opts: OpOptions = {}): Promise<string> {
|
||||||
assertCapability(this, "export");
|
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,
|
baseEnv: opts.env,
|
||||||
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
@@ -159,6 +163,17 @@ export class GbrainProvider implements CodeProvider {
|
|||||||
return r.stdout || "";
|
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
|
* Throw a typed failure unless the spawn succeeded. Distinguishes a missing
|
||||||
* CLI (ENOENT → PROVIDER_UNAVAILABLE, the degrade signal) from a timeout
|
* 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") {
|
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
|
||||||
throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id);
|
throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id);
|
||||||
}
|
}
|
||||||
if (/not configured|Cannot connect to database|config\.json/.test(stderr)) {
|
// Engine / DB / config problems are ENVIRONMENTAL — degrade to UNAVAILABLE
|
||||||
throw new CodeProviderError("PROVIDER_UNAVAILABLE", stderr || "gbrain not configured", this.id);
|
// (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 {
|
#wrap(err: unknown): CodeProviderError {
|
||||||
if (err instanceof CodeProviderError) return err;
|
if (err instanceof CodeProviderError) return err;
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (/not on PATH|command not found/.test(message)) {
|
// Same environmental-vs-real split as #assertOk: missing CLI, or engine/DB/
|
||||||
return new CodeProviderError("PROVIDER_UNAVAILABLE", message, this.id);
|
// 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_ERROR", firstLine(message), this.id);
|
||||||
return new CodeProviderError("PROVIDER_UNAVAILABLE", message, this.id);
|
|
||||||
}
|
|
||||||
return new CodeProviderError("PROVIDER_ERROR", 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) ?? "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify).
|
* Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify).
|
||||||
*
|
*
|
||||||
* Graphify is a LOCAL tree-sitter knowledge graph: `graphify <dir>` builds a
|
* Graphify is a LOCAL tree-sitter knowledge graph. The genuinely local, no-LLM
|
||||||
* `graphify-out/` (graph.json + report) in that dir, `graphify query "<q>"`
|
* build is `graphify update <dir>` — it writes `<dir>/graphify-out/graph.json`
|
||||||
* queries it, and nothing leaves the machine (no embeddings, no network). So it
|
* with NO embeddings and NO network (verified against graphify 0.9.23). NOTE:
|
||||||
* needs no egress consent and no MCP — the runtime just shells out to the CLI,
|
* the bare `graphify <dir>` build instead runs LLM semantic extraction (a gemini
|
||||||
* the same shape as the GBrain adapter.
|
* 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`,
|
* Query is `graphify query "<q>" --graph <dir>/graphify-out/graph.json`; the
|
||||||
* a user action the picker gates on. When the CLI is absent every op throws
|
* `--graph` flag points at the built graph so search never depends on cwd.
|
||||||
* PROVIDER_UNAVAILABLE and callers degrade to file-only.
|
*
|
||||||
|
* 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
|
* 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.
|
* (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);
|
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<SourceStatus> {
|
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
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);
|
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<SourceStatus> {
|
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
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);
|
return this.status(source, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `graphify query "<q>"` returns a traced answer over the graph. Its stdout is
|
* `graphify query "<q>" --graph <graph.json>` traces the graph and prints
|
||||||
* an answer, not a fixed hit schema (the CLI reference documents the command
|
* `NODE ...` / `EDGE ...` lines (plus a `Traversal:` header). We pass `--graph`
|
||||||
* but not a machine format), so we map non-empty output lines to hits
|
* explicitly so the query reads the indexed repo's graph regardless of cwd.
|
||||||
* tolerantly: a leading path-like token becomes the ref, the line the snippet.
|
|
||||||
* Reconcile against a live graphify if a stricter schema is needed.
|
|
||||||
*/
|
*/
|
||||||
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
const cwd = opts.source ?? this.#root;
|
const root = opts.source ?? this.#root;
|
||||||
const r = this.#run(["query", query], cwd, opts.timeout ?? DEFAULT_TIMEOUT_MS);
|
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);
|
this.#assertOk(r);
|
||||||
return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10);
|
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
|
* Parse real `graphify query` output into hits. The format (graphify 0.9.23):
|
||||||
* hit; a leading `path` or `path:line` token becomes the ref, else the whole
|
* Traversal: BFS depth=2 | Start: ['query()'] | ... | 4 nodes found
|
||||||
* line is the snippet. Exported for deterministic unit testing.
|
* 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=<file> loc=L<n>` on NODE, `at=<file>:L<n>` on
|
||||||
|
* EDGE), so the ref is `<file>:L<n>`. 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[] {
|
export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] {
|
||||||
const hits: CodeSearchHit[] = [];
|
const hits: CodeSearchHit[] = [];
|
||||||
for (const raw of stdout.split("\n")) {
|
for (const raw of stdout.split("\n")) {
|
||||||
const line = raw.trim();
|
const line = raw.trim();
|
||||||
if (!line) continue;
|
let ref: string | undefined;
|
||||||
const token = line.split(/\s+/)[0];
|
const node = line.match(/^NODE\b.*?\[src=(\S+)\s+loc=(L\d+)/);
|
||||||
const looksPath = /[/\\.]/.test(token) && !token.includes(" ");
|
const edge = line.match(/^EDGE\b.*?\bat=(\S+?):(L\d+)\b/);
|
||||||
hits.push({ ref: looksPath ? token : "graphify", snippet: line, kind: "graph-node" });
|
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;
|
if (hits.length >= limit) break;
|
||||||
}
|
}
|
||||||
return hits;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ export interface SourcebotOptions {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */
|
/** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */
|
||||||
configPath?: string;
|
configPath?: string;
|
||||||
|
/**
|
||||||
|
* API key for the Sourcebot REST API. Defaults to SOURCEBOT_API_KEY. Sourcebot
|
||||||
|
* v5 gates `/api/search` behind auth (`Authorization: Bearer <key>`); without
|
||||||
|
* it, `search` gets HTTP 401. Generate one in Settings -> API Keys.
|
||||||
|
*/
|
||||||
|
apiKey?: string;
|
||||||
/** Injectable fetch for tests. */
|
/** Injectable fetch for tests. */
|
||||||
fetch?: FetchLike;
|
fetch?: FetchLike;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
@@ -66,17 +72,23 @@ export class SourcebotProvider implements CodeProvider {
|
|||||||
readonly local: boolean;
|
readonly local: boolean;
|
||||||
readonly #baseUrl: string;
|
readonly #baseUrl: string;
|
||||||
readonly #configPath?: string;
|
readonly #configPath?: string;
|
||||||
|
readonly #apiKey?: string;
|
||||||
readonly #fetch: FetchLike;
|
readonly #fetch: FetchLike;
|
||||||
|
|
||||||
constructor(opts: SourcebotOptions = {}) {
|
constructor(opts: SourcebotOptions = {}) {
|
||||||
const env = opts.env ?? process.env;
|
const env = opts.env ?? process.env;
|
||||||
this.#baseUrl = (opts.baseUrl ?? env.SOURCEBOT_URL ?? DEFAULT_URL).replace(/\/$/, "");
|
this.#baseUrl = (opts.baseUrl ?? env.SOURCEBOT_URL ?? DEFAULT_URL).replace(/\/$/, "");
|
||||||
this.#configPath = opts.configPath ?? env.SOURCEBOT_CONFIG;
|
this.#configPath = opts.configPath ?? env.SOURCEBOT_CONFIG;
|
||||||
|
this.#apiKey = opts.apiKey ?? env.SOURCEBOT_API_KEY;
|
||||||
this.#fetch = opts.fetch ?? globalThis.fetch;
|
this.#fetch = opts.fetch ?? globalThis.fetch;
|
||||||
this.local = isLoopback(this.#baseUrl);
|
this.local = isLoopback(this.#baseUrl);
|
||||||
assertRequiredCapabilities(this.id, this.capabilities);
|
assertRequiredCapabilities(this.id, this.capabilities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#authHeaders(): Record<string, string> {
|
||||||
|
return this.#apiKey ? { Authorization: `Bearer ${this.#apiKey}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
has(capability: CodeProviderCapability): boolean {
|
has(capability: CodeProviderCapability): boolean {
|
||||||
return this.capabilities.has(capability);
|
return this.capabilities.has(capability);
|
||||||
}
|
}
|
||||||
@@ -127,8 +139,17 @@ export class SourcebotProvider implements CodeProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async status(_source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
async status(_source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
// `redirect: manual` so an auth-gated server (307 -> /login) reads as
|
||||||
|
// not-usable instead of following to a 200 and falsely reporting "ready".
|
||||||
try {
|
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}` };
|
return { id: "*", state: res.ok ? "ready" : "unknown", partial: true, detail: `HTTP ${res.status}` };
|
||||||
} catch {
|
} catch {
|
||||||
return { id: "*", state: "unknown", partial: true, detail: `unreachable at ${this.#baseUrl}` };
|
return { id: "*", state: "unknown", partial: true, detail: `unreachable at ${this.#baseUrl}` };
|
||||||
@@ -140,12 +161,15 @@ export class SourcebotProvider implements CodeProvider {
|
|||||||
try {
|
try {
|
||||||
res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, {
|
res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
headers: { "Content-Type": "application/json", Accept: "application/json", ...this.#authHeaders() },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}, timeout);
|
}, timeout);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot unreachable at ${this.#baseUrl}: ${(err as Error).message}`, this.id);
|
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);
|
if (!res.ok) throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot ${path} returned HTTP ${res.status}`, this.id);
|
||||||
try {
|
try {
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
|||||||
Reference in New Issue
Block a user