mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
feat: code-intelligence contract with real GBrain/Graphify/Sourcebot adapters
contract.ts: repo-oriented interface (four required ops, three optional), typed CodeProviderError, egress + capability consent guards. Three real, runtime-drivable adapters: - GbrainProvider: gbrain CLI (reuses lib/gbrain-exec + lib/gbrain-sources), all seven capabilities. - GraphifyProvider: graphify CLI — `graphify <dir>` builds the local graph, `graphify query` searches it, export reads graphify-out/graph.json. Fully local; never auto-installed. - SourcebotProvider: self-hosted server over HTTP — register writes a local git connection to config.json, search is POST /api/search, status is a liveness probe. Loopback base URL = local (no egress); remote = consent. selection.ts persists the chosen provider + per-repo indexing consent under $GSTACK_HOME. picker.ts recommends GBrain first, resolves the selected provider or null (provider-OFF), and probes live availability. Every adapter degrades to PROVIDER_UNAVAILABLE when its tool/server is absent. 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
b4421a4e69
commit
3eb4a1b23f
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify).
|
||||
*
|
||||
* Graphify is a LOCAL tree-sitter knowledge graph: `graphify <dir>` builds a
|
||||
* `graphify-out/` (graph.json + report) in that dir, `graphify query "<q>"`
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import {
|
||||
assertCapability,
|
||||
assertRequiredCapabilities,
|
||||
CodeProviderError,
|
||||
type CodeProvider,
|
||||
type CodeProviderCapability,
|
||||
type CodeSearchHit,
|
||||
type OpOptions,
|
||||
type RepoRef,
|
||||
type SearchOptions,
|
||||
type SourceRef,
|
||||
type SourceStatus,
|
||||
} from "./contract";
|
||||
|
||||
const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status", "export"];
|
||||
const OUT_DIR = "graphify-out";
|
||||
const GRAPH_JSON = "graph.json";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while
|
||||
const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows
|
||||
|
||||
export interface GraphifyOptions {
|
||||
/** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */
|
||||
root?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export class GraphifyProvider implements CodeProvider {
|
||||
readonly id = "graphify" as const;
|
||||
readonly label = "Graphify";
|
||||
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
|
||||
/** Fully local — no repo content leaves the machine. */
|
||||
readonly local = true;
|
||||
readonly #root: string;
|
||||
readonly #env?: NodeJS.ProcessEnv;
|
||||
|
||||
constructor(opts: GraphifyOptions = {}) {
|
||||
this.#root = opts.root ?? process.cwd();
|
||||
this.#env = opts.env;
|
||||
assertRequiredCapabilities(this.id, this.capabilities);
|
||||
}
|
||||
|
||||
has(capability: CodeProviderCapability): boolean {
|
||||
return this.capabilities.has(capability);
|
||||
}
|
||||
|
||||
#run(args: string[], cwd: string, timeout: number) {
|
||||
return spawnSync("graphify", args, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
timeout,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: this.#env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS,
|
||||
});
|
||||
}
|
||||
|
||||
#assertOk(r: { status: number | null; stderr?: string; error?: Error & { code?: string }; signal?: NodeJS.Signals | null }): void {
|
||||
if (r.status === 0) return;
|
||||
const stderr = (r.stderr || "").trim();
|
||||
if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) {
|
||||
throw new CodeProviderError("PROVIDER_UNAVAILABLE", "graphify CLI not on PATH (install: pip install graphifyy && graphify install)", this.id);
|
||||
}
|
||||
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
|
||||
throw new CodeProviderError("PROVIDER_TIMEOUT", "graphify timed out", 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). */
|
||||
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
this.#assertOk(this.#run(["."], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS));
|
||||
return this.status({ id: repo.path }, opts);
|
||||
}
|
||||
|
||||
/** Re-parse and merge changes into the existing graph. */
|
||||
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
this.#assertOk(this.#run([".", "--update"], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS));
|
||||
return this.status(source, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* `graphify query "<q>"` 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.
|
||||
*/
|
||||
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||
if (!query.trim()) return [];
|
||||
const cwd = opts.source ?? this.#root;
|
||||
const r = this.#run(["query", query], cwd, opts.timeout ?? DEFAULT_TIMEOUT_MS);
|
||||
this.#assertOk(r);
|
||||
return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10);
|
||||
}
|
||||
|
||||
async status(source?: SourceRef, _opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
const dir = source?.id ?? this.#root;
|
||||
const graphPath = join(dir, OUT_DIR, GRAPH_JSON);
|
||||
if (!existsSync(graphPath)) return { id: dir, state: "absent" };
|
||||
let itemCount: number | undefined;
|
||||
try {
|
||||
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
|
||||
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
|
||||
} catch {
|
||||
// graph.json present but unparseable — still ready, just no count.
|
||||
}
|
||||
return { id: dir, state: "ready", itemCount, detail: graphPath };
|
||||
}
|
||||
|
||||
async export(source: SourceRef, _opts: OpOptions = {}): Promise<string> {
|
||||
assertCapability(this, "export");
|
||||
const graphPath = join(source.id, OUT_DIR, GRAPH_JSON);
|
||||
if (!existsSync(graphPath)) {
|
||||
throw new CodeProviderError("SOURCE_NOT_REGISTERED", `no graph at ${graphPath}; index it first`, this.id);
|
||||
}
|
||||
return readFileSync(graphPath, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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" });
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
@@ -5,10 +5,20 @@
|
||||
|
||||
export * from "./contract";
|
||||
export { GbrainProvider, parseGbrainSearch } from "./gbrain-adapter";
|
||||
export { SourcebotProvider, GraphifyProvider } from "./mcp-adapters";
|
||||
export { GraphifyProvider, parseGraphifyQuery, type GraphifyOptions } from "./graphify-adapter";
|
||||
export { SourcebotProvider, parseSourcebotSearch, type SourcebotOptions } from "./sourcebot-adapter";
|
||||
export {
|
||||
readSelection,
|
||||
setProvider,
|
||||
setConsent,
|
||||
hasConsent,
|
||||
type Selection,
|
||||
} from "./selection";
|
||||
export {
|
||||
recommendCodeProvider,
|
||||
resolveCodeProvider,
|
||||
RECOMMENDED_ORDER,
|
||||
providerById,
|
||||
resolveSelectedProvider,
|
||||
detectAvailable,
|
||||
type PickerOptions,
|
||||
type Availability,
|
||||
} from "./picker";
|
||||
|
||||
@@ -1,43 +1,93 @@
|
||||
/**
|
||||
* Picker — recommends a code-intelligence provider, GBrain first.
|
||||
* Picker — constructs the code-intelligence provider the user selected, and
|
||||
* offers the recommendation order (GBrain first) for the selection UX.
|
||||
*
|
||||
* RECOMMENDED_ORDER is the static "GBrain first" fact the options UX and phase-2
|
||||
* resolution filter against. In phase 1 only GBrain is drivable from the runtime
|
||||
* (it has a CLI the runtime can spawn); Sourcebot and Graphify are MCP tools in
|
||||
* the host session and become resolvable in phase 2 once a host transport is
|
||||
* wired. So recommendCodeProvider returns GBrain-or-nothing today, and
|
||||
* resolveCodeProvider degrades to null — the provider-OFF path — when GBrain is
|
||||
* unavailable. Callers then use grep / the file-only decision store.
|
||||
* `resolveSelectedProvider()` reads the persisted selection and constructs that
|
||||
* provider, or returns null when nothing is selected — the provider-OFF path,
|
||||
* where callers degrade to grep / the file-only decision store. Availability is
|
||||
* proven at call time: a selected provider whose tool/server is absent throws
|
||||
* PROVIDER_UNAVAILABLE from its ops, which callers catch and degrade on. The
|
||||
* `detectAvailable()` probe drives the `options`/`status` display.
|
||||
*
|
||||
* GBrain availability uses the real detector, localEngineStatus() ("ok"/"timeout"
|
||||
* are usable). Graphify is NEVER auto-installed or auto-offered.
|
||||
* GBrain is recommended first. Graphify is NEVER auto-installed — it appears in
|
||||
* the options only once its CLI is present (a user install).
|
||||
*/
|
||||
|
||||
import { localEngineStatus, type LocalEngineStatus } from "../gbrain-local-status";
|
||||
import { localEngineStatus } from "../gbrain-local-status";
|
||||
import { GbrainProvider } from "./gbrain-adapter";
|
||||
import { GraphifyProvider, type GraphifyOptions } from "./graphify-adapter";
|
||||
import { SourcebotProvider, type SourcebotOptions } from "./sourcebot-adapter";
|
||||
import { readSelection } from "./selection";
|
||||
import type { CodeProvider, CodeProviderId } from "./contract";
|
||||
|
||||
/** Recommendation order — GBrain first. Sourcebot/Graphify join in phase 2. */
|
||||
/** Recommendation order — GBrain first. */
|
||||
export const RECOMMENDED_ORDER: readonly CodeProviderId[] = ["gbrain", "sourcebot", "graphify"];
|
||||
|
||||
export interface PickerOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Inject GBrain status for tests; otherwise probed via localEngineStatus(). */
|
||||
gbrainStatus?: LocalEngineStatus;
|
||||
graphify?: GraphifyOptions;
|
||||
sourcebot?: SourcebotOptions;
|
||||
}
|
||||
|
||||
const GBRAIN_USABLE: ReadonlySet<LocalEngineStatus> = new Set(["ok", "timeout"]);
|
||||
|
||||
/** Drivable providers, in recommendation order (GBrain first). */
|
||||
export function recommendCodeProvider(opts: PickerOptions = {}): CodeProvider[] {
|
||||
const status = opts.gbrainStatus ?? localEngineStatus({ env: opts.env });
|
||||
return GBRAIN_USABLE.has(status) ? [new GbrainProvider()] : [];
|
||||
/** Construct a provider by id (no availability check — ops degrade at call time). */
|
||||
export function providerById(id: CodeProviderId, opts: PickerOptions = {}): CodeProvider {
|
||||
switch (id) {
|
||||
case "gbrain":
|
||||
return new GbrainProvider();
|
||||
case "graphify":
|
||||
return new GraphifyProvider({ env: opts.env, ...opts.graphify });
|
||||
case "sourcebot":
|
||||
return new SourcebotProvider({ env: opts.env, ...opts.sourcebot });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single recommended provider, or null when none is drivable. Null is the
|
||||
* provider-OFF path: callers MUST degrade to grep / file-only, never fail.
|
||||
* The provider the user selected, constructed, or null when none is selected.
|
||||
* Null is the provider-OFF path: callers MUST degrade to grep / file-only.
|
||||
*/
|
||||
export function resolveCodeProvider(opts: PickerOptions = {}): CodeProvider | null {
|
||||
return recommendCodeProvider(opts)[0] ?? null;
|
||||
export function resolveSelectedProvider(opts: PickerOptions = {}): CodeProvider | null {
|
||||
const { provider } = readSelection(opts.env);
|
||||
return provider ? providerById(provider, opts) : null;
|
||||
}
|
||||
|
||||
export interface Availability {
|
||||
id: CodeProviderId;
|
||||
available: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe which providers are usable right now, in recommendation order. Used by
|
||||
* the `options`/`status` display. GBrain via the real localEngineStatus();
|
||||
* Graphify via its CLI status; Sourcebot via an HTTP liveness probe.
|
||||
*/
|
||||
export async function detectAvailable(opts: PickerOptions = {}): Promise<Availability[]> {
|
||||
const gbrainStatus = localEngineStatus({ env: opts.env });
|
||||
const gbrainOk = gbrainStatus === "ok" || gbrainStatus === "timeout";
|
||||
|
||||
let graphifyOk = false;
|
||||
let graphifyDetail = "graphify CLI not installed";
|
||||
try {
|
||||
const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status();
|
||||
graphifyOk = s.state === "ready";
|
||||
graphifyDetail = graphifyOk ? "graph indexed in this repo" : "installed; no graph in this repo yet";
|
||||
} catch {
|
||||
graphifyOk = false;
|
||||
}
|
||||
|
||||
let sourcebotOk = false;
|
||||
let sourcebotDetail = "server unreachable";
|
||||
try {
|
||||
const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status();
|
||||
sourcebotOk = s.state === "ready";
|
||||
sourcebotDetail = s.detail ?? "";
|
||||
} catch {
|
||||
sourcebotOk = false;
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: "gbrain", available: gbrainOk, detail: `gbrain engine: ${gbrainStatus}` },
|
||||
{ id: "sourcebot", available: sourcebotOk, detail: sourcebotDetail },
|
||||
{ id: "graphify", available: graphifyOk, detail: graphifyDetail },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* selection — persists the user's chosen code-intelligence provider and their
|
||||
* per-repo indexing consent. Stored at `$GSTACK_HOME/code-intelligence.json`
|
||||
* (default `~/.gstack/`), the same home the rest of gstack uses.
|
||||
*
|
||||
* Consent is per-repo (keyed by absolute repo path), because indexing consent
|
||||
* is "may THIS repo's content be indexed by the selected provider" — a decision
|
||||
* a user makes per project, not once for the machine. No selection at all is the
|
||||
* provider-OFF default: callers degrade to grep / the file-only decision store.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import type { CodeProviderId } from "./contract";
|
||||
|
||||
export interface Selection {
|
||||
provider: CodeProviderId | null;
|
||||
/** Absolute repo path → consented. */
|
||||
consents: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const EMPTY: Selection = { provider: null, consents: {} };
|
||||
|
||||
function storePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack");
|
||||
return join(home, "code-intelligence.json");
|
||||
}
|
||||
|
||||
export function readSelection(env: NodeJS.ProcessEnv = process.env): Selection {
|
||||
const p = storePath(env);
|
||||
if (!existsSync(p)) return { ...EMPTY };
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(p, "utf-8")) as Partial<Selection>;
|
||||
return {
|
||||
provider: raw.provider ?? null,
|
||||
consents: raw.consents && typeof raw.consents === "object" ? raw.consents : {},
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY };
|
||||
}
|
||||
}
|
||||
|
||||
function write(selection: Selection, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const p = storePath(env);
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify(selection, null, 2), "utf-8");
|
||||
renameSync(tmp, p);
|
||||
}
|
||||
|
||||
export function setProvider(provider: CodeProviderId | null, env: NodeJS.ProcessEnv = process.env): Selection {
|
||||
const next = { ...readSelection(env), provider };
|
||||
write(next, env);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Record per-repo indexing consent (repo path resolved to absolute). */
|
||||
export function setConsent(repoPath: string, consented: boolean, env: NodeJS.ProcessEnv = process.env): Selection {
|
||||
const current = readSelection(env);
|
||||
const next: Selection = { ...current, consents: { ...current.consents, [resolve(repoPath)]: consented } };
|
||||
write(next, env);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return readSelection(env).consents[resolve(repoPath)] === true;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Sourcebot adapter — real HTTP + config integration
|
||||
* (github.com/sourcebot-dev/sourcebot, YC F2025).
|
||||
*
|
||||
* Sourcebot is a self-hosted server that indexes repos declared in its
|
||||
* config.json and serves regex code search over `POST /api/search` (zoekt). So
|
||||
* the runtime drives it with plain HTTP + a config-file edit — no MCP:
|
||||
* - register_source: add `{ "type": "git", "url": "file:///abs/path" }` to the
|
||||
* server's config.json (it re-indexes automatically on config change).
|
||||
* - refresh: Sourcebot re-indexes on config change and on reindexIntervalMs;
|
||||
* there is no per-source trigger endpoint, so refresh reports current status.
|
||||
* - search: POST /api/search with a regex query, map files[] to hits.
|
||||
* - status: liveness GET against the base URL.
|
||||
* Declines the document ops (add/delete/export) — it is a whole-repo search index.
|
||||
*
|
||||
* Egress: a loopback base URL means the index runs on this machine, so no repo
|
||||
* content leaves it (local=true). A non-loopback base URL means content reaches
|
||||
* another host, so egress consent is required (local=false).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
||||
import {
|
||||
assertCapability,
|
||||
assertEgressConsent,
|
||||
assertRequiredCapabilities,
|
||||
CodeProviderError,
|
||||
type CodeProvider,
|
||||
type CodeProviderCapability,
|
||||
type CodeSearchHit,
|
||||
type OpOptions,
|
||||
type RepoRef,
|
||||
type SearchOptions,
|
||||
type SourceRef,
|
||||
type SourceStatus,
|
||||
} from "./contract";
|
||||
|
||||
const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status"];
|
||||
const DEFAULT_URL = "http://localhost:3000";
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
type FetchLike = typeof globalThis.fetch;
|
||||
|
||||
export interface SourcebotOptions {
|
||||
/** Base URL of the Sourcebot server. Defaults to SOURCEBOT_URL or http://localhost:3000. */
|
||||
baseUrl?: string;
|
||||
/** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */
|
||||
configPath?: string;
|
||||
/** Injectable fetch for tests. */
|
||||
fetch?: FetchLike;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function isLoopback(url: string): boolean {
|
||||
try {
|
||||
const h = new URL(url).hostname.toLowerCase();
|
||||
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class SourcebotProvider implements CodeProvider {
|
||||
readonly id = "sourcebot" as const;
|
||||
readonly label = "Sourcebot";
|
||||
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
|
||||
readonly local: boolean;
|
||||
readonly #baseUrl: string;
|
||||
readonly #configPath?: 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.#fetch = opts.fetch ?? globalThis.fetch;
|
||||
this.local = isLoopback(this.#baseUrl);
|
||||
assertRequiredCapabilities(this.id, this.capabilities);
|
||||
}
|
||||
|
||||
has(capability: CodeProviderCapability): boolean {
|
||||
return this.capabilities.has(capability);
|
||||
}
|
||||
|
||||
/** Add the repo as a local `git` connection in Sourcebot's config.json. */
|
||||
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
assertEgressConsent(this, opts); // no-op when the server is loopback (local)
|
||||
if (!this.#configPath) {
|
||||
throw new CodeProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
"set SOURCEBOT_CONFIG to the server's config.json path to register sources",
|
||||
this.id,
|
||||
);
|
||||
}
|
||||
let config: { connections?: Record<string, unknown> };
|
||||
try {
|
||||
config = existsSync(this.#configPath)
|
||||
? (JSON.parse(readFileSync(this.#configPath, "utf-8")) as typeof config)
|
||||
: {};
|
||||
} catch (err) {
|
||||
throw new CodeProviderError("PROVIDER_ERROR", `unreadable Sourcebot config: ${(err as Error).message}`, this.id);
|
||||
}
|
||||
config.connections = config.connections ?? {};
|
||||
config.connections[repo.id] = { type: "git", url: `file://${repo.path}` };
|
||||
// Atomic write so a running Sourcebot never reads a half-written config.
|
||||
const tmp = `${this.#configPath}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify(config, null, 2), "utf-8");
|
||||
renameSync(tmp, this.#configPath);
|
||||
return { id: repo.id, state: "registered", detail: "Sourcebot re-indexes on config change" };
|
||||
}
|
||||
|
||||
/** Sourcebot re-indexes automatically; report current liveness. */
|
||||
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
const live = await this.status(source, opts);
|
||||
return { ...live, detail: "Sourcebot re-indexes automatically (config change + reindexIntervalMs)" };
|
||||
}
|
||||
|
||||
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||
if (!query.trim()) return [];
|
||||
const body = {
|
||||
query: opts.source ? `repo:${opts.source} ${query}` : query,
|
||||
matches: opts.limit ?? 20,
|
||||
isRegexEnabled: true,
|
||||
isCaseSensitivityEnabled: false,
|
||||
};
|
||||
const payload = await this.#post("/api/search", body, opts.timeout ?? DEFAULT_TIMEOUT_MS);
|
||||
return parseSourcebotSearch(payload, opts.limit ?? 20);
|
||||
}
|
||||
|
||||
async status(_source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
try {
|
||||
const res = await this.#fetchWithTimeout(this.#baseUrl, { method: "GET" }, opts.timeout ?? DEFAULT_TIMEOUT_MS);
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
|
||||
async #post(path: string, body: unknown, timeout: number): Promise<unknown> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
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.ok) throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot ${path} returned HTTP ${res.status}`, this.id);
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot returned non-JSON: ${(err as Error).message}`, this.id);
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
return await this.#fetch(url, { ...init, signal: controller.signal });
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") throw new CodeProviderError("PROVIDER_TIMEOUT", "Sourcebot request timed out", this.id);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SourcebotMatchRange { start?: { lineNumber?: number } }
|
||||
interface SourcebotChunk { content?: string; matchRanges?: SourcebotMatchRange[] }
|
||||
interface SourcebotFile { fileName?: { text?: string }; repository?: string; chunks?: SourcebotChunk[] }
|
||||
|
||||
/**
|
||||
* Map a `POST /api/search` response `{ files: [...] }` to hits: one hit per file,
|
||||
* ref = file path, snippet = first matching chunk, kind = "file". Tolerant of a
|
||||
* missing/garbage payload (returns []). Exported for deterministic testing.
|
||||
*/
|
||||
export function parseSourcebotSearch(payload: unknown, limit: number): CodeSearchHit[] {
|
||||
const files = (payload as { files?: unknown })?.files;
|
||||
if (!Array.isArray(files)) return [];
|
||||
const hits: CodeSearchHit[] = [];
|
||||
for (const f of files as SourcebotFile[]) {
|
||||
const ref = f?.fileName?.text;
|
||||
if (typeof ref !== "string") continue;
|
||||
const chunk = f.chunks?.[0];
|
||||
const line = chunk?.matchRanges?.[0]?.start?.lineNumber;
|
||||
hits.push({
|
||||
ref: typeof line === "number" ? `${ref}:${line}` : ref,
|
||||
snippet: typeof chunk?.content === "string" ? chunk.content.trim() : undefined,
|
||||
kind: "file",
|
||||
});
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
Reference in New Issue
Block a user