Files
socraticode/src/services/watcher.ts
T
2026-02-28 17:06:21 +00:00

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),
});
});
}