fix: resolve false 404s and stale repo context during multi-repo switching on Windows

This commit is contained in:
Yogesh1290 2026-04-04 07:34:57 +05:45
parent 153262304c
commit faf63b4936
6 changed files with 222 additions and 83 deletions

View file

@ -56,16 +56,14 @@ const AppContent = () => {
// backend calls (queries, search, grep, readFile) scope to this repo.
const repoName = result.repoInfo.name;
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
// Normalize both Windows (\) and Unix (/) path separators before splitting
const projectName =
repoName || repoPath?.split('/').filter(Boolean).pop() || 'server-project';
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
'server-project';
setProjectName(projectName);
setCurrentRepo(projectName);
// Update URL so F5 / bookmarks preserve which repo is open
const url = new URL(window.location.href);
url.searchParams.set('project', projectName);
window.history.replaceState(null, '', url.toString());
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
for (const node of result.nodes) {
@ -76,6 +74,11 @@ const AppContent = () => {
}
setGraph(graph);
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', projectName);
window.history.replaceState(null, '', urlObj.toString());
// Transition directly to exploring view
setViewMode('exploring');
@ -99,21 +102,26 @@ const AppContent = () => {
],
);
// Auto-connect when ?server query param is present (bookmarkable shortcut).
// Also reads ?project= to connect to a specific repo.
// Auto-connect when ?server or ?project query param is present (bookmarkable shortcut)
const autoConnectRan = useRef(false);
useEffect(() => {
if (autoConnectRan.current) return;
const params = new URLSearchParams(window.location.search);
if (!params.has('server')) return;
const serverUrlParam = params.get('server');
const projectParam = params.get('project');
if (!serverUrlParam && !projectParam) return;
autoConnectRan.current = true;
const serverUrl = params.get('server') || window.location.origin;
const projectParam = params.get('project') || undefined;
// Keep ?server= in the URL so F5 reconnects to the same server.
// autoConnectRan.current prevents re-trigger within the same session.
// handleServerConnect() will add/update ?project= after connecting.
// Clean the "server" param so a refresh won't re-trigger it redundantly,
// but keep the "project" param so the user has a shareable URL.
if (serverUrlParam) {
params.delete('server');
const newSearch = params.toString();
const cleanUrl =
window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
window.history.replaceState(null, '', cleanUrl);
}
setProgress({
phase: 'extracting',
@ -123,39 +131,45 @@ const AppContent = () => {
});
setViewMode('loading');
const serverUrl = serverUrlParam || window.location.origin;
const baseUrl = normalizeServerUrl(serverUrl);
connectToServer(
serverUrl,
(phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
},
undefined,
projectParam,
)
const tryConnect = async () => {
return await connectToServer(
serverUrl,
(phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
},
undefined,
projectParam || undefined,
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
);
};
tryConnect()
.then(async (result) => {
await handleServerConnect(result);
setProgress(null);

View file

@ -231,7 +231,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
repo: projectName,
};
readFile(selectedFilePath, options)
readFile(selectedFilePath, { ...options, repo: projectName || undefined })
.then((result) => {
if (!cancelled) {
setFileResult(result);

View file

@ -579,6 +579,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
try {
const effectiveProjectName = overrideProjectName || projectName || 'project';
// Sync repoRef so all agent backend calls target the correct repo.
// initializeAgent can be called from App.tsx (handleServerConnect) which
// never sets repoRef.current directly — without this, queries default to repo[0].
if (overrideProjectName) {
repoRef.current = overrideProjectName;
}
const repo = repoRef.current;
// Build backend interface for Graph RAG tools
@ -610,7 +617,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setIsAgentInitializing(false);
}
},
[projectName],
// eslint-disable-next-line react-hooks/exhaustive-deps
[], // repoRef is a stable ref — we sync it explicitly on entry; no state deps needed
);
const sendChatMessage = useCallback(
@ -1042,6 +1050,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setCodePanelOpen(false);
setCodeReferenceFocus(null);
let connectedRepo: BackendRepo | undefined;
let pNameStr = repoName || 'server-project';
try {
const result: ConnectResult = await connectToServer(
serverBaseUrl,
@ -1073,44 +1084,28 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
},
undefined,
repoName,
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
);
// Build graph for visualization
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
// Prefer the registry name, then normalize Windows \ and Unix / paths
const pName =
repoName || result.repoInfo.name || repoPath?.split('/').pop() || 'server-project';
repoName ||
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
'server-project';
setProjectName(pName);
repoRef.current = pName;
// Update URL so F5 / bookmarks open the correct repo
const url = new URL(window.location.href);
url.searchParams.set('project', pName);
window.history.replaceState(null, '', url.toString());
connectedRepo = result.repoInfo;
pNameStr = pName;
const newGraph = createKnowledgeGraph();
for (const node of result.nodes) newGraph.addNode(node);
for (const rel of result.relationships) newGraph.addRelationship(rel);
setGraph(newGraph);
// No fileContents needed — grep/read tools use backend HTTP
// Initialize agent with backend queries, then start embeddings
try {
if (getActiveProviderConfig()) {
await initializeAgent(pName);
}
setViewMode('exploring');
startEmbeddingsWithFallback();
setProgress(null);
} catch (err) {
console.warn('Failed to initialize agent:', err);
setIsAgentReady(false);
agentRef.current = null;
setAgentError('Failed to initialize agent');
setViewMode('exploring');
setProgress(null);
}
} catch (err) {
} catch (err: unknown) {
console.error('Repo switch failed:', err);
setProgress({
phase: 'error',
@ -1124,6 +1119,36 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setViewMode('exploring');
setProgress(null);
}, ERROR_RESET_DELAY_MS);
return; // Abort the whole switchRepo process
}
if (pNameStr) {
// Persist the selected project in the URL so a refresh re-opens it
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', pNameStr);
window.history.replaceState(null, '', urlObj.toString());
}
// Reset the agent and clear chat history so the AI starts fresh for the new repo
agentRef.current = null;
setIsAgentReady(false);
setChatMessages([]);
// Re-initialize agent with the new repo's graph context
try {
if (getActiveProviderConfig()) {
await initializeAgent(pNameStr);
}
setViewMode('exploring');
startEmbeddingsWithFallback();
setProgress(null);
} catch (err) {
console.warn('Failed to initialize agent:', err);
setIsAgentReady(false);
agentRef.current = null;
setAgentError('Failed to initialize agent');
setViewMode('exploring');
setProgress(null);
}
},
[
@ -1143,6 +1168,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setCodeReferences,
setCodePanelOpen,
setCodeReferenceFocus,
setChatMessages,
],
);

View file

@ -386,10 +386,22 @@ export const fetchRepos = async (): Promise<BackendRepo[]> => {
return response.json() as Promise<BackendRepo[]>;
};
/** Fetch repo metadata. */
export const fetchRepoInfo = async (repo?: string): Promise<BackendRepo> => {
/** Fetch repo metadata.
* Pass `awaitAnalysis: true` when connecting to a repo that may still be cloning/analyzing
* this enables the backend's hold-queue and uses a 5-minute timeout to match.
* Normal calls (e.g. repo switching between already-indexed repos) use the default 10s timeout.
*
* Must stay in sync with HOLD_QUEUE_TIMEOUT_SECS in gitnexus/src/server/api.ts.
*/
const HOLD_QUEUE_TIMEOUT_MS = 300_000; // 5 minutes — matches backend HOLD_QUEUE_TIMEOUT_SECS
export const fetchRepoInfo = async (
repo?: string,
opts?: { awaitAnalysis?: boolean },
): Promise<BackendRepo> => {
const url = `${_backendUrl}/api/repo${repo ? `?${repoParam(repo)}` : ''}`;
const response = await fetchWithTimeout(url);
const timeout = opts?.awaitAnalysis ? HOLD_QUEUE_TIMEOUT_MS : undefined;
const response = await fetchWithTimeout(url, {}, timeout);
await assertOk(response);
const data = await response.json();
return { ...data, repoPath: data.repoPath ?? data.path };
@ -670,18 +682,21 @@ export interface ConnectResult {
/**
* Connect to a server: validate, fetch repo info, download graph.
* Content is NOT included (use readFile/grep for file access).
* Pass `awaitAnalysis: true` when the repo may still be cloning/analyzing
* this enables the backend hold-queue and a 5-minute fetch timeout.
*/
export async function connectToServer(
url: string,
onProgress?: (phase: string, downloaded: number, total: number | null) => void,
signal?: AbortSignal,
repoName?: string,
opts?: { awaitAnalysis?: boolean },
): Promise<ConnectResult> {
const baseUrl = normalizeServerUrl(url);
setBackendUrl(baseUrl);
onProgress?.('validating', 0, null);
const repoInfo = await fetchRepoInfo(repoName);
const repoInfo = await fetchRepoInfo(repoName, { awaitAnalysis: opts?.awaitAnalysis });
onProgress?.('downloading', 0, null);
const { nodes, relationships } = await fetchGraph(repoName, {

View file

@ -87,6 +87,11 @@ export class JobManager {
return this.jobs.get(id);
}
/** Return a snapshot of all tracked jobs for inspection. */
listJobs(): AnalyzeJob[] {
return Array.from(this.jobs.values());
}
updateJob(
id: string,
update: Partial<

View file

@ -311,12 +311,84 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
activeRepoPaths.delete(repoPath);
};
// Helper: resolve a repo by name from the global registry, or default to first
const resolveRepo = async (repoName?: string) => {
/**
* Maximum time the hold-queue will wait for an active analysis job to complete.
* Must stay in sync with the frontend's `fetchRepoInfo({ awaitAnalysis: true })` timeout.
*/
const HOLD_QUEUE_TIMEOUT_SECS = 300; // 5 minutes
// Helper: resolve a repo by name from the global registry, or default to first.
// Pass `req` to enable early exit if the client disconnects during the hold-queue wait.
const resolveRepo = async (repoName?: string, isRetry = false, req?: any): Promise<any> => {
const repos = await listRegisteredRepos();
if (repos.length === 0) return null;
if (repoName) return repos.find((r) => r.name === repoName) || null;
return repos[0]; // default to first
let found = null;
// Normalize: if a full path is passed, extract just the basename.
// e.g. "C:\Users\LENOVO\.gitnexus\repos\todo.txt-cli" -> "todo.txt-cli"
const normalizedName = repoName ? path.basename(repoName) : undefined;
if (normalizedName) {
found =
repos.find((r) => r.name === normalizedName) ||
repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) ||
null;
} else if (repos.length > 0) {
found = repos[0]; // default to first repo
}
// If not yet in the registry, check whether a background job is actively cloning or
// analyzing this repo. Hold the connection open (up to 5 minutes) until it completes.
// We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job
// whose repo is still missing means the registry sync failed; the fallback below handles it.
if (!found && normalizedName) {
const lower = normalizedName.toLowerCase();
// Track client disconnect to cancel the wait early
let clientGone = false;
req?.on('close', () => {
clientGone = true;
});
for (const job of jobManager.listJobs()) {
const isMatch =
job.repoName?.toLowerCase() === lower ||
(job.repoUrl && path.basename(job.repoUrl).replace('.git', '').toLowerCase() === lower) ||
(job.repoPath && path.basename(job.repoPath).toLowerCase() === lower);
if (isMatch && ['queued', 'cloning', 'analyzing'].includes(job.status)) {
if (process.env.DEBUG) {
console.log(
`[debug] resolveRepo waiting for active job ${job.id} (${normalizedName})...`,
);
}
for (let wait = 0; wait < HOLD_QUEUE_TIMEOUT_SECS; wait++) {
if (clientGone) return null; // client disconnected — stop polling
const currentJob = jobManager.getJob(job.id);
if (!currentJob || currentJob.status === 'failed') break;
if (currentJob.status === 'complete') {
await backend.init();
const freshRepos = await listRegisteredRepos();
return freshRepos.find((r) => r.name === normalizedName) || null;
}
await new Promise((r) => setTimeout(r, 1000));
}
// Timed out — signal to the caller with a specific message
return { __timedOut: true, repoName: normalizedName };
}
}
}
// Emergency fallback: re-sync the registry to handle Windows file-system race conditions
// (e.g. registry file not yet flushed after clone completes).
if (!found && normalizedName && !isRetry) {
if (process.env.DEBUG) {
console.log(`[debug] resolveRepo 404 for "${normalizedName}". Triggering deep init...`);
}
await backend.init();
return await resolveRepo(normalizedName, true, req);
}
return found;
};
// SSE heartbeat — clients connect to detect server liveness instantly.
@ -379,11 +451,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Get repo info
app.get('/api/repo', async (req, res) => {
try {
const entry = await resolveRepo(requestedRepo(req));
const entry = await resolveRepo(requestedRepo(req), false, req);
if (!entry) {
res.status(404).json({ error: 'Repository not found. Run: gitnexus analyze' });
return;
}
// Timed out waiting for an active analysis job
if (entry.__timedOut) {
res.status(503).json({
error: `Repository analysis for "${entry.repoName}" is taking longer than expected. Please try again in a moment.`,
});
return;
}
const meta = await loadMeta(entry.storagePath);
res.json({
name: entry.name,