Merge pull request #400 from JayceeB1/fix/server-mode-worker-hydration

closes issue #398
This commit is contained in:
Zander Raycraft 2026-03-22 14:28:05 -05:00 committed by GitHub
commit 0a200a51cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1286 additions and 70 deletions

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@huggingface/transformers": "^3.0.0",
@ -65,6 +66,7 @@
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^5.2.0",
"vite-plugin-static-copy": "^3.1.4"
"vite-plugin-static-copy": "^3.1.4",
"vitest": "^4.0.18"
}
}

View file

@ -26,6 +26,7 @@ const AppContent = () => {
isRightPanelOpen,
runPipeline,
runPipelineFromFiles,
hydrateServerGraph,
isSettingsPanelOpen,
setSettingsPanelOpen,
refreshLLMSettings,
@ -133,7 +134,7 @@ const AppContent = () => {
}
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]);
const handleServerConnect = useCallback((result: ConnectToServerResult) => {
const handleServerConnect = useCallback(async (result: ConnectToServerResult) => {
// Extract project name from repoPath
const repoPath = result.repoInfo.repoPath;
const projectName = repoPath.split('/').pop() || 'server-project';
@ -156,9 +157,14 @@ const AppContent = () => {
}
setFileContents(fileMap);
// Transition directly to exploring view
setViewMode('exploring');
setProgress(null);
try {
await hydrateServerGraph(result);
// Transition directly to exploring view
setViewMode('exploring');
} finally {
setProgress(null);
}
// Hydrate the worker-side DB (LadybugDB + BM25) so Query/Processes/embeddings work
hydrateWorkerFromServer(result.nodes, result.relationships, result.fileContents).then(() => {
@ -182,7 +188,7 @@ const AppContent = () => {
initializeAgent(projectName);
}
});
}, [setViewMode, setGraph, setFileContents, setProjectName, setProgress, initializeAgent, startEmbeddings, hydrateWorkerFromServer]);
}, [setViewMode, setGraph, setFileContents, setProjectName, setProgress, initializeAgent, startEmbeddings, hydrateServerGraph, hydrateWorkerFromServer]);
// Auto-connect when ?server query param is present (bookmarkable shortcut)
const autoConnectRan = useRef(false);
@ -214,7 +220,7 @@ const AppContent = () => {
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
}
}).then(async (result) => {
handleServerConnect(result);
await handleServerConnect(result);
// Store server URL and fetch available repos for the repo switcher
setServerBaseUrl(baseUrl);
@ -257,7 +263,7 @@ const AppContent = () => {
onFileSelect={handleFileSelect}
onGitClone={handleGitClone}
onServerConnect={async (result, serverUrl) => {
handleServerConnect(result);
await handleServerConnect(result);
if (serverUrl) {
const baseUrl = normalizeServerUrl(serverUrl);
setServerBaseUrl(baseUrl);

View file

@ -16,6 +16,7 @@ import {
NodeTableName,
} from './schema';
import { generateAllCSVs } from './csv-generator';
import { getQueryRows } from './query-result';
// Holds the reference to the dynamically loaded module
let lbug: any = null;
@ -190,7 +191,7 @@ export const loadGraphToLbug = async (
for (const tableName of NODE_TABLES) {
try {
const countRes = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
const countRows = await (countRes.getAll?.() ?? countRes.getAllObjects?.() ?? countRes.getAllRows?.() ?? []);
const countRows = await getQueryRows(countRes);
const countRow = countRows[0];
const count = countRow ? (countRow.cnt ?? countRow[0] ?? 0) : 0;
totalNodes += Number(count);
@ -293,8 +294,8 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
});
}
// Collect all rows (handle API differences across LadybugDB versions)
const allRows = await (result.getAll?.() ?? result.getAllObjects?.() ?? result.getAllRows?.() ?? []);
// Collect all rows
const allRows = await getQueryRows(result);
const rows: any[] = [];
for (const row of allRows) {
// Convert tuple to named object if we have column names and row is array
@ -331,7 +332,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
for (const tableName of NODE_TABLES) {
try {
const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
const nodeRows = await (nodeResult.getAll?.() ?? nodeResult.getAllObjects?.() ?? nodeResult.getAllRows?.() ?? []);
const nodeRows = await getQueryRows(nodeResult);
const nodeRow = nodeRows[0];
totalNodes += Number(nodeRow?.cnt ?? nodeRow?.[0] ?? 0);
} catch {
@ -343,7 +344,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
let totalEdges = 0;
try {
const edgeResult = await conn.query(`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`);
const edgeRows = await (edgeResult.getAll?.() ?? edgeResult.getAllObjects?.() ?? edgeResult.getAllRows?.() ?? []);
const edgeRows = await getQueryRows(edgeResult);
const edgeRow = edgeRows[0];
totalEdges = Number(edgeRow?.cnt ?? edgeRow?.[0] ?? 0);
} catch {
@ -408,7 +409,7 @@ export const executePrepared = async (
const result = await conn.execute(stmt, params);
const rows = await (result.getAll?.() ?? result.getAllObjects?.() ?? result.getAllRows?.() ?? []);
const rows = await getQueryRows(result);
await stmt.close();
return rows;
@ -472,7 +473,7 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str
for (const tableName of NODE_TABLES) {
try {
const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN n.id AS id LIMIT 1`);
const nodeRows = await (nodeResult.getAll?.() ?? nodeResult.getAllObjects?.() ?? nodeResult.getAllRows?.() ?? []);
const nodeRows = await getQueryRows(nodeResult);
const nodeRow = nodeRows[0];
if (nodeRow) {
testNodeId = nodeRow.id ?? nodeRow[0];
@ -509,7 +510,7 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str
const verifyResult = await conn.query(
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: '${testNodeId}'}) RETURN e.embedding AS emb`
);
const verifyRows = await (verifyResult.getAll?.() ?? verifyResult.getAllObjects?.() ?? verifyResult.getAllRows?.() ?? []);
const verifyRows = await getQueryRows(verifyResult);
const verifyRow = verifyRows[0];
const storedEmb = verifyRow?.emb ?? verifyRow?.[0];

View file

@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { getQueryRows } from './query-result';
describe('getQueryRows', () => {
it('prefers getAllObjects when available', async () => {
const rows = [{ name: 'foo' }];
const getAllObjects = vi.fn().mockResolvedValue(rows);
const getAllRows = vi.fn().mockResolvedValue([{ name: 'bar' }]);
const getAll = vi.fn().mockResolvedValue([['baz']]);
await expect(getQueryRows({ getAllObjects, getAllRows, getAll })).resolves.toEqual(rows);
expect(getAllObjects).toHaveBeenCalledTimes(1);
expect(getAllRows).not.toHaveBeenCalled();
expect(getAll).not.toHaveBeenCalled();
});
it('falls back to getAllRows when getAllObjects is absent', async () => {
const rows = [{ name: 'bar' }];
const getAllRows = vi.fn().mockResolvedValue(rows);
const getAll = vi.fn().mockResolvedValue([['baz']]);
await expect(getQueryRows({ getAllRows, getAll })).resolves.toEqual(rows);
expect(getAllRows).toHaveBeenCalledTimes(1);
expect(getAll).not.toHaveBeenCalled();
});
it('falls back to getAll as a final fallback', async () => {
const rows = [['baz']];
const getAll = vi.fn().mockResolvedValue(rows);
await expect(getQueryRows({ getAll })).resolves.toEqual(rows);
expect(getAll).toHaveBeenCalledTimes(1);
});
it('throws when no supported query API is exposed', async () => {
await expect(getQueryRows({})).rejects.toThrow('Unsupported LadybugDB QueryResult shape');
});
});

View file

@ -0,0 +1,21 @@
export const getQueryRows = async (result: unknown): Promise<any[]> => {
if (!result || typeof result !== 'object') return [];
const queryResult = result as {
getAllObjects?: () => Promise<any[]>;
getAllRows?: () => Promise<any[]>;
getAll?: () => Promise<any[]>;
};
if (typeof queryResult.getAllObjects === 'function') {
return await queryResult.getAllObjects();
}
if (typeof queryResult.getAllRows === 'function') {
return await queryResult.getAllRows();
}
if (typeof queryResult.getAll === 'function') {
return await queryResult.getAll();
}
throw new Error('Unsupported LadybugDB QueryResult shape');
};

View file

@ -123,6 +123,7 @@ interface AppState {
// Worker API (shared across app)
runPipeline: (file: File, onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
hydrateServerGraph: (result: ConnectToServerResult) => Promise<void>;
runQuery: (cypher: string) => Promise<any[]>;
isDatabaseReady: () => Promise<boolean>;
hydrateWorkerFromServer: (nodes: any[], relationships: any[], fileContents: Record<string, string>) => Promise<void>;
@ -467,6 +468,16 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
return deserializePipelineResult(serializedResult, createKnowledgeGraph);
}, []);
const hydrateServerGraph = useCallback(async (result: ConnectToServerResult): Promise<void> => {
const api = apiRef.current;
if (!api) throw new Error('Worker not initialized');
await api.hydrateServerGraph({
nodes: result.nodes,
relationships: result.relationships,
fileContents: result.fileContents,
});
}, []);
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
const api = apiRef.current;
if (!api) throw new Error('Worker not initialized');
@ -1028,6 +1039,8 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
for (const [p, c] of Object.entries(result.fileContents)) fileMap.set(p, c);
setFileContents(fileMap);
await hydrateServerGraph(result);
setViewMode('exploring');
setProgress(null);
@ -1056,7 +1069,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
});
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000);
}
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, hydrateWorkerFromServer, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, hydrateServerGraph, hydrateWorkerFromServer, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
const removeCodeReference = useCallback((id: string) => {
setCodeReferences(prev => {
@ -1159,6 +1172,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
switchRepo,
runPipeline,
runPipelineFromFiles,
hydrateServerGraph,
runQuery,
isDatabaseReady,
hydrateWorkerFromServer,

View file

@ -12,7 +12,9 @@ declare module '@ladybugdb/wasm-core' {
close(): Promise<void>;
}
export interface QueryResult {
getAll(): Promise<any[]>;
getAll?(): Promise<any[]>;
getAllRows?(): Promise<any[]>;
getAllObjects?(): Promise<any[]>;
hasNext(): Promise<boolean>;
getNext(): Promise<any>;
}

View file

@ -19,6 +19,7 @@ import { enrichClustersBatch, ClusterMemberInfo, ClusterEnrichment } from '../co
import { CommunityNode } from '../core/ingestion/community-processor';
import { PipelineResult } from '../types/pipeline';
import { buildCodebaseContext, type CodebaseContext } from '../core/llm/context-builder';
import { hydrateSerializedServerGraph } from './server-graph-hydration';
import {
buildBM25Index,
searchBM25,
@ -56,6 +57,29 @@ let enrichmentCancelled = false;
// Chat cancellation flag
let chatCancelled = false;
const loadResultIntoWorkerState = async (result: PipelineResult): Promise<void> => {
currentGraphResult = result;
storedFileContents = result.fileContents;
const bm25DocCount = buildBM25Index(storedFileContents);
if (import.meta.env.DEV) {
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
}
try {
const lbug = await getLbugAdapter();
await lbug.loadGraphToLbug(result.graph, result.fileContents);
if (import.meta.env.DEV) {
const stats = await lbug.getLbugStats();
console.log('LadybugDB loaded:', stats);
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
}
} catch {
// LadybugDB is optional - silently continue without it
}
};
// ============================================================
// HTTP helpers for backend mode
// ============================================================
@ -163,18 +187,7 @@ const workerApi = {
console.log('🔧 runPipeline called with clusteringConfig:', !!clusteringConfig);
// Run the actual pipeline
const result = await runIngestionPipeline(file, onProgress);
currentGraphResult = result;
// Store file contents for grep/read tools (full content, not truncated)
storedFileContents = result.fileContents;
// Build BM25 index for keyword search (instant, ~100ms)
const bm25DocCount = buildBM25Index(storedFileContents);
if (import.meta.env.DEV) {
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
}
// Load graph into LadybugDB for querying (optional - gracefully degrades)
// Load graph into local indexes for query/AI features.
try {
onProgress({
phase: 'complete',
@ -186,15 +199,7 @@ const workerApi = {
nodesCreated: result.graph.nodeCount,
},
});
const lbug = await getLbugAdapter();
await lbug.loadGraphToLbug(result.graph, result.fileContents);
if (import.meta.env.DEV) {
const stats = await lbug.getLbugStats();
console.log('LadybugDB loaded:', stats);
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
}
await loadResultIntoWorkerState(result);
} catch {
// LadybugDB is optional - silently continue without it
}
@ -311,18 +316,7 @@ const workerApi = {
// Run the pipeline
const result = await runPipelineFromFiles(files, onProgress);
currentGraphResult = result;
// Store file contents for grep/read tools (full content, not truncated)
storedFileContents = result.fileContents;
// Build BM25 index for keyword search (instant, ~100ms)
const bm25DocCount = buildBM25Index(storedFileContents);
if (import.meta.env.DEV) {
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
}
// Load graph into LadybugDB for querying (optional - gracefully degrades)
// Load graph into local indexes for query/AI features.
try {
onProgress({
phase: 'complete',
@ -334,15 +328,7 @@ const workerApi = {
nodesCreated: result.graph.nodeCount,
},
});
const lbug = await getLbugAdapter();
await lbug.loadGraphToLbug(result.graph, result.fileContents);
if (import.meta.env.DEV) {
const stats = await lbug.getLbugStats();
console.log('LadybugDB loaded:', stats);
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
}
await loadResultIntoWorkerState(result);
} catch {
// LadybugDB is optional - silently continue without it
}
@ -357,6 +343,10 @@ const workerApi = {
return serializePipelineResult(result);
},
async hydrateServerGraph(serialized: SerializablePipelineResult): Promise<void> {
await hydrateSerializedServerGraph(serialized, loadResultIntoWorkerState);
},
// ============================================================
// Embedding Pipeline Methods
// ============================================================

View file

@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import type { SerializablePipelineResult } from '../types/pipeline';
import { buildPipelineResultFromSerialized, hydrateSerializedServerGraph } from './server-graph-hydration';
describe('server graph hydration helpers', () => {
const serialized: SerializablePipelineResult = {
nodes: [
{
id: 'file:src/foo.ts',
label: 'File' as const,
properties: {
name: 'foo.ts',
filePath: 'src/foo.ts',
content: 'export function foo() {}',
},
},
{
id: 'func:src/foo.ts:foo',
label: 'Function' as const,
properties: {
name: 'foo',
filePath: 'src/foo.ts',
},
},
],
relationships: [
{
source: 'file:src/foo.ts',
target: 'func:src/foo.ts:foo',
type: 'CONTAINS' as const,
properties: { type: 'CONTAINS' },
},
],
fileContents: {
'src/foo.ts': 'export function foo() {}',
},
};
it('rebuilds a graph and file map from serialized server payloads', () => {
const result = buildPipelineResultFromSerialized(serialized);
expect(result.graph.nodeCount).toBe(2);
expect(result.graph.relationshipCount).toBe(1);
expect(result.fileContents.get('src/foo.ts')).toBe('export function foo() {}');
});
it('delegates the rebuilt result to the worker-side loader', async () => {
const loadResult = vi.fn().mockResolvedValue(undefined);
const result = await hydrateSerializedServerGraph(serialized, loadResult);
expect(loadResult).toHaveBeenCalledTimes(1);
expect(loadResult).toHaveBeenCalledWith(result);
expect(result.graph.nodeCount).toBe(2);
expect(result.fileContents.size).toBe(1);
});
});

View file

@ -0,0 +1,24 @@
import { createKnowledgeGraph } from '../core/graph/graph';
import type { PipelineResult, SerializablePipelineResult } from '../types/pipeline';
export const buildPipelineResultFromSerialized = (
serialized: SerializablePipelineResult,
): PipelineResult => {
const graph = createKnowledgeGraph();
serialized.nodes.forEach((node) => graph.addNode(node));
serialized.relationships.forEach((relationship) => graph.addRelationship(relationship));
return {
graph,
fileContents: new Map(Object.entries(serialized.fileContents)),
};
};
export const hydrateSerializedServerGraph = async (
serialized: SerializablePipelineResult,
loadResult: (result: PipelineResult) => Promise<void>,
): Promise<PipelineResult> => {
const result = buildPipelineResultFromSerialized(serialized);
await loadResult(result);
return result;
};

View file

@ -20,5 +20,6 @@
},
"types": ["vite/client"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}