/** * Live Configuration Manager * * Orchestrates live configuration reload functionality including: * - Hook configuration watching and reloading * - Configuration file watching for settings.json files * - Coordination between file watchers and configuration updates */ import { existsSync } from "fs"; import type { Scope } from "../types/configuration.js"; import { FileWatcherService, type FileWatchEvent, } from "../services/fileWatcher.js"; import type { HookManager } from "./hookManager.js"; import type { PermissionManager } from "./permissionManager.js"; import { isValidHookEvent } from "../types/hooks.js"; import { ConfigurationService } from "../services/configurationService.js"; import { Container } from "../utils/container.js"; import type { ConfigurationLoadResult, WaveConfiguration, } from "../types/configuration.js"; import { logger } from "../utils/globalLogger.js"; export interface LiveConfigManagerOptions { workdir: string; onReload?: (config: WaveConfiguration) => void; } export class LiveConfigManager { private readonly workdir: string; private isInitialized: boolean = false; // Configuration state private currentConfiguration: WaveConfiguration | null = null; private lastValidConfiguration: WaveConfiguration | null = null; // File watching state private fileWatcher: FileWatcherService; private userConfigPaths?: string[]; private projectConfigPaths?: string[]; private isWatching: boolean = false; private reloadInProgress: boolean = false; constructor( private container: Container, private options: LiveConfigManagerOptions, ) { this.workdir = options.workdir; this.fileWatcher = new FileWatcherService(logger); this.setupFileWatcherEvents(); } private get hookManager(): HookManager | undefined { return this.container.get("HookManager"); } private get permissionManager(): PermissionManager | undefined { return this.container.get("PermissionManager"); } private get configurationService(): ConfigurationService { return this.container.get("ConfigurationService")!; } /** * Initialize configuration watching * Maps to FR-004: System MUST watch settings.json files * Supports watching multiple file paths (e.g., local settings.local.json and settings.json) */ private async initializeWatching( userPaths: string[], projectPaths?: string[], ): Promise { try { this.userConfigPaths = userPaths; this.projectConfigPaths = projectPaths; // Load initial configuration await this.reloadConfiguration(); // Start watching user configs that exist for (const userPath of userPaths) { if (existsSync(userPath)) { await this.fileWatcher.watchFile(userPath, (event) => this.handleFileChange(event, "user"), ); } } // Start watching local configs that exist if (projectPaths) { for (const projectPath of projectPaths) { if (existsSync(projectPath)) { await this.fileWatcher.watchFile(projectPath, (event) => this.handleFileChange(event, "project"), ); } } } this.isWatching = true; } catch (error) { const errorMessage = `Failed to initialize configuration watching: ${(error as Error).message}`; logger?.error(`Live Config: ${errorMessage}`); throw new Error(errorMessage); } } /** * Get current configuration */ getCurrentConfiguration(): WaveConfiguration | null { return this.currentConfiguration ? { ...this.currentConfiguration } : null; } /** * Initialize configuration management with file watching */ async initialize(): Promise { if (this.isInitialized) { return; } try { // Get configuration file paths const { userPaths, projectPaths } = this.getConfigurationPaths(); // Initialize configuration watching await this.initializeWatching(userPaths, projectPaths); this.isInitialized = true; } catch (error) { logger?.error(`Failed to initialize: ${(error as Error).message}`); throw error; } } /** * Shutdown configuration management and cleanup resources */ async shutdown(): Promise { if (!this.isInitialized) { return; } try { this.isWatching = false; // Cleanup file watcher await this.fileWatcher.cleanup(); // Clean up state this.currentConfiguration = null; this.lastValidConfiguration = null; this.isInitialized = false; } catch (error) { logger?.error(`Error during shutdown: ${(error as Error).message}`); throw error; } } /** * Reload configuration from files * Maps to FR-008: Continue with previous valid configuration on errors */ private async reloadConfiguration(): Promise { if (this.reloadInProgress) { return this.currentConfiguration || {}; } this.reloadInProgress = true; try { // Load merged configuration using ConfigurationService const loadResult: ConfigurationLoadResult = await this.configurationService.loadMergedConfiguration(this.workdir); const newConfig = loadResult.configuration; // Check for errors during loading if (!loadResult.success) { const errorMessage = loadResult.error || "Configuration loading failed with unknown error"; logger?.error( `Live Config: Configuration loading failed: ${errorMessage}`, ); // Log warnings if any if (loadResult.warnings && loadResult.warnings.length > 0) { logger?.warn( `Live Config: Configuration warnings: ${loadResult.warnings.join("; ")}`, ); } // Use fallback configuration if available if (this.lastValidConfiguration) { this.currentConfiguration = this.lastValidConfiguration; // Apply environment variables to configuration service if configured if (this.lastValidConfiguration.env) { this.configurationService.setEnvironmentVars( this.lastValidConfiguration.env, ); } // Update hook manager if available if (this.hookManager) { this.hookManager.loadConfigurationFromWaveConfig( this.lastValidConfiguration, ); } return this.currentConfiguration; } else { logger?.warn( "Live Config: No previous valid configuration available, using empty config", ); this.currentConfiguration = {}; return this.currentConfiguration; } } // Log warnings from successful loading if (loadResult.warnings && loadResult.warnings.length > 0) { logger?.warn( `Live Config: Configuration warnings: ${loadResult.warnings.join("; ")}`, ); } // Detect changes between old and new configuration this.detectChanges(this.currentConfiguration, newConfig); // Update current configuration this.currentConfiguration = newConfig || {}; // Save as last valid configuration if it's valid and not empty if (newConfig && (newConfig.hooks || newConfig.env)) { this.lastValidConfiguration = { ...newConfig }; } // Note: Environment variables are already applied by loadMergedConfiguration() // No need to set them again here as currentConfiguration === newConfig // Update hook manager if available if (this.hookManager) { this.hookManager.loadConfigurationFromWaveConfig( this.currentConfiguration, ); } // Update permission manager if available if (this.permissionManager) { this.permissionManager.updateConfiguredPermissionMode( this.currentConfiguration.permissions?.permissionMode, ); this.permissionManager.updateAllowedRules( this.currentConfiguration.permissions?.allow || [], ); this.permissionManager.updateDeniedRules( this.currentConfiguration.permissions?.deny || [], ); this.permissionManager.updateAdditionalDirectories( this.currentConfiguration.permissions?.additionalDirectories || [], ); } // Trigger reload callback this.options.onReload?.(this.currentConfiguration); return this.currentConfiguration; } catch (error) { const errorMessage = `Configuration reload failed with exception: ${(error as Error).message}`; logger?.error(`Live Config: ${errorMessage}`); // Use previous valid configuration for error recovery if (this.lastValidConfiguration) { this.currentConfiguration = this.lastValidConfiguration; // Apply environment variables to configuration service if configured if (this.lastValidConfiguration.env) { this.configurationService.setEnvironmentVars( this.lastValidConfiguration.env, ); } // Update hook manager if available if (this.hookManager) { this.hookManager.loadConfigurationFromWaveConfig( this.lastValidConfiguration, ); } } else { logger?.warn( "Live Config: No previous valid configuration available, using empty config", ); this.currentConfiguration = {}; } return this.currentConfiguration; } finally { this.reloadInProgress = false; } } /** * Reload configuration from files (public method) */ async reload(): Promise { return await this.reloadConfiguration(); } /** * Check if watching is active */ isWatchingActive(): boolean { return this.isWatching; } /** * Get watcher status for monitoring */ getWatcherStatus() { const statuses = this.fileWatcher.getAllWatcherStatuses(); return { isActive: this.isWatching, configurationLoaded: this.currentConfiguration !== null, hasValidConfiguration: this.lastValidConfiguration !== null, reloadInProgress: this.reloadInProgress, watchedFiles: statuses.map((s) => ({ path: s.path, isActive: s.isActive, method: s.method, errorCount: s.errorCount, })), }; } private setupFileWatcherEvents(): void { this.fileWatcher.on("watcherError", (error: Error) => { logger?.error(`Live Config: File watcher error: ${error.message}`); }); } private async handleFileChange( event: FileWatchEvent, source: Scope, ): Promise { try { // Handle file deletion if (event.type === "delete") { // Reload configuration without the deleted file await this.reloadConfiguration(); return; } // Handle file creation or modification if (event.type === "change" || event.type === "create") { // Add small delay to ensure file write is complete await new Promise((resolve) => setTimeout(resolve, 50)); // Reload configuration await this.reloadConfiguration(); } } catch (error) { logger?.error( `Live Config: Error handling file change for ${source} config: ${(error as Error).message}`, ); } } private detectChanges( oldConfig: WaveConfiguration | null, newConfig: WaveConfiguration | null, ): { added: string[]; modified: string[]; removed: string[]; } { const added: string[] = []; const modified: string[] = []; const removed: string[] = []; // Handle environment variables changes const oldEnv = oldConfig?.env || {}; const newEnv = newConfig?.env || {}; for (const key of Object.keys(newEnv)) { if (!(key in oldEnv)) { added.push(`env.${key}`); } else if (oldEnv[key] !== newEnv[key]) { modified.push(`env.${key}`); } } for (const key of Object.keys(oldEnv)) { if (!(key in newEnv)) { removed.push(`env.${key}`); } } // Handle hooks changes (simplified) const oldHooks = oldConfig?.hooks || {}; const newHooks = newConfig?.hooks || {}; for (const event of Object.keys(newHooks)) { if (isValidHookEvent(event)) { if (!(event in oldHooks)) { added.push(`hooks.${event}`); } else if ( JSON.stringify(oldHooks[event]) !== JSON.stringify(newHooks[event]) ) { modified.push(`hooks.${event}`); } } } for (const event of Object.keys(oldHooks)) { if (isValidHookEvent(event) && !(event in newHooks)) { removed.push(`hooks.${event}`); } } return { added, modified, removed }; } /** * Get configuration file paths for user and project settings * Returns paths in priority order (local.json first, then .json) */ private getConfigurationPaths(): { userPaths: string[]; projectPaths: string[]; } { const paths = this.configurationService.getConfigurationPaths(this.workdir); return { userPaths: paths.userPaths, projectPaths: paths.projectPaths, }; } }