diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 75751dbc..ee029f91 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -47,6 +47,10 @@ import { isTeamDataRefreshPending, selectTeamDataForName, } from './slices/teamSlice'; +import { + noteTeamRefreshFanout, + type TeamRefreshFanoutOperation, +} from './teamRefreshFanoutDiagnostics'; import { createUISlice } from './slices/uiSlice'; import { createUpdateSlice } from './slices/updateSlice'; @@ -250,6 +254,8 @@ export function initializeNotificationListeners(): () => void { let teamListRefreshTimer: ReturnType | null = null; let globalTasksRefreshTimer: ReturnType | null = null; + const pendingTeamListRefreshDiagnostics = new Map>(); + const pendingGlobalTasksRefreshDiagnostics = new Map>(); const SESSION_REFRESH_DEBOUNCE_MS = 150; const PROJECT_REFRESH_DEBOUNCE_MS = 300; const TEAM_REFRESH_THROTTLE_MS = 800; @@ -257,6 +263,53 @@ export function initializeNotificationListeners(): () => void { const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; + const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`; + const addPendingGlobalRefreshDiagnostic = ( + pending: Map>, + teamName: string, + reason: string + ): void => { + const reasons = pending.get(teamName) ?? new Set(); + reasons.add(reason); + pending.set(teamName, reasons); + }; + const drainPendingGlobalRefreshDiagnostics = ( + pending: Map>, + operation: TeamRefreshFanoutOperation + ): void => { + const entries = Array.from(pending.entries()); + pending.clear(); + for (const [teamName, reasons] of entries) { + for (const reason of reasons) { + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: 'executed', + reason, + operation, + }); + } + } + }; + const noteGlobalRefreshScheduled = ( + pending: Map>, + teamName: string | null | undefined, + reason: string, + operation: TeamRefreshFanoutOperation, + coalesced: boolean + ): void => { + if (!teamName) { + return; + } + addPendingGlobalRefreshDiagnostic(pending, teamName, reason); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: coalesced ? 'coalesced' : 'scheduled', + reason, + operation, + }); + }; const refreshTrackedTeamMessages = async (teamName: string): Promise => { if (!teamName || !shouldRefreshTeamMessages(teamName)) { return; @@ -278,11 +331,26 @@ export function initializeNotificationListeners(): () => void { if (!teamName || !isTeamVisibleInAnyPane(teamName)) { return; } + const existingTimer = memberSpawnRefreshTimers.get(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: existingTimer ? 'coalesced' : 'scheduled', + reason: 'event:member-spawn', + operation: 'fetchMemberSpawnStatuses', + }); if (memberSpawnRefreshTimers.has(teamName)) { return; } const timer = setTimeout(() => { memberSpawnRefreshTimers.delete(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: 'event:member-spawn', + operation: 'fetchMemberSpawnStatuses', + }); void useStore.getState().fetchMemberSpawnStatuses(teamName); }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); memberSpawnRefreshTimers.set(teamName, timer); @@ -291,24 +359,57 @@ export function initializeNotificationListeners(): () => void { if (!teamName || !isTeamVisibleInAnyPane(teamName)) { return; } + const existingTimer = teamAgentRuntimeRefreshTimers.get(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: existingTimer ? 'coalesced' : 'scheduled', + reason: 'event:member-spawn', + operation: 'fetchTeamAgentRuntime', + }); if (teamAgentRuntimeRefreshTimers.has(teamName)) { return; } const timer = setTimeout(() => { teamAgentRuntimeRefreshTimers.delete(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: 'event:member-spawn', + operation: 'fetchTeamAgentRuntime', + }); void useStore.getState().fetchTeamAgentRuntime(teamName); }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); teamAgentRuntimeRefreshTimers.set(teamName, timer); }; - const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => { + const scheduleTrackedTeamMessageRefresh = ( + teamName: string | null | undefined, + reason: 'event:inbox' | 'event:lead-message' + ): void => { if (!teamName || !shouldRefreshTeamMessages(teamName)) { return; } + const existingTimer = teamMessageRefreshTimers.get(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: existingTimer ? 'coalesced' : 'scheduled', + reason, + operation: 'fetchTeamMessageHead', + }); if (teamMessageRefreshTimers.has(teamName)) { return; } const timer = setTimeout(() => { teamMessageRefreshTimers.delete(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: 'executed', + reason, + operation: 'fetchTeamMessageHead', + }); void refreshTrackedTeamMessages(teamName); }, TEAM_REFRESH_THROTTLE_MS); teamMessageRefreshTimers.set(teamName, timer); @@ -700,7 +801,16 @@ export function initializeNotificationListeners(): () => void { teamMessageFallbackPollInFlight = true; try { await Promise.allSettled( - Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName)) + Array.from(teamNames, (teamName) => { + noteTeamRefreshFanout({ + teamName, + surface: 'pending-reply-fallback', + phase: 'executed', + reason: 'pending-reply:fallback-poll', + operation: 'fetchTeamMessageHead', + }); + return refreshTrackedTeamMessages(teamName); + }) ); } finally { teamMessageFallbackPollInFlight = false; @@ -1213,7 +1323,7 @@ export function initializeNotificationListeners(): () => void { } if (event.type === 'inbox') { - scheduleTrackedTeamMessageRefresh(event.teamName); + scheduleTrackedTeamMessageRefresh(event.teamName, 'event:inbox'); return; } @@ -1224,7 +1334,7 @@ export function initializeNotificationListeners(): () => void { return; } seedCurrentRunIdIfMissing(); - scheduleTrackedTeamMessageRefresh(event.teamName); + scheduleTrackedTeamMessageRefresh(event.teamName, 'event:lead-message'); return; } @@ -1232,22 +1342,47 @@ export function initializeNotificationListeners(): () => void { if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { return; } - if (teamPresenceRefreshTimers.has(event.teamName)) { + const existingTimer = teamPresenceRefreshTimers.get(event.teamName); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: existingTimer ? 'coalesced' : 'scheduled', + reason: 'event:log-source-change', + operation: 'refreshTaskChangePresence', + }); + if (existingTimer) { return; } const timer = setTimeout(() => { teamPresenceRefreshTimers.delete(event.teamName); const current = useStore.getState(); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: 'event:log-source-change', + operation: 'refreshTaskChangePresence', + }); void current.refreshTeamChangePresence(event.teamName); }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); teamPresenceRefreshTimers.set(event.teamName, timer); return; } + const eventReason = buildTeamChangeFanoutReason(event.type); + // Throttled refresh of summary list (keeps TeamListView current without flooding). + noteGlobalRefreshScheduled( + pendingTeamListRefreshDiagnostics, + event.teamName, + eventReason, + 'fetchTeams', + teamListRefreshTimer != null + ); if (!teamListRefreshTimer) { teamListRefreshTimer = setTimeout(() => { teamListRefreshTimer = null; + drainPendingGlobalRefreshDiagnostics(pendingTeamListRefreshDiagnostics, 'fetchTeams'); void useStore.getState().fetchTeams(); }, TEAM_LIST_REFRESH_THROTTLE_MS); } @@ -1255,11 +1390,24 @@ export function initializeNotificationListeners(): () => void { const shouldRefreshGlobalTasks = event.type === 'task' || event.type === 'config'; // Throttled refresh of global tasks list for sidebar. - if (shouldRefreshGlobalTasks && !globalTasksRefreshTimer) { - globalTasksRefreshTimer = setTimeout(() => { - globalTasksRefreshTimer = null; - void useStore.getState().fetchAllTasks(); - }, GLOBAL_TASKS_REFRESH_THROTTLE_MS); + if (shouldRefreshGlobalTasks) { + noteGlobalRefreshScheduled( + pendingGlobalTasksRefreshDiagnostics, + event.teamName, + eventReason, + 'fetchAllTasks', + globalTasksRefreshTimer != null + ); + if (!globalTasksRefreshTimer) { + globalTasksRefreshTimer = setTimeout(() => { + globalTasksRefreshTimer = null; + drainPendingGlobalRefreshDiagnostics( + pendingGlobalTasksRefreshDiagnostics, + 'fetchAllTasks' + ); + void useStore.getState().fetchAllTasks(); + }, GLOBAL_TASKS_REFRESH_THROTTLE_MS); + } } if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { @@ -1268,13 +1416,38 @@ export function initializeNotificationListeners(): () => void { // Per-team throttle (not debounce): keep at most one pending detail refresh per team. // Debounce would delay indefinitely while inbox messages keep arriving. - if (teamRefreshTimers.has(event.teamName)) { + const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName; + const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName; + const existingDetailTimer = teamRefreshTimers.get(event.teamName); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: existingDetailTimer ? 'coalesced' : 'scheduled', + reason: eventReason, + operation: 'refreshTeamData', + eventType: event.type, + selected: selectedForRefresh, + visible: true, + activeTab: activeTabForRefresh, + }); + if (existingDetailTimer) { return; } const timer = setTimeout(() => { teamRefreshTimers.delete(event.teamName); const current = useStore.getState(); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: eventReason, + operation: 'refreshTeamData', + eventType: event.type, + selected: current.selectedTeamName === event.teamName, + visible: isTeamVisibleInAnyPane(event.teamName), + activeTab: getFocusedVisibleTeamName() === event.teamName, + }); void current.refreshTeamData(event.teamName, { withDedup: true }); }, TEAM_REFRESH_THROTTLE_MS); teamRefreshTimers.set(event.teamName, timer); @@ -1301,10 +1474,12 @@ export function initializeNotificationListeners(): () => void { clearTimeout(teamListRefreshTimer); teamListRefreshTimer = null; } + pendingTeamListRefreshDiagnostics.clear(); if (globalTasksRefreshTimer) { clearTimeout(globalTasksRefreshTimer); globalTasksRefreshTimer = null; } + pendingGlobalTasksRefreshDiagnostics.clear(); }); } } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b9adaedf..c676ec27 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -20,6 +20,7 @@ import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; @@ -4887,6 +4888,17 @@ export const createTeamSlice: StateCreator = (set, if (isCanonicalRun && becameConfigReady) { const state = get(); if (isVisibleInActiveTeamSurface(state, progress.teamName)) { + const willSelectTeam = + state.selectedTeamName === progress.teamName && state.selectedTeamData == null; + noteTeamRefreshFanout({ + teamName: progress.teamName, + surface: 'provisioning-progress', + phase: 'scheduled', + reason: 'provisioning:config-ready', + operation: willSelectTeam ? 'selectTeam' : 'refreshTeamData', + selected: state.selectedTeamName === progress.teamName, + visible: true, + }); if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) { void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true }); } else { @@ -4939,8 +4951,27 @@ export const createTeamSlice: StateCreator = (set, } if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) { + const terminalReason = + progress.state === 'ready' + ? 'provisioning:terminal-ready' + : 'provisioning:terminal-disconnected'; + noteTeamRefreshFanout({ + teamName: progress.teamName, + surface: 'provisioning-progress', + phase: 'scheduled', + reason: terminalReason, + operation: 'fetchTeams', + }); void get().fetchTeams(); if (hydratedVisibleTeam) { + noteTeamRefreshFanout({ + teamName: progress.teamName, + surface: 'provisioning-progress', + phase: 'skipped', + reason: 'provisioning:already-hydrated-visible-team', + operation: 'refreshTeamData', + visible: true, + }); return; } @@ -4951,6 +4982,15 @@ export const createTeamSlice: StateCreator = (set, // If the user already opened the team tab, reload team data now that // config.json is guaranteed to exist. + noteTeamRefreshFanout({ + teamName: progress.teamName, + surface: 'provisioning-progress', + phase: 'scheduled', + reason: terminalReason, + operation: state.selectedTeamName === progress.teamName ? 'selectTeam' : 'refreshTeamData', + selected: state.selectedTeamName === progress.teamName, + visible: true, + }); if (state.selectedTeamName === progress.teamName) { void state.selectTeam(progress.teamName); } else { diff --git a/src/renderer/store/teamRefreshFanoutDiagnostics.ts b/src/renderer/store/teamRefreshFanoutDiagnostics.ts new file mode 100644 index 00000000..e4db20a3 --- /dev/null +++ b/src/renderer/store/teamRefreshFanoutDiagnostics.ts @@ -0,0 +1,148 @@ +export type TeamRefreshFanoutSurface = + | 'team-change-listener' + | 'provisioning-progress' + | 'pending-reply-fallback' + | 'manual-refresh'; + +export type TeamRefreshFanoutPhase = 'scheduled' | 'coalesced' | 'executed' | 'skipped'; + +export type TeamRefreshFanoutOperation = + | 'fetchTeams' + | 'fetchAllTasks' + | 'refreshTeamData' + | 'selectTeam' + | 'fetchTeamMessageHead' + | 'fetchMemberSpawnStatuses' + | 'fetchTeamAgentRuntime' + | 'refreshTaskChangePresence'; + +export interface TeamRefreshFanoutNote { + teamName: string; + surface: TeamRefreshFanoutSurface; + phase: TeamRefreshFanoutPhase; + reason: string; + operation: TeamRefreshFanoutOperation; + eventType?: string; + tabId?: string; + selected?: boolean; + visible?: boolean; + activeTab?: boolean; +} + +export interface TeamRefreshFanoutRecentNote { + at: number; + surface: TeamRefreshFanoutSurface; + phase: TeamRefreshFanoutPhase; + reason: string; + operation: TeamRefreshFanoutOperation; + eventType?: string; + tabId?: string; + selected?: boolean; + visible?: boolean; + activeTab?: boolean; +} + +export interface TeamRefreshFanoutSnapshot { + counts: Record; + recent: TeamRefreshFanoutRecentNote[]; + lastAt: number; +} + +interface TeamRefreshFanoutBucket { + counts: Record; + recent: TeamRefreshFanoutRecentNote[]; + lastAt: number; +} + +export const MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS = 100; +export const MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES = 50; + +const buckets = new Map(); + +function createEmptyBucket(): TeamRefreshFanoutBucket { + return { + counts: {}, + recent: [], + lastAt: 0, + }; +} + +function ensureTeamBucket(teamName: string): TeamRefreshFanoutBucket { + if (!buckets.has(teamName) && buckets.size >= MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS) { + const oldestKey = buckets.keys().next().value as string | undefined; + if (oldestKey) { + buckets.delete(oldestKey); + } + } + + let bucket = buckets.get(teamName); + if (!bucket) { + bucket = createEmptyBucket(); + buckets.set(teamName, bucket); + } + + return bucket; +} + +function cloneBucket( + bucket: TeamRefreshFanoutBucket | undefined +): TeamRefreshFanoutSnapshot | null { + if (!bucket) { + return null; + } + + return { + counts: { ...bucket.counts }, + recent: bucket.recent.map((note) => ({ ...note })), + lastAt: bucket.lastAt, + }; +} + +export function buildTeamRefreshFanoutCountKey(note: TeamRefreshFanoutNote): string { + return `${note.surface}:${note.reason}:${note.operation}:${note.phase}`; +} + +export function noteTeamRefreshFanout(note: TeamRefreshFanoutNote): void { + if (!note.teamName || !note.reason || !note.operation) { + return; + } + + const bucket = ensureTeamBucket(note.teamName); + const key = buildTeamRefreshFanoutCountKey(note); + const now = Date.now(); + + bucket.counts[key] = (bucket.counts[key] ?? 0) + 1; + bucket.lastAt = now; + bucket.recent.push({ + at: now, + surface: note.surface, + phase: note.phase, + reason: note.reason, + operation: note.operation, + eventType: note.eventType, + tabId: note.tabId, + selected: note.selected, + visible: note.visible, + activeTab: note.activeTab, + }); + + if (bucket.recent.length > MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES) { + bucket.recent.splice(0, bucket.recent.length - MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES); + } +} + +export function getTeamRefreshFanoutSnapshotForTests( + teamName?: string +): TeamRefreshFanoutSnapshot | Record | null { + if (teamName) { + return cloneBucket(buckets.get(teamName)); + } + + return Object.fromEntries( + Array.from(buckets.entries(), ([key, bucket]) => [key, cloneBucket(bucket)]) + ) as Record; +} + +export function __resetTeamRefreshFanoutDiagnosticsForTests(): void { + buckets.clear(); +} diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 47fd6c9c..491ce366 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -63,6 +63,11 @@ vi.mock('@renderer/api', () => ({ import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice'; +import { + __resetTeamRefreshFanoutDiagnosticsForTests, + getTeamRefreshFanoutSnapshotForTests, + type TeamRefreshFanoutSnapshot, +} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics'; import { api } from '@renderer/api'; describe('team change throttling', () => { @@ -71,6 +76,7 @@ describe('team change throttling', () => { beforeEach(async () => { vi.useFakeTimers(); __resetTeamSliceModuleStateForTests(); + __resetTeamRefreshFanoutDiagnosticsForTests(); const fetchTeams = vi.fn(async () => undefined); const fetchMemberSpawnStatuses = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); @@ -117,6 +123,7 @@ describe('team change throttling', () => { cleanup?.(); cleanup = null; __resetTeamSliceModuleStateForTests(); + __resetTeamRefreshFanoutDiagnosticsForTests(); vi.mocked(console.warn).mockClear(); vi.useRealTimers(); }); @@ -163,6 +170,48 @@ describe('team change throttling', () => { expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); }); + it('keeps process events on the existing structural refresh path and records fanout', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + + const snapshot = getTeamRefreshFanoutSnapshotForTests( + 'my-team' + ) as TeamRefreshFanoutSnapshot | null; + expect( + snapshot?.counts['team-change-listener:event:process:refreshTeamData:scheduled'] + ).toBe(1); + expect(snapshot?.counts['team-change-listener:event:process:refreshTeamData:executed']).toBe( + 1 + ); + }); + + it('keeps task and config events on the existing global task refresh path', async () => { + const fetchAllTasksSpy = vi.fn(async () => undefined); + useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); + + hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' }); + hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(500); + + expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1); + + const snapshot = getTeamRefreshFanoutSnapshotForTests( + 'my-team' + ) as TeamRefreshFanoutSnapshot | null; + expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:scheduled']).toBe(1); + expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:coalesced']).toBe(1); + expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:executed']).toBe(1); + expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:executed']).toBe(1); + }); + it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => { const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); diff --git a/test/renderer/store/teamRefreshFanoutDiagnostics.test.ts b/test/renderer/store/teamRefreshFanoutDiagnostics.test.ts new file mode 100644 index 00000000..88ad5c7b --- /dev/null +++ b/test/renderer/store/teamRefreshFanoutDiagnostics.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetTeamRefreshFanoutDiagnosticsForTests, + buildTeamRefreshFanoutCountKey, + getTeamRefreshFanoutSnapshotForTests, + MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES, + MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS, + noteTeamRefreshFanout, + type TeamRefreshFanoutSnapshot, +} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics'; + +function snapshotFor(teamName: string): TeamRefreshFanoutSnapshot { + const snapshot = getTeamRefreshFanoutSnapshotForTests(teamName); + expect(snapshot).not.toBeNull(); + return snapshot as TeamRefreshFanoutSnapshot; +} + +describe('teamRefreshFanoutDiagnostics', () => { + beforeEach(() => { + vi.useFakeTimers(); + __resetTeamRefreshFanoutDiagnosticsForTests(); + }); + + afterEach(() => { + __resetTeamRefreshFanoutDiagnosticsForTests(); + vi.useRealTimers(); + }); + + it('records scheduled and executed fanout counts separately', () => { + const scheduled = { + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:process', + operation: 'refreshTeamData', + } as const; + const executed = { + ...scheduled, + phase: 'executed', + } as const; + + noteTeamRefreshFanout(scheduled); + noteTeamRefreshFanout(executed); + + const snapshot = snapshotFor('team-a'); + expect(snapshot.counts[buildTeamRefreshFanoutCountKey(scheduled)]).toBe(1); + expect(snapshot.counts[buildTeamRefreshFanoutCountKey(executed)]).toBe(1); + }); + + it('records coalesced notes separately from scheduled notes', () => { + const scheduled = { + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:member-spawn', + operation: 'fetchMemberSpawnStatuses', + } as const; + const coalesced = { + ...scheduled, + phase: 'coalesced', + } as const; + + noteTeamRefreshFanout(scheduled); + noteTeamRefreshFanout(coalesced); + noteTeamRefreshFanout(coalesced); + + const snapshot = snapshotFor('team-a'); + expect(snapshot.counts[buildTeamRefreshFanoutCountKey(scheduled)]).toBe(1); + expect(snapshot.counts[buildTeamRefreshFanoutCountKey(coalesced)]).toBe(2); + }); + + it('caps recent notes per team', () => { + for (let index = 0; index < MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES + 5; index += 1) { + noteTeamRefreshFanout({ + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: `event:${index}`, + operation: 'refreshTeamData', + }); + } + + const snapshot = snapshotFor('team-a'); + expect(snapshot.recent).toHaveLength(MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES); + expect(snapshot.recent[0]?.reason).toBe('event:5'); + }); + + it('caps team buckets by evicting the oldest bucket', () => { + for (let index = 0; index < MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS + 1; index += 1) { + noteTeamRefreshFanout({ + teamName: `team-${index}`, + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:process', + operation: 'refreshTeamData', + }); + } + + expect(getTeamRefreshFanoutSnapshotForTests('team-0')).toBeNull(); + expect( + getTeamRefreshFanoutSnapshotForTests(`team-${MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS}`) + ).not.toBeNull(); + }); + + it('reset clears all diagnostic state', () => { + noteTeamRefreshFanout({ + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:process', + operation: 'refreshTeamData', + }); + + __resetTeamRefreshFanoutDiagnosticsForTests(); + + expect(getTeamRefreshFanoutSnapshotForTests('team-a')).toBeNull(); + expect(getTeamRefreshFanoutSnapshotForTests()).toEqual({}); + }); + + it('ignores invalid empty team or reason values', () => { + noteTeamRefreshFanout({ + teamName: '', + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:process', + operation: 'refreshTeamData', + }); + noteTeamRefreshFanout({ + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: '', + operation: 'refreshTeamData', + }); + + expect(getTeamRefreshFanoutSnapshotForTests()).toEqual({}); + }); +}); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index b2abffb9..8991ade7 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -12,6 +12,11 @@ import { selectResolvedMemberForTeamName, selectResolvedMembersForTeamName, } from '../../../src/renderer/store/slices/teamSlice'; +import { + __resetTeamRefreshFanoutDiagnosticsForTests, + getTeamRefreshFanoutSnapshotForTests, + type TeamRefreshFanoutSnapshot, +} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics'; const hoisted = vi.hoisted(() => ({ list: vi.fn(), @@ -198,6 +203,7 @@ describe('teamSlice actions', () => { beforeEach(() => { vi.clearAllMocks(); __resetTeamSliceModuleStateForTests(); + __resetTeamRefreshFanoutDiagnosticsForTests(); hoisted.list.mockResolvedValue([]); hoisted.getData.mockResolvedValue(createTeamSnapshot()); hoisted.getMessagesPage.mockResolvedValue({ @@ -238,6 +244,57 @@ describe('teamSlice actions', () => { hoisted.skipMemberForLaunch.mockResolvedValue(undefined); }); + it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => { + const store = createSliceStore(); + const fetchTeams = vi.fn(async () => undefined); + const refreshTeamData = vi.fn(async () => undefined); + store.setState({ + fetchTeams, + refreshTeamData, + selectedTeamName: 'other-team', + selectedTeamData: createTeamSnapshot({ + teamName: 'other-team', + config: { name: 'Other Team' }, + }), + paneLayout: { + focusedPaneId: 'pane-default', + panes: [ + { + id: 'pane-default', + widthFraction: 1, + tabs: [{ id: 'graph-my-team', type: 'graph', teamName: 'my-team', label: 'Graph' }], + activeTabId: 'graph-my-team', + }, + ], + }, + }); + + store.getState().onProvisioningProgress({ + runId: 'run-ready', + teamName: 'my-team', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:01.000Z', + } as never); + + expect(fetchTeams).toHaveBeenCalledTimes(1); + expect(refreshTeamData).toHaveBeenCalledTimes(1); + expect(refreshTeamData).toHaveBeenCalledWith('my-team', { withDedup: true }); + + const snapshot = getTeamRefreshFanoutSnapshotForTests( + 'my-team' + ) as TeamRefreshFanoutSnapshot | null; + expect( + snapshot?.counts['provisioning-progress:provisioning:terminal-ready:fetchTeams:scheduled'] + ).toBe(1); + expect( + snapshot?.counts[ + 'provisioning-progress:provisioning:terminal-ready:refreshTeamData:scheduled' + ] + ).toBe(1); + }); + it('maps inbox verify failure to user-friendly text', async () => { const store = createSliceStore(); hoisted.sendMessage.mockRejectedValue(new Error('Failed to verify inbox write'));