From 993982311d74ea9f03dddd412c056af865565a87 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:08:11 +0300 Subject: [PATCH] refactor(team): extract provisioning state rules --- src/renderer/store/slices/teamSlice.ts | 40 +++--------- .../store/team/teamProvisioningStateRules.ts | 44 +++++++++++++ .../store/teamProvisioningStateRules.test.ts | 61 +++++++++++++++++++ 3 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 src/renderer/store/team/teamProvisioningStateRules.ts create mode 100644 test/renderer/store/teamProvisioningStateRules.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index bf6b7dc6..d0b14d51 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -82,6 +82,11 @@ import { clearPendingReplyRefreshWaits, setPendingReplyRefreshEnabled, } from '../team/teamPendingReplyWaits'; +import { + isActiveProvisioningState, + isTerminalProvisioningState, + shouldIgnoreProvisioningProgressRegression, +} from '../team/teamProvisioningStateRules'; import { clearAllTeamRefreshBurstDiagnostics, clearTeamRefreshBurstDiagnostics, @@ -532,33 +537,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const ACTIVE_PROVISIONING_STATES = new Set([ - 'validating', - 'spawning', - 'configuring', - 'assembling', - 'finalizing', - 'verifying', -]); -const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']); - -function shouldIgnoreProvisioningProgressRegression( - currentState: TeamProvisioningProgress['state'], - nextState: TeamProvisioningProgress['state'] -): boolean { - if (currentState === 'ready') { - return nextState !== 'ready' && nextState !== 'disconnected'; - } - if ( - currentState === 'failed' || - currentState === 'cancelled' || - currentState === 'disconnected' - ) { - return nextState !== currentState; - } - return false; -} - function isPendingProvisioningRunId(runId: string): boolean { return runId.startsWith('pending:'); } @@ -698,12 +676,12 @@ async function pollProvisioningStatus( for (let attempt = 1; attempt <= maxAttempts; attempt++) { const state = getState(); const current = state.provisioningRuns[runId]; - if (current && TERMINAL_PROVISIONING_STATES.has(current.state)) { + if (current && isTerminalProvisioningState(current.state)) { return; } try { const progress = await state.getProvisioningStatus(runId); - if (TERMINAL_PROVISIONING_STATES.has(progress.state)) { + if (isTerminalProvisioningState(progress.state)) { return; } } catch (error) { @@ -2345,7 +2323,7 @@ export function isTeamProvisioningActive( teamName: string ): boolean { const current = getCurrentProvisioningProgressForTeam(state, teamName); - return current != null && ACTIVE_PROVISIONING_STATES.has(current.state); + return current != null && isActiveProvisioningState(current.state); } function loadAllLaunchParams(): Record { @@ -5342,7 +5320,7 @@ export const createTeamSlice: StateCreator = (set, } } - if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) { + if (isCanonicalRun && isTerminalProvisioningState(progress.state)) { set((prev) => { const next = { ...prev.memberSpawnStatusesByTeam }; const nextSnapshots = { ...prev.memberSpawnSnapshotsByTeam }; diff --git a/src/renderer/store/team/teamProvisioningStateRules.ts b/src/renderer/store/team/teamProvisioningStateRules.ts new file mode 100644 index 00000000..3c574141 --- /dev/null +++ b/src/renderer/store/team/teamProvisioningStateRules.ts @@ -0,0 +1,44 @@ +import type { TeamProvisioningProgress } from '@shared/types'; + +type TeamProvisioningProgressState = TeamProvisioningProgress['state']; + +const ACTIVE_PROVISIONING_STATES: ReadonlySet = new Set([ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]); + +const TERMINAL_PROVISIONING_STATES: ReadonlySet = new Set([ + 'ready', + 'failed', + 'disconnected', + 'cancelled', +]); + +export function isActiveProvisioningState(state: TeamProvisioningProgressState): boolean { + return ACTIVE_PROVISIONING_STATES.has(state); +} + +export function isTerminalProvisioningState(state: TeamProvisioningProgressState): boolean { + return TERMINAL_PROVISIONING_STATES.has(state); +} + +export function shouldIgnoreProvisioningProgressRegression( + currentState: TeamProvisioningProgressState, + nextState: TeamProvisioningProgressState +): boolean { + if (currentState === 'ready') { + return nextState !== 'ready' && nextState !== 'disconnected'; + } + if ( + currentState === 'failed' || + currentState === 'cancelled' || + currentState === 'disconnected' + ) { + return nextState !== currentState; + } + return false; +} diff --git a/test/renderer/store/teamProvisioningStateRules.test.ts b/test/renderer/store/teamProvisioningStateRules.test.ts new file mode 100644 index 00000000..cc2e4dc8 --- /dev/null +++ b/test/renderer/store/teamProvisioningStateRules.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + isActiveProvisioningState, + isTerminalProvisioningState, + shouldIgnoreProvisioningProgressRegression, +} from '../../../src/renderer/store/team/teamProvisioningStateRules'; + +import type { TeamProvisioningProgress } from '../../../src/shared/types'; + +type ProgressState = TeamProvisioningProgress['state']; + +const activeStates: ProgressState[] = [ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]; + +const terminalStates: ProgressState[] = ['ready', 'failed', 'disconnected', 'cancelled']; + +describe('teamProvisioningStateRules', () => { + it('classifies active provisioning states', () => { + for (const state of activeStates) { + expect(isActiveProvisioningState(state), state).toBe(true); + expect(isTerminalProvisioningState(state), state).toBe(false); + } + }); + + it('classifies terminal provisioning states', () => { + for (const state of terminalStates) { + expect(isTerminalProvisioningState(state), state).toBe(true); + expect(isActiveProvisioningState(state), state).toBe(false); + } + }); + + it('allows active state progressions and regressions to be processed', () => { + expect(shouldIgnoreProvisioningProgressRegression('spawning', 'validating')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('validating', 'spawning')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('verifying', 'ready')).toBe(false); + }); + + it('prevents ready from regressing except to disconnected', () => { + expect(shouldIgnoreProvisioningProgressRegression('ready', 'validating')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'failed')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'cancelled')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'ready')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'disconnected')).toBe(false); + }); + + it('locks failed, cancelled, and disconnected to their current terminal state', () => { + expect(shouldIgnoreProvisioningProgressRegression('failed', 'failed')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('failed', 'ready')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'cancelled')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'spawning')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'disconnected')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'ready')).toBe(true); + }); +});