import { EventEmitter } from 'events'; import * as os from 'os'; import * as path from 'path'; import { Readable } from 'stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as FsType from 'fs'; type MockChokidarWatcher = { targets: string[]; options: unknown; handlers: Map void>>; on: (event: string, handler: (...args: unknown[]) => void) => MockChokidarWatcher; close: ReturnType; emit: (event: string, ...args: unknown[]) => void; add: (paths: string | string[]) => void; unwatch: (paths: string | string[]) => void; }; const chokidarMock = vi.hoisted(() => { const instances: MockChokidarWatcher[] = []; const createWatchImplementation = () => (targets: string | string[], options: unknown) => { const watcher = { targets: (Array.isArray(targets) ? targets : [targets]).map((target) => String(target)), options, handlers: new Map void>>(), close: vi.fn().mockResolvedValue(undefined), emit(event: string, ...args: unknown[]) { for (const handler of watcher.handlers.get(event) ?? []) { handler(...args); } }, add(paths: string | string[]) { for (const p of (Array.isArray(paths) ? paths : [paths]).map((x) => String(x))) { if (!watcher.targets.includes(p)) watcher.targets.push(p); } }, unwatch(paths: string | string[]) { const drop = new Set((Array.isArray(paths) ? paths : [paths]).map((x) => String(x))); watcher.targets = watcher.targets.filter((t) => !drop.has(t)); }, } as MockChokidarWatcher; watcher.on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = watcher.handlers.get(event) ?? []; handlers.push(handler); watcher.handlers.set(event, handlers); return watcher; }); instances.push(watcher); return watcher; }; const watch = vi.fn(createWatchImplementation()); return { instances, watch, createWatcher(targets: string | string[], options: unknown): MockChokidarWatcher { return createWatchImplementation()(targets, options); }, reset() { instances.length = 0; watch.mockReset(); watch.mockImplementation(createWatchImplementation()); }, }; }); vi.mock('chokidar', () => ({ watch: chokidarMock.watch, })); vi.mock('@shared/utils/logger', () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }), })); vi.mock('fs', async () => { const actual = await vi.importActual('fs'); return { ...actual, existsSync: vi.fn(), watch: vi.fn(), // Stash the real existsSync so tests can delegate to it for real file I/O __realExistsSync: actual.existsSync, }; }); vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises'); return { ...actual, access: vi.fn(), // Stash the real access for tests with real files __realAccess: actual.access, }; }); vi.mock('../../../../src/main/services/error/ErrorDetector', () => ({ errorDetector: { detectErrors: vi.fn().mockResolvedValue([]), }, })); vi.mock('../../../../src/main/services/infrastructure/ConfigManager', () => ({ ConfigManager: { getInstance: () => ({ getConfig: () => ({ notifications: { includeSubagentErrors: true, triggers: [] }, }), }), }, })); vi.mock('../../../../src/main/services/discovery/ProjectPathResolver', () => ({ projectPathResolver: { invalidateProject: vi.fn(), }, })); import * as fs from 'fs'; import * as fsp from 'fs/promises'; import { errorDetector } from '../../../../src/main/services/error/ErrorDetector'; import { DataCache } from '../../../../src/main/services/infrastructure/DataCache'; import { FileWatcher } from '../../../../src/main/services/infrastructure/FileWatcher'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '../../../../src/shared/constants/opencodeTaskLogAttribution'; function createFakeWatcher(): FsType.FSWatcher { const emitter = new EventEmitter() as EventEmitter & { close: () => void }; emitter.close = vi.fn(() => { emitter.emit('close'); }); return emitter as unknown as FsType.FSWatcher; } function getChokidarWatcherForRoot(rootPath: string): MockChokidarWatcher { const normalizedRoot = path.normalize(rootPath); const watcher = [...chokidarMock.instances] .reverse() .find((instance) => instance.targets.includes(normalizedRoot)); if (!watcher) { throw new Error(`Missing chokidar watcher for ${normalizedRoot}`); } return watcher; } function expectChokidarOptions(watcher: MockChokidarWatcher): void { expect(watcher.options).toEqual({ ignoreInitial: true, ignorePermissionErrors: true, followSymlinks: false, depth: 0, }); } /** Make existsSync delegate to the real implementation (needed for tests with real temp files) */ function useRealExistsSync() { const realFn = (fs as unknown as { __realExistsSync: typeof fs.existsSync }).__realExistsSync; vi.mocked(fs.existsSync).mockImplementation((p) => realFn(p)); } function useRealAccess() { const realFn = (fsp as unknown as { __realAccess: typeof fsp.access }).__realAccess; vi.mocked(fsp.access).mockImplementation((p, mode) => realFn(p, mode)); } function createMockNotificationManager() { return { addError: vi.fn().mockResolvedValue(null), } as unknown as Parameters[0]; } function createFsDirent( name: string, type: 'file' | 'directory', metadata: { size?: number; mtimeMs?: number } = {} ) { return { name, ...metadata, isFile: () => type === 'file', isDirectory: () => type === 'directory', }; } type TestChangeSourceName = 'projects' | 'todos' | 'teams' | 'tasks'; interface TestChangeSourceState { currentPollingTimer: NodeJS.Timeout | null; isPollingPrimed: boolean; pollOnce: () => Promise; } function getChangeSource(watcher: FileWatcher, name: TestChangeSourceName): TestChangeSourceState { return ( watcher as unknown as { changeSources: Record; } ).changeSources[name]; } function getRetryTimer(watcher: FileWatcher): NodeJS.Timeout | null { return (watcher as unknown as { retryTimer: NodeJS.Timeout | null }).retryTimer; } function setWatcherActive(watcher: FileWatcher): void { (watcher as unknown as { isWatching: boolean }).isWatching = true; } type NativeWatchCallback = (eventType: string, filename: string) => void; function getNativeWatchCallback( optionsOrListener: unknown, maybeListener: unknown ): NativeWatchCallback | undefined { return ( typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener ) as NativeWatchCallback | undefined; } function mockFsWatchImplementation(implementation: (...args: unknown[]) => FsType.FSWatcher): void { ( vi.mocked(fs.watch) as unknown as { mockImplementation: (nextImplementation: (...args: unknown[]) => FsType.FSWatcher) => void; } ).mockImplementation(implementation); } /** Helper to write a valid JSONL line */ function jsonlLine(uuid: string, text: string): string { return ( JSON.stringify({ type: 'assistant', uuid, timestamp: '2026-01-01T00:00:00.000Z', message: { role: 'assistant', content: [{ type: 'text', text }], }, }) + '\n' ); } describe('FileWatcher', () => { beforeEach(() => { vi.useFakeTimers(); chokidarMock.reset(); }); afterEach(() => { setClaudeBasePathOverride(null); vi.unstubAllEnvs(); vi.useRealTimers(); vi.restoreAllMocks(); }); it('retries and starts watchers when directories appear later', async () => { const dataCache = new DataCache(50, 10, false); let dirsAvailable = false; const accessMock = vi.mocked(fsp.access); accessMock.mockImplementation(async (targetPath) => { if ((targetPath === '/tmp/projects' || targetPath === '/tmp/todos') && dirsAvailable) { return; } throw new Error('ENOENT'); }); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); watcher.start(); await vi.advanceTimersByTimeAsync(0); expect(watchMock).toHaveBeenCalledTimes(0); dirsAvailable = true; await vi.advanceTimersByTimeAsync(2000); expect(watchMock).toHaveBeenCalledTimes(2); watcher.stop(); }); it('recovers from watcher errors by re-registering affected watcher', async () => { const dataCache = new DataCache(50, 10, false); const projectWatcher = createFakeWatcher(); const todoWatcher = createFakeWatcher(); const replacementProjectWatcher = createFakeWatcher(); const accessMock = vi.mocked(fsp.access); accessMock.mockImplementation(async (targetPath) => { if (targetPath === '/tmp/projects' || targetPath === '/tmp/todos') { return; } throw new Error('ENOENT'); }); const watchMock = vi.mocked(fs.watch); watchMock .mockImplementationOnce(() => projectWatcher) .mockImplementationOnce(() => todoWatcher) .mockImplementationOnce(() => replacementProjectWatcher); const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); watcher.start(); await vi.advanceTimersByTimeAsync(0); expect(watchMock).toHaveBeenCalledTimes(2); (projectWatcher as unknown as EventEmitter).emit('error', new Error('watch failed')); await vi.advanceTimersByTimeAsync(2000); expect(watchMock).toHaveBeenCalledTimes(3); watcher.stop(); }); it('ignores stale native watcher callbacks after stop and restart', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-native-stale-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const projectDir = path.join(projectsDir, 'encoded-project'); fs.mkdirSync(projectDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(tempDir, 'teams', 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tempDir, 'tasks', 'base-1'), { recursive: true }); fs.writeFileSync(path.join(projectDir, 'old-session.jsonl'), jsonlLine('old', 'old'), 'utf8'); fs.writeFileSync(path.join(projectDir, 'new-session.jsonl'), jsonlLine('new', 'new'), 'utf8'); fs.writeFileSync(path.join(todosDir, 'old-todo.json'), '{"items":[]}', 'utf8'); fs.writeFileSync(path.join(todosDir, 'new-todo.json'), '{"items":[]}', 'utf8'); useRealAccess(); const projectCallbacks: Array<(eventType: string, filename: string) => void> = []; const todoCallbacks: Array<(eventType: string, filename: string) => void> = []; mockFsWatchImplementation((targetPath, optionsOrListener, maybeListener) => { const listener = getNativeWatchCallback(optionsOrListener, maybeListener); if (String(targetPath) === projectsDir && listener) { projectCallbacks.push(listener); } else if (String(targetPath) === todosDir && listener) { todoCallbacks.push(listener); } return createFakeWatcher(); }); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const fileEvents: unknown[] = []; const todoEvents: unknown[] = []; watcher.on('file-change', (event) => fileEvents.push(event)); watcher.on('todo-change', (event) => todoEvents.push(event)); watcher.start(); await vi.waitFor(() => { expect(projectCallbacks).toHaveLength(1); expect(todoCallbacks).toHaveLength(1); }); watcher.stop(); watcher.start(); await vi.waitFor(() => { expect(projectCallbacks).toHaveLength(2); expect(todoCallbacks).toHaveLength(2); }); projectCallbacks[0]('rename', 'encoded-project/old-session.jsonl'); todoCallbacks[0]('rename', 'old-todo.json'); projectCallbacks[1]('rename', 'encoded-project/new-session.jsonl'); todoCallbacks[1]('rename', 'new-todo.json'); await vi.advanceTimersByTimeAsync(100); await vi.waitFor(() => { expect(fileEvents).toEqual([ { type: 'add', path: path.join(projectDir, 'new-session.jsonl'), projectId: 'encoded-project', sessionId: 'new-session', isSubagent: false, }, ]); expect(todoEvents).toEqual([ { type: 'add', path: path.join(todosDir, 'new-todo.json'), sessionId: 'new-todo', isSubagent: false, }, ]); }); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('falls back to projects polling on EMFILE and still emits session and subagent changes', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-project-emfile-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); const projectDir = path.join(projectsDir, 'encoded-project'); const sessionPath = path.join(projectDir, 'session-1.jsonl'); fs.mkdirSync(projectDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); fs.writeFileSync(sessionPath, jsonlLine('a1', 'baseline'), 'utf8'); useRealAccess(); const projectWatcher = createFakeWatcher(); const todoWatcher = createFakeWatcher(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation((targetPath) => { if (String(targetPath) === projectsDir) return projectWatcher; if (String(targetPath) === todosDir) return todoWatcher; throw new Error(`Unexpected watch path: ${String(targetPath)}`); }); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)); (projectWatcher as unknown as EventEmitter).emit( 'error', Object.assign(new Error('too many open files'), { code: 'EMFILE' }) ); await vi.advanceTimersByTimeAsync(0); const projectsSource = getChangeSource(watcher, 'projects'); expect(projectsSource.currentPollingTimer).not.toBeNull(); expect(getRetryTimer(watcher)).toBeNull(); expect(projectWatcher.close).toHaveBeenCalled(); await vi.waitFor(() => expect(projectsSource.isPollingPrimed).toBe(true)); fs.appendFileSync(sessionPath, jsonlLine('a2', 'changed'), 'utf8'); await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); await vi.waitFor(() => expect(events).toContainEqual({ type: 'change', path: sessionPath, projectId: 'encoded-project', sessionId: 'session-1', isSubagent: false, }) ); const subagentsDir = path.join(projectDir, 'session-1', 'subagents'); const subagentPath = path.join(subagentsDir, 'agent-worker.jsonl'); fs.mkdirSync(subagentsDir, { recursive: true }); fs.writeFileSync(subagentPath, jsonlLine('s1', 'subagent'), 'utf8'); await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); await vi.waitFor(() => expect(events).toContainEqual({ type: 'add', path: subagentPath, projectId: 'encoded-project', sessionId: 'session-1', isSubagent: true, }) ); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('ignores stale native project callbacks after EMFILE fallback switches to polling', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-project-stale-fallback-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); const projectDir = path.join(projectsDir, 'encoded-project'); fs.mkdirSync(projectDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); fs.writeFileSync(path.join(projectDir, 'stale-session.jsonl'), jsonlLine('stale', 'stale'), 'utf8'); useRealAccess(); const projectWatcher = createFakeWatcher(); const todoWatcher = createFakeWatcher(); const projectCallbacks: Array<(eventType: string, filename: string) => void> = []; mockFsWatchImplementation((targetPath, optionsOrListener, maybeListener) => { const listener = getNativeWatchCallback(optionsOrListener, maybeListener); if (String(targetPath) === projectsDir) { if (listener) { projectCallbacks.push(listener); } return projectWatcher; } if (String(targetPath) === todosDir) { return todoWatcher; } throw new Error(`Unexpected watch path: ${String(targetPath)}`); }); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(projectCallbacks).toHaveLength(1)); (projectWatcher as unknown as EventEmitter).emit( 'error', Object.assign(new Error('too many open files'), { code: 'EMFILE' }) ); await vi.advanceTimersByTimeAsync(0); const projectsSource = getChangeSource(watcher, 'projects'); expect(projectsSource.currentPollingTimer).not.toBeNull(); await vi.waitFor(() => expect(projectsSource.isPollingPrimed).toBe(true)); projectCallbacks[0]('rename', 'encoded-project/stale-session.jsonl'); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('falls back to todos polling on EMFILE and still emits todo changes', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-todo-emfile-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); const todoPath = path.join(todosDir, 'session-1.json'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); fs.writeFileSync(todoPath, '{"items":[]}', 'utf8'); useRealAccess(); const projectWatcher = createFakeWatcher(); const todoWatcher = createFakeWatcher(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation((targetPath) => { if (String(targetPath) === projectsDir) return projectWatcher; if (String(targetPath) === todosDir) return todoWatcher; throw new Error(`Unexpected watch path: ${String(targetPath)}`); }); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('todo-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)); (todoWatcher as unknown as EventEmitter).emit( 'error', Object.assign(new Error('too many open files'), { code: 'EMFILE' }) ); await vi.advanceTimersByTimeAsync(0); const todosSource = getChangeSource(watcher, 'todos'); expect(todosSource.currentPollingTimer).not.toBeNull(); expect(getRetryTimer(watcher)).toBeNull(); expect(todoWatcher.close).toHaveBeenCalled(); await vi.waitFor(() => expect(todosSource.isPollingPrimed).toBe(true)); fs.writeFileSync(todoPath, '{"items":[{"text":"done"}]}', 'utf8'); await todosSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); await vi.waitFor(() => expect(events).toContainEqual({ type: 'change', path: todoPath, sessionId: 'session-1', isSubagent: false, }) ); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('uses a custom local provider for project polling snapshots', async () => { const projectsDir = '/virtual/projects'; const todosDir = '/virtual/todos'; const sessionEntry = createFsDirent('session-1.jsonl', 'file', { size: 10, mtimeMs: 1000 }); const fsProvider = { type: 'local' as const, exists: vi.fn().mockResolvedValue(true), readFile: vi.fn().mockResolvedValue(''), stat: vi.fn().mockResolvedValue({ size: 10, mtimeMs: 1000, birthtimeMs: 1000, isFile: () => true, isDirectory: () => false, }), readdir: vi.fn(async (dirPath: string) => { if (dirPath === projectsDir) { return [createFsDirent('encoded-project', 'directory')]; } if (dirPath === path.join(projectsDir, 'encoded-project')) { return [sessionEntry]; } return []; }), createReadStream: vi.fn(() => Readable.from([])), dispose: vi.fn(), }; const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); setWatcherActive(watcher); const projectsSource = getChangeSource(watcher, 'projects'); await projectsSource.pollOnce(); expect(events).toEqual([]); sessionEntry.size = 12; sessionEntry.mtimeMs = 2000; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(fsProvider.readdir).toHaveBeenCalledWith(projectsDir); expect(events).toContainEqual({ type: 'change', path: path.join(projectsDir, 'encoded-project', 'session-1.jsonl'), projectId: 'encoded-project', sessionId: 'session-1', isSubagent: false, }); watcher.stop(); }); it('chunks broad project polling baselines and still emits changes after priming', async () => { const projectsDir = '/virtual/projects'; const todosDir = '/virtual/todos'; const projectNames = Array.from({ length: 65 }, (_, index) => `encoded-project-${String(index).padStart(3, '0')}` ); const fileState = new Map(projectNames.map((name) => [name, { size: 10, mtimeMs: 1000 }])); const fsProvider = { type: 'local' as const, exists: vi.fn().mockResolvedValue(true), readFile: vi.fn().mockResolvedValue(''), stat: vi.fn().mockResolvedValue({ size: 10, mtimeMs: 1000, birthtimeMs: 1000, isFile: () => true, isDirectory: () => false, }), readdir: vi.fn(async (dirPath: string) => { if (dirPath === projectsDir) { return projectNames.map((name) => createFsDirent(name, 'directory')); } const projectName = path.basename(dirPath); const state = fileState.get(projectName); if (state) { return [createFsDirent('session-1.jsonl', 'file', state)]; } return []; }), createReadStream: vi.fn(() => Readable.from([])), dispose: vi.fn(), }; const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); setWatcherActive(watcher); const projectsSource = getChangeSource(watcher, 'projects'); await projectsSource.pollOnce(); expect(projectsSource.isPollingPrimed).toBe(false); expect(events).toEqual([]); await projectsSource.pollOnce(); expect(projectsSource.isPollingPrimed).toBe(true); expect(events).toEqual([]); fileState.set(projectNames[0], { size: 12, mtimeMs: 2000 }); await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toContainEqual({ type: 'change', path: path.join(projectsDir, projectNames[0], 'session-1.jsonl'), projectId: projectNames[0], sessionId: 'session-1', isSubagent: false, }); watcher.stop(); }); it('treats SSH not-found subagent directories as empty during project polling', async () => { const projectsDir = '/remote/projects'; const todosDir = '/remote/todos'; const projectDir = path.join(projectsDir, 'encoded-project'); const sessionPath = path.join(projectDir, 'session-1.jsonl'); let size = 10; let mtimeMs = 1000; const fsProvider = { type: 'ssh' as const, exists: vi.fn(async (filePath: string) => filePath === sessionPath), readFile: vi.fn().mockResolvedValue(''), stat: vi.fn(async (filePath: string) => { if (filePath !== sessionPath) { throw Object.assign(new Error('not found'), { code: '2' }); } return { size, mtimeMs, birthtimeMs: 1000, isFile: () => true, isDirectory: () => false, }; }), readdir: vi.fn(async (dirPath: string) => { if (dirPath === projectsDir) { return [createFsDirent('encoded-project', 'directory')]; } if (dirPath === projectDir) { return [ createFsDirent('session-1.jsonl', 'file'), createFsDirent('session-1', 'directory'), ]; } if (dirPath === path.join(projectDir, 'session-1', 'subagents')) { throw Object.assign(new Error('not found'), { code: '2' }); } return []; }), createReadStream: vi.fn(() => Readable.from([])), dispose: vi.fn(), }; const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); setWatcherActive(watcher); const projectsSource = getChangeSource(watcher, 'projects'); await projectsSource.pollOnce(); expect(events).toEqual([]); size = 12; mtimeMs = 2000; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toContainEqual({ type: 'change', path: sessionPath, projectId: 'encoded-project', sessionId: 'session-1', isSubagent: false, }); watcher.stop(); }); it.each(['EMFILE', 'ENOENT'])( 'does not emit false project deletes when a polling stat fails with %s', async (failureCode) => { const projectsDir = '/virtual/projects'; const todosDir = '/virtual/todos'; const sessionPath = path.join(projectsDir, 'encoded-project', 'session-1.jsonl'); let statShouldFail = false; let size = 10; let mtimeMs = 1000; const fsProvider = { type: 'local' as const, exists: vi.fn().mockResolvedValue(true), readFile: vi.fn().mockResolvedValue(''), stat: vi.fn(async () => { if (statShouldFail) { throw Object.assign(new Error(failureCode), { code: failureCode }); } return { size, mtimeMs, birthtimeMs: 1000, isFile: () => true, isDirectory: () => false, }; }), readdir: vi.fn(async (dirPath: string) => { if (dirPath === projectsDir) { return [createFsDirent('encoded-project', 'directory')]; } if (dirPath === path.join(projectsDir, 'encoded-project')) { return [createFsDirent('session-1.jsonl', 'file')]; } return []; }), createReadStream: vi.fn(() => Readable.from([])), dispose: vi.fn(), }; const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); setWatcherActive(watcher); const projectsSource = getChangeSource(watcher, 'projects'); await projectsSource.pollOnce(); expect(events).toEqual([]); statShouldFail = true; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([]); statShouldFail = false; size = 12; mtimeMs = 2000; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toContainEqual({ type: 'change', path: sessionPath, projectId: 'encoded-project', sessionId: 'session-1', isSubagent: false, }); watcher.stop(); } ); it('does not emit false project deletes when the polling root is temporarily missing', async () => { const projectsDir = '/virtual/projects'; const todosDir = '/virtual/todos'; const sessionPath = path.join(projectsDir, 'encoded-project', 'session-1.jsonl'); let rootMissing = false; let size = 10; let mtimeMs = 1000; const fsProvider = { type: 'local' as const, exists: vi.fn().mockResolvedValue(true), readFile: vi.fn().mockResolvedValue(''), stat: vi.fn().mockImplementation(async () => ({ size, mtimeMs, birthtimeMs: 1000, isFile: () => true, isDirectory: () => false, })), readdir: vi.fn(async (dirPath: string) => { if (dirPath === projectsDir) { if (rootMissing) { throw Object.assign(new Error('missing root'), { code: 'ENOENT' }); } return [createFsDirent('encoded-project', 'directory')]; } if (dirPath === path.join(projectsDir, 'encoded-project')) { return [createFsDirent('session-1.jsonl', 'file')]; } return []; }), createReadStream: vi.fn(() => Readable.from([])), dispose: vi.fn(), }; const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); setWatcherActive(watcher); const projectsSource = getChangeSource(watcher, 'projects'); await projectsSource.pollOnce(); expect(events).toEqual([]); rootMissing = true; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([]); rootMissing = false; size = 12; mtimeMs = 2000; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toContainEqual({ type: 'change', path: sessionPath, projectId: 'encoded-project', sessionId: 'session-1', isSubagent: false, }); watcher.stop(); }); it('still emits project deletes when polling no longer lists a file', async () => { const projectsDir = '/virtual/projects'; const todosDir = '/virtual/todos'; const sessionPath = path.join(projectsDir, 'encoded-project', 'session-1.jsonl'); let filePresent = true; const fsProvider = { type: 'local' as const, exists: vi.fn(async (filePath: string) => filePath !== sessionPath || filePresent), readFile: vi.fn().mockResolvedValue(''), stat: vi.fn().mockResolvedValue({ size: 10, mtimeMs: 1000, birthtimeMs: 1000, isFile: () => true, isDirectory: () => false, }), readdir: vi.fn(async (dirPath: string) => { if (dirPath === projectsDir) { return [createFsDirent('encoded-project', 'directory')]; } if (dirPath === path.join(projectsDir, 'encoded-project')) { return filePresent ? [createFsDirent('session-1.jsonl', 'file')] : []; } return []; }), createReadStream: vi.fn(() => Readable.from([])), dispose: vi.fn(), }; const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); setWatcherActive(watcher); const projectsSource = getChangeSource(watcher, 'projects'); await projectsSource.pollOnce(); expect(events).toEqual([]); filePresent = false; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toContainEqual({ type: 'unlink', path: sessionPath, projectId: 'encoded-project', sessionId: 'session-1', isSubagent: false, }); watcher.stop(); }); it('does not emit false project deletes when a listed project dir vanishes during polling', async () => { const projectsDir = '/virtual/projects'; const todosDir = '/virtual/todos'; const projectDir = path.join(projectsDir, 'encoded-project'); const sessionPath = path.join(projectDir, 'session-1.jsonl'); let rootListsProject = true; let projectDirMissing = false; const fsProvider = { type: 'local' as const, exists: vi.fn(async (filePath: string) => filePath !== sessionPath || rootListsProject), readFile: vi.fn().mockResolvedValue(''), stat: vi.fn().mockResolvedValue({ size: 10, mtimeMs: 1000, birthtimeMs: 1000, isFile: () => true, isDirectory: () => false, }), readdir: vi.fn(async (dirPath: string) => { if (dirPath === projectsDir) { return rootListsProject ? [createFsDirent('encoded-project', 'directory')] : []; } if (dirPath === projectDir) { if (projectDirMissing) { throw Object.assign(new Error('missing project dir'), { code: 'ENOENT' }); } return [createFsDirent('session-1.jsonl', 'file')]; } return []; }), createReadStream: vi.fn(() => Readable.from([])), dispose: vi.fn(), }; const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider); const events: unknown[] = []; watcher.on('file-change', (event) => events.push(event)); setWatcherActive(watcher); const projectsSource = getChangeSource(watcher, 'projects'); await projectsSource.pollOnce(); expect(events).toEqual([]); projectDirMissing = true; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([]); rootListsProject = false; await projectsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toContainEqual({ type: 'unlink', path: sessionPath, projectId: 'encoded-project', sessionId: 'session-1', isSubagent: false, }); watcher.stop(); }); it('falls back to teams polling when the chokidar registry hits the file descriptor limit', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-emfile-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.writeFileSync(path.join(teamsDir, 'base-1', 'inboxes', 'user.json'), '[]', 'utf8'); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchersByPath = new Map([ [projectsDir, createFakeWatcher()], [todosDir, createFakeWatcher()], ]); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation((targetPath) => { const watcherForPath = watchersByPath.get(String(targetPath)); if (!watcherForPath) { throw new Error(`Unexpected watch path: ${String(targetPath)}`); } return watcherForPath; }); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); teamsWatcher.emit( 'error', Object.assign(new Error('too many open files'), { code: 'EMFILE', path: path.join(teamsDir, 'base-1', 'inboxes'), syscall: 'scandir', }) ); await vi.advanceTimersByTimeAsync(0); const teamsSource = getChangeSource(watcher, 'teams'); expect(teamsSource.currentPollingTimer).not.toBeNull(); expect(getRetryTimer(watcher)).toBeNull(); await vi.waitFor(() => expect(teamsSource.isPollingPrimed).toBe(true)); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([]); await vi.advanceTimersByTimeAsync(2000); expect(watchMock).toHaveBeenCalledTimes(2); expect(teamsWatcher.close).toHaveBeenCalled(); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it.each(['ENOSPC', 'ERR_FS_WATCHER_LIMIT', 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM'])( 'falls back to tasks polling when chokidar reports %s', async (code) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-task-limit-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const tasksWatcher = getChokidarWatcherForRoot(tasksDir); tasksWatcher.emit('error', Object.assign(new Error(code), { code })); await vi.advanceTimersByTimeAsync(0); expect(getChangeSource(watcher, 'tasks').currentPollingTimer).not.toBeNull(); expect(getRetryTimer(watcher)).toBeNull(); expect(tasksWatcher.close).toHaveBeenCalled(); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); } ); it('falls back to polling when chokidar throws a known error during initial teams start', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-sync-limit-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); chokidarMock.watch.mockImplementation((targets, options) => { const targetList = Array.isArray(targets) ? targets : [targets]; if (targetList.includes(path.normalize(teamsDir))) { throw Object.assign(new Error('watch limit'), { code: 'EMFILE' }); } return chokidarMock.createWatcher(targets, options); }); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); watcher.start(); await vi.waitFor(() => { expect(getChangeSource(watcher, 'teams').currentPollingTimer).not.toBeNull(); expect(getRetryTimer(watcher)).toBeNull(); }); expect(chokidarMock.watch).toHaveBeenCalledTimes(2); expect(getChokidarWatcherForRoot(tasksDir)).toBeTruthy(); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('closes a partially-created teams registry when initial start fails', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-partial-limit-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const partialWatcher = chokidarMock.createWatcher([path.normalize(teamsDir)], {}); partialWatcher.on = vi.fn(() => { throw Object.assign(new Error('watch limit during listener registration'), { code: 'EMFILE', }); }); chokidarMock.instances.length = 0; chokidarMock.watch.mockImplementation((targets, options) => { const targetList = Array.isArray(targets) ? targets : [targets]; if (targetList.includes(path.normalize(teamsDir))) { return partialWatcher; } return chokidarMock.createWatcher(targets, options); }); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); watcher.start(); await vi.waitFor(() => { expect(getChangeSource(watcher, 'teams').currentPollingTimer).not.toBeNull(); expect(partialWatcher.close).toHaveBeenCalled(); }); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('retries chokidar registry after a non-limit error without enabling polling', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-nonlimit-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); teamsWatcher.emit('error', Object.assign(new Error('permission denied'), { code: 'EACCES' })); await vi.advanceTimersByTimeAsync(0); expect(getChangeSource(watcher, 'teams').currentPollingTimer).toBeNull(); expect(getRetryTimer(watcher)).not.toBeNull(); expect(teamsWatcher.close).toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(2000); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(3)); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('does not allow the legacy env var to force teams and tasks polling', async () => { vi.stubEnv('AGENT_TEAMS_FILEWATCHER_TEAM_TASK_POLLING', '1'); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-chokidar-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); watcher.start(); await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); expect(getChangeSource(watcher, 'teams').currentPollingTimer).toBeNull(); expect(getChangeSource(watcher, 'tasks').currentPollingTimer).toBeNull(); expect(watchMock).not.toHaveBeenCalledWith(teamsDir, expect.anything(), expect.anything()); expect(watchMock).not.toHaveBeenCalledWith(tasksDir, expect.anything(), expect.anything()); expectChokidarOptions(getChokidarWatcherForRoot(teamsDir)); expectChokidarOptions(getChokidarWatcherForRoot(tasksDir)); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('emits team and task changes from the chokidar registry with stable relative paths', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-events-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'config.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'kanban-state.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'inboxes', 'user.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'sentMessages.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'processes.json')); const tasksWatcher = getChokidarWatcherForRoot(tasksDir); tasksWatcher.emit('change', path.join(tasksDir, 'base-1', 'task-1.json')); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([ { type: 'config', teamName: 'base-1', detail: 'config.json' }, { type: 'config', teamName: 'base-1', detail: 'kanban-state.json' }, { type: 'inbox', teamName: 'base-1', detail: 'inboxes/user.json' }, { type: 'inbox', teamName: 'base-1', detail: 'sentMessages.json' }, { type: 'process', teamName: 'base-1', detail: 'processes.json' }, { type: 'task', teamName: 'base-1', detail: 'task-1.json', taskId: 'task-1' }, ]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('emits unlink events from the chokidar registry with the same relative path contract', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-unlink-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); getChokidarWatcherForRoot(teamsDir).emit( 'unlink', path.join(teamsDir, 'base-1', 'config.json') ); getChokidarWatcherForRoot(tasksDir).emit( 'unlink', path.join(tasksDir, 'base-1', 'task-1.json') ); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([ { type: 'config', teamName: 'base-1', detail: 'config.json' }, { type: 'task', teamName: 'base-1', detail: 'task-1.json', taskId: 'task-1' }, ]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('normalizes relative chokidar paths and ignores paths outside the watched root', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-paths-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); teamsWatcher.emit('change', 'base-1/config.json'); teamsWatcher.emit('change', path.join(tempDir, 'outside', 'base-2', 'config.json')); const tasksWatcher = getChokidarWatcherForRoot(tasksDir); tasksWatcher.emit('change', 'base-1/task-1.json'); tasksWatcher.emit('change', path.join(tempDir, 'outside', 'base-2', 'task-2.json')); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([ { type: 'config', teamName: 'base-1', detail: 'config.json' }, { type: 'task', teamName: 'base-1', detail: 'task-1.json', taskId: 'task-1' }, ]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('does not emit existing files from the initial chokidar registry baseline', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-baseline-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); fs.writeFileSync(path.join(teamsDir, 'base-1', 'config.json'), '{}', 'utf8'); fs.writeFileSync(path.join(teamsDir, 'base-1', 'inboxes', 'user.json'), '[]', 'utf8'); fs.writeFileSync(path.join(tasksDir, 'base-1', 'task-1.json'), '{}', 'utf8'); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('emits existing files once when reconciliation discovers new team and task directories', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-new-files-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); fs.mkdirSync(path.join(teamsDir, 'base-2', 'inboxes'), { recursive: true }); fs.writeFileSync(path.join(teamsDir, 'base-2', 'config.json'), '{}', 'utf8'); fs.writeFileSync(path.join(teamsDir, 'base-2', 'inboxes', 'user.json'), '[]', 'utf8'); fs.mkdirSync(path.join(tasksDir, 'base-2'), { recursive: true }); fs.writeFileSync(path.join(tasksDir, 'base-2', 'task-2.json'), '{}', 'utf8'); await vi.advanceTimersByTimeAsync(30_000); await vi.waitFor(() => { expect(events).toHaveLength(3); expect(events).toEqual( expect.arrayContaining([ { type: 'config', teamName: 'base-2', detail: 'config.json' }, { type: 'inbox', teamName: 'base-2', detail: 'inboxes/user.json' }, { type: 'task', teamName: 'base-2', detail: 'task-2.json', taskId: 'task-2' }, ]) ); }); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('emits existing inbox files once when reconciliation discovers a new inbox directory', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-new-inbox-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); const inboxDir = path.join(teamsDir, 'base-1', 'inboxes'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); expect(getChokidarWatcherForRoot(teamsDir).targets).not.toContain(path.normalize(inboxDir)); fs.mkdirSync(inboxDir, { recursive: true }); fs.writeFileSync(path.join(inboxDir, 'user.json'), '[]', 'utf8'); await vi.advanceTimersByTimeAsync(30_000); await vi.waitFor(() => { expect(getChokidarWatcherForRoot(teamsDir).targets).toContain(path.normalize(inboxDir)); expect(events).toEqual([ { type: 'inbox', teamName: 'base-1', detail: 'inboxes/user.json' }, ]); }); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('reconciles new team directories immediately after an addDir event', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-adddir-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); const addedTeamDir = path.join(teamsDir, 'base-2'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); fs.mkdirSync(addedTeamDir, { recursive: true }); fs.writeFileSync(path.join(addedTeamDir, 'config.json'), '{}', 'utf8'); teamsWatcher.emit('addDir', addedTeamDir); await vi.waitFor(() => { // Incremental: the existing watcher is updated via add(), not recreated, so // watch() is still only called twice (teams + tasks). expect(chokidarMock.watch).toHaveBeenCalledTimes(2); expect(getChokidarWatcherForRoot(teamsDir).targets).toContain(path.normalize(addedTeamDir)); expect(events).toEqual([{ type: 'config', teamName: 'base-2', detail: 'config.json' }]); }); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('unwatches removed team dirs incrementally without recreating the watcher', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-unwatch-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); const removedTeamDir = path.join(teamsDir, 'base-2'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(removedTeamDir, 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); expect(teamsWatcher.targets).toContain(path.normalize(removedTeamDir)); fs.rmSync(removedTeamDir, { recursive: true, force: true }); teamsWatcher.emit('unlinkDir', removedTeamDir); await vi.waitFor(() => { expect(getChokidarWatcherForRoot(teamsDir).targets).not.toContain( path.normalize(removedTeamDir) ); }); // Same persistent watcher instance; no recreate. expect(chokidarMock.watch).toHaveBeenCalledTimes(2); expect(getChokidarWatcherForRoot(teamsDir)).toBe(teamsWatcher); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('reuses the persistent chokidar watcher across reconciles and keeps handling its events', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-persistent-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); fs.mkdirSync(path.join(teamsDir, 'base-2'), { recursive: true }); await vi.advanceTimersByTimeAsync(30_000); await vi.waitFor(() => expect(getChokidarWatcherForRoot(teamsDir).targets).toContain( path.normalize(path.join(teamsDir, 'base-2')) ) ); // The watcher is reused (same instance, no extra watch() call), so there is no // stale "old generation" to ignore. expect(chokidarMock.watch).toHaveBeenCalledTimes(2); expect(getChokidarWatcherForRoot(teamsDir)).toBe(teamsWatcher); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'config.json')); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([{ type: 'config', teamName: 'base-1', detail: 'config.json' }]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('ignores stale chokidar events after stop closes the registry', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-stale-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); const tasksWatcher = getChokidarWatcherForRoot(tasksDir); watcher.stop(); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'config.json')); tasksWatcher.emit('change', path.join(tasksDir, 'base-1', 'task-1.json')); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([]); expect(teamsWatcher.close).toHaveBeenCalled(); expect(tasksWatcher.close).toHaveBeenCalled(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('creates fresh chokidar registries after stop and start', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-restart-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const oldTeamsWatcher = getChokidarWatcherForRoot(teamsDir); watcher.stop(); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(4)); const newTeamsWatcher = getChokidarWatcherForRoot(teamsDir); expect(newTeamsWatcher).not.toBe(oldTeamsWatcher); oldTeamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'config.json')); newTeamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'config.json')); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([{ type: 'config', teamName: 'base-1', detail: 'config.json' }]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('filters irrelevant team and task registry events before they reach FileWatcher handlers', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-filter-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const teamsWatcher = getChokidarWatcherForRoot(teamsDir); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'members', 'member.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', '.opencode-runtime', 'state.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'runtime', 'runtime.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'notes.json')); teamsWatcher.emit('addDir', path.join(teamsDir, 'base-1', 'members')); const tasksWatcher = getChokidarWatcherForRoot(tasksDir); tasksWatcher.emit('change', path.join(tasksDir, 'base-1', '.lock')); tasksWatcher.emit('change', path.join(tasksDir, 'base-1', '.highwatermark')); tasksWatcher.emit('change', path.join(tasksDir, 'base-1', 'notes.txt')); tasksWatcher.emit('change', path.join(tasksDir, 'base-1', 'nested', 'task.json')); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('keeps the teams registry shallow and excludes irrelevant runtime directories', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-scope-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); const teamDir = path.join(teamsDir, 'base-1'); const inboxDir = path.join(teamDir, 'inboxes'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(inboxDir, { recursive: true }); fs.mkdirSync(path.join(teamDir, 'members'), { recursive: true }); fs.mkdirSync(path.join(teamDir, '.opencode-runtime'), { recursive: true }); fs.mkdirSync(path.join(teamDir, 'runtime'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); const targets = getChokidarWatcherForRoot(teamsDir).targets; expect(targets).toContain(path.normalize(teamsDir)); expect(targets).toContain(path.normalize(teamDir)); expect(targets).toContain(path.normalize(inboxDir)); expect(targets).not.toContain(path.normalize(path.join(teamDir, 'members'))); expect(targets).not.toContain(path.normalize(path.join(teamDir, '.opencode-runtime'))); expect(targets).not.toContain(path.normalize(path.join(teamDir, 'runtime'))); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('reconciles new and removed team inbox directories without content polling', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-reconcile-')); setClaudeBasePathOverride(tempDir); const projectsDir = path.join(tempDir, 'projects'); const todosDir = path.join(tempDir, 'todos'); const teamsDir = path.join(tempDir, 'teams'); const tasksDir = path.join(tempDir, 'tasks'); const addedTeamDir = path.join(teamsDir, 'base-2'); const addedInboxDir = path.join(addedTeamDir, 'inboxes'); fs.mkdirSync(projectsDir, { recursive: true }); fs.mkdirSync(todosDir, { recursive: true }); fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true }); fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true }); useRealAccess(); const watchMock = vi.mocked(fs.watch); watchMock.mockImplementation(() => createFakeWatcher()); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, projectsDir, todosDir); watcher.start(); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); expect(getChokidarWatcherForRoot(teamsDir).targets).not.toContain(path.normalize(addedTeamDir)); fs.mkdirSync(addedInboxDir, { recursive: true }); await vi.advanceTimersByTimeAsync(30_000); await vi.waitFor(() => expect(getChokidarWatcherForRoot(teamsDir).targets).toContain(path.normalize(addedInboxDir)) ); fs.rmSync(addedTeamDir, { recursive: true, force: true }); await vi.advanceTimersByTimeAsync(30_000); await vi.waitFor(() => expect(getChokidarWatcherForRoot(teamsDir).targets).not.toContain( path.normalize(addedTeamDir) ) ); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('emits team inbox changes from the teams polling fallback', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-poll-')); setClaudeBasePathOverride(tempDir); const teamsDir = path.join(tempDir, 'teams'); const inboxDir = path.join(teamsDir, 'base-1', 'inboxes'); fs.mkdirSync(inboxDir, { recursive: true }); const inboxPath = path.join(inboxDir, 'user.json'); fs.writeFileSync(inboxPath, '[]', 'utf8'); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher( dataCache, path.join(tempDir, 'projects'), path.join(tempDir, 'todos') ); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); setWatcherActive(watcher); const teamsSource = getChangeSource(watcher, 'teams'); await teamsSource.pollOnce(); expect(events).toEqual([]); fs.writeFileSync(inboxPath, '[{"messageId":"m1"}]', 'utf8'); await teamsSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([{ type: 'inbox', teamName: 'base-1', detail: 'inboxes/user.json' }]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('emits task changes from the tasks polling fallback', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-task-poll-')); setClaudeBasePathOverride(tempDir); const taskDir = path.join(tempDir, 'tasks', 'base-1'); fs.mkdirSync(taskDir, { recursive: true }); const taskPath = path.join(taskDir, 'task-1.json'); fs.writeFileSync(taskPath, '{"status":"queued"}', 'utf8'); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher( dataCache, path.join(tempDir, 'projects'), path.join(tempDir, 'todos') ); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); setWatcherActive(watcher); const tasksSource = getChangeSource(watcher, 'tasks'); await tasksSource.pollOnce(); expect(events).toEqual([]); fs.writeFileSync(taskPath, '{"status":"running"}', 'utf8'); await tasksSource.pollOnce(); await vi.advanceTimersByTimeAsync(100); expect(events).toEqual([ { type: 'task', teamName: 'base-1', detail: 'task-1.json', taskId: 'task-1' }, ]); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('emits log-source-change when OpenCode task-log attribution manifest changes', () => { const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); ( watcher as unknown as { processTeamsChange: (eventType: string, filename: string) => void; } ).processTeamsChange('change', `team-a/${OPENCODE_TASK_LOG_ATTRIBUTION_FILE}`); expect(events).toEqual([ { type: 'log-source-change', teamName: 'team-a', detail: OPENCODE_TASK_LOG_ATTRIBUTION_FILE, }, ]); }); it('emits config team-change events for team and members metadata changes', () => { const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); const events: unknown[] = []; watcher.on('team-change', (event) => events.push(event)); const testWatcher = watcher as unknown as { processTeamsChange: (eventType: string, filename: string) => void; }; testWatcher.processTeamsChange('change', 'team-a/team.meta.json'); testWatcher.processTeamsChange('change', 'team-a/members.meta.json'); expect(events).toEqual([ { type: 'config', teamName: 'team-a', detail: 'team.meta.json' }, { type: 'config', teamName: 'team-a', detail: 'members.meta.json' }, ]); }); it('keeps append offset pinned for partial trailing lines until completed', async () => { vi.useRealTimers(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-')); const filePath = path.join(tempDir, 'session.jsonl'); const firstLine = jsonlLine('a1', 'hi'); fs.writeFileSync(filePath, firstLine, 'utf8'); const dataCache = new DataCache(50, 10, false); const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); const firstPass = await ( watcher as unknown as { parseAppendedMessages: ( targetPath: string, startOffset: number ) => Promise<{ parsedLineCount: number; consumedBytes: number }>; } ).parseAppendedMessages(filePath, 0); expect(firstPass.parsedLineCount).toBe(1); expect(firstPass.consumedBytes).toBe(Buffer.byteLength(firstLine, 'utf8')); const partialSuffix = '{"type":"assistant","uuid":"a2","timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"'; fs.appendFileSync(filePath, partialSuffix, 'utf8'); const partialPass = await ( watcher as unknown as { parseAppendedMessages: ( targetPath: string, startOffset: number ) => Promise<{ parsedLineCount: number; consumedBytes: number }>; } ).parseAppendedMessages(filePath, firstPass.consumedBytes); expect(partialPass.parsedLineCount).toBe(0); expect(partialPass.consumedBytes).toBe(0); const completion = '}]}}\n'; fs.appendFileSync(filePath, completion, 'utf8'); const completedPass = await ( watcher as unknown as { parseAppendedMessages: ( targetPath: string, startOffset: number ) => Promise<{ parsedLineCount: number; consumedBytes: number }>; } ).parseAppendedMessages(filePath, firstPass.consumedBytes); expect(completedPass.parsedLineCount).toBe(1); expect(completedPass.consumedBytes).toBeGreaterThan(0); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('pins fallback processed size to the last complete line until a trailing JSON object is completed', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-fallback-partial-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'session-1.jsonl'); const firstLine = jsonlLine('u1', 'hello'); const partialSuffix = '{"type":"assistant","uuid":"u2","timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"'; fs.writeFileSync(filePath, firstLine + partialSuffix, 'utf8'); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); vi.mocked(errorDetector.detectErrors).mockClear(); vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( projectId: string, sessionId: string, filePath: string ) => Promise; lastProcessedLineCount: Map; lastProcessedSize: Map; instanceCreatedAt: number; }; watcherAny.instanceCreatedAt = 0; await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1); expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(1); expect(watcherAny.lastProcessedSize.get(filePath)).toBe(Buffer.byteLength(firstLine, 'utf8')); fs.appendFileSync(filePath, '}]}}\n', 'utf8'); await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); expect(errorDetector.detectErrors).toHaveBeenCalledTimes(2); const secondCallArgs = vi.mocked(errorDetector.detectErrors).mock.calls[1]; expect(secondCallArgs?.[0]).toHaveLength(1); expect(secondCallArgs?.[0][0]?.uuid).toBe('u2'); expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); // =========================================================================== // Catch-Up Scan Tests // =========================================================================== describe('catch-up scan', () => { it('detects file growth missed by fs.watch', async () => { vi.useRealTimers(); useRealExistsSync(); vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-catchup-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'session-1.jsonl'); const line1 = jsonlLine('u1', 'hello'); fs.writeFileSync(filePath, line1, 'utf8'); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); // Simulate having previously processed the file by directly setting tracking state const watcherAny = watcher as unknown as { isWatching: boolean; lastProcessedLineCount: Map; lastProcessedSize: Map; activeSessionFiles: Map< string, { projectId: string; sessionId: string; lastObservedAt: number } >; runCatchUpScan: () => Promise; }; const initialSize = fs.statSync(filePath).size; watcherAny.isWatching = true; watcherAny.lastProcessedLineCount.set(filePath, 1); watcherAny.lastProcessedSize.set(filePath, initialSize); watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'session-1', lastObservedAt: Date.now(), }); // Append new data WITHOUT triggering fs.watch (simulating a missed event) const line2 = jsonlLine('u2', 'world'); fs.appendFileSync(filePath, line2, 'utf8'); // Run catch-up scan manually await watcherAny.runCatchUpScan(); // The error detector should have been called with the new message expect(errorDetector.detectErrors).toHaveBeenCalled(); const calls = vi.mocked(errorDetector.detectErrors).mock.calls; const lastCall = calls[calls.length - 1]; expect(lastCall[1]).toBe('session-1'); expect(lastCall[2]).toBe('test-project'); // Verify tracking state was updated expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2); expect(watcherAny.lastProcessedSize.get(filePath)).toBeGreaterThan(initialSize); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('skips files with no size change', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-noop-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'session-1.jsonl'); const line1 = jsonlLine('u1', 'hello'); fs.writeFileSync(filePath, line1, 'utf8'); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); const watcherAny = watcher as unknown as { isWatching: boolean; lastProcessedLineCount: Map; lastProcessedSize: Map; activeSessionFiles: Map< string, { projectId: string; sessionId: string; lastObservedAt: number } >; runCatchUpScan: () => Promise; }; const currentSize = fs.statSync(filePath).size; watcherAny.isWatching = true; watcherAny.lastProcessedLineCount.set(filePath, 1); watcherAny.lastProcessedSize.set(filePath, currentSize); watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'session-1', lastObservedAt: Date.now(), }); vi.mocked(errorDetector.detectErrors).mockClear(); // Run catch-up scan without any file changes await watcherAny.runCatchUpScan(); // Error detector should NOT have been called since file hasn't changed expect(errorDetector.detectErrors).not.toHaveBeenCalled(); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('removes stale files older than 1 hour from active tracking', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-stale-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'old-session.jsonl'); fs.writeFileSync(filePath, jsonlLine('u1', 'old'), 'utf8'); // Set file mtime to 2 hours ago const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); fs.utimesSync(filePath, twoHoursAgo, twoHoursAgo); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); const watcherAny = watcher as unknown as { isWatching: boolean; activeSessionFiles: Map< string, { projectId: string; sessionId: string; lastObservedAt: number } >; lastProcessedSize: Map; runCatchUpScan: () => Promise; }; watcherAny.isWatching = true; watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'old-session', lastObservedAt: Date.now(), }); watcherAny.lastProcessedSize.set(filePath, 0); await watcherAny.runCatchUpScan(); // Stale file should be removed from active tracking expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('handles deleted files gracefully during catch-up scan', async () => { vi.useRealTimers(); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); watcher.setNotificationManager(notificationManager); const filePath = '/tmp/projects/test-project/nonexistent.jsonl'; const watcherAny = watcher as unknown as { isWatching: boolean; activeSessionFiles: Map< string, { projectId: string; sessionId: string; lastObservedAt: number } >; lastProcessedSize: Map; lastProcessedLineCount: Map; runCatchUpScan: () => Promise; }; watcherAny.isWatching = true; watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'nonexistent', lastObservedAt: Date.now(), }); watcherAny.lastProcessedSize.set(filePath, 100); watcherAny.lastProcessedLineCount.set(filePath, 5); // Should not throw await watcherAny.runCatchUpScan(); // Deleted file should be cleaned up expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false); expect(watcherAny.lastProcessedSize.has(filePath)).toBe(false); expect(watcherAny.lastProcessedLineCount.has(filePath)).toBe(false); watcher.stop(); }); it('retires catch-up files after repeated stat timeouts', async () => { vi.useRealTimers(); vi.mocked(errorDetector.detectErrors).mockClear(); const fsProvider = { type: 'local' as const, exists: vi.fn().mockResolvedValue(true), readFile: vi.fn().mockResolvedValue(''), stat: vi.fn().mockRejectedValue(new Error('stat timeout')), readdir: vi.fn().mockResolvedValue([]), createReadStream: vi.fn(() => Readable.from([])), dispose: vi.fn(), }; const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher( dataCache, '/watch-root/projects', '/watch-root/todos', fsProvider ); watcher.setNotificationManager(notificationManager); const filePath = '/watch-root/projects/test-project/session-timeout.jsonl'; const watcherAny = watcher as unknown as { isWatching: boolean; activeSessionFiles: Map< string, { projectId: string; sessionId: string; lastObservedAt: number } >; catchUpStatFailures: Map; lastProcessedSize: Map; lastProcessedLineCount: Map; runCatchUpScan: () => Promise; }; watcherAny.isWatching = true; watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'session-timeout', lastObservedAt: Date.now(), }); watcherAny.lastProcessedSize.set(filePath, 100); watcherAny.lastProcessedLineCount.set(filePath, 5); await watcherAny.runCatchUpScan(); expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true); expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(1); await watcherAny.runCatchUpScan(); expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true); expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(2); await watcherAny.runCatchUpScan(); expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false); expect(watcherAny.catchUpStatFailures.has(filePath)).toBe(false); expect(watcherAny.lastProcessedSize.get(filePath)).toBe(100); expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(5); expect(errorDetector.detectErrors).not.toHaveBeenCalled(); watcher.stop(); }); }); // =========================================================================== // Concurrency Guard Tests // =========================================================================== describe('concurrency guard', () => { it('prevents concurrent processing of the same file', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-concurrent-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'session-1.jsonl'); fs.writeFileSync(filePath, jsonlLine('u1', 'hello'), 'utf8'); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); // Make detectErrors slow to simulate long processing let detectResolve: () => void; const detectPromise = new Promise((resolve) => { detectResolve = resolve; }); const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( projectId: string, sessionId: string, filePath: string ) => Promise; processingInProgress: Set; pendingReprocess: Set; instanceCreatedAt: number; }; // Ensure watcher treats the file as pre-existing so first call baselines watcherAny.instanceCreatedAt = Date.now() + 60_000; // First call establishes baseline (skips error detection on first read) vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); // Append new data so subsequent calls have new lines to process fs.appendFileSync(filePath, jsonlLine('u2', 'world')); // Now make detectErrors slow to simulate long processing vi.mocked(errorDetector.detectErrors).mockImplementation( () => new Promise((resolve) => { detectPromise.then(() => resolve([])); }) ); // Start call that will block on detectErrors (not first read anymore) const first = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); // Wait a tick so the first call enters the processing block and reaches detectErrors await new Promise((r) => setTimeout(r, 50)); // Verify the file is marked as processing expect(watcherAny.processingInProgress.has(filePath)).toBe(true); // Second call should be deferred (returns immediately) const second = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); await second; // Verify pending reprocess was set expect(watcherAny.pendingReprocess.has(filePath)).toBe(true); // Resolve the slow detectErrors detectResolve!(); await first; // After first completes, pending reprocess triggers a re-run // Wait for the re-run to complete await new Promise((r) => setTimeout(r, 100)); // pendingReprocess should be cleared after reprocessing expect(watcherAny.pendingReprocess.has(filePath)).toBe(false); expect(watcherAny.processingInProgress.has(filePath)).toBe(false); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); }); // =========================================================================== // Fallback Size Tracking Tests // =========================================================================== describe('lastProcessedSize in fallback path', () => { it('re-stats file after full parse to capture concurrent writes', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-size-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'session-1.jsonl'); const line1 = jsonlLine('u1', 'hello'); fs.writeFileSync(filePath, line1, 'utf8'); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( projectId: string, sessionId: string, filePath: string ) => Promise; lastProcessedSize: Map; lastProcessedLineCount: Map; instanceCreatedAt: number; }; // Treat file as new (created after watcher) so it goes through the full parse path watcherAny.instanceCreatedAt = 0; // First call - fallback path (no lastProcessedLineCount) await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); // The lastProcessedSize should match the actual file size on disk const actualSize = fs.statSync(filePath).size; expect(watcherAny.lastProcessedSize.get(filePath)).toBe(actualSize); expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(1); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); }); // =========================================================================== // First-Read Baseline Tests (prevents old session error flooding) // =========================================================================== describe('first-read baseline behavior', () => { it('establishes baseline without detecting errors for pre-existing files', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-baseline-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'session-1.jsonl'); // Write a file with multiple lines (simulating an existing session with errors) fs.writeFileSync( filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'world') + jsonlLine('u3', 'error line'), 'utf8' ); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); // Simulate watcher starting well after the file was created const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( projectId: string, sessionId: string, filePath: string ) => Promise; lastProcessedLineCount: Map; lastProcessedSize: Map; instanceCreatedAt: number; }; watcherAny.instanceCreatedAt = Date.now() + 60_000; // watcher "started" in the future vi.mocked(errorDetector.detectErrors).mockClear(); // First read should establish baseline, NOT detect errors await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); // errorDetector.detectErrors should NOT have been called expect(errorDetector.detectErrors).not.toHaveBeenCalled(); // Baseline tracking should be established expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3); expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size); // notificationManager.addError should NOT have been called expect(notificationManager.addError).not.toHaveBeenCalled(); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('detects errors only in new data after baseline is established', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-post-baseline-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'session-1.jsonl'); // Initial content (old session data) fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'world'), 'utf8'); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); // Simulate watcher starting well after the file was created const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( projectId: string, sessionId: string, filePath: string ) => Promise; lastProcessedLineCount: Map; instanceCreatedAt: number; }; watcherAny.instanceCreatedAt = Date.now() + 60_000; vi.mocked(errorDetector.detectErrors).mockClear(); vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); // First read: baseline only await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); expect(errorDetector.detectErrors).not.toHaveBeenCalled(); expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2); // Append new data fs.appendFileSync(filePath, jsonlLine('u3', 'new error')); // Second read: should detect errors in new data only await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1); // Verify only the new message was passed to detectErrors const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0]; expect(callArgs[0]).toHaveLength(1); // only 1 new message // Tracking should now reflect all 3 lines expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('preserves line offset for oversized pre-existing files without notifications', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-large-baseline-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.join(projectDir, 'session-large.jsonl'); const largeLineCount = 17_000; const largePayload = 'old data '.repeat(120); fs.writeFileSync( filePath, Array.from({ length: largeLineCount }, (_, index) => jsonlLine(`large-${index}`, largePayload) ).join(''), 'utf8' ); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( projectId: string, sessionId: string, filePath: string ) => Promise; lastProcessedLineCount: Map; lastProcessedSize: Map; instanceCreatedAt: number; }; watcherAny.instanceCreatedAt = Date.now() + 60_000; vi.mocked(errorDetector.detectErrors).mockClear(); await watcherAny.detectErrorsInSessionFile('test-project', 'session-large', filePath); expect(errorDetector.detectErrors).not.toHaveBeenCalled(); expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(largeLineCount); expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size); expect(notificationManager.addError).not.toHaveBeenCalled(); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('detects errors immediately for files created after watcher startup', async () => { vi.useRealTimers(); useRealExistsSync(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-newfile-')); const projectsDir = path.join(tempDir, 'projects'); const projectDir = path.join(projectsDir, 'test-project'); fs.mkdirSync(projectDir, { recursive: true }); const dataCache = new DataCache(50, 10, false); const notificationManager = createMockNotificationManager(); const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); watcher.setNotificationManager(notificationManager); vi.mocked(errorDetector.detectErrors).mockClear(); vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); // instanceCreatedAt is already set to "now" by the constructor, // and the file created below will have birthtimeMs >= instanceCreatedAt, // so it will be treated as a new file (no baseline skip) const filePath = path.join(projectDir, 'session-new.jsonl'); fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'error'), 'utf8'); const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( projectId: string, sessionId: string, filePath: string ) => Promise; lastProcessedLineCount: Map; instanceCreatedAt: number; }; // Make the "new file after startup" case deterministic across filesystems // whose birthtime precision can differ on CI runners. watcherAny.instanceCreatedAt = 0; // First read of a NEW file should detect errors (not baseline-skip) await watcherAny.detectErrorsInSessionFile('test-project', 'session-new', filePath); expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1); const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0]; expect(callArgs[0]).toHaveLength(2); // all messages scanned expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); }); }); // =========================================================================== // Timer Lifecycle Tests // =========================================================================== describe('timer lifecycle', () => { it('starts catch-up timer on start() and clears on stop()', () => { const dataCache = new DataCache(50, 10, false); vi.mocked(fsp.access).mockResolvedValue(); vi.mocked(fs.watch).mockImplementation(() => createFakeWatcher()); const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); const watcherAny = watcher as unknown as { catchUpTimer: NodeJS.Timeout | null; }; expect(watcherAny.catchUpTimer).toBeNull(); watcher.start(); expect(watcherAny.catchUpTimer).not.toBeNull(); watcher.stop(); expect(watcherAny.catchUpTimer).toBeNull(); }); it('clears all tracking state on stop()', () => { const dataCache = new DataCache(50, 10, false); vi.mocked(fsp.access).mockResolvedValue(); vi.mocked(fs.watch).mockImplementation(() => createFakeWatcher()); const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); const watcherAny = watcher as unknown as { activeSessionFiles: Map; processingInProgress: Set; pendingReprocess: Set; }; watcher.start(); // Add some tracking state watcherAny.activeSessionFiles.set('/tmp/file.jsonl', { projectId: 'p', sessionId: 's', }); watcherAny.processingInProgress.add('/tmp/file.jsonl'); watcherAny.pendingReprocess.add('/tmp/file.jsonl'); watcher.stop(); expect(watcherAny.activeSessionFiles.size).toBe(0); expect(watcherAny.processingInProgress.size).toBe(0); expect(watcherAny.pendingReprocess.size).toBe(0); }); }); });