918 lines · 28.3 KB · TypeScript
RawDownload
/** * FileWatcher service - Watches for changes in Claude Code project files. * * Responsibilities: * - Watch ~/.claude/projects/ directory for session changes * - Watch ~/.claude/todos/ directory for todo changes * - Detect new/modified/deleted files * - Emit events to notify renderer process * - Invalidate cache entries when files change * - Detect errors in changed session files and notify NotificationManager */ import { type FileChangeEvent, type ParsedMessage } from '@main/types';import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl';import { getProjectsBasePath, getTodosBasePath } from '@main/utils/pathDecoder';import { createLogger } from '@shared/utils/logger';import { EventEmitter } from 'events';import * as fs from 'fs';import * as path from 'path'; import { projectPathResolver } from '../discovery/ProjectPathResolver';import { errorDetector } from '../error/ErrorDetector'; import { ConfigManager } from './ConfigManager';import { type DataCache } from './DataCache';import { LocalFileSystemProvider } from './LocalFileSystemProvider';import { type NotificationManager } from './NotificationManager'; import type { FileSystemProvider, FsDirent } from './FileSystemProvider'; const logger = createLogger('Service:FileWatcher'); /** Debounce window for file change events */const DEBOUNCE_MS = 100;/** Retry delay when watched directories are unavailable or watcher errors occur */const WATCHER_RETRY_MS = 2000;/** Interval for periodic catch-up scan to detect missed fs.watch events */const CATCH_UP_INTERVAL_MS = 30_000;/** Only catch-up scan files modified within this window */const CATCH_UP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour interface AppendedParseResult {  messages: ParsedMessage[];  parsedLineCount: number;  consumedBytes: number;} interface ActiveSessionFile {  projectId: string;  sessionId: string;  subagentId?: string;} export class FileWatcher extends EventEmitter {  private projectsWatcher: fs.FSWatcher | null = null;  private todosWatcher: fs.FSWatcher | null = null;  private retryTimer: NodeJS.Timeout | null = null;  private projectsPath: string;  private todosPath: string;  private dataCache: DataCache;  private fsProvider: FileSystemProvider;  private notificationManager: NotificationManager | null = null;  private isWatching: boolean = false;  private debounceTimers = new Map<string, NodeJS.Timeout>();  /** Track last processed line count per file for incremental error detection */  private lastProcessedLineCount = new Map<string, number>();  /** Track last processed file size in bytes for append-only parsing optimization */  private lastProcessedSize = new Map<string, number>();  /** Active session files tracked for periodic catch-up scan */  private activeSessionFiles = new Map<string, ActiveSessionFile>();  /** Timer for periodic catch-up scan */  private catchUpTimer: NodeJS.Timeout | null = null;  /** Timer for SSH polling mode (replaces fs.watch) */  private pollingTimer: NodeJS.Timeout | null = null;  /** Polling interval for SSH mode */  private static readonly SSH_POLL_INTERVAL_MS = 3000;  /** Guard to prevent overlapping SSH polling runs */  private pollingInProgress = false;  /** Indicates whether the first polling baseline snapshot has completed */  private sshPollPrimed = false;  /** Track file sizes for SSH polling change detection */  private polledFileSizes = new Map<string, number>();  /** Files currently being processed (concurrency guard) */  private processingInProgress = new Set<string>();  /** Files that need reprocessing after current processing completes */  private pendingReprocess = new Set<string>();  /** Flag to prevent reuse after disposal */  private disposed = false;   constructor(    dataCache: DataCache,    projectsPath?: string,    todosPath?: string,    fsProvider?: FileSystemProvider  ) {    super();    this.projectsPath = projectsPath ?? getProjectsBasePath();    this.todosPath = todosPath ?? getTodosBasePath();    this.dataCache = dataCache;    this.fsProvider = fsProvider ?? new LocalFileSystemProvider();  }   /**   * Sets the NotificationManager for error detection integration.   * Must be called before start() to enable error notifications.   */  setNotificationManager(manager: NotificationManager): void {    this.notificationManager = manager;  }   /**   * Sets the filesystem provider. Used when switching between local and SSH modes.   */  setFileSystemProvider(provider: FileSystemProvider): void {    this.fsProvider = provider;  }   // ===========================================================================  // Watcher Control  // ===========================================================================   /**   * Starts watching the projects and todos directories.   */  start(): void {    if (this.disposed) {      logger.error('Cannot start disposed FileWatcher');      return;    }     if (this.isWatching) {      logger.warn('Already watching');      return;    }     this.isWatching = true;    if (this.fsProvider.type === 'ssh') {      this.startPollingMode();    } else {      this.ensureWatchers();    }    this.startCatchUpTimer();  }   /**   * Stops all watchers.   */  stop(): void {    this.isWatching = false;     if (this.retryTimer) {      clearTimeout(this.retryTimer);      this.retryTimer = null;    }     if (this.projectsWatcher) {      this.projectsWatcher.close();      this.projectsWatcher = null;    }     if (this.todosWatcher) {      this.todosWatcher.close();      this.todosWatcher = null;    }     // Clear any pending debounce timers    for (const timer of this.debounceTimers.values()) {      clearTimeout(timer);    }    this.debounceTimers.clear();     // Clear catch-up timer    if (this.catchUpTimer) {      clearInterval(this.catchUpTimer);      this.catchUpTimer = null;    }     // Clear SSH polling timer    if (this.pollingTimer) {      clearInterval(this.pollingTimer);      this.pollingTimer = null;    }    this.pollingInProgress = false;    this.sshPollPrimed = false;    this.polledFileSizes.clear();     // Clear error detection tracking    this.lastProcessedLineCount.clear();    this.lastProcessedSize.clear();    this.activeSessionFiles.clear();    this.processingInProgress.clear();    this.pendingReprocess.clear();     logger.info('Stopped watching');  }   /**   * Disposes all resources and prevents reuse.   * Performs comprehensive cleanup of all timers, watchers, maps, and listeners.   *   * After calling dispose(), this FileWatcher cannot be restarted.   * Use stop() for temporary pausing that can be resumed with start().   */  dispose(): void {    if (this.disposed) {      logger.warn('FileWatcher already disposed');      return;    }     logger.info('Disposing FileWatcher');     // 1. Stop watchers and clear timers (uses existing stop() logic)    this.stop();     // 2. Clear retry timer (stop() already handles this, but being explicit)    if (this.retryTimer) {      clearTimeout(this.retryTimer);      this.retryTimer = null;    }     // 3. Clear all debounce timers (stop() already handles this)    for (const timer of this.debounceTimers.values()) {      clearTimeout(timer);    }    this.debounceTimers.clear();     // 4. Clear catch-up timer (stop() already handles this)    if (this.catchUpTimer) {      clearInterval(this.catchUpTimer);      this.catchUpTimer = null;    }     // 5. Clear polling timer (stop() already handles this)    if (this.pollingTimer) {      clearInterval(this.pollingTimer);      this.pollingTimer = null;    }     // 6. Clear all tracking maps (stop() already handles most of these)    this.lastProcessedLineCount.clear();    this.lastProcessedSize.clear();    this.activeSessionFiles.clear();    this.polledFileSizes.clear();    this.processingInProgress.clear();    this.pendingReprocess.clear();     // 7. Remove all EventEmitter listeners (MUST be last)    this.removeAllListeners();     // 8. Mark as disposed    this.disposed = true;     logger.info('FileWatcher disposed');  }   /**   * Starts the projects directory watcher.   */  private startProjectsWatcher(): void {    if (this.projectsWatcher) {      return;    }     try {      if (!fs.existsSync(this.projectsPath)) {        logger.warn(`FileWatcher: Projects directory does not exist: ${this.projectsPath}`);        this.scheduleWatcherRetry();        return;      }       this.projectsWatcher = fs.watch(        this.projectsPath,        { recursive: true },        (eventType, filename) => {          if (filename) {            this.handleProjectsChange(eventType, filename);          }        }      );      this.attachWatcherRecovery(this.projectsWatcher, 'projects');       logger.info(`FileWatcher: Started watching projects at ${this.projectsPath}`);    } catch (error) {      logger.error('Error starting projects watcher:', error);      this.projectsWatcher = null;      this.scheduleWatcherRetry();    }  }   /**   * Starts the todos directory watcher.   */  private startTodosWatcher(): void {    if (this.todosWatcher) {      return;    }     try {      if (!fs.existsSync(this.todosPath)) {        // Todos directory may not exist yet - that's OK        this.scheduleWatcherRetry();        return;      }       this.todosWatcher = fs.watch(this.todosPath, (eventType, filename) => {        if (filename) {          this.handleTodosChange(eventType, filename);        }      });      this.attachWatcherRecovery(this.todosWatcher, 'todos');       logger.info(`FileWatcher: Started watching todos at ${this.todosPath}`);    } catch (error) {      logger.error('Error starting todos watcher:', error);      this.todosWatcher = null;      this.scheduleWatcherRetry();    }  }   private ensureWatchers(): void {    if (!this.isWatching || this.fsProvider.type === 'ssh') {      return;    }     this.startProjectsWatcher();    this.startTodosWatcher();     if (!this.projectsWatcher || !this.todosWatcher) {      this.scheduleWatcherRetry();    }  }   private scheduleWatcherRetry(): void {    if (!this.isWatching || this.retryTimer) {      return;    }     this.retryTimer = setTimeout(() => {      this.retryTimer = null;      this.ensureWatchers();    }, WATCHER_RETRY_MS);  }   private attachWatcherRecovery(watcher: fs.FSWatcher, watcherType: 'projects' | 'todos'): void {    watcher.on('error', (error) => {      logger.error(`FileWatcher: ${watcherType} watcher error:`, error);      if (watcherType === 'projects') {        this.projectsWatcher = null;      } else {        this.todosWatcher = null;      }      this.scheduleWatcherRetry();    });     watcher.on('close', () => {      if (!this.isWatching) {        return;      }      if (watcherType === 'projects') {        this.projectsWatcher = null;      } else {        this.todosWatcher = null;      }      this.scheduleWatcherRetry();    });  }   // ===========================================================================  // SSH Polling Mode  // ===========================================================================   /**   * Starts polling mode for SSH connections.   * Polls the projects directory for file changes instead of using fs.watch().   */  private startPollingMode(): void {    if (this.pollingTimer) return;     logger.info('FileWatcher: Starting SSH polling mode');    const runPoll = (): void => {      if (this.pollingInProgress) {        return;      }       this.pollingInProgress = true;      this.pollForChanges()        .catch((err) => {          logger.error('Error during SSH polling:', err);        })        .finally(() => {          this.pollingInProgress = false;        });    };     // Prime immediately so newly created sessions appear without waiting a full interval.    runPoll();    this.pollingTimer = setInterval(runPoll, FileWatcher.SSH_POLL_INTERVAL_MS);  }   /**   * Polls the projects directory for file changes in SSH mode.   */  private async pollForChanges(): Promise<void> {    try {      const seenFiles = new Set<string>();      const projectDirs = await this.fsProvider.readdir(this.projectsPath);      for (const dir of projectDirs) {        if (!dir.isDirectory()) continue;         const projectPath = path.join(this.projectsPath, dir.name);        let entries: FsDirent[];        try {          entries = await this.fsProvider.readdir(projectPath);        } catch {          continue;        }         for (const entry of entries) {          if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;           const fullPath = path.join(projectPath, entry.name);          seenFiles.add(fullPath);          try {            const observedSize =              typeof entry.size === 'number'                ? entry.size                : (await this.fsProvider.stat(fullPath)).size;            const lastSize = this.polledFileSizes.get(fullPath);            const relativePath = path.join(dir.name, entry.name);             if (lastSize === undefined) {              // First time seeing this file: after baseline, emit add.              this.polledFileSizes.set(fullPath, observedSize);              if (this.sshPollPrimed) {                this.handleProjectsChange('rename', relativePath);              }            } else if (observedSize !== lastSize) {              // File changed              this.polledFileSizes.set(fullPath, observedSize);              this.handleProjectsChange('change', relativePath);            }          } catch {            continue;          }        }      }       // Detect deleted files after baseline is established.      if (this.sshPollPrimed) {        const removedFiles: string[] = [];        for (const trackedPath of this.polledFileSizes.keys()) {          if (!seenFiles.has(trackedPath)) {            removedFiles.push(trackedPath);          }        }        for (const removedPath of removedFiles) {          this.polledFileSizes.delete(removedPath);          const relativePath = path.relative(this.projectsPath, removedPath);          if (relativePath && !relativePath.startsWith('..')) {            this.handleProjectsChange('rename', relativePath);          }        }      } else {        this.sshPollPrimed = true;      }    } catch (err) {      logger.error('Error polling for changes:', err);    }  }   // ===========================================================================  // Event Handling  // ===========================================================================   /**   * Handles file change events in the projects directory.   */  private handleProjectsChange(eventType: string, filename: string): void {    try {      // Ignore non-JSONL files      if (!filename.endsWith('.jsonl')) {        return;      }       // Debounce rapid changes to the same file      this.debounce(filename, () => this.processProjectsChange(eventType, filename));    } catch (error) {      logger.error('Error handling projects change:', error);    }  }   /**   * Process a debounced projects change.   */  private async processProjectsChange(eventType: string, filename: string): Promise<void> {    const fullPath = path.isAbsolute(filename)      ? path.normalize(filename)      : path.join(this.projectsPath, filename);    const relativePath = path.relative(this.projectsPath, fullPath); 

Lines 1–500 of 918.