diff --git a/src/config/__tests__/config-loader-facade.test.ts b/src/config/__tests__/config-loader-facade.test.ts index 837ce030..1a9f9f8b 100644 --- a/src/config/__tests__/config-loader-facade.test.ts +++ b/src/config/__tests__/config-loader-facade.test.ts @@ -208,5 +208,27 @@ describe('config-loader-facade', () => { expect(config.profiles).toBeDefined(); expect(config.cliproxy).toBeDefined(); }); + + it('auto-invalidates when config file is modified externally', async () => { + const facade = await importFacade(); + + // Prime the cache + const first = facade.getCachedConfig(); + expect(first.version).toBeDefined(); + + // Modify config file directly on disk (simulating external code + // bypassing the facade) + const configPath = path.join(tempHome, '.ccs', 'config.yaml'); + // Touch the file to update mtime (wait briefly for mtime resolution) + const content = fs.readFileSync(configPath, 'utf8'); + await new Promise((r) => setTimeout(r, 10)); + fs.writeFileSync(configPath, content + '# touched\n', 'utf8'); + + // getCachedConfig should detect the mtime change and re-read + const second = facade.getCachedConfig(); + expect(first).not.toBe(second); + // Content should still be valid (re-read from disk) + expect(second.version).toBeDefined(); + }); }); }); diff --git a/src/config/config-loader-facade.ts b/src/config/config-loader-facade.ts index 19188ba4..462efa37 100644 --- a/src/config/config-loader-facade.ts +++ b/src/config/config-loader-facade.ts @@ -52,34 +52,42 @@ export type { GeminiWebSearchInfo } from './unified-config-loader'; export { loadSettings, loadConfigSafe, readConfig, getCcsDir } from '../utils/config-manager'; // Internal imports for memoization wrappers +import { statSync } from 'fs'; import type { UnifiedConfig } from './unified-config-types'; import { loadOrCreateUnifiedConfig as _loadOrCreateUnifiedConfig, saveUnifiedConfig as _saveUnifiedConfig, mutateUnifiedConfig as _mutateUnifiedConfig, updateUnifiedConfig as _updateUnifiedConfig, + getConfigYamlPath as _getConfigYamlPath, } from './unified-config-loader'; // --------------------------------------------------------------------------- -// Memoization cache +// Memoization cache with mtime-based staleness detection // --------------------------------------------------------------------------- let _configCache: UnifiedConfig | null = null; +let _cacheMtimeMs: number = 0; + +function getConfigFileMtime(): number { + try { + return statSync(_getConfigYamlPath()).mtimeMs; + } catch { + return 0; + } +} /** * Get the unified config with in-memory caching. - * First call reads from disk; subsequent calls return a deep copy. - * Returns a copy to prevent callers from silently mutating the cache. - * - * NOTE: `loadOrCreateUnifiedConfig` (re-exported above) is NOT cached. - * Use `getCachedConfig()` for cached reads, or the direct import for uncached. - * - * Call invalidateConfigCache() or use mutateConfig()/updateConfig() - * to force a re-read from disk. + * Checks file mtime on each call — if the config file was modified + * externally (e.g. by code importing unified-config-loader directly), + * the cache is automatically invalidated and re-read from disk. */ export function getCachedConfig(): UnifiedConfig { - if (!_configCache) { + const currentMtime = getConfigFileMtime(); + if (!_configCache || currentMtime > _cacheMtimeMs) { _configCache = _loadOrCreateUnifiedConfig(); + _cacheMtimeMs = getConfigFileMtime(); } return structuredClone(_configCache); } @@ -90,6 +98,7 @@ export function getCachedConfig(): UnifiedConfig { */ export function invalidateConfigCache(): void { _configCache = null; + _cacheMtimeMs = 0; } /**