diff --git a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.client.test.tsx index 568ac3382..4476b6c2a 100644 --- a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.client.test.tsx @@ -59,6 +59,7 @@ describe('StepComputeProvider', () => { persistedDefaultProvider: null, setupSatisfied: false, setupSatisfiedByRuntimeEnv: false, + excludedProviders: [], workerImage: { envVarName: 'DOCKER_WORKER_IMAGE', label: 'Worker Image', @@ -100,4 +101,45 @@ describe('StepComputeProvider', () => { expect(screen.getByRole('button', { name: /daytona/i })).toBeTruthy(); expect(screen.getByRole('button', { name: /local docker/i })).toBeTruthy(); }); + + it('hides providers excluded by the deployment', () => { + const computeSetup: SetupComputeStatus = { + selectedProvider: null, + preselectedProvider: 'modal', + runtimeDefaultProvider: 'modal', + persistedDefaultProvider: null, + setupSatisfied: false, + setupSatisfiedByRuntimeEnv: false, + excludedProviders: ['docker'], + workerImage: { + envVarName: 'DOCKER_WORKER_IMAGE', + label: 'Worker Image', + runtimeSatisfied: false, + savedSatisfied: false, + hostedImageRef: null, + hostedReady: false, + }, + providers: [ + buildProvider({ + provider: 'modal', + label: 'Modal', + infrastructureSatisfied: false, + }), + buildProvider({ + provider: 'docker', + label: 'Local Docker', + infrastructureSatisfied: true, + }), + ], + }; + + render( + , + ); + + expect(screen.getByRole('button', { name: /modal/i })).toBeTruthy(); + expect( + screen.queryByRole('button', { name: /local docker/i }), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx index 806bea9c3..0e5945b09 100644 --- a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx @@ -30,11 +30,13 @@ export function StepComputeProvider({ onBack?: () => void; disabled?: boolean; }) { - // All providers are offered. Hosted providers whose worker image is not yet - // available are still selectable: their config step collects a shared worker - // image (and can auto-derive or provision the provider artifact from it), so - // the operator is no longer dead-ended by missing infrastructure. - const availableProviders = computeSetup.providers; + // Hosted providers whose worker image is not yet available remain selectable: + // their config step can collect or provision the missing infrastructure. + // Explicit deployment exclusions are stronger and must not be offered. + const excludedProviders = new Set(computeSetup.excludedProviders ?? []); + const availableProviders = computeSetup.providers.filter( + (provider) => !excludedProviders.has(provider.provider), + ); return (
diff --git a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx index 29b597d76..52e3a1486 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx @@ -434,6 +434,83 @@ describe('useSetupFlow', () => { }); }); + it('requires a new choice when the saved compute provider is excluded', async () => { + mockStatus({ + hasSlack: true, + authSetup: { + setupSatisfiedByRuntimeEnv: false, + selectedProvider: 'slack', + preselectedProvider: 'slack', + runtimeConfiguredProvider: null, + runtimeConfiguredProviders: [], + lockReason: null, + providers: [ + { + id: 'slack', + label: 'Slack', + fields: [], + runtimeSatisfied: true, + savedSatisfied: false, + setupSatisfied: true, + }, + ], + }, + modelSetup: { + setupSatisfied: true, + setupSatisfiedByRuntimeEnv: true, + preselectedProvider: 'openrouter', + }, + computeSetup: { + setupSatisfied: true, + selectedProvider: 'modal', + preselectedProvider: 'modal', + runtimeDefaultProvider: 'modal', + persistedDefaultProvider: null, + excludedProviders: ['docker'], + providers: [ + { + provider: 'modal', + label: 'Modal', + description: '', + supportsSnapshots: true, + fields: [], + runtimeConfigSatisfied: true, + savedConfigSatisfied: false, + configSatisfied: true, + }, + ], + }, + sourceControlSetup: { + setupSatisfied: true, + setupSatisfiedByRuntimeEnv: false, + selectedProvider: 'github', + preselectedProvider: 'github', + runtimeConfiguredProvider: null, + runtimeConfiguredProviders: [], + lockReason: null, + connectedProvider: 'github', + providers: [], + }, + setupNewState: { + authProvider: 'slack', + modelProvider: 'openrouter', + computeProvider: 'docker', + sourceControlProvider: 'github', + selectedRepositoryIds: [], + onboardingTaskId: null, + onboardingTaskStartedAt: null, + slackChannel: null, + slackThreadTs: null, + }, + }); + + const { result } = renderHook(() => useSetupFlow()); + + await waitFor(() => { + expect(result.current.step).toBe('compute-provider'); + }); + }); + it('shows compute-config when a compute provider is chosen but not yet configured', async () => { mockStatus({ hasSlack: true, diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index 74786918f..e9ca32f92 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.ts +++ b/apps/web/src/app/(onboarding)/setup/hooks.ts @@ -337,6 +337,10 @@ export function useSetupFlow( status.setupNewState.authProvider ?? status.authSetup.runtimeConfiguredProvider ?? status.authSetup.selectedProvider; + const selectedComputeProvider = status.computeSetup.selectedProvider; + const hasStaleComputeProvider = + status.setupNewState.computeProvider !== null && + selectedComputeProvider !== status.setupNewState.computeProvider; switch (candidate) { case 'welcome': @@ -389,17 +393,15 @@ export function useSetupFlow( return activeQualificationBlock === null; case 'compute-provider': return ( - status.computeSetup.setupSatisfied || - status.setupNewState.computeProvider != null + !hasStaleComputeProvider && + (status.computeSetup.setupSatisfied || + selectedComputeProvider !== null) ); case 'compute-config': { - if (status.computeSetup.setupSatisfied) { + if (hasStaleComputeProvider || status.computeSetup.setupSatisfied) { return true; } - const selectedComputeProvider = - status.setupNewState.computeProvider ?? null; - if (!selectedComputeProvider) { return true; } diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index ca40c8756..048a7d904 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -664,6 +664,32 @@ describe('setup-new compute config commands', () => { expect(result.runtimeComputeConfig.defaultProvider).toBe('docker'); }); + it('rejects an excluded provider choice', async () => { + vi.stubEnv('EXCLUDED_COMPUTE_PROVIDERS', 'docker'); + + await expect( + saveSetupNewComputeProviderChoiceCommand(buildMockAuth(), { + provider: 'docker', + }), + ).rejects.toThrow('Selected sandbox provider is unavailable.'); + }); + + it('rejects configuration for an excluded provider', async () => { + vi.stubEnv('EXCLUDED_COMPUTE_PROVIDERS', 'modal'); + + await expect( + saveSetupNewComputeConfigCommand(buildMockAuth(), { + provider: 'modal', + values: { + MODAL_TOKEN_ID: 'token-id', + MODAL_TOKEN_SECRET: 'token-secret', + }, + }), + ).rejects.toThrow('Selected sandbox provider is unavailable.'); + + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + it('persists a Modal base image derived from the worker image', async () => { vi.stubEnv( 'DOCKER_WORKER_IMAGE', @@ -985,6 +1011,11 @@ describe('setup-new onboarding task start command', () => { }, ]), ); + if (setupNewState?.computeProvider) { + mockTxSelect.mockReturnValueOnce( + createSelectChain([{ runtimeComputeConfig: null }]), + ); + } mockTxSelect.mockReturnValueOnce( createSelectChain(slackInstallation ? [slackInstallation] : []), ); @@ -1072,6 +1103,22 @@ describe('setup-new onboarding task start command', () => { ); }); + it('rejects a stale excluded compute provider before launch', async () => { + vi.stubEnv('EXCLUDED_COMPUTE_PROVIDERS', 'docker'); + mockOnboardingTransaction({ + slackInstallation: null, + setupNewState: { computeProvider: 'docker' }, + }); + + await expect( + startSetupNewOnboardingTaskCommand(buildMockAuth()), + ).rejects.toThrow( + 'Selected sandbox provider is no longer available. Choose another provider before starting setup.', + ); + + expect(enqueueTask).not.toHaveBeenCalled(); + }); + it('creates the onboarding task without dispatching while first-time E2B provisioning is running', async () => { vi.stubEnv('E2B_TEMPLATE_ID', ''); mockGetPersistedEnvironmentVariableNames.mockResolvedValue(['E2B_API_KEY']); diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 5da5deb0e..be177bdaa 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -80,6 +80,7 @@ import { SETUP_COMPUTE_PROVISIONING_STATE_FIELDS, SHARED_WORKER_IMAGE_ENV_VAR, type SetupAuthProviderId, + type SetupComputeStatus, type SetupModelProviderId, type SetupProvisionableComputeProvider, type SourceControlProvider, @@ -539,6 +540,19 @@ type SetupOnboardingComputeGate = { error: string | null; }; +function findAvailableSetupComputeProvider( + computeSetup: SetupComputeStatus, + provider: ComputeProvider, +) { + if (computeSetup.excludedProviders?.includes(provider)) { + return undefined; + } + + return computeSetup.providers.find( + (candidate) => candidate.provider === provider, + ); +} + async function getSetupOnboardingComputeGate( setupNewState: PersistedSetupNewState, executor: DatabaseOrTransaction, @@ -1537,8 +1551,9 @@ export async function saveSetupNewComputeProviderChoiceCommand( persistedComputeConfig: persistedRuntimeComputeConfig, selectedProvider: input.provider, }); - const providerStatus = computeSetup.providers.find( - (candidate) => candidate.provider === input.provider, + const providerStatus = findAvailableSetupComputeProvider( + computeSetup, + input.provider, ); if (!providerStatus) { @@ -1602,12 +1617,6 @@ export async function saveSetupNewComputeConfigCommand( templateRef: string; } | null = null; - if (isSetupProvisionableComputeProvider(input.provider)) { - await acquireComputeProvisioningLock(input.provider, tx); - } - - await purgeSavedDeploymentWorkerImage(tx); - const [ currentState, persistedRuntimeComputeConfig, @@ -1635,14 +1644,21 @@ export async function saveSetupNewComputeConfigCommand( persistedComputeConfig: persistedRuntimeComputeConfig, selectedProvider: input.provider, }); - const providerStatus = computeSetup.providers.find( - (candidate) => candidate.provider === input.provider, + const providerStatus = findAvailableSetupComputeProvider( + computeSetup, + input.provider, ); if (!providerStatus) { throw new Error('Selected sandbox provider is unavailable.'); } + if (isSetupProvisionableComputeProvider(input.provider)) { + await acquireComputeProvisioningLock(input.provider, tx); + } + + await purgeSavedDeploymentWorkerImage(tx); + // Derive MODAL_BASE_IMAGE_REF when not env-provided or already saved. // Form submissions are ignored (deployment-managed like E2B/Daytona // artifacts). @@ -2266,6 +2282,22 @@ export async function startSetupNewOnboardingTaskCommand( }; } + if (currentState.computeProvider) { + const persistedRuntimeComputeConfig = + await getPersistedRuntimeComputeConfig(tx); + const computeSetup = buildSetupComputeStatus({ + runtimeEnv: process.env, + persistedComputeConfig: persistedRuntimeComputeConfig, + selectedProvider: currentState.computeProvider, + }); + + if (computeSetup.selectedProvider !== currentState.computeProvider) { + throw new Error( + 'Selected sandbox provider is no longer available. Choose another provider before starting setup.', + ); + } + } + const { normalizedRepositoryIds, selectedRepositories } = await resolveSelectedRepositories(currentState.selectedRepositoryIds); await assertHasCommittedRepositorySelection(normalizedRepositoryIds);