import { createHash } from 'crypto'; import { mkdtemp, mkdir, rm, stat, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import * as path from 'path'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { getTeamsBasePath, setClaudeBasePathOverride, } from '../../../../src/main/utils/pathDecoder'; import { shouldIgnoreLogSourceWatcherPath, TeamLogSourceTracker, } from '../../../../src/main/services/team/TeamLogSourceTracker'; import type { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder'; import type { TeamChangeEvent } from '../../../../src/shared/types'; const originalChokidarUsePolling = process.env.CHOKIDAR_USEPOLLING; const originalChokidarInterval = process.env.CHOKIDAR_INTERVAL; function safeTaskIdSegment(taskId: string): string { return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`; } function teamLogFreshnessDir(teamName = 'demo'): string { return path.join(getTeamsBasePath(), teamName, 'task-log-freshness'); } describe('TeamLogSourceTracker', () => { let tempDir: string | null = null; beforeAll(() => { process.env.CHOKIDAR_USEPOLLING = '1'; process.env.CHOKIDAR_INTERVAL = '25'; }); afterEach(async () => { setClaudeBasePathOverride(null); if (tempDir) { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } }); afterAll(() => { if (originalChokidarUsePolling === undefined) { delete process.env.CHOKIDAR_USEPOLLING; } else { process.env.CHOKIDAR_USEPOLLING = originalChokidarUsePolling; } if (originalChokidarInterval === undefined) { delete process.env.CHOKIDAR_INTERVAL; } else { process.env.CHOKIDAR_INTERVAL = originalChokidarInterval; } }); it('emits task-log-change for matching runtime freshness signals without broad log-source-change', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-')); setClaudeBasePathOverride(path.join(tempDir, '.claude')); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'change_presence'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 350)); const taskId = '123e4567-e89b-12d3-a456-426614174999'; const signalDir = teamLogFreshnessDir(); await mkdir(signalDir, { recursive: true }); await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}'); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'task-log-change', teamName: 'demo', taskId, taskSignalKind: 'log', }); }); expect(emitter.mock.calls.map(([event]) => event.type)).not.toContain('log-source-change'); await tracker.disableTracking('demo', 'change_presence'); }); it('keeps task-log tracking alive until the last consumer unsubscribes', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-')); setClaudeBasePathOverride(path.join(tempDir, '.claude')); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'task_log_stream'); await tracker.enableTracking('demo', 'task_log_stream'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 350)); await tracker.disableTracking('demo', 'task_log_stream'); const taskId = '223e4567-e89b-12d3-a456-426614174999'; const signalDir = teamLogFreshnessDir(); await mkdir(signalDir, { recursive: true }); await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}'); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'task-log-change', teamName: 'demo', taskId, taskSignalKind: 'log', }); }); emitter.mockClear(); await tracker.disableTracking('demo', 'task_log_stream'); await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":false}'); await new Promise((resolve) => setTimeout(resolve, 350)); expect(emitter).not.toHaveBeenCalled(); }); it('creates team log freshness dir without creating missing live cwd roots', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-missing-root-')); setClaudeBasePathOverride(path.join(tempDir, '.claude')); const transcriptProjectDir = path.join(tempDir, 'transcript-project'); const missingWorkspaceDir = path.join(tempDir, 'missing-workspace'); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: transcriptProjectDir, projectPath: missingWorkspaceDir, taskFreshnessRootDirs: [missingWorkspaceDir], sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'task_log_stream'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 100)); expect((await stat(teamLogFreshnessDir())).isDirectory()).toBe(true); await expect(stat(missingWorkspaceDir)).rejects.toThrow(); const taskId = 'transcript-root-task'; await writeFile( path.join(teamLogFreshnessDir(), `${encodeURIComponent(taskId)}.json`), JSON.stringify({ taskId }), 'utf8' ); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'task-log-change', teamName: 'demo', taskId, taskSignalKind: 'log', }); }); await tracker.disableTracking('demo', 'task_log_stream'); }); it('emits log freshness kind from Windows-safe hashed task-log freshness files', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-log-')); setClaudeBasePathOverride(path.join(tempDir, '.claude')); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'task_log_stream'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 100)); const taskId = 'AUX'; const signalDir = teamLogFreshnessDir(); await mkdir(signalDir, { recursive: true }); await writeFile( path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`), JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }), 'utf8' ); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'task-log-change', teamName: 'demo', taskId, taskSignalKind: 'log', }); }); await tracker.disableTracking('demo', 'task_log_stream'); }); it('watches team-scoped log freshness and live cwd task-change freshness roots', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-codex-root-')); setClaudeBasePathOverride(path.join(tempDir, '.claude')); const transcriptProjectDir = path.join(tempDir, 'transcripts'); const workspaceProjectDir = path.join(tempDir, 'workspace'); const memberProjectDir = path.join(tempDir, 'member-workspace'); await mkdir(transcriptProjectDir, { recursive: true }); await mkdir(workspaceProjectDir, { recursive: true }); await mkdir(memberProjectDir, { recursive: true }); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: transcriptProjectDir, projectPath: workspaceProjectDir, taskFreshnessRootDirs: [workspaceProjectDir, memberProjectDir], sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'task_log_stream'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 350)); await expect(stat(path.join(memberProjectDir, '.board-task-log-freshness'))).rejects.toThrow(); await expect(stat(path.join(workspaceProjectDir, '.board-task-log-freshness'))).rejects.toThrow(); const changeTaskId = 'codex-task-2'; await mkdir(path.join(workspaceProjectDir, '.board-task-change-freshness'), { recursive: true, }); await writeFile( path.join( workspaceProjectDir, '.board-task-change-freshness', `${encodeURIComponent(changeTaskId)}.json` ), JSON.stringify({ taskId: changeTaskId }), 'utf8' ); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'task-log-change', teamName: 'demo', taskId: changeTaskId, taskSignalKind: 'change', }); }); await tracker.disableTracking('demo', 'task_log_stream'); }); it('emits log-source-change for scoped root transcripts', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-scoped-root-')); await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":1}\n'); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: ['lead-session'], watchSessionIds: ['lead-session'], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'change_presence'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 100)); await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":2}\n'); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'log-source-change', teamName: 'demo', }); }); await tracker.disableTracking('demo', 'change_presence'); }); it('ignores old unscoped root transcript changes', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-unscoped-root-')); await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":1}\n'); await writeFile(path.join(tempDir, 'old-session.jsonl'), '{"seq":1}\n'); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: ['lead-session'], watchSessionIds: ['lead-session'], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'change_presence'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 100)); await writeFile(path.join(tempDir, 'old-session.jsonl'), '{"seq":2}\n'); await new Promise((resolve) => setTimeout(resolve, 450)); expect(emitter.mock.calls.map(([event]) => event.type)).not.toContain('log-source-change'); await tracker.disableTracking('demo', 'change_presence'); }); it('emits log-source-change when a scoped root transcript appears', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-pending-root-')); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: ['new-runtime'], watchSessionIds: ['new-runtime'], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'change_presence'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 100)); await writeFile(path.join(tempDir, 'new-runtime.jsonl'), '{"seq":1}\n'); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'log-source-change', teamName: 'demo', }); }); await tracker.disableTracking('demo', 'change_presence'); }); it('does not reinitialize when another consumer joins an already tracked team', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-init-')); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); await tracker.enableTracking('demo', 'tool_activity'); await tracker.enableTracking('demo', 'task_log_stream'); expect(logsFinder.getLiveLogSourceWatchContext).toHaveBeenCalledTimes(1); await tracker.disableTracking('demo', 'task_log_stream'); await tracker.disableTracking('demo', 'tool_activity'); }); it('notifies log-source listeners before forwarding the external team change event', () => { const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: '/tmp/demo', sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const events: string[] = []; tracker.onLogSourceChange(() => { events.push('listener'); }); tracker.setEmitter(() => { events.push('emitter'); }); ( tracker as unknown as { emitLogSourceChange: (teamName: string) => void; } ).emitLogSourceChange('demo'); expect(events).toEqual(['listener', 'emitter']); }); it('supports stall_monitor as an independent tracking consumer', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-stall-monitor-')); setClaudeBasePathOverride(path.join(tempDir, '.claude')); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'stall_monitor'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 100)); const taskId = '323e4567-e89b-12d3-a456-426614174999'; const signalDir = teamLogFreshnessDir(); await mkdir(signalDir, { recursive: true }); await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}'); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'task-log-change', teamName: 'demo', taskId, taskSignalKind: 'log', }); }); await tracker.disableTracking('demo', 'stall_monitor'); }); it('emits the task id from Windows-safe hashed task-change freshness files', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-task-')); const logsFinder = { getLiveLogSourceWatchContext: vi.fn(async () => ({ projectDir: tempDir!, sessionIds: [], watchSessionIds: [], })), } as unknown as TeamMemberLogsFinder; const tracker = new TeamLogSourceTracker(logsFinder); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); tracker.setEmitter(emitter); await tracker.enableTracking('demo', 'change_presence'); emitter.mockClear(); await new Promise((resolve) => setTimeout(resolve, 100)); const taskId = 'CON'; const signalDir = path.join(tempDir, '.board-task-change-freshness'); await mkdir(signalDir, { recursive: true }); await writeFile( path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`), JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }), 'utf8' ); await vi.waitFor(() => { expect(emitter).toHaveBeenCalledWith({ type: 'task-log-change', teamName: 'demo', taskId, taskSignalKind: 'change', }); }); expect(emitter.mock.calls).not.toContainEqual([ expect.objectContaining({ type: 'task-log-change', taskId: safeTaskIdSegment(taskId) }), ]); await tracker.disableTracking('demo', 'change_presence'); }); it('ignores internal ledger artifact paths but keeps freshness signals visible', () => { const projectDir = '/tmp/demo-project'; const scopedSessionIds = new Set(['lead-session']); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, '.board-task-changes', 'events', 'task.jsonl') ) ).toBe(true); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, '.board-task-changes', 'locks', 'task.lock', 'owner.json') ) ).toBe(true); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, '.board-task-change-freshness', 'task.json') ) ).toBe(false); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, '.board-task-log-freshness', 'task.json'), { scopedSessionIds } ) ).toBe(false); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, 'lead-session.jsonl'), { scopedSessionIds } ) ).toBe(false); expect( shouldIgnoreLogSourceWatcherPath(projectDir, path.join(projectDir, 'old-session.jsonl'), { scopedSessionIds, }) ).toBe(true); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, 'pending-session.jsonl'), { scopedSessionIds, pendingRootSessionIds: new Set(['pending-session']), } ) ).toBe(false); expect( shouldIgnoreLogSourceWatcherPath(projectDir, path.join(projectDir, 'lead-session'), { scopedSessionIds, }) ).toBe(false); expect( shouldIgnoreLogSourceWatcherPath(projectDir, path.join(projectDir, 'pending-session'), { scopedSessionIds, pendingRootSessionIds: new Set(['pending-session']), }) ).toBe(true); expect( shouldIgnoreLogSourceWatcherPath(projectDir, path.join(projectDir, 'old-session'), { scopedSessionIds, }) ).toBe(true); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, 'lead-session', 'subagents', 'agent-worker.jsonl'), { scopedSessionIds } ) ).toBe(false); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, 'lead-session', 'subagents', 'agent-acompact-worker.jsonl'), { scopedSessionIds } ) ).toBe(true); expect( shouldIgnoreLogSourceWatcherPath( projectDir, path.join(projectDir, 'old-session', 'subagents', 'agent-worker.jsonl'), { scopedSessionIds } ) ).toBe(true); }); });