UNPKG

@invisiblecities/sidequest-cqo

Version:

Configuration-agnostic TypeScript and ESLint orchestrator with real-time watch mode, SQLite persistence, and intelligent terminal detection

377 lines 16.9 kB
/** * Watch Mode Controller * Manages watch mode lifecycle, coordinates services, and handles state transitions * Extracted from cli.ts to reduce monolithic architecture and improve testability */ import { EventEmitter } from "node:events"; import { WatchStateManager } from "../services/watch-state-manager.js"; import { processViolationSummary } from "./cli.js"; import { debugLog } from "../utils/debug-logger.js"; /** * WatchController manages the complete watch mode lifecycle * - Handles session restoration/creation * - Coordinates analysis and display updates * - Manages graceful shutdown * - Prevents race conditions through explicit state management */ // eslint-disable-next-line unicorn/prefer-event-target export class WatchController extends EventEmitter { config; stateManager; watchInterval = undefined; watchTimeout = undefined; constructor(config) { super(); debugLog("WatchController", "Constructor started"); this.config = config; debugLog("WatchController", "Config assigned"); this.stateManager = new WatchStateManager(undefined, { flags: config.flags, }); debugLog("WatchController", "State manager created"); // Forward state manager events this.stateManager.on("stateChange", (transition) => { this.emit("stateChange", transition); }); this.stateManager.on("invalidTransition", (attempt) => { this.emit("invalidTransition", attempt); }); debugLog("WatchController", "Constructor completed successfully"); } /** * Start watch mode with proper lifecycle management */ async start() { const { flags, orchestrator, sessionManager, display, colors } = this.config; // Pre-flight checks debugLog("WatchController", "Starting pre-flight checks..."); debugLog("WatchController", `Working directory: ${process.cwd()}`); debugLog("WatchController", `Node version: ${process.version}`); debugLog("WatchController", `Platform: ${process.platform}`); debugLog("WatchController", "Flags configuration", flags); try { // Handle session restoration or creation let session = undefined; if (flags.resumeSession) { session = await sessionManager.loadSession(); if (session && sessionManager.canResumeSession(session, flags)) { this.stateManager.setSessionId(session.id); debugLog("WatchController", "Resuming previous session", { sessionId: session.id, checksCount: session.checksCount, minutesAgo: Math.floor((Date.now() - session.startTime) / 60_000), }); console.log(`${colors.success}🔄 Resuming previous session (${session.checksCount} checks, ${Math.floor((Date.now() - session.startTime) / 60_000)}min ago)...${colors.reset}`); // Restore display state but mark that baseline needs refresh display.restoreFromSession({ sessionStart: session.startTime, baseline: session.baseline, current: session.current, viewMode: session.viewMode, }); } else { debugLog("WatchController", "Cannot resume previous session, starting fresh"); console.log(`${colors.warning}⚠️ Cannot resume previous session, starting fresh...${colors.reset}`); session = undefined; } } if (!session) { debugLog("WatchController", "Creating new session"); session = await sessionManager.createSession(flags); this.stateManager.setSessionId(session.id); debugLog("WatchController", "New session created", { sessionId: session.id, }); } // Start orchestrator watch mode debugLog("WatchController", "Starting orchestrator watch mode with config", { intervalMs: 3000, debounceMs: 500, autoCleanup: true, maxConcurrentChecks: 3, }); await orchestrator.startWatchMode({ intervalMs: 3000, debounceMs: 500, autoCleanup: true, maxConcurrentChecks: 3, }); // Enable silent mode for services during watch debugLog("WatchController", "Enabling silent mode for orchestrator"); orchestrator.setSilentMode(true); console.log(`${colors.bold}${colors.info}Starting Enhanced Code Quality Watch...${colors.reset}`); // Perform initial analysis before starting watch cycle debugLog("WatchController", "Starting initial analysis cycle..."); this.stateManager.startAnalysis(); let initialAnalysisResult = undefined; await Promise.race([ this.runAnalysisCycle().then((result) => { initialAnalysisResult = result; }), new Promise((_, reject) => setTimeout(() => reject(new Error("Initial analysis timeout after 120s")), 120_000)), ]); this.stateManager.completeAnalysis(); debugLog("WatchController", "Initial analysis completed, starting watch cycle..."); // Force an initial display update now that we're in 'ready' state debugLog("WatchController", "Performing initial display update after state transition"); if (this.stateManager.canUpdateDisplay()) { try { // Use the stored result from initial analysis instead of running again if (initialAnalysisResult && "violations" in initialAnalysisResult) { const violations = initialAnalysisResult.violations; await display.updateDisplay(violations, this.stateManager.getChecksCount(), orchestrator); debugLog("WatchController", "Initial display update completed successfully"); } else { debugLog("WatchController", "No initial analysis result available for display"); } } catch (error) { debugLog("WatchController", "Initial display update failed", error); } } else { debugLog("WatchController", "Cannot perform initial display update - not allowed in current state", { phase: this.stateManager.getPhase(), canUpdate: this.stateManager.canUpdateDisplay(), }); } // Start watch cycle this.watchInterval = setInterval(() => { if (this.stateManager.canStartAnalysis()) { this.stateManager.startAnalysis(); this.runAnalysisCycle() .then(() => this.stateManager.completeAnalysis()) .catch((error) => this.handleError(error)); } }, 3000); // Initial inactivity timeout (10 minutes) - will be reset on activity this.resetTimeout(); // Setup graceful shutdown handlers this.setupShutdownHandlers(); } catch (error) { this.handleError(error); throw error; } } /** * Reset the inactivity timeout (called on any activity) */ resetTimeout() { if (this.watchTimeout) { clearTimeout(this.watchTimeout); } // Reset 10-minute inactivity timeout this.watchTimeout = setTimeout(() => this.shutdown("timeout"), 10 * 60 * 1000); debugLog("WatchController", "Inactivity timeout reset (10 minutes)"); } /** * Run a single analysis cycle */ async runAnalysisCycle() { const { legacyOrchestrator, orchestrator, sessionManager, display, flags } = this.config; try { debugLog("WatchController", "Starting analysis cycle..."); // Reset timeout on any analysis activity this.resetTimeout(); // Get current violations using legacy orchestrator with timeout debugLog("WatchController", "Running legacy orchestrator analysis..."); const result = (await Promise.race([ legacyOrchestrator.analyze(), new Promise((_, reject) => setTimeout(() => reject(new Error("Analysis timeout after 60s")), 60_000)), ])); debugLog("WatchController", `Analysis completed, found ${result.violations?.length || 0} violations`); const checksCount = this.stateManager.getChecksCount() + 1; // Note: Persistence is now handled automatically by UnifiedOrchestrator // Update session state debugLog("WatchController", "Updating session state..."); const current = processViolationSummary(result.violations); await sessionManager.updateSession({ checksCount, current, baseline: undefined, // Let display manage baseline }); debugLog("WatchController", "Session state updated", { checksCount, violationTotal: current.total, }); if (flags.verbose) { debugLog("WatchController", "Getting dashboard data for verbose output..."); const enhancedResult = { ...result, database: { dashboard: (await Promise.race([ orchestrator.getStorageService().getDashboardData(), new Promise((_, reject) => setTimeout(() => reject(new Error("Dashboard data timeout after 30s")), 30_000)), ])), }, }; console.log(JSON.stringify(enhancedResult, undefined, 2)); } else { // Only update display if analysis is allowed (prevents race conditions) debugLog("WatchController", "Checking if display update is allowed", { canUpdate: this.stateManager.canUpdateDisplay(), phase: this.stateManager.getPhase(), analysisInProgress: this.stateManager.isAnalyzing(), stateSummary: this.stateManager.getStateSummary(), }); if (this.stateManager.canUpdateDisplay()) { debugLog("WatchController", "Calling display.updateDisplay with violations", { violationCount: result.violations.length, checksCount, }); await display.updateDisplay(result.violations, checksCount, orchestrator); debugLog("WatchController", "Display update completed"); } else { debugLog("WatchController", "Display update skipped - not allowed in current state"); } } // Emit success event this.emit("analysisComplete", { checksCount, violationCount: result.violations.length, }); // Return the result for initial display update return result; } catch (error) { this.handleError(error); throw error; } } // processViolationsWithPersistence method removed - // persistence is now handled automatically by UnifiedOrchestrator /** * Handle watch mode errors with comprehensive diagnostics */ async handleError(error) { const { sessionManager, colors } = this.config; const errorObject = error instanceof Error ? error : new Error(String(error)); const timestamp = new Date().toISOString(); // Update state manager with error this.stateManager.handleAnalysisError(errorObject); const errorDetails = { timestamp, error: errorObject.message, stack: errorObject.stack, checksCount: this.stateManager.getChecksCount(), phase: this.stateManager.getPhase(), cwd: process.cwd(), nodeVersion: process.version, platform: process.platform, }; // Log to console with user-friendly message console.error(`\n${colors.error}🚨 Watch Mode Error at ${timestamp}${colors.reset}`); console.error(`${colors.warning}Reason: ${errorObject.message}${colors.reset}`); console.error(`${colors.secondary}Check ${this.stateManager.getChecksCount()} failed. Watch mode continuing...${colors.reset}\n`); // Log error to session await sessionManager.logError(errorObject, this.stateManager.getChecksCount(), { nodeVersion: process.version, platform: process.platform, }); // Log detailed error to file for debugging await this.logErrorToFile(errorDetails); // Emit error event for potential recovery this.emit("error", errorObject, this.stateManager.getChecksCount()); // Try to recover to running state setTimeout(() => { this.stateManager.recover(); }, 5000); } /** * Log error details to file system */ async logErrorToFile(errorDetails) { const { colors } = this.config; try { const { existsSync, mkdirSync, appendFileSync } = await import("node:fs"); // eslint-disable-next-line unicorn/import-style const pathModule = await import("node:path"); const path = pathModule.default; const logDirectory = path.join(process.cwd(), ".sidequest-logs"); const logFile = path.join(logDirectory, "watch-errors.log"); if (!existsSync(logDirectory)) { mkdirSync(logDirectory, { recursive: true }); } const logEntry = `${JSON.stringify(errorDetails, undefined, 2)}\n\n`; appendFileSync(logFile, logEntry); console.error(`${colors.info}📝 Error logged to: ${logFile}${colors.reset}`); } catch (logError) { const logErrorObject = logError instanceof Error ? logError : new Error(String(logError)); console.error(`${colors.warning}⚠️ Could not log error details: ${logErrorObject.message}${colors.reset}`); } } /** * Setup graceful shutdown handlers */ setupShutdownHandlers() { process.on("SIGINT", this.handleShutdownSignal.bind(this)); process.on("SIGTERM", this.handleShutdownSignal.bind(this)); } handleShutdownSignal() { this.shutdown("interrupt"); } /** * Shutdown watch mode gracefully */ async shutdown(reason = "interrupt") { const { orchestrator, display } = this.config; // Update state manager this.stateManager.shutdown(reason); if (this.watchInterval) { clearInterval(this.watchInterval); this.watchInterval = undefined; } if (this.watchTimeout) { clearTimeout(this.watchTimeout); this.watchTimeout = undefined; } try { await orchestrator.stopWatchMode(); await orchestrator.shutdown(); } catch (error) { console.warn("Error during orchestrator shutdown:", error); } // Clean shutdown of display system display.shutdown(); const reasonMessages = { timeout: "⏰ Watch mode stopped after 10 minutes of inactivity.", interrupt: "👋 Enhanced Code Quality Orchestrator watch stopped.", error: "💥 Watch mode stopped due to critical error.", }; console.log(`\n\n${reasonMessages[reason]}`); this.emit("shutdown", reason); process.exit(reason === "error" ? 1 : 0); } /** * Get current state (read-only) */ getState() { return this.stateManager.getState(); } /** * Get current phase */ getPhase() { return this.stateManager.getPhase(); } /** * Check if watch mode is ready for display updates */ isReady() { return this.stateManager.canUpdateDisplay(); } /** * Get state summary for debugging */ getStateSummary() { return this.stateManager.getStateSummary(); } } //# sourceMappingURL=watch-controller.js.map