mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
|
|
import path from "node:path";
|
|
import type { AsyncSubscription, Event } from "@parcel/watcher";
|
|
import watcher from "@parcel/watcher";
|
|
import { collectionName, projectIdFromPath } from "../config.js";
|
|
import { SPECIAL_FILES, SUPPORTED_EXTENSIONS } from "../constants.js";
|
|
import { invalidateGraphCache } from "./code-graph.js";
|
|
import { createIgnoreFilter, shouldIgnore } from "./ignore.js";
|
|
import { isIndexingInProgress, updateProjectIndex } from "./indexer.js";
|
|
import { acquireProjectLock, isProjectLocked, releaseProjectLock } from "./lock.js";
|
|
import { logger } from "./logger.js";
|
|
import { getCollectionInfo, getProjectMetadata } from "./qdrant.js";
|
|
|
|
/** Active subscriptions per project path */
|
|
const subscriptions = new Map<string, AsyncSubscription>();
|
|
|
|
/** Debounce timers per project */
|
|
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
|
|
|
const DEBOUNCE_MS = 2000;
|
|
|
|
/** Maximum consecutive watcher errors before auto-stopping */
|
|
const MAX_WATCHER_ERRORS = 10;
|
|
const watcherErrorCounts = new Map<string, number>();
|
|
|
|
/**
|
|
* Cache of projects confirmed to be watched by another process.
|
|
* Maps resolvedPath → timestamp of last confirmation.
|
|
* Prevents ensureWatcherStarted from retrying the lock on every tool call.
|
|
*/
|
|
const externalWatchCache = new Map<string, number>();
|
|
|
|
/** How long to cache the "another process is watching" result before rechecking */
|
|
const EXTERNAL_WATCH_CACHE_TTL_MS = 60_000;
|
|
|
|
function isIndexableFile(filePath: string): boolean {
|
|
const fileName = path.basename(filePath);
|
|
if (SPECIAL_FILES.has(fileName)) return true;
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
return SUPPORTED_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
/**
|
|
* Build ignore globs for @parcel/watcher.
|
|
* These are directory names that should be excluded from native OS watching.
|
|
*/
|
|
function buildIgnoreGlobs(): string[] {
|
|
return [
|
|
"node_modules",
|
|
".git",
|
|
".svn",
|
|
".hg",
|
|
"dist",
|
|
"build",
|
|
"out",
|
|
".next",
|
|
".nuxt",
|
|
"__pycache__",
|
|
".venv",
|
|
"venv",
|
|
"env",
|
|
".tox",
|
|
"target",
|
|
".gradle",
|
|
".idea",
|
|
".vscode",
|
|
".vs",
|
|
"coverage",
|
|
".nyc_output",
|
|
".cache",
|
|
".parcel-cache",
|
|
".turbo",
|
|
"vendor",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Start watching a project directory for file changes.
|
|
* Uses @parcel/watcher for native OS-level file watching (FSEvents on macOS,
|
|
* ReadDirectoryChangesW on Windows, inotify on Linux). Creates a single native
|
|
* subscription for the entire directory tree — no per-file enumeration.
|
|
*
|
|
* On change, triggers an incremental index update (debounced).
|
|
*/
|
|
export async function startWatching(
|
|
projectPath: string,
|
|
onProgress?: (message: string) => void,
|
|
): Promise<boolean> {
|
|
const resolvedPath = path.resolve(projectPath);
|
|
|
|
if (subscriptions.has(resolvedPath)) {
|
|
onProgress?.(`Already watching ${resolvedPath}`);
|
|
return true;
|
|
}
|
|
|
|
// Acquire cross-process lock for watching
|
|
const lockAcquired = await acquireProjectLock(resolvedPath, "watch");
|
|
if (!lockAcquired) {
|
|
logger.info("Another process is already watching this project, skipping", { projectPath: resolvedPath });
|
|
onProgress?.(`Another process is already watching ${resolvedPath}, skipping`);
|
|
return false;
|
|
}
|
|
|
|
const ig = createIgnoreFilter(resolvedPath);
|
|
const ignoreGlobs = buildIgnoreGlobs();
|
|
|
|
// Reset error count
|
|
watcherErrorCounts.set(resolvedPath, 0);
|
|
|
|
const scheduleUpdate = () => {
|
|
const existing = debounceTimers.get(resolvedPath);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
debounceTimers.set(
|
|
resolvedPath,
|
|
setTimeout(async () => {
|
|
debounceTimers.delete(resolvedPath);
|
|
try {
|
|
onProgress?.(`Detected changes, updating index for ${resolvedPath}...`);
|
|
|
|
// Invalidate the code graph cache so it will be rebuilt
|
|
invalidateGraphCache(resolvedPath);
|
|
|
|
const result = await updateProjectIndex(resolvedPath, onProgress);
|
|
onProgress?.(
|
|
`Auto-update: ${result.added} added, ${result.updated} updated, ${result.removed} removed`,
|
|
);
|
|
|
|
// Note: code graph rebuild is now handled inside updateProjectIndex itself
|
|
} catch (err) {
|
|
// Graceful degradation: log but don't crash the watcher
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
logger.error("Watch auto-update failed", { projectPath: resolvedPath, error: message });
|
|
onProgress?.(`Auto-update failed (will retry on next change): ${message}`);
|
|
|
|
// If Qdrant is unreachable, don't spam retries — back off
|
|
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Request Timeout")) {
|
|
logger.warn("Infrastructure appears down, pausing watcher updates for 30s", { projectPath: resolvedPath });
|
|
await new Promise((resolve) => setTimeout(resolve, 30_000));
|
|
}
|
|
}
|
|
}, DEBOUNCE_MS),
|
|
);
|
|
};
|
|
|
|
try {
|
|
const subscription = await watcher.subscribe(
|
|
resolvedPath,
|
|
(err: Error | null, events: Event[]) => {
|
|
if (err) {
|
|
const count = (watcherErrorCounts.get(resolvedPath) ?? 0) + 1;
|
|
watcherErrorCounts.set(resolvedPath, count);
|
|
|
|
// Throttle error logging: first 3, then every 100th
|
|
if (count <= 3 || count % 100 === 0) {
|
|
logger.error("File watcher error", {
|
|
projectPath: resolvedPath,
|
|
error: err.message,
|
|
errorCount: count,
|
|
});
|
|
}
|
|
|
|
if (count >= MAX_WATCHER_ERRORS) {
|
|
logger.error("Too many watcher errors, stopping watcher", {
|
|
projectPath: resolvedPath,
|
|
totalErrors: count,
|
|
});
|
|
// Stop asynchronously to avoid re-entrancy issues
|
|
stopWatching(resolvedPath).catch(() => { /* already stopping */ });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Reset error count on successful event delivery
|
|
watcherErrorCounts.set(resolvedPath, 0);
|
|
|
|
// Filter events: only indexable files that pass ignore rules
|
|
const relevantEvents = events.filter((event) => {
|
|
if (!isIndexableFile(event.path)) return false;
|
|
const relative = path.relative(resolvedPath, event.path);
|
|
if (!relative || relative.startsWith("..")) return false;
|
|
return !shouldIgnore(ig, relative);
|
|
});
|
|
|
|
if (relevantEvents.length > 0) {
|
|
scheduleUpdate();
|
|
}
|
|
},
|
|
{
|
|
ignore: ignoreGlobs,
|
|
},
|
|
);
|
|
|
|
subscriptions.set(resolvedPath, subscription);
|
|
externalWatchCache.delete(resolvedPath);
|
|
onProgress?.(`Started watching ${resolvedPath}`);
|
|
logger.info("File watcher started", { projectPath: resolvedPath });
|
|
return true;
|
|
} catch (err) {
|
|
// Release lock if subscription failed
|
|
await releaseProjectLock(resolvedPath, "watch");
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
logger.error("Failed to start file watcher", { projectPath: resolvedPath, error: message });
|
|
onProgress?.(`Failed to start watching ${resolvedPath}: ${message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Stop watching a project directory */
|
|
export async function stopWatching(projectPath: string): Promise<void> {
|
|
const resolvedPath = path.resolve(projectPath);
|
|
const subscription = subscriptions.get(resolvedPath);
|
|
|
|
if (subscription) {
|
|
await subscription.unsubscribe();
|
|
subscriptions.delete(resolvedPath);
|
|
watcherErrorCounts.delete(resolvedPath);
|
|
|
|
const timer = debounceTimers.get(resolvedPath);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
debounceTimers.delete(resolvedPath);
|
|
}
|
|
|
|
await releaseProjectLock(resolvedPath, "watch");
|
|
logger.info("File watcher stopped", { projectPath: resolvedPath });
|
|
}
|
|
}
|
|
|
|
/** Stop all active watchers */
|
|
export async function stopAllWatchers(): Promise<void> {
|
|
for (const [projectPath] of subscriptions) {
|
|
await stopWatching(projectPath);
|
|
}
|
|
}
|
|
|
|
/** Check if a specific project is being watched */
|
|
export function isWatching(projectPath: string): boolean {
|
|
return subscriptions.has(path.resolve(projectPath));
|
|
}
|
|
|
|
/** Get list of currently watched project paths */
|
|
export function getWatchedProjects(): string[] {
|
|
return Array.from(subscriptions.keys());
|
|
}
|
|
|
|
/**
|
|
* Check if a project is being watched by any process (this one or another).
|
|
* First checks the in-memory subscriptions map (fast, synchronous), then falls
|
|
* back to checking the cross-process file lock for the "watch" operation.
|
|
*/
|
|
export async function isWatchedByAnyProcess(projectPath: string): Promise<boolean> {
|
|
const resolvedPath = path.resolve(projectPath);
|
|
if (subscriptions.has(resolvedPath)) return true;
|
|
return isProjectLocked(resolvedPath, "watch");
|
|
}
|
|
|
|
/** Clear the external watch cache. Exported for testing. */
|
|
export function clearExternalWatchCache(): void {
|
|
externalWatchCache.clear();
|
|
}
|
|
|
|
/**
|
|
* Ensure the file watcher is running for a project if conditions are met.
|
|
* This is a fire-and-forget, non-blocking, non-fatal helper called by tools
|
|
* (search, status, update, graph) to auto-activate watching on first interaction.
|
|
*
|
|
* Conditions:
|
|
* 1. Not already watching this project
|
|
* 2. No full indexing or incremental update currently in progress
|
|
* 3. A fully indexed and COMPLETED collection exists in Qdrant
|
|
*
|
|
* If any condition fails (including incomplete/interrupted indexes), this
|
|
* silently returns without starting the watcher.
|
|
*/
|
|
export function ensureWatcherStarted(projectPath: string): void {
|
|
const resolvedPath = path.resolve(projectPath);
|
|
|
|
// Already watching in this process — nothing to do
|
|
if (subscriptions.has(resolvedPath)) return;
|
|
|
|
// Skip if we recently confirmed another process is watching (avoids retrying on every tool call)
|
|
const cachedAt = externalWatchCache.get(resolvedPath);
|
|
if (cachedAt && Date.now() - cachedAt < EXTERNAL_WATCH_CACHE_TTL_MS) return;
|
|
|
|
// Indexing in progress — don't interfere with ongoing operations
|
|
if (isIndexingInProgress(resolvedPath)) return;
|
|
|
|
// Fire-and-forget: check collection exists and index is complete, then start watcher
|
|
const projectId = projectIdFromPath(resolvedPath);
|
|
const collection = collectionName(projectId);
|
|
|
|
getCollectionInfo(collection)
|
|
.then(async (info) => {
|
|
if (!info) return; // No collection — project not indexed yet
|
|
if (info.pointsCount === 0) return; // Empty collection — index may have been interrupted early
|
|
|
|
// Check if indexing was completed (not interrupted)
|
|
const metadata = await getProjectMetadata(collection);
|
|
if (metadata && metadata.indexingStatus !== "completed") {
|
|
logger.info("Skipping watcher auto-start: index is incomplete (interrupted)", {
|
|
projectPath: resolvedPath,
|
|
indexingStatus: metadata.indexingStatus,
|
|
filesIndexed: metadata.filesIndexed,
|
|
filesTotal: metadata.filesTotal,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Re-check conditions after async gap
|
|
if (subscriptions.has(resolvedPath)) return;
|
|
if (isIndexingInProgress(resolvedPath)) return;
|
|
|
|
const started = await startWatching(resolvedPath);
|
|
if (started) {
|
|
logger.info("Auto-started file watcher on tool use", { projectPath: resolvedPath });
|
|
} else if (!subscriptions.has(resolvedPath)) {
|
|
// Another process holds the watch lock — cache to avoid retrying on every tool call
|
|
externalWatchCache.set(resolvedPath, Date.now());
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
// Non-fatal — watcher auto-start is opportunistic
|
|
logger.debug("Auto-start watcher check failed (non-fatal)", {
|
|
projectPath: resolvedPath,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
});
|
|
}
|