refactor(serve): rewrite API server to use LocalBackend for multi-repo support

Replace direct single-repo KuzuDB access with LocalBackend, the same
backend the MCP server uses. The serve command now works with ALL
indexed repos via the global registry instead of only the CWD repo.

New/updated endpoints:
- GET /api/repos (list all indexed repos)
- GET /api/repo?name=X (repo metadata)
- GET /api/graph?repo=X (full knowledge graph)
- POST /api/query (raw Cypher via backend)
- POST /api/search (process-grouped search via backend)
- GET /api/file?repo=X&path=Y (file read with path traversal protection)
- GET /api/processes?repo=X, /api/process?repo=X&name=Y
- GET /api/clusters?repo=X, /api/cluster?repo=X&name=Y
This commit is contained in:
Paul Robello 2026-02-22 09:53:18 -08:00
parent 28339c995d
commit 8e8bb90fe4

View file

@ -1,24 +1,29 @@
/**
* HTTP API Server
*
* REST API for browser-based clients to query the local .gitnexus/ index.
* HTTP API Server (Multi-Repo)
*
* REST API for browser-based clients to query indexed repositories.
* Uses LocalBackend for multi-repo support via the global registry —
* the same backend the MCP server uses.
*/
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs/promises';
import { findRepo, loadMeta } from '../storage/repo-manager.js';
import { initKuzu, executeQuery } from '../core/kuzu/kuzu-adapter.js';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { NODE_TABLES } from '../core/kuzu/schema.js';
import { GraphNode, GraphRelationship } from '../core/graph/types.js';
import { searchFTSFromKuzu } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';
import { semanticSearch } from '../core/embeddings/embedding-pipeline.js';
import { isEmbedderReady } from '../core/embeddings/embedder.js';
const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
/**
* Build the full knowledge graph for a repo by querying each node table
* and all relationships via the backend's cypher tool.
*/
const buildGraph = async (
backend: LocalBackend,
repoName: string,
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
const nodes: GraphNode[] = [];
for (const table of NODE_TABLES) {
try {
let query = '';
@ -34,7 +39,10 @@ const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphR
query = `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`;
}
const rows = await executeQuery(query);
const result = await backend.callTool('cypher', { repo: repoName, query });
// cypher returns the rows directly (array), or { error } on failure
const rows = Array.isArray(result) ? result : [];
for (const row of rows) {
nodes.push({
id: row.id ?? row[0],
@ -62,108 +70,245 @@ const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphR
}
const relationships: GraphRelationship[] = [];
const relRows = await executeQuery(
`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`
);
for (const row of relRows) {
relationships.push({
id: `${row.sourceId}_${row.type}_${row.targetId}`,
type: row.type,
sourceId: row.sourceId,
targetId: row.targetId,
confidence: row.confidence,
reason: row.reason,
step: row.step,
try {
const relResult = await backend.callTool('cypher', {
repo: repoName,
query: `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`,
});
const relRows = Array.isArray(relResult) ? relResult : [];
for (const row of relRows) {
relationships.push({
id: `${row.sourceId}_${row.type}_${row.targetId}`,
type: row.type,
sourceId: row.sourceId,
targetId: row.targetId,
confidence: row.confidence,
reason: row.reason,
step: row.step,
});
}
} catch {
// ignore relationship query failures
}
return { nodes, relationships };
};
export const createServer = async (port: number) => {
const backend = new LocalBackend();
const hasRepos = await backend.init();
if (!hasRepos) {
console.warn('GitNexus: No indexed repositories found. The server will start but most endpoints will return errors.');
console.warn('Run "gitnexus analyze" in a repository to index it first.');
}
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// Get repo info
app.get('/api/repo', async (_req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed. Run: gitnexus analyze' });
return;
// ─── GET /api/repos ─────────────────────────────────────────────
// List all indexed repositories
app.get('/api/repos', async (_req, res) => {
try {
const repos = await backend.listRepos();
res.json(repos);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to list repos' });
}
res.json({
repoPath: repo.repoPath,
indexedAt: repo.meta.indexedAt,
stats: repo.meta.stats || {},
});
});
// Get full graph
app.get('/api/graph', async (_req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed' });
return;
// ─── GET /api/repo?name=X ──────────────────────────────────────
// Get metadata for a specific repo
app.get('/api/repo', async (req, res) => {
try {
const repoName = req.query.name as string | undefined;
const repo = await backend.resolveRepo(repoName);
res.json({
name: repo.name,
repoPath: repo.repoPath,
indexedAt: repo.indexedAt,
lastCommit: repo.lastCommit,
stats: repo.stats || {},
});
} catch (err: any) {
res.status(404).json({ error: err.message || 'Repository not found' });
}
await initKuzu(repo.kuzuPath);
const graph = await buildGraph();
res.json(graph);
});
// Execute Cypher query
// ─── GET /api/graph?repo=X ─────────────────────────────────────
// Full knowledge graph (all nodes + relationships)
app.get('/api/graph', async (req, res) => {
try {
const repoName = req.query.repo as string | undefined;
// Resolve repo to validate it exists and get the name
const repo = await backend.resolveRepo(repoName);
const graph = await buildGraph(backend, repo.name);
res.json(graph);
} catch (err: any) {
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
.json({ error: err.message || 'Failed to build graph' });
}
});
// ─── POST /api/query ───────────────────────────────────────────
// Execute a raw Cypher query
app.post('/api/query', async (req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed' });
return;
try {
const repoName = (req.body.repo ?? req.query.repo) as string | undefined;
const cypher = req.body.cypher as string;
if (!cypher) {
res.status(400).json({ error: 'Missing "cypher" in request body' });
return;
}
const result = await backend.callTool('cypher', { repo: repoName, query: cypher });
res.json({ result });
} catch (err: any) {
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
.json({ error: err.message || 'Query failed' });
}
await initKuzu(repo.kuzuPath);
const result = await executeQuery(req.body.cypher);
res.json({ result });
});
// Search
// ─── POST /api/search ──────────────────────────────────────────
// Process-grouped semantic search
app.post('/api/search', async (req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed' });
return;
}
await initKuzu(repo.kuzuPath);
try {
const repoName = (req.body.repo ?? req.query.repo) as string | undefined;
const query = req.body.query as string;
const limit = req.body.limit as number | undefined;
const query = req.body.query ?? '';
const limit = req.body.limit ?? 10;
if (!query) {
res.status(400).json({ error: 'Missing "query" in request body' });
return;
}
if (isEmbedderReady()) {
const results = await hybridSearch(query, limit, executeQuery, semanticSearch);
const results = await backend.callTool('query', {
repo: repoName,
query,
limit,
});
res.json({ results });
return;
} catch (err: any) {
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
.json({ error: err.message || 'Search failed' });
}
// FTS-only fallback when embeddings aren't loaded
const results = await searchFTSFromKuzu(query, limit);
res.json({ results });
});
// Read file
// ─── GET /api/file?repo=X&path=Y ──────────────────────────────
// Read a file from a resolved repo path on disk
app.get('/api/file', async (req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed' });
return;
try {
const repoName = req.query.repo as string | undefined;
const filePath = req.query.path as string;
if (!filePath) {
res.status(400).json({ error: 'Missing "path" query parameter' });
return;
}
const repo = await backend.resolveRepo(repoName);
// Resolve the full path and validate it stays within the repo root
const repoRoot = path.resolve(repo.repoPath);
const fullPath = path.resolve(repoRoot, filePath);
if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
res.status(403).json({ error: 'Path traversal denied: path escapes repo root' });
return;
}
const content = await fs.readFile(fullPath, 'utf-8');
res.json({ content });
} catch (err: any) {
if (err.code === 'ENOENT') {
res.status(404).json({ error: 'File not found' });
} else if (err.message?.includes('not found') || err.message?.includes('No indexed')) {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || 'Failed to read file' });
}
}
const filePath = req.query.path as string;
if (!filePath) {
res.status(400).json({ error: 'Missing path' });
return;
});
// ─── GET /api/processes?repo=X ─────────────────────────────────
// List all processes for a repo
app.get('/api/processes', async (req, res) => {
try {
const repoName = req.query.repo as string | undefined;
const result = await backend.queryProcesses(repoName);
res.json(result);
} catch (err: any) {
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
.json({ error: err.message || 'Failed to query processes' });
}
});
// ─── GET /api/process?repo=X&name=Y ───────────────────────────
// Get detailed process info including steps
app.get('/api/process', async (req, res) => {
try {
const repoName = req.query.repo as string | undefined;
const name = req.query.name as string;
if (!name) {
res.status(400).json({ error: 'Missing "name" query parameter' });
return;
}
const result = await backend.queryProcessDetail(name, repoName);
if (result.error) {
res.status(404).json({ error: result.error });
return;
}
res.json(result);
} catch (err: any) {
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
.json({ error: err.message || 'Failed to query process detail' });
}
});
// ─── GET /api/clusters?repo=X ─────────────────────────────────
// List all clusters for a repo
app.get('/api/clusters', async (req, res) => {
try {
const repoName = req.query.repo as string | undefined;
const result = await backend.queryClusters(repoName);
res.json(result);
} catch (err: any) {
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
.json({ error: err.message || 'Failed to query clusters' });
}
});
// ─── GET /api/cluster?repo=X&name=Y ───────────────────────────
// Get detailed cluster info including members
app.get('/api/cluster', async (req, res) => {
try {
const repoName = req.query.repo as string | undefined;
const name = req.query.name as string;
if (!name) {
res.status(400).json({ error: 'Missing "name" query parameter' });
return;
}
const result = await backend.queryClusterDetail(name, repoName);
if (result.error) {
res.status(404).json({ error: result.error });
return;
}
res.json(result);
} catch (err: any) {
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
.json({ error: err.message || 'Failed to query cluster detail' });
}
const fullPath = path.join(repo.repoPath, filePath);
const content = await fs.readFile(fullPath, 'utf-8');
res.json({ content });
});
app.listen(port, () => {
console.log(`GitNexus server running on http://localhost:${port}`);
console.log(`Serving ${hasRepos ? 'all indexed repositories' : 'no repositories (run gitnexus analyze first)'}`);
});
};