diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b2d8f654..4a14cfd5 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -86,6 +86,11 @@ import { hasTeamRefreshBurstDiagnostics, noteTeamRefreshBurst, } from '../team/teamRefreshBurstDiagnostics'; +import { + buildTeamScopedProgressTombstones, + collectTeamScopedStateRemovals, + collectTeamScopedVisibleLoadingResets, +} from '../team/teamScopedStateCleanup'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -293,166 +298,6 @@ function clearTeamScopedTransientState(teamName: string): void { clearTeamScopedSelectorCaches(teamName); } -function collectTeamScopedVisibleLoadingResets( - state: Pick< - TeamSlice, - 'teamMessagesByName' | 'selectedTeamName' | 'selectedTeamLoading' | 'selectedTeamError' - >, - teamName: string -): Partial { - const nextTeamMessagesEntry = state.teamMessagesByName[teamName]; - const nextTeamMessagesByName = - nextTeamMessagesEntry && - (nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder) - ? { - ...state.teamMessagesByName, - [teamName]: { - ...nextTeamMessagesEntry, - loadingHead: false, - loadingOlder: false, - }, - } - : null; - - const shouldResetSelectedSurface = - state.selectedTeamName === teamName && - (state.selectedTeamLoading || state.selectedTeamError != null); - - return { - ...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}), - ...(shouldResetSelectedSurface - ? { - selectedTeamLoading: false, - selectedTeamError: null, - } - : {}), - }; -} - -function omitTeamKey(record: Record, teamName: string): Record | null { - if (!(teamName in record)) { - return null; - } - const next = { ...record }; - delete next[teamName]; - return next; -} - -function collectTeamScopedStateRemovals( - state: Pick< - TeamSlice, - | 'provisioningRuns' - | 'teamDataCacheByName' - | 'teamAgentRuntimeByTeam' - | 'teamMessagesByName' - | 'memberActivityMetaByTeam' - | 'provisioningSnapshotByTeam' - | 'currentProvisioningRunIdByTeam' - | 'currentRuntimeRunIdByTeam' - | 'provisioningStartedAtFloorByTeam' - | 'leadActivityByTeam' - | 'leadContextByTeam' - | 'activeTaskLogActivityByTeam' - | 'activeToolsByTeam' - | 'finishedVisibleByTeam' - | 'toolHistoryByTeam' - | 'memberSpawnStatusesByTeam' - | 'memberSpawnSnapshotsByTeam' - | 'provisioningErrorByTeam' - >, - teamName: string -): Partial { - const nextProvisioningRuns = Object.fromEntries( - Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName) - ) as Record; - const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); - const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName); - const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); - const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); - const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); - const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName); - const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName); - const nextProvisioningStartedAtFloor = omitTeamKey( - state.provisioningStartedAtFloorByTeam, - teamName - ); - const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); - const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); - const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName); - const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); - const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); - const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); - const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName); - const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName); - const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName); - - return { - ...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length - ? { provisioningRuns: nextProvisioningRuns } - : {}), - ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), - ...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}), - ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), - ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), - ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), - ...(nextCurrentProvisioningRunId - ? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId } - : {}), - ...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}), - ...(nextProvisioningStartedAtFloor - ? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor } - : {}), - ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), - ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), - ...(nextActiveTaskLogActivity - ? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity } - : {}), - ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), - ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), - ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), - ...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}), - ...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}), - ...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}), - }; -} - -function buildTeamScopedProgressTombstones( - state: Pick< - TeamSlice, - | 'currentProvisioningRunIdByTeam' - | 'currentRuntimeRunIdByTeam' - | 'ignoredProvisioningRunIds' - | 'ignoredRuntimeRunIds' - | 'provisioningStartedAtFloorByTeam' - >, - teamName: string, - floor: string -): Pick< - TeamSlice, - 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' -> { - const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds }; - const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds }; - - const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName]; - const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName]; - if (currentProvisioningRunId) { - nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName; - } - if (currentRuntimeRunId) { - nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName; - } - - return { - ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds, - ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, - provisioningStartedAtFloorByTeam: { - ...state.provisioningStartedAtFloorByTeam, - [teamName]: floor, - }, - }; -} - function beginInFlightTeamDataRefresh(teamName: string): symbol { const token = Symbol(teamName); const existing = inFlightRefreshTeamDataCalls.get(teamName); diff --git a/src/renderer/store/team/teamScopedStateCleanup.ts b/src/renderer/store/team/teamScopedStateCleanup.ts new file mode 100644 index 00000000..a91e89ef --- /dev/null +++ b/src/renderer/store/team/teamScopedStateCleanup.ts @@ -0,0 +1,190 @@ +interface TeamMessagesLoadingEntry { + loadingHead: boolean; + loadingOlder: boolean; +} + +interface TeamScopedVisibleLoadingResetState< + TTeamMessagesEntry extends TeamMessagesLoadingEntry, +> { + teamMessagesByName: Record; + selectedTeamName: string | null; + selectedTeamLoading: boolean; + selectedTeamError: string | null; +} + +interface TeamScopedProvisioningRun { + teamName: string; +} + +type TeamScopedRecord = Record; + +interface TeamScopedStateRemovalState< + TProvisioningRun extends TeamScopedProvisioningRun = TeamScopedProvisioningRun, +> { + provisioningRuns: Record; + teamDataCacheByName: TeamScopedRecord; + teamAgentRuntimeByTeam: TeamScopedRecord; + teamMessagesByName: TeamScopedRecord; + memberActivityMetaByTeam: TeamScopedRecord; + provisioningSnapshotByTeam: TeamScopedRecord; + currentProvisioningRunIdByTeam: TeamScopedRecord; + currentRuntimeRunIdByTeam: TeamScopedRecord; + provisioningStartedAtFloorByTeam: TeamScopedRecord; + leadActivityByTeam: TeamScopedRecord; + leadContextByTeam: TeamScopedRecord; + activeTaskLogActivityByTeam: TeamScopedRecord; + activeToolsByTeam: TeamScopedRecord; + finishedVisibleByTeam: TeamScopedRecord; + toolHistoryByTeam: TeamScopedRecord; + memberSpawnStatusesByTeam: TeamScopedRecord; + memberSpawnSnapshotsByTeam: TeamScopedRecord; + provisioningErrorByTeam: TeamScopedRecord; +} + +type TeamScopedStateRemovalKey = keyof TeamScopedStateRemovalState; + +interface TeamScopedProgressTombstoneState { + currentProvisioningRunIdByTeam: Record; + currentRuntimeRunIdByTeam: Record; + ignoredProvisioningRunIds: Record; + ignoredRuntimeRunIds: Record; + provisioningStartedAtFloorByTeam: Record; +} + +export function collectTeamScopedVisibleLoadingResets< + TTeamMessagesEntry extends TeamMessagesLoadingEntry, +>( + state: TeamScopedVisibleLoadingResetState, + teamName: string +): Partial> { + const nextTeamMessagesEntry = state.teamMessagesByName[teamName]; + const nextTeamMessagesByName = + nextTeamMessagesEntry && + (nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder) + ? { + ...state.teamMessagesByName, + [teamName]: { + ...nextTeamMessagesEntry, + loadingHead: false, + loadingOlder: false, + } as TTeamMessagesEntry, + } + : null; + + const shouldResetSelectedSurface = + state.selectedTeamName === teamName && + (state.selectedTeamLoading || state.selectedTeamError != null); + + return { + ...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}), + ...(shouldResetSelectedSurface + ? { + selectedTeamLoading: false, + selectedTeamError: null, + } + : {}), + }; +} + +function omitTeamKey>( + record: TRecord, + teamName: string +): TRecord | null { + if (!(teamName in record)) { + return null; + } + const next = { ...record }; + delete next[teamName]; + return next; +} + +export function collectTeamScopedStateRemovals( + state: TState, + teamName: string +): Partial> { + const nextProvisioningRuns = Object.fromEntries( + Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName) + ) as TState['provisioningRuns']; + const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); + const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName); + const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); + const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); + const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); + const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName); + const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName); + const nextProvisioningStartedAtFloor = omitTeamKey( + state.provisioningStartedAtFloorByTeam, + teamName + ); + const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); + const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); + const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName); + const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); + const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); + const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); + const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName); + const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName); + const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName); + + return { + ...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length + ? { provisioningRuns: nextProvisioningRuns } + : {}), + ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), + ...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}), + ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), + ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), + ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), + ...(nextCurrentProvisioningRunId + ? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId } + : {}), + ...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}), + ...(nextProvisioningStartedAtFloor + ? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor } + : {}), + ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), + ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), + ...(nextActiveTaskLogActivity + ? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity } + : {}), + ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), + ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), + ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), + ...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}), + ...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}), + ...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}), + }; +} + +export function buildTeamScopedProgressTombstones( + state: TState, + teamName: string, + floor: string +): Pick< + TState, + 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' +> { + const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds }; + const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds }; + + const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName]; + const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName]; + if (currentProvisioningRunId) { + nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName; + } + if (currentRuntimeRunId) { + nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName; + } + + return { + ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds, + ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + provisioningStartedAtFloorByTeam: { + ...state.provisioningStartedAtFloorByTeam, + [teamName]: floor, + }, + } as Pick< + TState, + 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' + >; +} diff --git a/test/renderer/store/teamScopedStateCleanup.test.ts b/test/renderer/store/teamScopedStateCleanup.test.ts new file mode 100644 index 00000000..e071fd63 --- /dev/null +++ b/test/renderer/store/teamScopedStateCleanup.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTeamScopedProgressTombstones, + collectTeamScopedStateRemovals, + collectTeamScopedVisibleLoadingResets, +} from '../../../src/renderer/store/team/teamScopedStateCleanup'; + +const teamScopedRecordKeys = [ + 'teamDataCacheByName', + 'teamAgentRuntimeByTeam', + 'teamMessagesByName', + 'memberActivityMetaByTeam', + 'provisioningSnapshotByTeam', + 'currentProvisioningRunIdByTeam', + 'currentRuntimeRunIdByTeam', + 'provisioningStartedAtFloorByTeam', + 'leadActivityByTeam', + 'leadContextByTeam', + 'activeTaskLogActivityByTeam', + 'activeToolsByTeam', + 'finishedVisibleByTeam', + 'toolHistoryByTeam', + 'memberSpawnStatusesByTeam', + 'memberSpawnSnapshotsByTeam', + 'provisioningErrorByTeam', +] as const; + +function buildRecord(label: string): Record { + return { + 'my-team': `${label}:mine`, + 'other-team': `${label}:other`, + }; +} + +function buildRemovalState(): Parameters[0] { + return { + provisioningRuns: { + 'run-mine-1': { teamName: 'my-team' }, + 'run-other': { teamName: 'other-team' }, + 'run-mine-2': { teamName: 'my-team' }, + }, + teamDataCacheByName: buildRecord('teamDataCacheByName'), + teamAgentRuntimeByTeam: buildRecord('teamAgentRuntimeByTeam'), + teamMessagesByName: buildRecord('teamMessagesByName'), + memberActivityMetaByTeam: buildRecord('memberActivityMetaByTeam'), + provisioningSnapshotByTeam: buildRecord('provisioningSnapshotByTeam'), + currentProvisioningRunIdByTeam: buildRecord('currentProvisioningRunIdByTeam'), + currentRuntimeRunIdByTeam: buildRecord('currentRuntimeRunIdByTeam'), + provisioningStartedAtFloorByTeam: buildRecord('provisioningStartedAtFloorByTeam'), + leadActivityByTeam: buildRecord('leadActivityByTeam'), + leadContextByTeam: buildRecord('leadContextByTeam'), + activeTaskLogActivityByTeam: buildRecord('activeTaskLogActivityByTeam'), + activeToolsByTeam: buildRecord('activeToolsByTeam'), + finishedVisibleByTeam: buildRecord('finishedVisibleByTeam'), + toolHistoryByTeam: buildRecord('toolHistoryByTeam'), + memberSpawnStatusesByTeam: buildRecord('memberSpawnStatusesByTeam'), + memberSpawnSnapshotsByTeam: buildRecord('memberSpawnSnapshotsByTeam'), + provisioningErrorByTeam: buildRecord('provisioningErrorByTeam'), + }; +} + +describe('teamScopedStateCleanup', () => { + it('resets visible team loading and message loading flags for the scoped team', () => { + const otherEntry = { + loadingHead: true, + loadingOlder: false, + marker: 'other', + }; + const patch = collectTeamScopedVisibleLoadingResets( + { + teamMessagesByName: { + 'my-team': { + loadingHead: true, + loadingOlder: true, + marker: 'mine', + }, + 'other-team': otherEntry, + }, + selectedTeamName: 'my-team', + selectedTeamLoading: true, + selectedTeamError: 'Boom', + }, + 'my-team' + ); + + expect(patch).toEqual({ + teamMessagesByName: { + 'my-team': { + loadingHead: false, + loadingOlder: false, + marker: 'mine', + }, + 'other-team': otherEntry, + }, + selectedTeamLoading: false, + selectedTeamError: null, + }); + }); + + it('does not emit visible loading changes when the scoped team is already idle', () => { + const patch = collectTeamScopedVisibleLoadingResets( + { + teamMessagesByName: { + 'my-team': { + loadingHead: false, + loadingOlder: false, + }, + }, + selectedTeamName: 'other-team', + selectedTeamLoading: false, + selectedTeamError: null, + }, + 'my-team' + ); + + expect(patch).toEqual({}); + }); + + it('removes scoped team records and provisioning runs while preserving other teams', () => { + const patch = collectTeamScopedStateRemovals(buildRemovalState(), 'my-team'); + + expect(patch.provisioningRuns).toEqual({ + 'run-other': { teamName: 'other-team' }, + }); + for (const key of teamScopedRecordKeys) { + expect(patch[key]).toEqual({ + 'other-team': `${key}:other`, + }); + } + }); + + it('does not emit removal changes when the team is absent', () => { + const state = buildRemovalState(); + const patch = collectTeamScopedStateRemovals(state, 'missing-team'); + + expect(patch).toEqual({}); + }); + + it('tombstones current provisioning and runtime run ids for the scoped team', () => { + const tombstones = buildTeamScopedProgressTombstones( + { + currentProvisioningRunIdByTeam: { + 'my-team': 'provisioning-run-1', + 'other-team': 'provisioning-run-2', + }, + currentRuntimeRunIdByTeam: { + 'my-team': 'runtime-run-1', + }, + ignoredProvisioningRunIds: { + old: 'old-team', + }, + ignoredRuntimeRunIds: { + 'old-runtime': 'old-team', + }, + provisioningStartedAtFloorByTeam: { + 'other-team': '2026-01-01T00:00:00.000Z', + }, + }, + 'my-team', + '2026-05-22T10:00:00.000Z' + ); + + expect(tombstones).toEqual({ + ignoredProvisioningRunIds: { + old: 'old-team', + 'provisioning-run-1': 'my-team', + }, + ignoredRuntimeRunIds: { + 'old-runtime': 'old-team', + 'runtime-run-1': 'my-team', + }, + provisioningStartedAtFloorByTeam: { + 'other-team': '2026-01-01T00:00:00.000Z', + 'my-team': '2026-05-22T10:00:00.000Z', + }, + }); + }); + + it('still records a floor when there are no current run ids to tombstone', () => { + const tombstones = buildTeamScopedProgressTombstones( + { + currentProvisioningRunIdByTeam: {}, + currentRuntimeRunIdByTeam: { + 'my-team': null, + }, + ignoredProvisioningRunIds: {}, + ignoredRuntimeRunIds: {}, + provisioningStartedAtFloorByTeam: {}, + }, + 'my-team', + '2026-05-22T10:00:00.000Z' + ); + + expect(tombstones).toEqual({ + ignoredProvisioningRunIds: {}, + ignoredRuntimeRunIds: {}, + provisioningStartedAtFloorByTeam: { + 'my-team': '2026-05-22T10:00:00.000Z', + }, + }); + }); +});