UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

260 lines • 11.4 kB
// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import path from "node:path"; import { collectionName } from "../config.js"; import { getWatcherMode } from "../constants.js"; import { invalidateGraphCache } from "./code-graph.js"; import { gitProjectId, gitRefreshTarget, readGitState, sameGitState } from "./git-state.js"; import { isIndexingInProgress, updateProjectIndex } from "./indexer.js"; import { logger } from "./logger.js"; import { getCollectionInfo } from "./qdrant.js"; import { dropSymbolGraphCache } from "./symbol-graph-cache.js"; const POLL_MS = 10_000; const projects = new Map(); /** Return the process-local monitor for a resolved checkout path. */ function project(projectPath) { const resolved = path.resolve(projectPath); let entry = projects.get(resolved); if (!entry) { entry = { path: resolved, registered: false, stopped: false, pending: false, allowCreate: false, revision: 0, retryAt: 0 }; projects.set(resolved, entry); } return entry; } /** Poll registered checkouts without keeping the MCP process alive. */ function schedule(entry) { if (entry.stopped || !entry.registered || entry.timer) return; if (getWatcherMode() !== "git") { stopGitRefresh(entry.path); return; } entry.timer = setTimeout(() => { entry.timer = undefined; void checkGitRefresh(entry.path).then(() => schedule(entry)); }, POLL_MS); entry.timer.unref(); } /** Require a successful refresh of the latest observation with no pending work. */ function isCurrent(entry) { return !entry.pending && !entry.running && !entry.observationError && !entry.refreshError && sameGitState(entry.synchronized, entry.observed); } /** Record ref or identity changes and invalidate results from the previous state. */ function observe(entry, state) { const identity = gitProjectId(entry.path, state); if (entry.observed && (!sameGitState(entry.observed, state) || entry.projectId !== identity)) { // Preserve a new branch's pending first index across further commits, but // do not transfer that permission to a newly configured explicit identity. entry.allowCreate = entry.registered && (entry.projectId === identity ? entry.allowCreate : gitProjectId(entry.path, entry.observed) !== identity); entry.pending = true; entry.revision++; entry.retryAt = 0; invalidateGraphCache(entry.path); dropSymbolGraphCache(identity); } entry.observed = state; entry.projectId = identity; entry.observationError = undefined; } /** Refresh one captured checkout state, retaining failed or superseded work for retry. */ async function refresh(entry) { const state = entry.observed; if (!state || !entry.projectId || entry.stopped) return; const revision = entry.revision; const target = gitRefreshTarget(entry.path, state, entry.allowCreate); const assertGitCurrent = target.assertCurrent; target.assertCurrent = async () => { if (entry.stopped) throw new Error("Git refresh stopped."); if (entry.revision !== revision) throw new Error("Git checkout changed during indexing; refresh remains pending."); await assertGitCurrent(); }; try { // Selecting git mode never creates an index. Only an observed transition // to a different branch-aware identity may create its first collection. const canRefresh = target.allowCreate || await getCollectionInfo(collectionName(target.projectId)); await target.assertCurrent(); if (!canRefresh) { entry.registered = false; entry.pending = false; entry.synchronized = undefined; return; } const result = await updateProjectIndex(entry.path, undefined, entry.extraExtensions, target); if (result.skipped) throw new Error(result.skipped); if (result.cancelled) throw new Error("Index update was cancelled; Git refresh remains pending."); await target.assertCurrent(); entry.refreshError = undefined; if (entry.revision === revision) { entry.synchronized = state; entry.pending = false; entry.allowCreate = false; invalidateGraphCache(entry.path); dropSymbolGraphCache(target.projectId); } } catch (err) { entry.pending = true; entry.refreshError = err instanceof Error ? err.message : String(err); entry.retryAt = Date.now() + POLL_MS; logger.warn("Git refresh remains pending", { projectPath: entry.path, error: entry.refreshError }); } } /** Observe without waiting for indexing. Tools can report pending results. */ export async function checkGitRefresh(projectPath, catchUp = false) { if (getWatcherMode() !== "git") return; const entry = project(projectPath); if (entry.checking) { await entry.checking; if (catchUp) return checkGitRefresh(projectPath, true); return; } entry.checking = (async () => { try { const state = await readGitState(entry.path); if (entry.stopped) return; observe(entry, state); if (!entry.registered) { if (!await getCollectionInfo(collectionName(entry.projectId))) return; if (entry.stopped) return; entry.registered = true; entry.pending = entry.pending || catchUp || process.env.SOCRATICODE_AUTO_RESUME?.trim().toLowerCase() !== "off"; } else if (catchUp) { entry.pending = true; } schedule(entry); if (entry.pending && !entry.running && !isIndexingInProgress(entry.path) && Date.now() >= entry.retryAt) { entry.running = refresh(entry).finally(() => { entry.running = undefined; }); } } catch (err) { entry.observationError = err instanceof Error ? err.message : String(err); } })(); try { await entry.checking; } finally { entry.checking = undefined; } } /** Await startup catch-up and return its captured project identity for cleanup. */ export async function resumeGitRefresh(projectPath) { await checkGitRefresh(projectPath, true); const entry = projects.get(path.resolve(projectPath)); const projectId = entry?.projectId; await entry?.running; return projectId; } /** Capture an explicit operation so its successful result can seed monitoring. */ export async function prepareGitIndex(projectPath, extraExtensions) { if (getWatcherMode() !== "git") return undefined; const entry = project(projectPath); entry.extraExtensions = extraExtensions; try { const state = await readGitState(entry.path); observe(entry, state); return { state, target: gitRefreshTarget(entry.path, state, true), entry, revision: entry.revision }; } catch (err) { entry.observationError = err instanceof Error ? err.message : String(err); logger.warn(entry.observationError, { projectPath: entry.path }); // Explicit indexing remains available in a non-Git project. Its tool // response/status reports the Git error; monitoring never falls back. return undefined; } } /** Seed monitoring only from a successful explicit operation on the same checkout state. */ export async function finishGitIndex(projectPath, operation, result) { if (getWatcherMode() !== "git" || !operation) return; const entry = projects.get(path.resolve(projectPath)); if (!entry || entry !== operation.entry || entry.stopped) return; entry.registered = true; try { if (result.skipped) throw new Error(result.skipped); if (result.cancelled) throw new Error("Index update was cancelled; Git refresh remains pending."); await operation.target.assertCurrent(); if (entry.revision !== operation.revision) throw new Error("Git checkout changed during indexing; refresh remains pending."); entry.synchronized = operation.state; entry.pending = false; entry.refreshError = undefined; entry.allowCreate = false; invalidateGraphCache(entry.path); dropSymbolGraphCache(operation.target.projectId); } catch (err) { entry.pending = true; entry.refreshError = err instanceof Error ? err.message : String(err); entry.retryAt = Date.now() + POLL_MS; } schedule(entry); } /** Describe observed freshness without treating pending work as synchronized. */ export function gitRefreshStatus(projectPath) { const entry = projects.get(path.resolve(projectPath)); if (!entry) return "Git refresh: not monitoring this project yet."; if (entry.observationError) return `Git refresh unavailable: ${entry.observationError}\nRun codebase_update explicitly; automatic file watching is disabled.`; if (!entry.registered) return "Git refresh: no existing index to monitor. Run codebase_index explicitly."; if (entry.refreshError) return `Git refresh: FAILED, pending retry. ${entry.refreshError}\nResults may be stale until a refresh succeeds.`; if (entry.running || entry.pending) return "Git refresh: PENDING. Results may be stale until indexing and graph reconciliation finish."; if (!isCurrent(entry)) return "Git refresh: monitoring; index freshness is unverified because startup catch-up is disabled. Run codebase_update explicitly."; return `Git refresh: synchronized with ${entry.observed?.ref ?? "detached HEAD"} at ${entry.observed?.head.slice(0, 12)}.\nWorking-tree edits without a ref/HEAD change require codebase_update.`; } /** Annotate a read with freshness, including a refresh that completes during the read. */ export async function withGitRefreshNotice(projectPath, read) { if (getWatcherMode() !== "git") return read(); await checkGitRefresh(projectPath); const entry = project(projectPath); const wasCurrent = isCurrent(entry); const before = entry.observed; const result = await read(); await checkGitRefresh(projectPath); const changedDuringRead = !sameGitState(before, entry.observed); const notice = (!wasCurrent || changedDuringRead) && isCurrent(entry) ? "Git refresh completed while this request was running. Repeat the request for current results." : gitRefreshStatus(projectPath); return `${notice}\n\n${result}`; } /** Stop polling a checkout and invalidate any in-flight refresh target. */ export function stopGitRefresh(projectPath) { const resolved = path.resolve(projectPath); const entry = projects.get(resolved); if (!entry) return; entry.stopped = true; if (entry.timer) clearTimeout(entry.timer); projects.delete(resolved); } /** Stop every registered Git monitor during server shutdown. */ export function stopAllGitRefreshes() { for (const key of projects.keys()) stopGitRefresh(key); } //# sourceMappingURL=git-refresh.js.map