chore(team): instrument refresh fanout diagnostics
This commit is contained in:
parent
e21d89a057
commit
7e55fdd9cd
6 changed files with 619 additions and 11 deletions
|
|
@ -47,6 +47,10 @@ import {
|
||||||
isTeamDataRefreshPending,
|
isTeamDataRefreshPending,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
} from './slices/teamSlice';
|
} from './slices/teamSlice';
|
||||||
|
import {
|
||||||
|
noteTeamRefreshFanout,
|
||||||
|
type TeamRefreshFanoutOperation,
|
||||||
|
} from './teamRefreshFanoutDiagnostics';
|
||||||
import { createUISlice } from './slices/uiSlice';
|
import { createUISlice } from './slices/uiSlice';
|
||||||
import { createUpdateSlice } from './slices/updateSlice';
|
import { createUpdateSlice } from './slices/updateSlice';
|
||||||
|
|
||||||
|
|
@ -250,6 +254,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
|
|
||||||
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let globalTasksRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let globalTasksRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const pendingTeamListRefreshDiagnostics = new Map<string, Set<string>>();
|
||||||
|
const pendingGlobalTasksRefreshDiagnostics = new Map<string, Set<string>>();
|
||||||
const SESSION_REFRESH_DEBOUNCE_MS = 150;
|
const SESSION_REFRESH_DEBOUNCE_MS = 150;
|
||||||
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
|
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
|
||||||
const TEAM_REFRESH_THROTTLE_MS = 800;
|
const TEAM_REFRESH_THROTTLE_MS = 800;
|
||||||
|
|
@ -257,6 +263,53 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
||||||
|
const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`;
|
||||||
|
const addPendingGlobalRefreshDiagnostic = (
|
||||||
|
pending: Map<string, Set<string>>,
|
||||||
|
teamName: string,
|
||||||
|
reason: string
|
||||||
|
): void => {
|
||||||
|
const reasons = pending.get(teamName) ?? new Set<string>();
|
||||||
|
reasons.add(reason);
|
||||||
|
pending.set(teamName, reasons);
|
||||||
|
};
|
||||||
|
const drainPendingGlobalRefreshDiagnostics = (
|
||||||
|
pending: Map<string, Set<string>>,
|
||||||
|
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<string, Set<string>>,
|
||||||
|
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<void> => {
|
const refreshTrackedTeamMessages = async (teamName: string): Promise<void> => {
|
||||||
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -278,11 +331,26 @@ export function initializeNotificationListeners(): () => void {
|
||||||
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||||
return;
|
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)) {
|
if (memberSpawnRefreshTimers.has(teamName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
memberSpawnRefreshTimers.delete(teamName);
|
memberSpawnRefreshTimers.delete(teamName);
|
||||||
|
noteTeamRefreshFanout({
|
||||||
|
teamName,
|
||||||
|
surface: 'team-change-listener',
|
||||||
|
phase: 'executed',
|
||||||
|
reason: 'event:member-spawn',
|
||||||
|
operation: 'fetchMemberSpawnStatuses',
|
||||||
|
});
|
||||||
void useStore.getState().fetchMemberSpawnStatuses(teamName);
|
void useStore.getState().fetchMemberSpawnStatuses(teamName);
|
||||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||||
memberSpawnRefreshTimers.set(teamName, timer);
|
memberSpawnRefreshTimers.set(teamName, timer);
|
||||||
|
|
@ -291,24 +359,57 @@ export function initializeNotificationListeners(): () => void {
|
||||||
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||||
return;
|
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)) {
|
if (teamAgentRuntimeRefreshTimers.has(teamName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
teamAgentRuntimeRefreshTimers.delete(teamName);
|
teamAgentRuntimeRefreshTimers.delete(teamName);
|
||||||
|
noteTeamRefreshFanout({
|
||||||
|
teamName,
|
||||||
|
surface: 'team-change-listener',
|
||||||
|
phase: 'executed',
|
||||||
|
reason: 'event:member-spawn',
|
||||||
|
operation: 'fetchTeamAgentRuntime',
|
||||||
|
});
|
||||||
void useStore.getState().fetchTeamAgentRuntime(teamName);
|
void useStore.getState().fetchTeamAgentRuntime(teamName);
|
||||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||||
teamAgentRuntimeRefreshTimers.set(teamName, timer);
|
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)) {
|
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const existingTimer = teamMessageRefreshTimers.get(teamName);
|
||||||
|
noteTeamRefreshFanout({
|
||||||
|
teamName,
|
||||||
|
surface: 'team-change-listener',
|
||||||
|
phase: existingTimer ? 'coalesced' : 'scheduled',
|
||||||
|
reason,
|
||||||
|
operation: 'fetchTeamMessageHead',
|
||||||
|
});
|
||||||
if (teamMessageRefreshTimers.has(teamName)) {
|
if (teamMessageRefreshTimers.has(teamName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
teamMessageRefreshTimers.delete(teamName);
|
teamMessageRefreshTimers.delete(teamName);
|
||||||
|
noteTeamRefreshFanout({
|
||||||
|
teamName,
|
||||||
|
surface: 'team-change-listener',
|
||||||
|
phase: 'executed',
|
||||||
|
reason,
|
||||||
|
operation: 'fetchTeamMessageHead',
|
||||||
|
});
|
||||||
void refreshTrackedTeamMessages(teamName);
|
void refreshTrackedTeamMessages(teamName);
|
||||||
}, TEAM_REFRESH_THROTTLE_MS);
|
}, TEAM_REFRESH_THROTTLE_MS);
|
||||||
teamMessageRefreshTimers.set(teamName, timer);
|
teamMessageRefreshTimers.set(teamName, timer);
|
||||||
|
|
@ -700,7 +801,16 @@ export function initializeNotificationListeners(): () => void {
|
||||||
teamMessageFallbackPollInFlight = true;
|
teamMessageFallbackPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled(
|
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 {
|
} finally {
|
||||||
teamMessageFallbackPollInFlight = false;
|
teamMessageFallbackPollInFlight = false;
|
||||||
|
|
@ -1213,7 +1323,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'inbox') {
|
if (event.type === 'inbox') {
|
||||||
scheduleTrackedTeamMessageRefresh(event.teamName);
|
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:inbox');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1224,7 +1334,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
seedCurrentRunIdIfMissing();
|
seedCurrentRunIdIfMissing();
|
||||||
scheduleTrackedTeamMessageRefresh(event.teamName);
|
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:lead-message');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1232,22 +1342,47 @@ export function initializeNotificationListeners(): () => void {
|
||||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
teamPresenceRefreshTimers.delete(event.teamName);
|
teamPresenceRefreshTimers.delete(event.teamName);
|
||||||
const current = useStore.getState();
|
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);
|
void current.refreshTeamChangePresence(event.teamName);
|
||||||
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
|
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
|
||||||
teamPresenceRefreshTimers.set(event.teamName, timer);
|
teamPresenceRefreshTimers.set(event.teamName, timer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventReason = buildTeamChangeFanoutReason(event.type);
|
||||||
|
|
||||||
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
||||||
|
noteGlobalRefreshScheduled(
|
||||||
|
pendingTeamListRefreshDiagnostics,
|
||||||
|
event.teamName,
|
||||||
|
eventReason,
|
||||||
|
'fetchTeams',
|
||||||
|
teamListRefreshTimer != null
|
||||||
|
);
|
||||||
if (!teamListRefreshTimer) {
|
if (!teamListRefreshTimer) {
|
||||||
teamListRefreshTimer = setTimeout(() => {
|
teamListRefreshTimer = setTimeout(() => {
|
||||||
teamListRefreshTimer = null;
|
teamListRefreshTimer = null;
|
||||||
|
drainPendingGlobalRefreshDiagnostics(pendingTeamListRefreshDiagnostics, 'fetchTeams');
|
||||||
void useStore.getState().fetchTeams();
|
void useStore.getState().fetchTeams();
|
||||||
}, TEAM_LIST_REFRESH_THROTTLE_MS);
|
}, TEAM_LIST_REFRESH_THROTTLE_MS);
|
||||||
}
|
}
|
||||||
|
|
@ -1255,11 +1390,24 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const shouldRefreshGlobalTasks = event.type === 'task' || event.type === 'config';
|
const shouldRefreshGlobalTasks = event.type === 'task' || event.type === 'config';
|
||||||
|
|
||||||
// Throttled refresh of global tasks list for sidebar.
|
// Throttled refresh of global tasks list for sidebar.
|
||||||
if (shouldRefreshGlobalTasks && !globalTasksRefreshTimer) {
|
if (shouldRefreshGlobalTasks) {
|
||||||
globalTasksRefreshTimer = setTimeout(() => {
|
noteGlobalRefreshScheduled(
|
||||||
globalTasksRefreshTimer = null;
|
pendingGlobalTasksRefreshDiagnostics,
|
||||||
void useStore.getState().fetchAllTasks();
|
event.teamName,
|
||||||
}, GLOBAL_TASKS_REFRESH_THROTTLE_MS);
|
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)) {
|
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.
|
// Per-team throttle (not debounce): keep at most one pending detail refresh per team.
|
||||||
// Debounce would delay indefinitely while inbox messages keep arriving.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
teamRefreshTimers.delete(event.teamName);
|
teamRefreshTimers.delete(event.teamName);
|
||||||
const current = useStore.getState();
|
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 });
|
void current.refreshTeamData(event.teamName, { withDedup: true });
|
||||||
}, TEAM_REFRESH_THROTTLE_MS);
|
}, TEAM_REFRESH_THROTTLE_MS);
|
||||||
teamRefreshTimers.set(event.teamName, timer);
|
teamRefreshTimers.set(event.teamName, timer);
|
||||||
|
|
@ -1301,10 +1474,12 @@ export function initializeNotificationListeners(): () => void {
|
||||||
clearTimeout(teamListRefreshTimer);
|
clearTimeout(teamListRefreshTimer);
|
||||||
teamListRefreshTimer = null;
|
teamListRefreshTimer = null;
|
||||||
}
|
}
|
||||||
|
pendingTeamListRefreshDiagnostics.clear();
|
||||||
if (globalTasksRefreshTimer) {
|
if (globalTasksRefreshTimer) {
|
||||||
clearTimeout(globalTasksRefreshTimer);
|
clearTimeout(globalTasksRefreshTimer);
|
||||||
globalTasksRefreshTimer = null;
|
globalTasksRefreshTimer = null;
|
||||||
}
|
}
|
||||||
|
pendingGlobalTasksRefreshDiagnostics.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
|
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
||||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -4887,6 +4888,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (isCanonicalRun && becameConfigReady) {
|
if (isCanonicalRun && becameConfigReady) {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (isVisibleInActiveTeamSurface(state, progress.teamName)) {
|
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) {
|
if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) {
|
||||||
void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true });
|
void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4939,8 +4951,27 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) {
|
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();
|
void get().fetchTeams();
|
||||||
if (hydratedVisibleTeam) {
|
if (hydratedVisibleTeam) {
|
||||||
|
noteTeamRefreshFanout({
|
||||||
|
teamName: progress.teamName,
|
||||||
|
surface: 'provisioning-progress',
|
||||||
|
phase: 'skipped',
|
||||||
|
reason: 'provisioning:already-hydrated-visible-team',
|
||||||
|
operation: 'refreshTeamData',
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4951,6 +4982,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
// If the user already opened the team tab, reload team data now that
|
// If the user already opened the team tab, reload team data now that
|
||||||
// config.json is guaranteed to exist.
|
// 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) {
|
if (state.selectedTeamName === progress.teamName) {
|
||||||
void state.selectTeam(progress.teamName);
|
void state.selectTeam(progress.teamName);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
148
src/renderer/store/teamRefreshFanoutDiagnostics.ts
Normal file
148
src/renderer/store/teamRefreshFanoutDiagnostics.ts
Normal file
|
|
@ -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<string, number>;
|
||||||
|
recent: TeamRefreshFanoutRecentNote[];
|
||||||
|
lastAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamRefreshFanoutBucket {
|
||||||
|
counts: Record<string, number>;
|
||||||
|
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<string, TeamRefreshFanoutBucket>();
|
||||||
|
|
||||||
|
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<string, TeamRefreshFanoutSnapshot> | null {
|
||||||
|
if (teamName) {
|
||||||
|
return cloneBucket(buckets.get(teamName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Array.from(buckets.entries(), ([key, bucket]) => [key, cloneBucket(bucket)])
|
||||||
|
) as Record<string, TeamRefreshFanoutSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetTeamRefreshFanoutDiagnosticsForTests(): void {
|
||||||
|
buckets.clear();
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,11 @@ vi.mock('@renderer/api', () => ({
|
||||||
|
|
||||||
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
|
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
|
||||||
import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice';
|
import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice';
|
||||||
|
import {
|
||||||
|
__resetTeamRefreshFanoutDiagnosticsForTests,
|
||||||
|
getTeamRefreshFanoutSnapshotForTests,
|
||||||
|
type TeamRefreshFanoutSnapshot,
|
||||||
|
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
|
||||||
describe('team change throttling', () => {
|
describe('team change throttling', () => {
|
||||||
|
|
@ -71,6 +76,7 @@ describe('team change throttling', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
__resetTeamSliceModuleStateForTests();
|
__resetTeamSliceModuleStateForTests();
|
||||||
|
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||||
const fetchTeams = vi.fn(async () => undefined);
|
const fetchTeams = vi.fn(async () => undefined);
|
||||||
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
|
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
|
||||||
const refreshTeamData = vi.fn(async () => undefined);
|
const refreshTeamData = vi.fn(async () => undefined);
|
||||||
|
|
@ -117,6 +123,7 @@ describe('team change throttling', () => {
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
cleanup = null;
|
cleanup = null;
|
||||||
__resetTeamSliceModuleStateForTests();
|
__resetTeamSliceModuleStateForTests();
|
||||||
|
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||||
vi.mocked(console.warn).mockClear();
|
vi.mocked(console.warn).mockClear();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
@ -163,6 +170,48 @@ describe('team change throttling', () => {
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
|
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 () => {
|
it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => {
|
||||||
const state = useStore.getState();
|
const state = useStore.getState();
|
||||||
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||||
|
|
|
||||||
139
test/renderer/store/teamRefreshFanoutDiagnostics.test.ts
Normal file
139
test/renderer/store/teamRefreshFanoutDiagnostics.test.ts
Normal file
|
|
@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,11 @@ import {
|
||||||
selectResolvedMemberForTeamName,
|
selectResolvedMemberForTeamName,
|
||||||
selectResolvedMembersForTeamName,
|
selectResolvedMembersForTeamName,
|
||||||
} from '../../../src/renderer/store/slices/teamSlice';
|
} from '../../../src/renderer/store/slices/teamSlice';
|
||||||
|
import {
|
||||||
|
__resetTeamRefreshFanoutDiagnosticsForTests,
|
||||||
|
getTeamRefreshFanoutSnapshotForTests,
|
||||||
|
type TeamRefreshFanoutSnapshot,
|
||||||
|
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
|
|
@ -198,6 +203,7 @@ describe('teamSlice actions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
__resetTeamSliceModuleStateForTests();
|
__resetTeamSliceModuleStateForTests();
|
||||||
|
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||||
hoisted.list.mockResolvedValue([]);
|
hoisted.list.mockResolvedValue([]);
|
||||||
hoisted.getData.mockResolvedValue(createTeamSnapshot());
|
hoisted.getData.mockResolvedValue(createTeamSnapshot());
|
||||||
hoisted.getMessagesPage.mockResolvedValue({
|
hoisted.getMessagesPage.mockResolvedValue({
|
||||||
|
|
@ -238,6 +244,57 @@ describe('teamSlice actions', () => {
|
||||||
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
|
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 () => {
|
it('maps inbox verify failure to user-friendly text', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
hoisted.sendMessage.mockRejectedValue(new Error('Failed to verify inbox write'));
|
hoisted.sendMessage.mockRejectedValue(new Error('Failed to verify inbox write'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue