diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/config.test.ts similarity index 60% rename from apps/web/src/trpc/commands/setup-new/index.test.ts rename to apps/web/src/trpc/commands/setup-new/config.test.ts index 7d569df5..76a65082 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/config.test.ts @@ -177,25 +177,7 @@ import { saveSetupNewComputeProviderChoiceCommand, saveSetupNewSourceControlConfigCommand, saveSetupNewSourceControlProviderChoiceCommand, - startSetupNewOnboardingTaskCommand, -} from './index'; -import { TaskPayloadKind } from '@roomote/types'; -import { enqueueCloudTask } from '@roomote/cloud-agents/server'; -import { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; -import { resolveTelegramRuntimeCredentials } from '@roomote/db/server'; -import { - createTeamsCommunicationProviderFromRuntimeCredentials, - findTelegramPrimaryChatId, - findTeamsPrimaryConversation, -} from '@roomote/sdk/server'; -import { SlackNotifier } from '@roomote/slack'; -import { getRepositories } from '@/lib/server'; -import { - appendEnvironmentDefinitionGuidance, - buildSetupNewKickoffPrompt, - buildSetupNewWorkspacePayload, - normalizeRepositorySelection, -} from '@/lib/setup-new'; +} from './config'; function buildMockAuth( overrides: Partial = {}, @@ -910,507 +892,3 @@ describe('setup-new compute config commands', () => { ); }); }); - -describe('setup-new onboarding task start command', () => { - const insertOnConflictMock = vi.fn(async () => undefined); - const insertValuesMock = vi.fn(() => ({ - onConflictDoUpdate: insertOnConflictMock, - })); - const txExecuteMock = vi.fn(async () => undefined); - - function mockOnboardingTransaction({ - slackInstallation, - slackUserMapping, - setupNewState, - }: { - slackInstallation: { botAccessToken: string; teamId: string } | null; - slackUserMapping?: { slackUserId: string } | null; - setupNewState?: { - selectedModelId?: string | null; - selectedRepositoryIds?: string[]; - version?: number; - }; - }) { - mockDbTransaction.mockImplementation(async (callback) => { - const tx = { - execute: txExecuteMock, - select: mockTxSelect, - insert: vi.fn(() => ({ values: insertValuesMock })), - }; - - mockTxSelect.mockReset(); - mockTxSelect.mockReturnValueOnce( - createSelectChain([ - { - setupNewState: { - version: 1, - selectedRepositoryIds: ['repo-1'], - ...setupNewState, - }, - }, - ]), - ); - mockTxSelect.mockReturnValueOnce( - createSelectChain(slackInstallation ? [slackInstallation] : []), - ); - - if (slackInstallation) { - mockTxSelect.mockReturnValueOnce( - createSelectChain(slackUserMapping ? [slackUserMapping] : []), - ); - } - - return callback(tx); - }); - } - - beforeEach(() => { - vi.clearAllMocks(); - mockTxSelect.mockReset(); - - vi.mocked(getRepositories).mockResolvedValue([ - { id: 'repo-1', fullName: 'acme/api' }, - ] as Awaited>); - vi.mocked(normalizeRepositorySelection).mockReturnValue(['repo-1']); - vi.mocked(buildSetupNewWorkspacePayload).mockReturnValue({ - repo: 'acme/api', - }); - vi.mocked(buildSetupNewKickoffPrompt).mockReturnValue('kickoff prompt'); - vi.mocked(appendEnvironmentDefinitionGuidance).mockReturnValue( - 'kickoff prompt', - ); - vi.mocked(resolveTelegramRuntimeCredentials).mockResolvedValue({ - botToken: null, - webhookSecret: null, - botUsername: null, - } as Awaited>); - vi.mocked(findTelegramPrimaryChatId).mockResolvedValue(null); - vi.mocked(findTeamsPrimaryConversation).mockResolvedValue(null); - vi.mocked( - createTeamsCommunicationProviderFromRuntimeCredentials, - ).mockResolvedValue(null); - vi.mocked(enqueueCloudTask).mockResolvedValue({ - taskId: 'task-onboarding-1', - id: 'cloud-job-1', - } as unknown as Awaited>); - }); - - it('launches a web onboarding task when no Slack workspace is connected', async () => { - mockOnboardingTransaction({ slackInstallation: null }); - - const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(result.taskId).toBe('task-onboarding-1'); - expect(enqueueCloudTask).toHaveBeenCalledTimes(1); - expect(enqueueCloudTask).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - type: TaskPayloadKind.StandardTask, - payload: expect.objectContaining({ - repo: 'acme/api', - description: 'kickoff prompt', - visibleInTranscript: false, - }), - }), - initiator: { kind: 'user', userId: 'setup-test-user' }, - workflow: 'setup_onboarding', - surface: 'web', - trigger: 'manual', - }), - ); - expect(SlackNotifier).not.toHaveBeenCalled(); - expect(insertValuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewState: expect.objectContaining({ - onboardingTaskId: 'task-onboarding-1', - slackTeamId: null, - slackChannel: null, - slackThreadTs: null, - }), - }), - ); - }); - - it('falls back to a Telegram kickoff when no Slack workspace is connected but a primary chat exists', async () => { - mockOnboardingTransaction({ slackInstallation: null }); - - const telegramPostMessage = vi.fn(async () => ({ - provider: 'telegram' as const, - channelId: '8846357662', - messageId: '900', - })); - - vi.mocked(resolveTelegramRuntimeCredentials).mockResolvedValue({ - botToken: 'bot-token', - webhookSecret: null, - botUsername: null, - } as Awaited>); - vi.mocked(findTelegramPrimaryChatId).mockResolvedValue('8846357662'); - vi.mocked(TelegramCommunicationProvider).mockImplementation(function ( - this: unknown, - ) { - return { - postMessage: telegramPostMessage, - } as unknown as TelegramCommunicationProvider; - }); - - const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(result.taskId).toBe('task-onboarding-1'); - expect(telegramPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ channelId: '8846357662' }), - ); - expect(SlackNotifier).not.toHaveBeenCalled(); - expect(enqueueCloudTask).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - type: TaskPayloadKind.StandardTask, - payload: expect.objectContaining({ - description: 'kickoff prompt', - visibleInTranscript: false, - communicationProvider: 'telegram', - communicationChannelId: '8846357662', - communicationMessageId: '900', - }), - }), - initiator: { kind: 'user', userId: 'setup-test-user' }, - workflow: 'setup_onboarding', - surface: 'web', - trigger: 'manual', - }), - ); - expect(insertValuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewState: expect.objectContaining({ - onboardingTaskId: 'task-onboarding-1', - slackChannel: null, - chatHandoffProvider: 'telegram', - chatHandoffChannelId: '8846357662', - chatHandoffThreadId: '900', - chatHandoffServiceUrl: null, - }), - }), - ); - }); - - it('falls back to a Teams kickoff when no Slack or Telegram destination exists but a primary conversation was captured', async () => { - mockOnboardingTransaction({ slackInstallation: null }); - - const teamsPostMessage = vi.fn(async () => ({ - provider: 'teams' as const, - channelId: '19:channel@thread.tacv2', - messageId: '1751000000000', - })); - - vi.mocked(findTeamsPrimaryConversation).mockResolvedValue({ - conversationId: '19:channel@thread.tacv2', - serviceUrl: 'https://smba.trafficmanager.net/amer/', - conversationType: 'channel', - }); - vi.mocked( - createTeamsCommunicationProviderFromRuntimeCredentials, - ).mockResolvedValue({ - postMessage: teamsPostMessage, - } as unknown as Awaited< - ReturnType - >); - - const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(result.taskId).toBe('task-onboarding-1'); - expect(teamsPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - channelId: '19:channel@thread.tacv2', - serviceUrl: 'https://smba.trafficmanager.net/amer/', - }), - ); - expect(SlackNotifier).not.toHaveBeenCalled(); - expect(enqueueCloudTask).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - type: TaskPayloadKind.StandardTask, - payload: expect.objectContaining({ - description: 'kickoff prompt', - visibleInTranscript: false, - communicationProvider: 'teams', - communicationChannelId: '19:channel@thread.tacv2', - communicationMessageId: '1751000000000', - communicationThreadId: '1751000000000', - communicationServiceUrl: 'https://smba.trafficmanager.net/amer/', - }), - }), - initiator: { kind: 'user', userId: 'setup-test-user' }, - workflow: 'setup_onboarding', - surface: 'web', - trigger: 'manual', - }), - ); - expect(insertValuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewState: expect.objectContaining({ - onboardingTaskId: 'task-onboarding-1', - slackChannel: null, - chatHandoffProvider: 'teams', - chatHandoffChannelId: '19:channel@thread.tacv2', - chatHandoffThreadId: '1751000000000', - chatHandoffServiceUrl: 'https://smba.trafficmanager.net/amer/', - }), - }), - ); - }); - - it('falls back to a web onboarding task when a Teams conversation exists but bot credentials are missing', async () => { - mockOnboardingTransaction({ slackInstallation: null }); - - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - vi.mocked(findTeamsPrimaryConversation).mockResolvedValue({ - conversationId: '19:channel@thread.tacv2', - serviceUrl: 'https://smba.trafficmanager.net/amer/', - conversationType: 'channel', - }); - - try { - const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(result.taskId).toBe('task-onboarding-1'); - expect(enqueueCloudTask).toHaveBeenCalledTimes(1); - - const enqueueInput = vi.mocked(enqueueCloudTask).mock.calls[0]?.[0]; - expect(enqueueInput?.task.payload).toBeDefined(); - expect(enqueueInput?.task.payload).not.toHaveProperty( - 'communicationProvider', - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Teams bot credentials could not be resolved'), - ); - expect(insertValuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewState: expect.objectContaining({ - onboardingTaskId: 'task-onboarding-1', - chatHandoffProvider: null, - chatHandoffChannelId: null, - chatHandoffThreadId: null, - chatHandoffServiceUrl: null, - }), - }), - ); - } finally { - consoleWarnSpy.mockRestore(); - } - }); - - it('falls back to a web onboarding task when the Teams kickoff post fails', async () => { - mockOnboardingTransaction({ slackInstallation: null }); - - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const teamsPostMessage = vi.fn(async () => { - throw new Error('Teams post failed'); - }); - - vi.mocked(findTeamsPrimaryConversation).mockResolvedValue({ - conversationId: '19:channel@thread.tacv2', - serviceUrl: 'https://smba.trafficmanager.net/amer/', - conversationType: 'channel', - }); - vi.mocked( - createTeamsCommunicationProviderFromRuntimeCredentials, - ).mockResolvedValue({ - postMessage: teamsPostMessage, - } as unknown as Awaited< - ReturnType - >); - - try { - const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(result.taskId).toBe('task-onboarding-1'); - expect(teamsPostMessage).toHaveBeenCalled(); - expect(enqueueCloudTask).toHaveBeenCalledTimes(1); - - const enqueueInput = vi.mocked(enqueueCloudTask).mock.calls[0]?.[0]; - expect(enqueueInput?.task.payload).toBeDefined(); - expect(enqueueInput?.task.payload).not.toHaveProperty( - 'communicationProvider', - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to post the Teams setup kickoff'), - expect.any(Error), - ); - expect(insertValuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewState: expect.objectContaining({ - onboardingTaskId: 'task-onboarding-1', - chatHandoffProvider: null, - chatHandoffChannelId: null, - chatHandoffThreadId: null, - chatHandoffServiceUrl: null, - }), - }), - ); - } finally { - consoleWarnSpy.mockRestore(); - } - }); - - it('falls back to a web onboarding task when the Teams kickoff post returns no message id', async () => { - mockOnboardingTransaction({ slackInstallation: null }); - - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const teamsPostMessage = vi.fn(async () => ({ - provider: 'teams' as const, - channelId: '19:channel@thread.tacv2', - messageId: '', - })); - - vi.mocked(findTeamsPrimaryConversation).mockResolvedValue({ - conversationId: '19:channel@thread.tacv2', - serviceUrl: 'https://smba.trafficmanager.net/amer/', - conversationType: 'channel', - }); - vi.mocked( - createTeamsCommunicationProviderFromRuntimeCredentials, - ).mockResolvedValue({ - postMessage: teamsPostMessage, - } as unknown as Awaited< - ReturnType - >); - - try { - const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(result.taskId).toBe('task-onboarding-1'); - expect(teamsPostMessage).toHaveBeenCalled(); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('returned no message id'), - ); - - const enqueueInput = vi.mocked(enqueueCloudTask).mock.calls[0]?.[0]; - expect(enqueueInput?.task.payload).toBeDefined(); - expect(enqueueInput?.task.payload).not.toHaveProperty( - 'communicationProvider', - ); - expect(insertValuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewState: expect.objectContaining({ - chatHandoffProvider: null, - chatHandoffChannelId: null, - }), - }), - ); - } finally { - consoleWarnSpy.mockRestore(); - } - }); - - it('applies the selected setup model to web onboarding tasks', async () => { - mockOnboardingTransaction({ - slackInstallation: null, - setupNewState: { - selectedModelId: 'openrouter/z-ai/glm-5.2', - }, - }); - - await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(enqueueCloudTask).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - harness: 'opencode-server', - type: TaskPayloadKind.StandardTask, - payload: expect.objectContaining({ - harnessModelOverrides: { - 'opencode-server': 'openrouter/z-ai/glm-5.2', - }, - }), - }), - initiator: { kind: 'user', userId: 'setup-test-user' }, - workflow: 'setup_onboarding', - surface: 'web', - trigger: 'manual', - }), - ); - }); - - it('falls back to a web onboarding task when the admin has no Slack user mapping', async () => { - mockOnboardingTransaction({ - slackInstallation: { botAccessToken: 'xoxb-token', teamId: 'T1' }, - slackUserMapping: null, - }); - - const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(result.taskId).toBe('task-onboarding-1'); - expect(SlackNotifier).not.toHaveBeenCalled(); - expect(enqueueCloudTask).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - type: TaskPayloadKind.StandardTask, - }), - initiator: { kind: 'user', userId: 'setup-test-user' }, - workflow: 'setup_onboarding', - surface: 'web', - trigger: 'manual', - }), - ); - }); - - it('keeps the Slack DM handoff when Slack is connected and mapped', async () => { - mockOnboardingTransaction({ - slackInstallation: { botAccessToken: 'xoxb-token', teamId: 'T1' }, - slackUserMapping: { slackUserId: 'U1' }, - }); - - const openConversationMock = vi.fn(async () => 'D1'); - const postMessageMock = vi.fn(async () => '171.0001'); - vi.mocked(SlackNotifier).mockImplementation(function (this: unknown) { - return { - openConversation: openConversationMock, - postMessage: postMessageMock, - deleteMessage: vi.fn(), - } as unknown as InstanceType; - }); - - const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); - - expect(result.taskId).toBe('task-onboarding-1'); - expect(openConversationMock).toHaveBeenCalledWith('U1'); - expect(enqueueCloudTask).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - type: TaskPayloadKind.SlackAppMention, - payload: expect.objectContaining({ - channel: 'D1', - user: 'U1', - ts: '171.0001', - thread_ts: '171.0001', - }), - }), - initiator: { kind: 'user', userId: 'setup-test-user' }, - workflow: 'setup_onboarding', - surface: 'slack', - trigger: 'manual', - channels: { - slackChannelId: 'D1', - slackThreadTs: '171.0001', - }, - }), - ); - expect(insertValuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewState: expect.objectContaining({ - onboardingTaskId: 'task-onboarding-1', - slackTeamId: 'T1', - slackChannel: 'D1', - slackThreadTs: '171.0001', - }), - }), - ); - }); -}); diff --git a/apps/web/src/trpc/commands/setup-new/config.ts b/apps/web/src/trpc/commands/setup-new/config.ts new file mode 100644 index 00000000..d26d57ea --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/config.ts @@ -0,0 +1,809 @@ +import { + db, + environmentVariables, + and, + inArray, + isNull, + purgeSavedDeploymentWorkerImage, + isChatGptSubscriptionConnected, +} from '@roomote/db/server'; +import { + buildSetupAuthStatus, + buildSetupComputeStatus, + collectSetupModelProviderCredentialValues, + deriveWorkerImageFromReleaseVersion, + getSetupAuthProvider, + getSetupComputeProvider, + getSetupModelProvider, + isAutoProvisionedComputeArtifactField, + isComputeInfrastructureField, + isConfiguredEnvValue, + isRequiredComputeField, + normalizeDeploymentComputeConfig, + normalizeDeploymentModelConfig, + getSetupNewComputeProvisioningState, + isSetupProvisionableComputeProvider, + normalizeSetupNewState, + resolveDerivedModalBaseImageRef, + SETUP_COMPUTE_PROVISIONING_STATE_FIELDS, + SHARED_WORKER_IMAGE_ENV_VAR, + type ComputeProvider, + type SetupAuthProviderId, + type SetupModelProviderId, + type SetupProvisionableComputeProvider, + type SourceControlProvider, +} from '@roomote/types'; + +import type { UserAuthSuccess } from '@/types'; +import { + assertSetupTokenValid, + getRequestInviteToken, + isSetupTokenRequired, + isSetupTokenValid, +} from '@/lib/server'; + +import { assertAdmin, getSetupBootstrapState } from '../setup/shared'; +import { + getPersistedEnvironmentVariableNames, + upsertDeploymentEnvironmentVariables, +} from '../environment-variables'; +import { + getPersistedRuntimeComputeConfig, + savePersistedRuntimeComputeConfig, +} from '../compute'; +import { + createPendingComputeProvisioning, + prepareComputeProvisioningStart, + runComputeProvisioning, +} from '../compute/compute-provisioning'; +import { + assertValidSourceControlConfigInput, + saveSourceControlConfigValues, +} from '../source-control'; +import { getPersistedRawTaskModelSettings } from '../task-models'; +import { + buildAutoAddedTaskModelSettings, + collectConnectedTaskModelProviderIds, +} from '../task-models/auto-add-models'; +import { + ensureSetupBootstrapAuditUser, + assertSetupBootstrapOpen, + getPersistedSetupNewState, + savePersistedSetupNewState, + savePersistedRuntimeModelConfig, + savePersistedTaskModelSettings, +} from './shared'; + +export async function saveSetupNewModelConfigCommand( + auth: UserAuthSuccess, + input: { + provider: SetupModelProviderId; + apiKey?: string; + additionalEnvValues?: Record; + }, +) { + assertAdmin(auth); + + const { userId } = auth; + const provider = getSetupModelProvider(input.provider); + const isOauthProvider = provider.authKind === 'oauth'; + + const chatgptConnected = await isChatGptSubscriptionConnected(); + + // OAuth providers (the ChatGPT subscription) are connected through the + // device-code flow rather than an API key. The setup wizard's Continue + // records the provider choice and default model only after the operator + // has connected an account, so the env-vars step becomes satisfied without + // a credential env var. + if (isOauthProvider && !chatgptConnected) { + throw new Error( + `Connect your ${provider.label} account to continue, or pick a different provider.`, + ); + } + + return db.transaction(async (tx) => { + const [currentState, persistedEnvVarNames, persistedTaskModelSettings] = + await Promise.all([ + getPersistedSetupNewState(tx), + getPersistedEnvironmentVariableNames(tx), + getPersistedRawTaskModelSettings(tx), + ]); + const persistedEnvVarNameSet = new Set(persistedEnvVarNames); + + if (!isOauthProvider) { + const { values: credentialValues, clearedEnvVarNames } = + collectSetupModelProviderCredentialValues({ + provider, + apiKey: input.apiKey, + additionalEnvValues: input.additionalEnvValues, + isEnvVarSatisfied: (envVarName) => + persistedEnvVarNameSet.has(envVarName) || + isConfiguredEnvValue(process.env[envVarName]), + action: 'continue', + }); + + if (credentialValues.length > 0) { + await upsertDeploymentEnvironmentVariables(tx, { + userId, + values: credentialValues, + }); + } + + // Optional fields submitted as blank clear their previously saved value + // (deployment-level rows only, mirroring how they are stored). + const clearedPersistedEnvVarNames = clearedEnvVarNames.filter((name) => + persistedEnvVarNameSet.has(name), + ); + + if (clearedPersistedEnvVarNames.length > 0) { + await tx + .delete(environmentVariables) + .where( + and( + isNull(environmentVariables.userId), + inArray(environmentVariables.name, clearedPersistedEnvVarNames), + ), + ); + } + } + + const setupNewState = normalizeSetupNewState({ + ...currentState, + modelProvider: input.provider, + lastInteractedByUserId: userId, + }); + const runtimeModelConfig = normalizeDeploymentModelConfig({ + roomoteModel: provider.defaultRoomoteModel, + }); + + // Mirror the models settings page: connecting a provider the deployment + // has no models for yet auto-adds its recommended models so the first + // launch offers a usable model list. + const connectedProviderIds = new Set([ + provider.id, + ...collectConnectedTaskModelProviderIds({ + runtimeEnv: process.env, + persistedEnvVarNames, + chatgptConnected, + }), + ]); + const autoAdd = buildAutoAddedTaskModelSettings({ + provider, + persistedTaskModelSettings, + connectedProviderIds, + }); + + await Promise.all([ + savePersistedSetupNewState(setupNewState, tx), + savePersistedRuntimeModelConfig(runtimeModelConfig, tx), + ...(autoAdd + ? [savePersistedTaskModelSettings(autoAdd.taskModelSettings, tx)] + : []), + ]); + + return { + setupNewState, + runtimeModelConfig, + }; + }); +} + +export async function saveSetupNewComputeProviderChoiceCommand( + auth: UserAuthSuccess, + input: { + provider: ComputeProvider; + }, +) { + assertAdmin(auth); + + const { userId } = auth; + + return db.transaction(async (tx) => { + const [currentState, persistedRuntimeComputeConfig, persistedEnvVarNames] = + await Promise.all([ + getPersistedSetupNewState(tx), + getPersistedRuntimeComputeConfig(tx), + getPersistedEnvironmentVariableNames(tx), + ]); + const computeSetup = buildSetupComputeStatus({ + runtimeEnv: process.env, + persistedEnvVarNames, + persistedComputeConfig: persistedRuntimeComputeConfig, + selectedProvider: input.provider, + }); + const providerStatus = computeSetup.providers.find( + (candidate) => candidate.provider === input.provider, + ); + + if (!providerStatus) { + throw new Error('Selected sandbox provider is unavailable.'); + } + + const hasCredentialFields = providerStatus.fields.length > 0; + const runtimeComputeConfig = hasCredentialFields + ? persistedRuntimeComputeConfig + : normalizeDeploymentComputeConfig({ + defaultProvider: input.provider, + }); + + // Providers with credentials are only recorded as the wizard choice here. + // The runtime default commits when their config step is confirmed, so + // merely browsing a hosted provider must not switch the deployment onto it. + // Credentialless providers such as Local Docker have no config step to + // confirm, so choosing them commits the runtime default immediately. + const setupNewState = normalizeSetupNewState({ + ...currentState, + computeProvider: input.provider, + lastInteractedByUserId: userId, + }); + + await Promise.all([ + savePersistedSetupNewState(setupNewState, tx), + ...(hasCredentialFields + ? [] + : [savePersistedRuntimeComputeConfig(runtimeComputeConfig, tx)]), + ]); + + return { + setupNewState, + runtimeComputeConfig, + }; + }); +} + +export async function saveSetupNewComputeConfigCommand( + auth: UserAuthSuccess, + input: { + provider: ComputeProvider; + values?: Partial>; + }, +) { + assertAdmin(auth); + + const { userId } = auth; + const provider = getSetupComputeProvider(input.provider); + + const { provisioningToStart, ...result } = await db.transaction( + async (tx) => { + // Resolved inside the transaction, kicked off only after it commits so + // the detached build reads the credentials the save just persisted. + let provisioningToStart: { + provider: SetupProvisionableComputeProvider; + imageRef: string; + templateRef: string; + } | null = null; + + await purgeSavedDeploymentWorkerImage(tx); + + const [ + currentState, + persistedRuntimeComputeConfig, + persistedEnvVarNames, + ] = await Promise.all([ + getPersistedSetupNewState(tx), + getPersistedRuntimeComputeConfig(tx), + getPersistedEnvironmentVariableNames(tx), + ]); + + // In-request DOCKER_WORKER_IMAGE is only used for this save/provisioning + // pass. It is not persisted as sticky deployment state; process env and + // RELEASE_VERSION derivation own runtime configuration. + const submittedWorkerImage = + input.values?.[SHARED_WORKER_IMAGE_ENV_VAR]?.trim() || null; + const effectiveWorkerImage = + process.env[SHARED_WORKER_IMAGE_ENV_VAR]?.trim() || + submittedWorkerImage || + deriveWorkerImageFromReleaseVersion(process.env) || + undefined; + + const computeSetup = buildSetupComputeStatus({ + runtimeEnv: process.env, + persistedEnvVarNames, + persistedComputeConfig: persistedRuntimeComputeConfig, + selectedProvider: input.provider, + }); + const providerStatus = computeSetup.providers.find( + (candidate) => candidate.provider === input.provider, + ); + + if (!providerStatus) { + throw new Error('Selected sandbox provider is unavailable.'); + } + + // Derive MODAL_BASE_IMAGE_REF when not env-provided or already saved. + // Form submissions are ignored (deployment-managed like E2B/Daytona + // artifacts). + const derivedInfraDefaults = new Map(); + + if (input.provider === 'modal') { + const baseImageField = providerStatus.fields.find( + (field) => field.envVarName === 'MODAL_BASE_IMAGE_REF', + ); + const derivedBaseImageRef = resolveDerivedModalBaseImageRef({ + ...process.env, + DOCKER_WORKER_IMAGE: effectiveWorkerImage, + }); + + if ( + baseImageField && + !baseImageField.runtimeSatisfied && + !baseImageField.savedSatisfied && + derivedBaseImageRef + ) { + derivedInfraDefaults.set('MODAL_BASE_IMAGE_REF', derivedBaseImageRef); + } + } + + // Provisionable providers' base images (the E2B worker template, the + // Daytona worker snapshot) cannot be derived like the Modal base image + // — they are artifacts inside the operator's provider account. Manual + // form overrides are not accepted; process env or auto-provisioning + // owns them. When a registry-qualified worker image exists, the save + // records credentials, marks the run as pending, and the run executes + // detached after commit. + const setupProvisioningFieldNames = new Set(); + + if (isSetupProvisionableComputeProvider(input.provider)) { + const provisionableProvider = input.provider; + const artifactEnvVar = + provisionableProvider === 'e2b' + ? 'E2B_TEMPLATE_ID' + : 'DAYTONA_SNAPSHOT_NAME'; + const provisioning = await prepareComputeProvisioningStart({ + provider: provisionableProvider, + providerStatus, + existingState: getSetupNewComputeProvisioningState( + currentState, + provisionableProvider, + ), + // Process env, in-request override, or RELEASE_VERSION derivation. + dockerWorkerImage: effectiveWorkerImage, + runtimeEnv: process.env, + markPending: (nextState) => { + provisioningToStart = { + provider: provisionableProvider, + imageRef: nextState.imageRef, + templateRef: nextState.templateRef ?? '', + }; + }, + }); + + if (provisioning.fieldPending) { + if (!provisioning.provisionable) { + throw new Error( + `${providerStatus.label} needs a registry-qualified worker image (for example via DOCKER_WORKER_IMAGE) so Roomote can provision the worker base image automatically.`, + ); + } + + setupProvisioningFieldNames.add(artifactEnvVar); + } + + if (provisioning.start) { + provisioningToStart = provisioning.start; + } + } + + // Credentials and operator-editable infrastructure are persisted as + // encrypted deployment env vars. Managed artifacts are process-env / + // derived / provisioning only. + const valuesToSave: Array<{ name: string; value: string }> = []; + const envVarsToClear: string[] = []; + + for (const field of providerStatus.fields) { + if (field.runtimeSatisfied) { + continue; + } + + if (isAutoProvisionedComputeArtifactField(field)) { + continue; + } + + const submitted = + field.envVarName === 'MODAL_BASE_IMAGE_REF' + ? '' + : (input.values?.[field.envVarName]?.trim() ?? ''); + const nextValue = + submitted || + (isComputeInfrastructureField(field) + ? (derivedInfraDefaults.get(field.envVarName) ?? '') + : ''); + + if (!nextValue) { + if ( + field.secret !== true && + !isRequiredComputeField(field) && + field.savedSatisfied + ) { + envVarsToClear.push(field.envVarName); + } + continue; + } + + valuesToSave.push({ name: field.envVarName, value: nextValue }); + } + + const hasMissingRequiredValue = providerStatus.fields.some((field) => { + if (setupProvisioningFieldNames.has(field.envVarName)) { + return false; + } + + // Modal base image is not form-collected; only runtime / saved / derived + // values count toward satisfaction (same as the save loop above). + const submitted = + field.envVarName === 'MODAL_BASE_IMAGE_REF' + ? '' + : (input.values?.[field.envVarName]?.trim() ?? ''); + const nextValue = + submitted || + (isComputeInfrastructureField(field) + ? (derivedInfraDefaults.get(field.envVarName) ?? '') + : ''); + + return ( + isRequiredComputeField(field) && + !field.runtimeSatisfied && + !field.savedSatisfied && + nextValue.length === 0 + ); + }); + + if (hasMissingRequiredValue) { + throw new Error( + `Enter the required ${provider.label} configuration values to continue.`, + ); + } + + if (valuesToSave.length > 0) { + await upsertDeploymentEnvironmentVariables(tx, { + userId, + values: valuesToSave, + }); + } + + if (envVarsToClear.length > 0) { + await tx + .delete(environmentVariables) + .where( + and( + isNull(environmentVariables.userId), + inArray(environmentVariables.name, envVarsToClear), + ), + ); + } + + const setupNewState = normalizeSetupNewState({ + ...currentState, + computeProvider: input.provider, + ...(provisioningToStart + ? { + [SETUP_COMPUTE_PROVISIONING_STATE_FIELDS[ + provisioningToStart.provider + ]]: createPendingComputeProvisioning(provisioningToStart), + } + : {}), + lastInteractedByUserId: userId, + }); + const runtimeComputeConfig = normalizeDeploymentComputeConfig({ + defaultProvider: input.provider, + }); + + await Promise.all([ + savePersistedSetupNewState(setupNewState, tx), + savePersistedRuntimeComputeConfig(runtimeComputeConfig, tx), + ]); + + return { + setupNewState, + runtimeComputeConfig, + provisioningToStart, + }; + }, + ); + + if (provisioningToStart) { + void runComputeProvisioning({ + userId, + ...provisioningToStart, + }); + } + + return result; +} + +export async function saveSetupNewAuthProviderChoiceCommand( + auth: UserAuthSuccess, + input: { + provider: SetupAuthProviderId; + }, +) { + assertAdmin(auth); + + const { userId } = auth; + return saveSetupAuthProviderChoice({ + provider: input.provider, + actorUserId: userId, + }); +} + +async function saveSetupAuthProviderChoice(input: { + provider: SetupAuthProviderId; + actorUserId: string | null; + requireBootstrapOpen?: boolean; +}) { + if (input.requireBootstrapOpen) { + await assertSetupBootstrapOpen(); + } + + return db.transaction(async (tx) => { + const currentState = await getPersistedSetupNewState(tx); + const setupNewState = normalizeSetupNewState({ + ...currentState, + authProvider: input.provider, + lastInteractedByUserId: input.actorUserId, + }); + + await savePersistedSetupNewState(setupNewState, tx); + + return { + setupNewState, + }; + }); +} + +export async function saveSetupNewAuthConfigCommand( + auth: UserAuthSuccess, + input: { + provider: SetupAuthProviderId; + values?: Partial>; + }, +) { + assertAdmin(auth); + + const { userId } = auth; + return saveSetupAuthConfig({ + provider: input.provider, + values: input.values, + actorUserId: userId, + }); +} + +async function saveSetupAuthConfig(input: { + provider: SetupAuthProviderId; + values?: Partial>; + actorUserId: string | null; + requireBootstrapOpen?: boolean; +}) { + if (input.requireBootstrapOpen) { + await assertSetupBootstrapOpen(); + } + const provider = getSetupAuthProvider(input.provider); + + return db.transaction(async (tx) => { + const [currentState, persistedEnvVarNames] = await Promise.all([ + getPersistedSetupNewState(tx), + getPersistedEnvironmentVariableNames(tx), + ]); + const authSetup = buildSetupAuthStatus({ + runtimeEnv: process.env, + persistedEnvVarNames, + selectedProvider: input.provider, + }); + const providerStatus = authSetup.providers.find( + (candidate) => candidate.id === input.provider, + ); + + if (!providerStatus) { + throw new Error('Selected auth provider is unavailable.'); + } + + const valuesToSave = providerStatus.fields.flatMap((field) => { + const nextValue = input.values?.[field.envVarName]?.trim() ?? ''; + + if (!nextValue) { + return []; + } + + return [ + { + name: field.envVarName, + value: nextValue, + }, + ]; + }); + + const hasMissingRequiredValue = providerStatus.fields.some((field) => { + const nextValue = input.values?.[field.envVarName]?.trim() ?? ''; + + return ( + field.required !== false && + !field.runtimeSatisfied && + !field.savedSatisfied && + nextValue.length === 0 + ); + }); + + if (hasMissingRequiredValue) { + throw new Error( + `Enter the required ${provider.label} configuration values to continue.`, + ); + } + + if (valuesToSave.length > 0) { + const auditUserId = + input.actorUserId ?? (await ensureSetupBootstrapAuditUser(tx)); + + await upsertDeploymentEnvironmentVariables(tx, { + userId: auditUserId, + values: valuesToSave, + }); + } + + const setupNewState = normalizeSetupNewState({ + ...currentState, + authProvider: input.provider, + lastInteractedByUserId: input.actorUserId, + }); + + await savePersistedSetupNewState(setupNewState, tx); + + return { + setupNewState, + }; + }); +} + +export async function saveSetupNewSourceControlProviderChoiceCommand( + auth: UserAuthSuccess, + input: { + provider: SourceControlProvider; + }, +) { + assertAdmin(auth); + + const { userId } = auth; + + return db.transaction(async (tx) => { + const currentState = await getPersistedSetupNewState(tx); + const setupNewState = normalizeSetupNewState({ + ...currentState, + sourceControlProvider: input.provider, + lastInteractedByUserId: userId, + }); + + await savePersistedSetupNewState(setupNewState, tx); + + return { + setupNewState, + }; + }); +} + +export async function saveSetupNewSourceControlConfigCommand( + auth: UserAuthSuccess, + input: { + provider: SourceControlProvider; + values?: Partial>; + }, +) { + assertAdmin(auth); + + const { userId } = auth; + + return saveSourceControlConfig({ + provider: input.provider, + values: input.values, + actorUserId: userId, + }); +} + +async function saveSourceControlConfig(input: { + provider: SourceControlProvider; + values?: Partial>; + actorUserId: string; +}) { + // Provider-API validation happens before the transaction so the external + // HTTP round-trip never holds a pooled DB connection open. + await assertValidSourceControlConfigInput(input); + + return db.transaction(async (tx) => { + const currentState = await getPersistedSetupNewState(tx); + + await saveSourceControlConfigValues({ + executor: tx, + actorUserId: input.actorUserId, + provider: input.provider, + values: input.values, + }); + + const setupNewState = normalizeSetupNewState({ + ...currentState, + sourceControlProvider: input.provider, + lastInteractedByUserId: input.actorUserId, + }); + + await savePersistedSetupNewState(setupNewState, tx); + + return { + setupNewState, + }; + }); +} + +/** + * Resolves the setup token for bootstrap commands: the explicit input when + * the client still has it (e.g. from the ?token= query param), otherwise the + * invite cookie, which is the only place the token survives OAuth sign-in + * round-trips. + */ +async function resolveSetupTokenInput( + setupToken: string | undefined, +): Promise { + if (setupToken != null) { + return setupToken; + } + + return (await getRequestInviteToken()) ?? undefined; +} + +export async function getSetupBootstrapStatusCommand(input?: { + setupToken?: string; +}) { + const bootstrapState = await getSetupBootstrapState(); + const setupTokenRequired = bootstrapState.setupOpen && isSetupTokenRequired(); + + if ( + setupTokenRequired && + !isSetupTokenValid(await resolveSetupTokenInput(input?.setupToken)) + ) { + return { + setupOpen: bootstrapState.setupOpen, + setupTokenRequired, + setupTokenSatisfied: false, + authSetup: null, + }; + } + + const [setupNewState, persistedEnvVarNames] = await Promise.all([ + getPersistedSetupNewState(), + getPersistedEnvironmentVariableNames(), + ]); + + return { + setupOpen: bootstrapState.setupOpen, + setupTokenRequired, + setupTokenSatisfied: true, + authSetup: buildSetupAuthStatus({ + runtimeEnv: process.env, + persistedEnvVarNames, + selectedProvider: setupNewState.authProvider, + }), + }; +} + +export async function saveSetupBootstrapAuthProviderChoiceCommand(input: { + provider: SetupAuthProviderId; + setupToken?: string; +}) { + assertSetupTokenValid(await resolveSetupTokenInput(input.setupToken)); + + return saveSetupAuthProviderChoice({ + provider: input.provider, + actorUserId: null, + requireBootstrapOpen: true, + }); +} + +export async function saveSetupBootstrapAuthConfigCommand(input: { + provider: SetupAuthProviderId; + values?: Partial>; + setupToken?: string; +}) { + assertSetupTokenValid(await resolveSetupTokenInput(input.setupToken)); + + return saveSetupAuthConfig({ + provider: input.provider, + values: input.values, + actorUserId: null, + requireBootstrapOpen: true, + }); +} diff --git a/apps/web/src/trpc/commands/setup-new/handoff.ts b/apps/web/src/trpc/commands/setup-new/handoff.ts new file mode 100644 index 00000000..d2480ee2 --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/handoff.ts @@ -0,0 +1,169 @@ +import { + db, + slackInstallations, + slackUserMappings, + eq, + and, + resolveTelegramRuntimeCredentials, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import type { TeamsCommunicationProvider } from '@roomote/communication/teams-provider'; +import { + createTeamsCommunicationProviderFromRuntimeCredentials, + findTelegramPrimaryChatId, + findTeamsPrimaryConversation, +} from '@roomote/sdk/server'; + +async function getActiveSlackInstallation( + executor: DatabaseOrTransaction = db, +) { + const [installation] = await executor + .select({ + botAccessToken: slackInstallations.botAccessToken, + teamId: slackInstallations.teamId, + }) + .from(slackInstallations) + .where(eq(slackInstallations.isActive, true)) + .limit(1); + + return installation ?? null; +} + +async function getSlackUserMappingForTeam( + input: { + userId: string; + teamId: string; + }, + executor: DatabaseOrTransaction = db, +) { + const [mapping] = await executor + .select({ + slackUserId: slackUserMappings.slackUserId, + }) + .from(slackUserMappings) + .where( + and( + eq(slackUserMappings.userId, input.userId), + eq(slackUserMappings.slackTeamId, input.teamId), + ), + ) + .limit(1); + + return mapping ?? null; +} + +export async function getSetupSlackAccessStatus( + input: { + userId: string; + }, + executor: DatabaseOrTransaction = db, +) { + const installation = await getActiveSlackInstallation(executor); + + if (!installation) { + return { + hasSlackInstallation: false, + hasSlackUserMapping: false, + }; + } + + const mapping = await getSlackUserMappingForTeam( + { + userId: input.userId, + teamId: installation.teamId, + }, + executor, + ); + + return { + hasSlackInstallation: true, + hasSlackUserMapping: mapping !== null, + }; +} + +/** + * Resolves the Slack DM target for the setup onboarding handoff. Returns null + * when the deployment has no active Slack installation or the admin has no + * linked Slack account, so setup can fall back to a web-only onboarding task. + */ +export async function resolveSetupSlackHandoffTarget( + input: { + userId: string; + }, + executor: DatabaseOrTransaction = db, +) { + const installation = await getActiveSlackInstallation(executor); + + if (!installation) { + return null; + } + + const mapping = await getSlackUserMappingForTeam( + { + userId: input.userId, + teamId: installation.teamId, + }, + executor, + ); + + if (!mapping) { + return null; + } + + return { + botAccessToken: installation.botAccessToken, + slackTeamId: installation.teamId, + slackUserId: mapping.slackUserId, + }; +} + +type SetupChatFallbackHandoffTarget = + | { provider: 'telegram'; chatId: string; botToken: string } + | { + provider: 'teams'; + conversationId: string; + serviceUrl: string; + teams: TeamsCommunicationProvider; + }; + +/** + * Resolves the non-Slack chat destination for the setup onboarding kickoff, + * matching the proactive-messaging fallback ordering (Slack > Telegram > + * Teams). Returns null when no chat surface is available, so setup falls + * back to a web-only onboarding task. Teams is only selected when both a + * captured primary conversation and resolvable bot credentials exist, so a + * half-configured Teams deployment never blocks onboarding. + */ +export async function resolveSetupChatFallbackHandoffTarget(): Promise { + const { botToken } = await resolveTelegramRuntimeCredentials(); + + if (botToken) { + const chatId = await findTelegramPrimaryChatId(); + + if (chatId) { + return { provider: 'telegram', chatId, botToken }; + } + } + + const conversation = await findTeamsPrimaryConversation(); + + if (conversation) { + const teams = + await createTeamsCommunicationProviderFromRuntimeCredentials(); + + if (teams) { + return { + provider: 'teams', + conversationId: conversation.conversationId, + serviceUrl: conversation.serviceUrl, + teams, + }; + } + + console.warn( + '[setup-new] Skipping the Teams setup kickoff because Teams bot credentials could not be resolved; onboarding continues as a web-only task.', + ); + } + + return null; +} diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index bbf777c1..8c1abd41 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -1,2658 +1,24 @@ -import * as GitHub from '@roomote/github'; -import { enqueueCloudTask } from '@roomote/cloud-agents/server'; -import { - resolveEnvironmentSourceControlProvider, - resolveSingleSourceControlProvider, -} from '@/lib/server/source-control-provider'; -import { buildSetupKickoffText } from '@roomote/communication/chat-messages'; -import type { TeamsCommunicationProvider } from '@roomote/communication/teams-provider'; -import { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; -import { SlackNotifier } from '@roomote/slack'; -import { - db, - deploymentSettings, - environments, - environmentVariables, - users, - taskRuns, - workItems, - slackInstallations, - slackUserMappings, - asc, - eq, - and, - inArray, - isNull, - sql, - cancelTaskRunDirect, - claimWorkItem, - finalizeWorkItemLaunched, - releaseWorkItemClaim, - WORK_ITEM_LAUNCH_STALE_CLAIM_MS, - markTaskStartParallelCountEndedAt, - resolveDeploymentEnvVar, - purgeSavedDeploymentWorkerImage, - resolveTelegramRuntimeCredentials, - syncSetupQualificationBlock, - isChatGptSubscriptionConnected, - type DatabaseOrTransaction, -} from '@roomote/db/server'; -import { - createTeamsCommunicationProviderFromRuntimeCredentials, - findTelegramPrimaryChatId, - findTeamsPrimaryConversation, - recordSlackConversationMessageBestEffort, -} from '@roomote/sdk/server'; -import { - buildSetupAuthStatus, - buildSetupComputeStatus, - buildSetupModelStatus, - buildSetupSourceControlStatus, - collectSetupModelProviderCredentialValues, - createEmptySetupNewState, - CloudTaskStatus, - TaskPayloadKind, - resolveEvalHarnessSelection, - type ComputeProvider, - type DeploymentModelConfig, - deriveWorkerImageFromReleaseVersion, - getSetupAuthProvider, - getSetupComputeProvider, - getSetupModelProvider, - isAutoProvisionedComputeArtifactField, - isComputeInfrastructureField, - isConfiguredEnvValue, - isExitedCloudTaskStatus, - isRequiredComputeField, - NON_SECRET_COMPUTE_ENV_VAR_NAMES, - normalizeDeploymentComputeConfig, - normalizeDeploymentModelConfig, - getSetupNewComputeProvisioningState, - hasSetupChatHandoffDestination, - isSetupProvisionableComputeProvider, - normalizeSetupNewState, - presentSetupNewComputeProvisioning, - resolveDerivedModalBaseImageRef, - SETUP_COMPUTE_PROVISIONING_STATE_FIELDS, - SHARED_WORKER_IMAGE_ENV_VAR, - type SetupAuthProviderId, - type SetupModelProviderId, - type SetupProvisionableComputeProvider, - type SourceControlProvider, - type TaskModelSettings, -} from '@roomote/types'; - -import type { UserAuthSuccess } from '@/types'; -import { - assertSetupTokenValid, - getLatestCloudJobsByTaskId, - getRepositories, - getRequestInviteToken, - getSourceControlConnectionSummary, - isSetupTokenRequired, - isSetupTokenValid, -} from '@/lib/server'; -import { areAllRepositoriesEmpty } from '@/lib/repositories'; -import { - appendEnvironmentDefinitionGuidance, - buildSetupNewKickoffPrompt, - buildSetupNewWorkspacePayload, - findMatchingSetupNewEnvironment, - isSetupNewOnboardingFailureStatus, - isSetupNewOnboardingSuccessStatus, - isSetupNewOnboardingTerminalSuccessStatus, - normalizeRepositorySelection, -} from '@/lib/setup-new'; -import type { QueuedOnboardingTask } from './types'; - -import { - assertAdmin, - getSetupBootstrapState, - ensureDefaultSetupAgents, - getSetupBaseStatus, -} from '../setup/shared'; -import { - getPersistedEnvironmentVariableNames, - getPersistedEnvironmentVariableValues, - upsertDeploymentEnvironmentVariables, -} from '../environment-variables'; -import { - getPersistedRuntimeComputeConfig, - savePersistedRuntimeComputeConfig, -} from '../compute'; -import { - createPendingComputeProvisioning, - prepareComputeProvisioningStart, - runComputeProvisioning, -} from '../compute/compute-provisioning'; -import { - assertValidSourceControlConfigInput, - saveSourceControlConfigValues, -} from '../source-control'; -import { getPersistedRawTaskModelSettings } from '../task-models'; -import { - buildAutoAddedTaskModelSettings, - collectConnectedTaskModelProviderIds, -} from '../task-models/auto-add-models'; -import { triggerTaskSuggestionsCommand } from '../task-suggestions'; - -type PersistedSetupNewState = ReturnType; -type PersistedRuntimeModelConfig = DeploymentModelConfig; - -type SelectedRepositorySummary = { - id: string; - fullName: string; - sourceControlProvider: SourceControlProvider; -}; - -type PersistedQueuedSetupTask = QueuedOnboardingTask; - -type MutableQueuedSetupTask = PersistedQueuedSetupTask & { - launchClaimedAt: Date | null; -}; - -type ActiveSetupQualificationBlock = { - reason: 'github_organization_required'; - email: string | null; - emailDomain: string | null; - githubAccountLogin: string | null; - githubAccountType: string | null; - lastBlockedAt: Date; -}; - -const SETUP_BOOTSTRAP_USER_ID = 'setup-bootstrap-user'; - -async function ensureSetupBootstrapAuditUser( - executor: DatabaseOrTransaction, -): Promise { - const [existingUser] = await executor - .select({ id: users.id }) - .from(users) - .where(eq(users.id, SETUP_BOOTSTRAP_USER_ID)) - .limit(1); - - if (existingUser) { - return existingUser.id; - } - - await executor.insert(users).values({ - id: SETUP_BOOTSTRAP_USER_ID, - name: 'Setup Bootstrap', - email: 'setup-bootstrap@roomote.local', - imageUrl: '', - entity: { - id: SETUP_BOOTSTRAP_USER_ID, - name: 'Setup Bootstrap', - email: 'setup-bootstrap@roomote.local', - imageUrl: '', - }, - metadata: { - system: true, - }, - }); - - return SETUP_BOOTSTRAP_USER_ID; -} - -async function assertSetupBootstrapOpen() { - const bootstrapState = await getSetupBootstrapState(); - - if (!bootstrapState.setupOpen) { - throw new Error('Initial setup is no longer open.'); - } -} - -function getSetupQualificationBlockErrorMessage() { - return 'Setup is currently limited to work email addresses. Use another work email or contact the team if this seems wrong.'; -} - -async function getPersistedSetupNewState( - executor: DatabaseOrTransaction = db, -): Promise { - const [settings] = await executor - .select({ setupNewState: deploymentSettings.setupNewState }) - .from(deploymentSettings) - .where(eq(deploymentSettings.id, 'default')) - .limit(1); - - return normalizeSetupNewState(settings?.setupNewState ?? {}); -} - -async function getPersistedRuntimeModelConfig( - executor: DatabaseOrTransaction = db, -): Promise { - const [settings] = await executor - .select({ runtimeModelConfig: deploymentSettings.runtimeModelConfig }) - .from(deploymentSettings) - .where(eq(deploymentSettings.id, 'default')) - .limit(1); - - return normalizeDeploymentModelConfig(settings?.runtimeModelConfig); -} - -async function savePersistedSetupNewState( - setupNewState: PersistedSetupNewState, - executor: DatabaseOrTransaction = db, -) { - await executor - .insert(deploymentSettings) - .values({ - id: 'default', - setupNewState, - }) - .onConflictDoUpdate({ - target: deploymentSettings.id, - set: { - setupNewState, - updatedAt: new Date(), - }, - }); - - return setupNewState; -} - -async function savePersistedRuntimeModelConfig( - runtimeModelConfig: PersistedRuntimeModelConfig, - executor: DatabaseOrTransaction = db, -) { - await executor - .insert(deploymentSettings) - .values({ - id: 'default', - runtimeModelConfig, - }) - .onConflictDoUpdate({ - target: deploymentSettings.id, - set: { - runtimeModelConfig, - updatedAt: new Date(), - }, - }); - - return runtimeModelConfig; -} - -async function savePersistedTaskModelSettings( - taskModelSettings: TaskModelSettings, - executor: DatabaseOrTransaction = db, -) { - await executor - .insert(deploymentSettings) - .values({ - id: 'default', - taskModelSettings, - }) - .onConflictDoUpdate({ - target: deploymentSettings.id, - set: { - taskModelSettings, - updatedAt: new Date(), - }, - }); - - return taskModelSettings; -} - -// Persisted runtime compute config helpers are shared with the compute -// settings commands and imported from '../compute'. - -async function resolveSelectedRepositories(repositoryIds: string[]): Promise<{ - normalizedRepositoryIds: string[]; - selectedRepositories: SelectedRepositorySummary[]; -}> { - const uniqueRepositoryIds = [...new Set(repositoryIds)]; - const availableRepositories = await getRepositories(); - const availableRepositoriesById = new Map( - availableRepositories.map((repository) => [repository.id, repository]), - ); - const selectedRepositories: SelectedRepositorySummary[] = []; - - for (const repositoryId of uniqueRepositoryIds) { - const repository = availableRepositoriesById.get(repositoryId); - - if (!repository) { - throw new Error('Selected repositories are no longer available.'); - } - - selectedRepositories.push({ - id: repository.id, - fullName: repository.fullName, - sourceControlProvider: repository.sourceControlProvider, - }); - } - - return { - normalizedRepositoryIds: normalizeRepositorySelection(selectedRepositories), - selectedRepositories: selectedRepositories.sort((left, right) => - left.fullName.localeCompare(right.fullName), - ), - }; -} - -async function assertHasCommittedRepositorySelection(repositoryIds: string[]) { - const emptyStates = await GitHub.getRepositoryEmptyStates({ - repositoryIds, - }); - - if ( - emptyStates.size === repositoryIds.length && - areAllRepositoriesEmpty( - [...emptyStates.values()].map((isEmpty) => ({ isEmpty })), - ) - ) { - throw new Error( - emptyStates.size === 1 - ? 'The selected repository has no commits yet. Push an initial commit first, or choose a different repository.' - : 'All selected repositories have no commits yet. Push an initial commit first, or choose different repositories.', - ); - } -} - -async function clearTaskSuggestions( - sourceTaskId: string | null, - executor: DatabaseOrTransaction = db, -) { - if (!sourceTaskId) { - return; - } - - await executor - .delete(workItems) - .where( - and( - eq(workItems.sourceTaskId, sourceTaskId), - eq(workItems.kind, 'suggestion'), - ), - ); -} - -async function clearQueuedSetupTasks(executor: DatabaseOrTransaction = db) { - await executor.delete(workItems).where(eq(workItems.kind, 'onboarding')); -} - -async function getActiveSlackInstallation( - executor: DatabaseOrTransaction = db, -) { - const [installation] = await executor - .select({ - botAccessToken: slackInstallations.botAccessToken, - teamId: slackInstallations.teamId, - }) - .from(slackInstallations) - .where(eq(slackInstallations.isActive, true)) - .limit(1); - - return installation ?? null; -} - -async function getSlackUserMappingForTeam( - input: { - userId: string; - teamId: string; - }, - executor: DatabaseOrTransaction = db, -) { - const [mapping] = await executor - .select({ - slackUserId: slackUserMappings.slackUserId, - }) - .from(slackUserMappings) - .where( - and( - eq(slackUserMappings.userId, input.userId), - eq(slackUserMappings.slackTeamId, input.teamId), - ), - ) - .limit(1); - - return mapping ?? null; -} - -async function getSetupSlackAccessStatus( - input: { - userId: string; - }, - executor: DatabaseOrTransaction = db, -) { - const installation = await getActiveSlackInstallation(executor); - - if (!installation) { - return { - hasSlackInstallation: false, - hasSlackUserMapping: false, - }; - } - - const mapping = await getSlackUserMappingForTeam( - { - userId: input.userId, - teamId: installation.teamId, - }, - executor, - ); - - return { - hasSlackInstallation: true, - hasSlackUserMapping: mapping !== null, - }; -} - -/** - * Resolves the Slack DM target for the setup onboarding handoff. Returns null - * when the deployment has no active Slack installation or the admin has no - * linked Slack account, so setup can fall back to a web-only onboarding task. - */ -async function resolveSetupSlackHandoffTarget( - input: { - userId: string; - }, - executor: DatabaseOrTransaction = db, -) { - const installation = await getActiveSlackInstallation(executor); - - if (!installation) { - return null; - } - - const mapping = await getSlackUserMappingForTeam( - { - userId: input.userId, - teamId: installation.teamId, - }, - executor, - ); - - if (!mapping) { - return null; - } - - return { - botAccessToken: installation.botAccessToken, - slackTeamId: installation.teamId, - slackUserId: mapping.slackUserId, - }; -} - -type SetupChatFallbackHandoffTarget = - | { provider: 'telegram'; chatId: string; botToken: string } - | { - provider: 'teams'; - conversationId: string; - serviceUrl: string; - teams: TeamsCommunicationProvider; - }; - -/** - * Resolves the non-Slack chat destination for the setup onboarding kickoff, - * matching the proactive-messaging fallback ordering (Slack > Telegram > - * Teams). Returns null when no chat surface is available, so setup falls - * back to a web-only onboarding task. Teams is only selected when both a - * captured primary conversation and resolvable bot credentials exist, so a - * half-configured Teams deployment never blocks onboarding. - */ -async function resolveSetupChatFallbackHandoffTarget(): Promise { - const { botToken } = await resolveTelegramRuntimeCredentials(); - - if (botToken) { - const chatId = await findTelegramPrimaryChatId(); - - if (chatId) { - return { provider: 'telegram', chatId, botToken }; - } - } - - const conversation = await findTeamsPrimaryConversation(); - - if (conversation) { - const teams = - await createTeamsCommunicationProviderFromRuntimeCredentials(); - - if (teams) { - return { - provider: 'teams', - conversationId: conversation.conversationId, - serviceUrl: conversation.serviceUrl, - teams, - }; - } - - console.warn( - '[setup-new] Skipping the Teams setup kickoff because Teams bot credentials could not be resolved; onboarding continues as a web-only task.', - ); - } - - return null; -} - -function didSuggestionSourceChange({ - currentState, - nextRepositoryIds, - nextSetupGuidance, -}: { - currentState: PersistedSetupNewState; - nextRepositoryIds: string[]; - nextSetupGuidance: string | null; -}): boolean { - return ( - currentState.setupGuidance !== nextSetupGuidance || - currentState.selectedRepositoryIds.length !== nextRepositoryIds.length || - currentState.selectedRepositoryIds.some( - (repositoryId, index) => repositoryId !== nextRepositoryIds[index], - ) - ); -} - -async function getOnboardingTaskState(taskId: string | null) { - if (!taskId) { - return { - status: null, - taskPhase: null, - firstAssistantOutputAt: null, - }; - } - - const latestCloudJobs = await getLatestCloudJobsByTaskId([taskId]); - const latestJob = latestCloudJobs[taskId]; - - return { - status: latestJob?.status ?? null, - taskPhase: latestJob?.taskPhase ?? null, - firstAssistantOutputAt: latestJob?.firstAssistantOutputAt ?? null, - }; -} - -async function getPersistedTaskSuggestionRows(suggestionIds?: string[]) { - if (suggestionIds && suggestionIds.length === 0) { - return []; - } - - return db - .select({ - id: workItems.id, - title: workItems.title, - brief: workItems.brief, - sortOrder: workItems.sortOrder, - }) - .from(workItems) - .where( - suggestionIds - ? and( - eq(workItems.kind, 'suggestion'), - inArray(workItems.id, suggestionIds), - ) - : eq(workItems.kind, 'suggestion'), - ) - .orderBy(asc(workItems.sortOrder)); -} - -async function getPersistedQueuedSetupTasks( - setupOnboardingTaskId: string | null, - executor: DatabaseOrTransaction = db, -): Promise { - if (!setupOnboardingTaskId) { - return []; - } - - return executor - .select({ - id: workItems.id, - suggestionId: workItems.sourceWorkItemId, - title: workItems.title, - prompt: workItems.executionPrompt, - sortOrder: workItems.sortOrder, - launchedTaskId: workItems.launchedTaskId, - launchedAt: workItems.launchedAt, - environmentId: workItems.targetEnvironmentId, - }) - .from(workItems) - .where( - and( - eq(workItems.sourceTaskId, setupOnboardingTaskId), - eq(workItems.kind, 'onboarding'), - ), - ) - .orderBy(asc(workItems.sortOrder)); -} - -async function getMutableQueuedSetupTasks( - setupOnboardingTaskId: string, - executor: DatabaseOrTransaction = db, -): Promise { - return executor - .select({ - id: workItems.id, - suggestionId: workItems.sourceWorkItemId, - title: workItems.title, - prompt: workItems.executionPrompt, - sortOrder: workItems.sortOrder, - launchedTaskId: workItems.launchedTaskId, - launchedAt: workItems.launchedAt, - environmentId: workItems.targetEnvironmentId, - launchClaimedAt: workItems.launchClaimedAt, - }) - .from(workItems) - .where( - and( - eq(workItems.sourceTaskId, setupOnboardingTaskId), - eq(workItems.kind, 'onboarding'), - ), - ) - .orderBy(asc(workItems.sortOrder)); -} - -/** - * Mirrors a launched onboarding copy back onto its source suggestion so the - * suggestions UI shows the suggestion as launched once its queued onboarding - * copy launches. This is a status *mirror*, not a launch of its own, so it must - * never stomp a live claim held by another surface. - * - * It goes through the shared fenced CAS: `claimWorkItem` the suggestion, and - * only `finalizeWorkItemLaunched` when the claim succeeded. When another surface - * (e.g. web-implement) already holds a fresh claim, or the suggestion is already - * `launched`, the claim returns null and the mirror is skipped — never - * overwriting that surface's status/token. - * - * `dismissed` is opted into the claimable set (matching the web-implement - * surface in task-suggestions/implement.ts) with `clearDismissedAt`, so a - * suggestion that was dismissed after being queued still flips to `launched` - * when its onboarding copy launches. That is the least-surprising state: the - * work genuinely launched, so the suggestions UI should reflect it rather than - * showing the row as merely dismissed. - * - * The claim and finalize run in one transaction so a finalize failure rolls the - * claim back rather than stranding the suggestion in `launching` with a live - * claim until the 10-minute stale-claim recovery. - */ -async function markSuggestionWorkItemLaunched( - input: { - suggestionId: string; - launchedTaskId: string; - }, - executor: DatabaseOrTransaction = db, -) { - await executor.transaction(async (tx) => { - const claimedSuggestion = await claimWorkItem(tx, { - id: input.suggestionId, - additionalClaimableStatuses: ['dismissed'], - extraConditions: [eq(workItems.kind, 'suggestion')], - }); - - if (!claimedSuggestion) { - // Another surface holds a fresh claim, the suggestion already launched, or - // it is terminally failed. Skip the mirror rather than overwrite it. - console.warn( - `[setup-new] Skipped mirroring launched state onto suggestion ${input.suggestionId}: it is already launched or another surface holds a fresh claim.`, - ); - return; - } - - const finalized = await finalizeWorkItemLaunched(tx, { - id: input.suggestionId, - taskId: input.launchedTaskId, - claimedAt: claimedSuggestion.launchClaimedAt, - clearDismissedAt: true, - }); - - if (!finalized) { - // Our claim token was superseded between claim and finalize; leave the new - // claimant's state untouched. - console.warn( - `[setup-new] Suggestion ${input.suggestionId} mirror lost the fencing guard after claiming; another surface reclaimed it. Leaving its state untouched.`, - ); - } - }); -} - -function stripMutableQueuedSetupTask( - queuedTask: MutableQueuedSetupTask, -): PersistedQueuedSetupTask { - const { launchClaimedAt: _launchClaimedAt, ...persistedQueuedTask } = - queuedTask; - - return persistedQueuedTask; -} - -function buildCustomQueuedTaskTitle(prompt: string) { - const firstMeaningfulLine = prompt - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean); - - if (!firstMeaningfulLine) { - return 'Custom task'; - } - - // Persist a fuller title in storage; the pill strip truncates further for display. - return firstMeaningfulLine.length <= 120 - ? firstMeaningfulLine - : `${firstMeaningfulLine.slice(0, 117)}...`; -} - -async function replaceQueuedSetupTasks({ - setupOnboardingTaskId, - selectedByUserId, - selectedSuggestionIds, - customTaskPrompt, -}: { - setupOnboardingTaskId: string; - selectedByUserId: string; - selectedSuggestionIds: string[]; - customTaskPrompt: string | null; -}): Promise { - const suggestionRows = await getPersistedTaskSuggestionRows( - selectedSuggestionIds, - ); - const suggestionIdsBySortOrder = new Set(suggestionRows.map(({ id }) => id)); - - if (suggestionIdsBySortOrder.size !== selectedSuggestionIds.length) { - throw new Error( - 'One or more selected suggestions are no longer available.', - ); - } - - return db.transaction(async (tx) => { - const existingRows = await getMutableQueuedSetupTasks( - setupOnboardingTaskId, - tx, - ); - - // Refuse to replace the queue while a launch is genuinely in flight or an - // item already launched. A *stale* `launchClaimedAt` (older than the shared - // stale-claim window) is left by a crash between claim and finalize; with - // per-item stale-claim recovery now in place that row is recoverable, so it - // must not block re-queuing forever. Only a launched row or a fresh claim - // blocks replacement. - const staleClaimThreshold = new Date( - Date.now() - WORK_ITEM_LAUNCH_STALE_CLAIM_MS, - ); - - if ( - existingRows.some( - (queuedTask) => - queuedTask.launchedAt !== null || - (queuedTask.launchClaimedAt !== null && - queuedTask.launchClaimedAt > staleClaimThreshold), - ) - ) { - return existingRows.map(stripMutableQueuedSetupTask); - } - - await tx - .delete(workItems) - .where( - and( - eq(workItems.sourceTaskId, setupOnboardingTaskId), - eq(workItems.kind, 'onboarding'), - ), - ); - - const nextRows: Array<{ - kind: 'onboarding'; - sourceTaskId: string; - selectedByUserId: string; - sourceWorkItemId: string | null; - title: string; - executionPrompt: string; - sortOrder: number; - }> = suggestionRows.map((suggestion, index) => ({ - kind: 'onboarding', - sourceTaskId: setupOnboardingTaskId, - selectedByUserId, - sourceWorkItemId: suggestion.id, - title: suggestion.title, - executionPrompt: suggestion.brief ?? '', - sortOrder: index, - })); - - if (customTaskPrompt) { - nextRows.push({ - kind: 'onboarding', - sourceTaskId: setupOnboardingTaskId, - selectedByUserId, - sourceWorkItemId: null, - title: buildCustomQueuedTaskTitle(customTaskPrompt), - executionPrompt: customTaskPrompt, - sortOrder: nextRows.length, - }); - } - - if (nextRows.length === 0) { - return []; - } - - return tx.insert(workItems).values(nextRows).returning({ - id: workItems.id, - suggestionId: workItems.sourceWorkItemId, - title: workItems.title, - prompt: workItems.executionPrompt, - sortOrder: workItems.sortOrder, - launchedTaskId: workItems.launchedTaskId, - launchedAt: workItems.launchedAt, - environmentId: workItems.targetEnvironmentId, - }); - }); -} - -type ClaimedQueuedSetupTask = { - id: string; - suggestionId: string | null; - selectedByUserId: string | null; - prompt: string | null; - /** The `launchClaimedAt` fencing token for this claim. */ - claimedAt: Date; -}; - -/** - * Claims the queued onboarding items for a setup task through the shared fenced - * CAS, preserving the previous batch semantics: fetch the candidate onboarding - * rows for the setup task in queue order, attempt to claim each, and return - * whichever claims succeeded (each carrying its own fencing token). - * - * Migrated off the single batch UPDATE gated on `launchedAt IS NULL AND - * launchClaimedAt IS NULL`, which had no stale-claim recovery: a crash between - * claim and finalize left an item `launching` forever. `claimWorkItem` instead - * claims `open` OR a stale `launching` row (older than the shared stale-claim - * window) and guards `launched_task_id IS NULL`, so a crashed launch recovers, - * a fresh in-flight claim on one item is skipped (only that item), and an - * already-launched item is never re-claimed. Onboarding rows are inserted with - * the `work_items.status` default of `open`, so the claim predicate matches the - * rows produced by `replaceQueuedSetupTasks`. Each claim is its own atomic CAS, - * so no wrapping transaction is needed. - */ -async function claimQueuedSetupTasksForLaunch( - setupOnboardingTaskId: string, -): Promise { - const candidates = await db - .select({ id: workItems.id }) - .from(workItems) - .where( - and( - eq(workItems.sourceTaskId, setupOnboardingTaskId), - eq(workItems.kind, 'onboarding'), - ), - ) - .orderBy(asc(workItems.sortOrder)); - - const claimedTasks: ClaimedQueuedSetupTask[] = []; - - for (const candidate of candidates) { - const claimed = await claimWorkItem(db, { - id: candidate.id, - extraConditions: [eq(workItems.kind, 'onboarding')], - }); - - if (!claimed) { - continue; - } - - claimedTasks.push({ - id: claimed.id, - suggestionId: claimed.sourceWorkItemId, - selectedByUserId: claimed.selectedByUserId, - prompt: claimed.executionPrompt, - claimedAt: claimed.launchClaimedAt, - }); - } - - return claimedTasks; -} - -// Exported for the DB-backed launch-lifecycle tests, which exercise the fenced -// claim/finalize/release flow directly. Not part of the tRPC command surface. -export async function launchQueuedSetupTasksIfReady({ - setupOnboardingTaskId, - matchingEnvironmentId, - slackTeamId, - slackChannel, - slackThreadTs, - chatHandoffProvider, - chatHandoffChannelId, - chatHandoffThreadId, - chatHandoffServiceUrl, -}: { - setupOnboardingTaskId: string | null; - matchingEnvironmentId: string | null; - slackTeamId?: string | null; - slackChannel?: string | null; - slackThreadTs?: string | null; - chatHandoffProvider?: string | null; - chatHandoffChannelId?: string | null; - chatHandoffThreadId?: string | null; - chatHandoffServiceUrl?: string | null; -}) { - if (!setupOnboardingTaskId || !matchingEnvironmentId) { - return; - } - - const claimedTasks = await claimQueuedSetupTasksForLaunch( - setupOnboardingTaskId, - ); - - if (claimedTasks.length === 0) { - return; - } - - // Non-Slack kickoffs (Telegram, Teams) carry provider-neutral - // communication metadata so the launched starter tasks reply into the same - // chat that hosted the setup kickoff. - const nonSlackChatHandoffProvider = - chatHandoffProvider === 'telegram' || chatHandoffProvider === 'teams' - ? chatHandoffProvider - : null; - const communicationMetadata = - nonSlackChatHandoffProvider && chatHandoffChannelId - ? { - communicationProvider: nonSlackChatHandoffProvider, - communicationChannelId: chatHandoffChannelId, - // Telegram treats communicationThreadId as a forum-topic - // message_thread_id, which the private primary chat does not have, - // so only Teams threads starter-task replies under the kickoff - // message. - ...(nonSlackChatHandoffProvider === 'teams' && chatHandoffThreadId - ? { communicationThreadId: chatHandoffThreadId } - : {}), - ...(chatHandoffServiceUrl - ? { communicationServiceUrl: chatHandoffServiceUrl } - : {}), - } - : {}; - - const queuedSourceControlProvider = - await resolveEnvironmentSourceControlProvider(matchingEnvironmentId); - - await Promise.allSettled( - claimedTasks.map(async (queuedTask) => { - let launchResult: Awaited>; - - try { - launchResult = await enqueueCloudTask({ - task: { - type: TaskPayloadKind.StandardTask, - payload: { - repo: '', - environmentId: matchingEnvironmentId, - ...(queuedSourceControlProvider - ? { sourceControlProvider: queuedSourceControlProvider } - : {}), - ...(slackTeamId ? { teamId: slackTeamId } : {}), - ...(slackChannel ? { slackChannel } : {}), - ...(slackThreadTs ? { slackThreadTs } : {}), - ...communicationMetadata, - description: queuedTask.prompt ?? '', - }, - }, - initiator: { - kind: 'user', - userId: queuedTask.selectedByUserId ?? SETUP_BOOTSTRAP_USER_ID, - }, - workflow: 'setup_onboarding', - surface: 'web', - trigger: 'manual', - ...(slackChannel || slackThreadTs - ? { - channels: { - slackChannelId: slackChannel ?? null, - slackThreadTs: slackThreadTs ?? null, - }, - } - : {}), - }); - } catch (error) { - // The enqueue never succeeded, so no run exists: release our claim back - // to `open` so a later trigger can retry promptly. The fenced release - // never reverts a `launched` item and never reverts a claim already - // reclaimed by another launcher. Only a pre-enqueue failure may release; - // see the post-enqueue invariant below. - await releaseWorkItemClaim(db, { - id: queuedTask.id, - claimedAt: queuedTask.claimedAt, - extraConditions: [eq(workItems.kind, 'onboarding')], - }); - - console.warn( - `[setup-new] enqueue failed for onboarding work item ${queuedTask.id}; released its claim back to open — ${ - error instanceof Error ? error.message : String(error) - }.`, - ); - return; - } - - // Fenced finalize: `launching` -> `launched` only when our claim token - // still matches, stamping `targetEnvironmentId` in the same guarded write. - // Runs directly against `db` (not a transaction): it is a single fenced - // UPDATE, and the suggestion mirror below deliberately no longer shares - // its atomicity so a mirror failure can never roll back a healthy launch. - // - // Invariant: once the task is enqueued, a failure of unknown cause must - // never release the claim. Releasing would let the next readiness pass - // re-claim and launch a duplicate immediately, while leaving the claim in - // place lets stale-claim recovery retry safely only after the shared - // window. So a throw here is treated exactly like a lost finalize - // (`finalized = false`), which drives the orphan-cancel branch. In the - // rare ambiguous case where the finalize committed but its ack was lost, - // the cancel may kill a healthy linked run; that trade is intentional — a - // visibly canceled task beats a silent duplicate. - let finalized: boolean; - - try { - finalized = await finalizeWorkItemLaunched(db, { - id: queuedTask.id, - taskId: launchResult.taskId, - claimedAt: queuedTask.claimedAt, - targetEnvironmentId: matchingEnvironmentId, - }); - } catch (error) { - console.warn( - `[setup-new] finalize threw for onboarding work item ${queuedTask.id} after enqueuing task ${launchResult.taskId} (run ${launchResult.id}); treating as a lost finalize and leaving the claim for stale-claim recovery — ${ - error instanceof Error ? error.message : String(error) - }.`, - ); - finalized = false; - } - - if (!finalized) { - // The task is already enqueued but the finalize did not commit (the - // fencing guard rejected it, or it threw), so the run is orphaned from - // this work item. Best-effort cancel it while it is still pre-sandbox, - // and log loudly either way with the cancel outcome (matches the - // implement.ts orphan handling). Return before the mirror: a lost or - // failed finalize must never mirror a launch that did not link. - let cancelNote = 'orphaned run left running'; - - try { - const canceled = await cancelTaskRunDirect({ - runId: launchResult.id, - error: - 'Canceled: setup-new queued task launch finalize lost the claim fencing guard', - }); - cancelNote = canceled - ? 'orphaned run canceled' - : 'orphaned run cancel did not apply (already started or terminal)'; - } catch (cancelError) { - cancelNote = `orphaned run cancel failed: ${ - cancelError instanceof Error - ? cancelError.message - : String(cancelError) - }`; - } - - console.warn( - `[setup-new] finalize lost the fencing guard for onboarding work item ${queuedTask.id}; orphaned task ${launchResult.taskId} (run ${launchResult.id}) runs unlinked — ${cancelNote}.`, - ); - return; - } - - // Mirror the launched state onto the source suggestion only after a - // committed finalize, outside any transaction and best-effort: the launch - // link is already finalized and must stay finalized, so a mirror throw is - // logged and swallowed rather than allowed to undo the launch. - if (queuedTask.suggestionId) { - try { - await markSuggestionWorkItemLaunched({ - suggestionId: queuedTask.suggestionId, - launchedTaskId: launchResult.taskId, - }); - } catch (error) { - console.warn( - `[setup-new] failed to mirror launched state onto suggestion ${queuedTask.suggestionId} for task ${launchResult.taskId}; the onboarding launch stays finalized — ${ - error instanceof Error ? error.message : String(error) - }.`, - ); - } - } - }), - ); -} - -async function getMatchingEnvironmentSummary({ - selectedRepositoryFullNames, - onboardingTaskStartedAt, -}: { - selectedRepositoryFullNames: string[]; - onboardingTaskStartedAt: string | null; -}) { - if ( - selectedRepositoryFullNames.length === 0 || - onboardingTaskStartedAt === null - ) { - return null; - } - - const environmentList = await db.query.environments.findMany({ - where: eq(environments.isEval, false), - columns: { - id: true, - name: true, - config: true, - createdAt: true, - }, - }); - - const matchingEnvironment = findMatchingSetupNewEnvironment( - environmentList, - selectedRepositoryFullNames, - onboardingTaskStartedAt, - ); - - if (matchingEnvironment) { - return { - id: matchingEnvironment.id, - name: matchingEnvironment.name, - }; - } - - return null; -} - -async function getActiveSetupQualificationBlock( - auth: UserAuthSuccess, -): Promise { - await Promise.all([ - syncSetupQualificationBlock({ - userId: auth.userId, - reason: 'github_organization_required', - blocked: false, - }), - ]); - - return null; -} - -async function assertSetupQualificationNotBlocked(auth: UserAuthSuccess) { - const activeSetupQualificationBlock = - await getActiveSetupQualificationBlock(auth); - - if (!activeSetupQualificationBlock) { - return; - } - - throw new Error(getSetupQualificationBlockErrorMessage()); -} - -export async function getSetupNewStatusCommand(auth: UserAuthSuccess) { - assertAdmin(auth); - - const { userId } = auth; - await purgeSavedDeploymentWorkerImage(); - - const [ - baseStatus, - slackAccessStatus, - persistedRuntimeModelConfig, - persistedRuntimeComputeConfig, - envVarNames, - nonSecretComputeEnvValues, - chatgptConnected, - ] = await Promise.all([ - getSetupBaseStatus(auth), - getSetupSlackAccessStatus({ userId }), - getPersistedRuntimeModelConfig(), - getPersistedRuntimeComputeConfig(), - getPersistedEnvironmentVariableNames(), - getPersistedEnvironmentVariableValues([ - ...NON_SECRET_COMPUTE_ENV_VAR_NAMES, - ]), - isChatGptSubscriptionConnected(), - ]); - const activeSetupQualificationBlock = - await getActiveSetupQualificationBlock(auth); - let setupNewState = normalizeSetupNewState(baseStatus.setupNewState); - - if ( - setupNewState.onboardingTaskId && - setupNewState.suggestionTaskId === null && - hasSetupChatHandoffDestination(setupNewState) - ) { - try { - await triggerTaskSuggestionsCommand(auth); - setupNewState = await getPersistedSetupNewState(); - } catch (error) { - console.error( - `[getSetupNewStatusCommand] Failed to trigger task suggestions: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - - const { selectedRepositories } = await resolveSelectedRepositories( - setupNewState.selectedRepositoryIds, - ).catch(() => ({ - normalizedRepositoryIds: setupNewState.selectedRepositoryIds, - selectedRepositories: [] as SelectedRepositorySummary[], - })); - - const selectedRepositoryFullNames = selectedRepositories.map( - (repository) => repository.fullName, - ); - - const [onboardingTask, matchingEnvironment] = await Promise.all([ - getOnboardingTaskState(setupNewState.onboardingTaskId), - getMatchingEnvironmentSummary({ - selectedRepositoryFullNames, - onboardingTaskStartedAt: setupNewState.onboardingTaskStartedAt, - }), - ]); - const onboardingTaskStatus = onboardingTask.status; - const onboardingTaskPhase = onboardingTask.taskPhase; - const onboardingTaskFirstAssistantOutputAt = - onboardingTask.firstAssistantOutputAt; - - const onboardingSucceeded = - isSetupNewOnboardingSuccessStatus( - onboardingTaskStatus, - onboardingTaskPhase, - ) && matchingEnvironment !== null; - const onboardingEndedWithoutEnvironment = - isSetupNewOnboardingTerminalSuccessStatus( - onboardingTaskStatus, - onboardingTaskPhase, - ) && matchingEnvironment === null; - const onboardingFailed = - (isSetupNewOnboardingFailureStatus(onboardingTaskStatus) || - onboardingEndedWithoutEnvironment) && - !onboardingSucceeded; - - await launchQueuedSetupTasksIfReady({ - setupOnboardingTaskId: setupNewState.onboardingTaskId, - matchingEnvironmentId: onboardingSucceeded ? matchingEnvironment.id : null, - slackTeamId: setupNewState.slackTeamId, - slackChannel: setupNewState.slackChannel, - slackThreadTs: setupNewState.slackThreadTs, - chatHandoffProvider: setupNewState.chatHandoffProvider, - chatHandoffChannelId: setupNewState.chatHandoffChannelId, - chatHandoffThreadId: setupNewState.chatHandoffThreadId, - chatHandoffServiceUrl: setupNewState.chatHandoffServiceUrl, - }); - - const queuedOnboardingTasks = await getPersistedQueuedSetupTasks( - setupNewState.onboardingTaskId, - ); - const authSetup = buildSetupAuthStatus({ - runtimeEnv: process.env, - persistedEnvVarNames: envVarNames, - selectedProvider: setupNewState.authProvider, - }); - const modelSetup = buildSetupModelStatus({ - runtimeEnv: process.env, - persistedModelConfig: persistedRuntimeModelConfig, - persistedEnvVarNames: envVarNames, - selectedProvider: setupNewState.modelProvider, - chatgptConnected, - }); - const computeSetup = buildSetupComputeStatus({ - runtimeEnv: process.env, - persistedEnvVarNames: envVarNames, - persistedEnvVarValues: nonSecretComputeEnvValues, - persistedComputeConfig: persistedRuntimeComputeConfig, - selectedProvider: setupNewState.computeProvider, - }); - // Present stale in-flight provisioning runs as failed so the wizard - // offers a retry instead of polling forever after a web-process restart. - setupNewState = { - ...setupNewState, - e2bTemplateBuild: presentSetupNewComputeProvisioning( - setupNewState.e2bTemplateBuild, - ), - daytonaSnapshotBuild: presentSetupNewComputeProvisioning( - setupNewState.daytonaSnapshotBuild, - ), - }; - - const sourceControlConnection = await getSourceControlConnectionSummary(); - const gitlabBaseUrl = await resolveDeploymentEnvVar('GITLAB_BASE_URL'); - const sourceControlSetup = buildSetupSourceControlStatus({ - runtimeEnv: process.env, - persistedEnvVarNames: envVarNames, - selectedProvider: setupNewState.sourceControlProvider, - connectedProviders: sourceControlConnection.connectedProviders, - repositoryCounts: sourceControlConnection.repositoryCounts, - gitlabBaseUrl, - }); - - return { - hasGitHub: baseStatus.hasGitHub, - hasSlack: slackAccessStatus.hasSlackUserMapping, - hasSlackInstallation: slackAccessStatus.hasSlackInstallation, - hasLinear: baseStatus.hasLinear, - setupCompletedAt: baseStatus.setupCompletedAt, - setupNewState, - selectedRepositories, - onboardingTaskStatus, - onboardingTaskPhase, - onboardingTaskFirstAssistantOutputAt, - onboardingSucceeded, - onboardingFailed, - matchingEnvironment, - queuedOnboardingTasks, - authSetup, - modelSetup, - computeSetup, - sourceControlSetup, - setupQualification: { - activeBlock: activeSetupQualificationBlock, - }, - }; -} - -export async function saveSetupNewModelConfigCommand( - auth: UserAuthSuccess, - input: { - provider: SetupModelProviderId; - apiKey?: string; - additionalEnvValues?: Record; - }, -) { - assertAdmin(auth); - - const { userId } = auth; - const provider = getSetupModelProvider(input.provider); - const isOauthProvider = provider.authKind === 'oauth'; - - const chatgptConnected = await isChatGptSubscriptionConnected(); - - // OAuth providers (the ChatGPT subscription) are connected through the - // device-code flow rather than an API key. The setup wizard's Continue - // records the provider choice and default model only after the operator - // has connected an account, so the env-vars step becomes satisfied without - // a credential env var. - if (isOauthProvider && !chatgptConnected) { - throw new Error( - `Connect your ${provider.label} account to continue, or pick a different provider.`, - ); - } - - return db.transaction(async (tx) => { - const [currentState, persistedEnvVarNames, persistedTaskModelSettings] = - await Promise.all([ - getPersistedSetupNewState(tx), - getPersistedEnvironmentVariableNames(tx), - getPersistedRawTaskModelSettings(tx), - ]); - const persistedEnvVarNameSet = new Set(persistedEnvVarNames); - - if (!isOauthProvider) { - const { values: credentialValues, clearedEnvVarNames } = - collectSetupModelProviderCredentialValues({ - provider, - apiKey: input.apiKey, - additionalEnvValues: input.additionalEnvValues, - isEnvVarSatisfied: (envVarName) => - persistedEnvVarNameSet.has(envVarName) || - isConfiguredEnvValue(process.env[envVarName]), - action: 'continue', - }); - - if (credentialValues.length > 0) { - await upsertDeploymentEnvironmentVariables(tx, { - userId, - values: credentialValues, - }); - } - - // Optional fields submitted as blank clear their previously saved value - // (deployment-level rows only, mirroring how they are stored). - const clearedPersistedEnvVarNames = clearedEnvVarNames.filter((name) => - persistedEnvVarNameSet.has(name), - ); - - if (clearedPersistedEnvVarNames.length > 0) { - await tx - .delete(environmentVariables) - .where( - and( - isNull(environmentVariables.userId), - inArray(environmentVariables.name, clearedPersistedEnvVarNames), - ), - ); - } - } - - const setupNewState = normalizeSetupNewState({ - ...currentState, - modelProvider: input.provider, - lastInteractedByUserId: userId, - }); - const runtimeModelConfig = normalizeDeploymentModelConfig({ - roomoteModel: provider.defaultRoomoteModel, - }); - - // Mirror the models settings page: connecting a provider the deployment - // has no models for yet auto-adds its recommended models so the first - // launch offers a usable model list. - const connectedProviderIds = new Set([ - provider.id, - ...collectConnectedTaskModelProviderIds({ - runtimeEnv: process.env, - persistedEnvVarNames, - chatgptConnected, - }), - ]); - const autoAdd = buildAutoAddedTaskModelSettings({ - provider, - persistedTaskModelSettings, - connectedProviderIds, - }); - - await Promise.all([ - savePersistedSetupNewState(setupNewState, tx), - savePersistedRuntimeModelConfig(runtimeModelConfig, tx), - ...(autoAdd - ? [savePersistedTaskModelSettings(autoAdd.taskModelSettings, tx)] - : []), - ]); - - return { - setupNewState, - runtimeModelConfig, - }; - }); -} - -export async function saveSetupNewComputeProviderChoiceCommand( - auth: UserAuthSuccess, - input: { - provider: ComputeProvider; - }, -) { - assertAdmin(auth); - - const { userId } = auth; - - return db.transaction(async (tx) => { - const [currentState, persistedRuntimeComputeConfig, persistedEnvVarNames] = - await Promise.all([ - getPersistedSetupNewState(tx), - getPersistedRuntimeComputeConfig(tx), - getPersistedEnvironmentVariableNames(tx), - ]); - const computeSetup = buildSetupComputeStatus({ - runtimeEnv: process.env, - persistedEnvVarNames, - persistedComputeConfig: persistedRuntimeComputeConfig, - selectedProvider: input.provider, - }); - const providerStatus = computeSetup.providers.find( - (candidate) => candidate.provider === input.provider, - ); - - if (!providerStatus) { - throw new Error('Selected sandbox provider is unavailable.'); - } - - const hasCredentialFields = providerStatus.fields.length > 0; - const runtimeComputeConfig = hasCredentialFields - ? persistedRuntimeComputeConfig - : normalizeDeploymentComputeConfig({ - defaultProvider: input.provider, - }); - - // Providers with credentials are only recorded as the wizard choice here. - // The runtime default commits when their config step is confirmed, so - // merely browsing a hosted provider must not switch the deployment onto it. - // Credentialless providers such as Local Docker have no config step to - // confirm, so choosing them commits the runtime default immediately. - const setupNewState = normalizeSetupNewState({ - ...currentState, - computeProvider: input.provider, - lastInteractedByUserId: userId, - }); - - await Promise.all([ - savePersistedSetupNewState(setupNewState, tx), - ...(hasCredentialFields - ? [] - : [savePersistedRuntimeComputeConfig(runtimeComputeConfig, tx)]), - ]); - - return { - setupNewState, - runtimeComputeConfig, - }; - }); -} - -export async function saveSetupNewComputeConfigCommand( - auth: UserAuthSuccess, - input: { - provider: ComputeProvider; - values?: Partial>; - }, -) { - assertAdmin(auth); - - const { userId } = auth; - const provider = getSetupComputeProvider(input.provider); - - const { provisioningToStart, ...result } = await db.transaction( - async (tx) => { - // Resolved inside the transaction, kicked off only after it commits so - // the detached build reads the credentials the save just persisted. - let provisioningToStart: { - provider: SetupProvisionableComputeProvider; - imageRef: string; - templateRef: string; - } | null = null; - - await purgeSavedDeploymentWorkerImage(tx); - - const [ - currentState, - persistedRuntimeComputeConfig, - persistedEnvVarNames, - ] = await Promise.all([ - getPersistedSetupNewState(tx), - getPersistedRuntimeComputeConfig(tx), - getPersistedEnvironmentVariableNames(tx), - ]); - - // In-request DOCKER_WORKER_IMAGE is only used for this save/provisioning - // pass. It is not persisted as sticky deployment state; process env and - // RELEASE_VERSION derivation own runtime configuration. - const submittedWorkerImage = - input.values?.[SHARED_WORKER_IMAGE_ENV_VAR]?.trim() || null; - const effectiveWorkerImage = - process.env[SHARED_WORKER_IMAGE_ENV_VAR]?.trim() || - submittedWorkerImage || - deriveWorkerImageFromReleaseVersion(process.env) || - undefined; - - const computeSetup = buildSetupComputeStatus({ - runtimeEnv: process.env, - persistedEnvVarNames, - persistedComputeConfig: persistedRuntimeComputeConfig, - selectedProvider: input.provider, - }); - const providerStatus = computeSetup.providers.find( - (candidate) => candidate.provider === input.provider, - ); - - if (!providerStatus) { - throw new Error('Selected sandbox provider is unavailable.'); - } - - // Derive MODAL_BASE_IMAGE_REF when not env-provided or already saved. - // Form submissions are ignored (deployment-managed like E2B/Daytona - // artifacts). - const derivedInfraDefaults = new Map(); - - if (input.provider === 'modal') { - const baseImageField = providerStatus.fields.find( - (field) => field.envVarName === 'MODAL_BASE_IMAGE_REF', - ); - const derivedBaseImageRef = resolveDerivedModalBaseImageRef({ - ...process.env, - DOCKER_WORKER_IMAGE: effectiveWorkerImage, - }); - - if ( - baseImageField && - !baseImageField.runtimeSatisfied && - !baseImageField.savedSatisfied && - derivedBaseImageRef - ) { - derivedInfraDefaults.set('MODAL_BASE_IMAGE_REF', derivedBaseImageRef); - } - } - - // Provisionable providers' base images (the E2B worker template, the - // Daytona worker snapshot) cannot be derived like the Modal base image - // — they are artifacts inside the operator's provider account. Manual - // form overrides are not accepted; process env or auto-provisioning - // owns them. When a registry-qualified worker image exists, the save - // records credentials, marks the run as pending, and the run executes - // detached after commit. - const setupProvisioningFieldNames = new Set(); - - if (isSetupProvisionableComputeProvider(input.provider)) { - const provisionableProvider = input.provider; - const artifactEnvVar = - provisionableProvider === 'e2b' - ? 'E2B_TEMPLATE_ID' - : 'DAYTONA_SNAPSHOT_NAME'; - const provisioning = await prepareComputeProvisioningStart({ - provider: provisionableProvider, - providerStatus, - existingState: getSetupNewComputeProvisioningState( - currentState, - provisionableProvider, - ), - // Process env, in-request override, or RELEASE_VERSION derivation. - dockerWorkerImage: effectiveWorkerImage, - runtimeEnv: process.env, - markPending: (nextState) => { - provisioningToStart = { - provider: provisionableProvider, - imageRef: nextState.imageRef, - templateRef: nextState.templateRef ?? '', - }; - }, - }); - - if (provisioning.fieldPending) { - if (!provisioning.provisionable) { - throw new Error( - `${providerStatus.label} needs a registry-qualified worker image (for example via DOCKER_WORKER_IMAGE) so Roomote can provision the worker base image automatically.`, - ); - } - - setupProvisioningFieldNames.add(artifactEnvVar); - } - - if (provisioning.start) { - provisioningToStart = provisioning.start; - } - } - - // Credentials and operator-editable infrastructure are persisted as - // encrypted deployment env vars. Managed artifacts are process-env / - // derived / provisioning only. - const valuesToSave: Array<{ name: string; value: string }> = []; - const envVarsToClear: string[] = []; - - for (const field of providerStatus.fields) { - if (field.runtimeSatisfied) { - continue; - } - - if (isAutoProvisionedComputeArtifactField(field)) { - continue; - } - - const submitted = - field.envVarName === 'MODAL_BASE_IMAGE_REF' - ? '' - : (input.values?.[field.envVarName]?.trim() ?? ''); - const nextValue = - submitted || - (isComputeInfrastructureField(field) - ? (derivedInfraDefaults.get(field.envVarName) ?? '') - : ''); - - if (!nextValue) { - if ( - field.secret !== true && - !isRequiredComputeField(field) && - field.savedSatisfied - ) { - envVarsToClear.push(field.envVarName); - } - continue; - } - - valuesToSave.push({ name: field.envVarName, value: nextValue }); - } - - const hasMissingRequiredValue = providerStatus.fields.some((field) => { - if (setupProvisioningFieldNames.has(field.envVarName)) { - return false; - } - - // Modal base image is not form-collected; only runtime / saved / derived - // values count toward satisfaction (same as the save loop above). - const submitted = - field.envVarName === 'MODAL_BASE_IMAGE_REF' - ? '' - : (input.values?.[field.envVarName]?.trim() ?? ''); - const nextValue = - submitted || - (isComputeInfrastructureField(field) - ? (derivedInfraDefaults.get(field.envVarName) ?? '') - : ''); - - return ( - isRequiredComputeField(field) && - !field.runtimeSatisfied && - !field.savedSatisfied && - nextValue.length === 0 - ); - }); - - if (hasMissingRequiredValue) { - throw new Error( - `Enter the required ${provider.label} configuration values to continue.`, - ); - } - - if (valuesToSave.length > 0) { - await upsertDeploymentEnvironmentVariables(tx, { - userId, - values: valuesToSave, - }); - } - - if (envVarsToClear.length > 0) { - await tx - .delete(environmentVariables) - .where( - and( - isNull(environmentVariables.userId), - inArray(environmentVariables.name, envVarsToClear), - ), - ); - } - - const setupNewState = normalizeSetupNewState({ - ...currentState, - computeProvider: input.provider, - ...(provisioningToStart - ? { - [SETUP_COMPUTE_PROVISIONING_STATE_FIELDS[ - provisioningToStart.provider - ]]: createPendingComputeProvisioning(provisioningToStart), - } - : {}), - lastInteractedByUserId: userId, - }); - const runtimeComputeConfig = normalizeDeploymentComputeConfig({ - defaultProvider: input.provider, - }); - - await Promise.all([ - savePersistedSetupNewState(setupNewState, tx), - savePersistedRuntimeComputeConfig(runtimeComputeConfig, tx), - ]); - - return { - setupNewState, - runtimeComputeConfig, - provisioningToStart, - }; - }, - ); - - if (provisioningToStart) { - void runComputeProvisioning({ - userId, - ...provisioningToStart, - }); - } - - return result; -} - -export async function saveSetupNewAuthProviderChoiceCommand( - auth: UserAuthSuccess, - input: { - provider: SetupAuthProviderId; - }, -) { - assertAdmin(auth); - - const { userId } = auth; - return saveSetupAuthProviderChoice({ - provider: input.provider, - actorUserId: userId, - }); -} - -async function saveSetupAuthProviderChoice(input: { - provider: SetupAuthProviderId; - actorUserId: string | null; - requireBootstrapOpen?: boolean; -}) { - if (input.requireBootstrapOpen) { - await assertSetupBootstrapOpen(); - } - - return db.transaction(async (tx) => { - const currentState = await getPersistedSetupNewState(tx); - const setupNewState = normalizeSetupNewState({ - ...currentState, - authProvider: input.provider, - lastInteractedByUserId: input.actorUserId, - }); - - await savePersistedSetupNewState(setupNewState, tx); - - return { - setupNewState, - }; - }); -} - -export async function saveSetupNewAuthConfigCommand( - auth: UserAuthSuccess, - input: { - provider: SetupAuthProviderId; - values?: Partial>; - }, -) { - assertAdmin(auth); - - const { userId } = auth; - return saveSetupAuthConfig({ - provider: input.provider, - values: input.values, - actorUserId: userId, - }); -} - -async function saveSetupAuthConfig(input: { - provider: SetupAuthProviderId; - values?: Partial>; - actorUserId: string | null; - requireBootstrapOpen?: boolean; -}) { - if (input.requireBootstrapOpen) { - await assertSetupBootstrapOpen(); - } - const provider = getSetupAuthProvider(input.provider); - - return db.transaction(async (tx) => { - const [currentState, persistedEnvVarNames] = await Promise.all([ - getPersistedSetupNewState(tx), - getPersistedEnvironmentVariableNames(tx), - ]); - const authSetup = buildSetupAuthStatus({ - runtimeEnv: process.env, - persistedEnvVarNames, - selectedProvider: input.provider, - }); - const providerStatus = authSetup.providers.find( - (candidate) => candidate.id === input.provider, - ); - - if (!providerStatus) { - throw new Error('Selected auth provider is unavailable.'); - } - - const valuesToSave = providerStatus.fields.flatMap((field) => { - const nextValue = input.values?.[field.envVarName]?.trim() ?? ''; - - if (!nextValue) { - return []; - } - - return [ - { - name: field.envVarName, - value: nextValue, - }, - ]; - }); - - const hasMissingRequiredValue = providerStatus.fields.some((field) => { - const nextValue = input.values?.[field.envVarName]?.trim() ?? ''; - - return ( - field.required !== false && - !field.runtimeSatisfied && - !field.savedSatisfied && - nextValue.length === 0 - ); - }); - - if (hasMissingRequiredValue) { - throw new Error( - `Enter the required ${provider.label} configuration values to continue.`, - ); - } - - if (valuesToSave.length > 0) { - const auditUserId = - input.actorUserId ?? (await ensureSetupBootstrapAuditUser(tx)); - - await upsertDeploymentEnvironmentVariables(tx, { - userId: auditUserId, - values: valuesToSave, - }); - } - - const setupNewState = normalizeSetupNewState({ - ...currentState, - authProvider: input.provider, - lastInteractedByUserId: input.actorUserId, - }); - - await savePersistedSetupNewState(setupNewState, tx); - - return { - setupNewState, - }; - }); -} - -export async function saveSetupNewSourceControlProviderChoiceCommand( - auth: UserAuthSuccess, - input: { - provider: SourceControlProvider; - }, -) { - assertAdmin(auth); - - const { userId } = auth; - - return db.transaction(async (tx) => { - const currentState = await getPersistedSetupNewState(tx); - const setupNewState = normalizeSetupNewState({ - ...currentState, - sourceControlProvider: input.provider, - lastInteractedByUserId: userId, - }); - - await savePersistedSetupNewState(setupNewState, tx); - - return { - setupNewState, - }; - }); -} - -export async function saveSetupNewSourceControlConfigCommand( - auth: UserAuthSuccess, - input: { - provider: SourceControlProvider; - values?: Partial>; - }, -) { - assertAdmin(auth); - - const { userId } = auth; - - return saveSourceControlConfig({ - provider: input.provider, - values: input.values, - actorUserId: userId, - }); -} - -async function saveSourceControlConfig(input: { - provider: SourceControlProvider; - values?: Partial>; - actorUserId: string; -}) { - // Provider-API validation happens before the transaction so the external - // HTTP round-trip never holds a pooled DB connection open. - await assertValidSourceControlConfigInput(input); - - return db.transaction(async (tx) => { - const currentState = await getPersistedSetupNewState(tx); - - await saveSourceControlConfigValues({ - executor: tx, - actorUserId: input.actorUserId, - provider: input.provider, - values: input.values, - }); - - const setupNewState = normalizeSetupNewState({ - ...currentState, - sourceControlProvider: input.provider, - lastInteractedByUserId: input.actorUserId, - }); - - await savePersistedSetupNewState(setupNewState, tx); - - return { - setupNewState, - }; - }); -} - -/** - * Resolves the setup token for bootstrap commands: the explicit input when - * the client still has it (e.g. from the ?token= query param), otherwise the - * invite cookie, which is the only place the token survives OAuth sign-in - * round-trips. - */ -async function resolveSetupTokenInput( - setupToken: string | undefined, -): Promise { - if (setupToken != null) { - return setupToken; - } - - return (await getRequestInviteToken()) ?? undefined; -} - -export async function getSetupBootstrapStatusCommand(input?: { - setupToken?: string; -}) { - const bootstrapState = await getSetupBootstrapState(); - const setupTokenRequired = bootstrapState.setupOpen && isSetupTokenRequired(); - - if ( - setupTokenRequired && - !isSetupTokenValid(await resolveSetupTokenInput(input?.setupToken)) - ) { - return { - setupOpen: bootstrapState.setupOpen, - setupTokenRequired, - setupTokenSatisfied: false, - authSetup: null, - }; - } - - const [setupNewState, persistedEnvVarNames] = await Promise.all([ - getPersistedSetupNewState(), - getPersistedEnvironmentVariableNames(), - ]); - - return { - setupOpen: bootstrapState.setupOpen, - setupTokenRequired, - setupTokenSatisfied: true, - authSetup: buildSetupAuthStatus({ - runtimeEnv: process.env, - persistedEnvVarNames, - selectedProvider: setupNewState.authProvider, - }), - }; -} - -export async function saveSetupBootstrapAuthProviderChoiceCommand(input: { - provider: SetupAuthProviderId; - setupToken?: string; -}) { - assertSetupTokenValid(await resolveSetupTokenInput(input.setupToken)); - - return saveSetupAuthProviderChoice({ - provider: input.provider, - actorUserId: null, - requireBootstrapOpen: true, - }); -} - -export async function saveSetupBootstrapAuthConfigCommand(input: { - provider: SetupAuthProviderId; - values?: Partial>; - setupToken?: string; -}) { - assertSetupTokenValid(await resolveSetupTokenInput(input.setupToken)); - - return saveSetupAuthConfig({ - provider: input.provider, - values: input.values, - actorUserId: null, - requireBootstrapOpen: true, - }); -} - -export async function saveSetupNewSelectionCommand( - auth: UserAuthSuccess, - input: { - repositoryIds: string[]; - setupGuidance?: string; - selectedModelId?: string; - }, -) { - assertAdmin(auth); - await assertSetupQualificationNotBlocked(auth); - - const { userId } = auth; - - if (input.repositoryIds.length === 0) { - throw new Error('Select at least one repository to continue.'); - } - - const { normalizedRepositoryIds } = await resolveSelectedRepositories( - input.repositoryIds, - ); - await assertHasCommittedRepositorySelection(normalizedRepositoryIds); - const nextSetupGuidance = input.setupGuidance?.trim() || null; - const nextSelectedModelId = input.selectedModelId?.trim() || null; - - return db.transaction(async (tx) => { - const currentState = await getPersistedSetupNewState(tx); - const setupNewState = normalizeSetupNewState({ - ...currentState, - selectedRepositoryIds: normalizedRepositoryIds, - setupGuidance: nextSetupGuidance, - selectedModelId: nextSelectedModelId, - onboardingTaskId: null, - onboardingTaskStartedAt: null, - slackTeamId: null, - slackChannel: null, - slackThreadTs: null, - chatHandoffProvider: null, - chatHandoffChannelId: null, - chatHandoffThreadId: null, - chatHandoffServiceUrl: null, - suggestionTaskId: null, - suggestionTaskStartedAt: null, - suggestionGenerationTriggeredAt: null, - lastInteractedByUserId: userId, - }); - - await savePersistedSetupNewState(setupNewState, tx); - await clearQueuedSetupTasks(tx); - - if ( - didSuggestionSourceChange({ - currentState, - nextRepositoryIds: normalizedRepositoryIds, - nextSetupGuidance, - }) - ) { - await clearTaskSuggestions(currentState.suggestionTaskId, tx); - } - - return { - setupNewState, - }; - }); -} - -export async function startSetupNewOnboardingTaskCommand( - auth: UserAuthSuccess, -) { - assertAdmin(auth); - await assertSetupQualificationNotBlocked(auth); - - const { userId } = auth; - const startResult = await db.transaction(async (tx) => { - await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('setup-new'))`); - - const currentState = await getPersistedSetupNewState(tx); - - if (currentState.onboardingTaskId) { - return { - taskId: currentState.onboardingTaskId, - startedAt: currentState.onboardingTaskStartedAt, - launchedNewOnboardingTask: false as const, - }; - } - - const { normalizedRepositoryIds, selectedRepositories } = - await resolveSelectedRepositories(currentState.selectedRepositoryIds); - await assertHasCommittedRepositorySelection(normalizedRepositoryIds); - - if (selectedRepositories.length === 0) { - throw new Error('Select at least one repository before starting setup.'); - } - - const selectedRepositoryFullNames = selectedRepositories.map( - (repository) => repository.fullName, - ); - const workspacePayload = buildSetupNewWorkspacePayload( - selectedRepositoryFullNames, - ); - // Stamp the provider explicitly: dequeue defaults to GitHub when the - // payload omits it, which breaks non-GitHub deployments. - const setupSourceControlProvider = resolveSingleSourceControlProvider( - selectedRepositories.map( - (repository) => repository.sourceControlProvider, - ), - ); - const prompt = appendEnvironmentDefinitionGuidance( - buildSetupNewKickoffPrompt(selectedRepositoryFullNames), - currentState.setupGuidance, - ); - const modelSelection = resolveEvalHarnessSelection({ - model: currentState.selectedModelId ?? undefined, - }); - - if (!modelSelection.ok) { - throw new Error(modelSelection.error); - } - - const handoffTarget = await resolveSetupSlackHandoffTarget( - { - userId, - }, - tx, - ); - - if (!handoffTarget) { - // No connected Slack workspace (or the admin never linked their Slack - // account). Fall back to the next chat surface (Telegram, then Teams) - // so the kickoff still gets a real conversation thread; only when no - // chat surface exists does onboarding run as a web-only task whose - // progress stays visible in the setup wizard's task panel. - const fallbackTarget = await resolveSetupChatFallbackHandoffTarget(); - - if (fallbackTarget) { - const kickoffMessage = buildSetupKickoffText(); - let kickoffMessageId: string | null = null; - let kickoffChannelId: string | null = null; - - if (fallbackTarget.provider === 'telegram') { - const telegram = new TelegramCommunicationProvider({ - botToken: fallbackTarget.botToken, - }); - const posted = await telegram.postMessage({ - channelId: fallbackTarget.chatId, - text: kickoffMessage, - textFormat: 'markdown', - }); - - if (!posted.messageId) { - throw new Error( - 'Roomote could not post the Telegram setup kickoff.', - ); - } - - kickoffMessageId = posted.messageId; - kickoffChannelId = fallbackTarget.chatId; - } else { - // Teams is best-effort: when the kickoff post fails, onboarding - // continues as a web-only task instead of failing setup, and the - // persisted handoff fields stay null so Teams never looks - // connected without a delivered kickoff. - try { - const posted = await fallbackTarget.teams.postMessage({ - channelId: fallbackTarget.conversationId, - serviceUrl: fallbackTarget.serviceUrl, - text: kickoffMessage, - textFormat: 'markdown', - }); - - if (posted.messageId) { - kickoffMessageId = posted.messageId; - kickoffChannelId = fallbackTarget.conversationId; - } else { - console.warn( - '[setup-new] The Teams setup kickoff post returned no message id; onboarding continues as a web-only task.', - ); - } - } catch (error) { - console.warn( - '[setup-new] Failed to post the Teams setup kickoff; onboarding continues as a web-only task.', - error, - ); - } - } - - if (kickoffMessageId && kickoffChannelId) { - const startedAt = new Date().toISOString(); - const launchResult = await enqueueCloudTask({ - task: { - ...(modelSelection.harness - ? { harness: modelSelection.harness } - : {}), - type: TaskPayloadKind.StandardTask, - payload: { - ...workspacePayload, - ...(setupSourceControlProvider - ? { sourceControlProvider: setupSourceControlProvider } - : {}), - description: prompt, - visibleInTranscript: false, - communicationProvider: fallbackTarget.provider, - communicationChannelId: kickoffChannelId, - communicationMessageId: kickoffMessageId, - ...(fallbackTarget.provider === 'teams' - ? { - communicationThreadId: kickoffMessageId, - communicationServiceUrl: fallbackTarget.serviceUrl, - } - : {}), - ...(modelSelection.harnessModelOverrides - ? { - harnessModelOverrides: - modelSelection.harnessModelOverrides, - } - : {}), - }, - }, - initiator: { kind: 'user', userId }, - workflow: 'setup_onboarding', - surface: 'web', - trigger: 'manual', - }); - - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...currentState, - selectedRepositoryIds: normalizedRepositoryIds, - setupGuidance: currentState.setupGuidance ?? null, - onboardingTaskId: launchResult.taskId, - onboardingTaskStartedAt: startedAt, - slackTeamId: null, - slackChannel: null, - slackThreadTs: null, - chatHandoffProvider: fallbackTarget.provider, - chatHandoffChannelId: kickoffChannelId, - chatHandoffThreadId: kickoffMessageId, - chatHandoffServiceUrl: - fallbackTarget.provider === 'teams' - ? fallbackTarget.serviceUrl - : null, - lastInteractedByUserId: userId, - }), - tx, - ); - - return { - taskId: launchResult.taskId, - startedAt, - launchedNewOnboardingTask: true as const, - }; - } - } - - const startedAt = new Date().toISOString(); - const launchResult = await enqueueCloudTask({ - task: { - ...(modelSelection.harness - ? { harness: modelSelection.harness } - : {}), - type: TaskPayloadKind.StandardTask, - payload: { - ...workspacePayload, - ...(setupSourceControlProvider - ? { sourceControlProvider: setupSourceControlProvider } - : {}), - description: prompt, - visibleInTranscript: false, - ...(modelSelection.harnessModelOverrides - ? { - harnessModelOverrides: modelSelection.harnessModelOverrides, - } - : {}), - }, - }, - initiator: { kind: 'user', userId }, - workflow: 'setup_onboarding', - surface: 'web', - trigger: 'manual', - }); - - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...currentState, - selectedRepositoryIds: normalizedRepositoryIds, - setupGuidance: currentState.setupGuidance ?? null, - onboardingTaskId: launchResult.taskId, - onboardingTaskStartedAt: startedAt, - slackTeamId: null, - slackChannel: null, - slackThreadTs: null, - chatHandoffProvider: null, - chatHandoffChannelId: null, - chatHandoffThreadId: null, - chatHandoffServiceUrl: null, - lastInteractedByUserId: userId, - }), - tx, - ); - - return { - taskId: launchResult.taskId, - startedAt, - launchedNewOnboardingTask: true as const, - }; - } - - const slack = new SlackNotifier(handoffTarget.botAccessToken); - const slackChannel = await slack.openConversation( - handoffTarget.slackUserId, - ); - - if (!slackChannel) { - throw new Error('Roomote could not open a Slack DM for setup.'); - } - - const kickoffMessage = buildSetupKickoffText({ - userMention: `<@${handoffTarget.slackUserId}>`, - }); - const slackThreadTs = await slack.postMessage({ - channel: slackChannel, - text: kickoffMessage, - }); - - if (!slackThreadTs) { - throw new Error('Roomote could not post the Slack setup kickoff.'); - } - - const startedAt = new Date().toISOString(); - let launchResult: Awaited>; - - try { - launchResult = await enqueueCloudTask({ - task: { - ...(modelSelection.harness - ? { harness: modelSelection.harness } - : {}), - type: TaskPayloadKind.SlackAppMention, - payload: { - ...workspacePayload, - ...(setupSourceControlProvider - ? { sourceControlProvider: setupSourceControlProvider } - : {}), - channel: slackChannel, - user: handoffTarget.slackUserId, - text: prompt, - ts: slackThreadTs, - thread_ts: slackThreadTs, - webPath: '/setup', - visibleInTranscript: false, - ...(modelSelection.harnessModelOverrides - ? { - harnessModelOverrides: modelSelection.harnessModelOverrides, - } - : {}), - }, - }, - initiator: { kind: 'user', userId }, - workflow: 'setup_onboarding', - surface: 'slack', - trigger: 'manual', - channels: { - slackChannelId: slackChannel, - slackThreadTs, - }, - }); - } catch (error) { - await slack.deleteMessage({ channel: slackChannel, ts: slackThreadTs }); - throw error; - } - - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...currentState, - selectedRepositoryIds: normalizedRepositoryIds, - setupGuidance: currentState.setupGuidance ?? null, - onboardingTaskId: launchResult.taskId, - onboardingTaskStartedAt: startedAt, - slackTeamId: handoffTarget.slackTeamId, - slackChannel, - slackThreadTs, - chatHandoffProvider: 'slack', - chatHandoffChannelId: slackChannel, - chatHandoffThreadId: slackThreadTs, - chatHandoffServiceUrl: null, - lastInteractedByUserId: userId, - }), - tx, - ); - - await recordSlackConversationMessageBestEffort({ - logContext: 'setupNew.startOnboardingTask', - subjectUserId: userId, - slackTeamId: handoffTarget.slackTeamId, - subjectSlackUserId: handoffTarget.slackUserId, - slackChannelId: slackChannel, - conversationKind: 'dm', - messageTs: slackThreadTs, - direction: 'outbound', - authorKind: 'roomote', - source: 'setup_dm', - text: kickoffMessage, - taskId: launchResult.taskId, - cloudJobId: launchResult.id, - }); - - return { - taskId: launchResult.taskId, - startedAt, - launchedNewOnboardingTask: true as const, - }; - }); - - try { - await triggerTaskSuggestionsCommand(auth); - } catch (error) { - console.error( - `[startSetupNewOnboardingTaskCommand] Failed to trigger task suggestions: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - - return { - taskId: startResult.taskId, - startedAt: startResult.startedAt, - }; -} - -export async function cancelSetupNewOnboardingTaskCommand( - auth: UserAuthSuccess, -) { - assertAdmin(auth); - - const currentState = await getPersistedSetupNewState(); - - if (!currentState.onboardingTaskId) { - return { success: true as const }; - } - - const jobs = await db - .select({ - id: taskRuns.id, - status: taskRuns.status, - }) - .from(taskRuns) - .where(eq(taskRuns.taskId, currentState.onboardingTaskId)); - - const activeJobIds = jobs - .filter((job) => !isExitedCloudTaskStatus(job.status)) - .map((job) => job.id); - - if (activeJobIds.length > 0) { - const endedAt = new Date(); - - await db.transaction(async (tx) => { - await tx - .update(taskRuns) - .set({ - status: CloudTaskStatus.Canceled, - canceledAt: endedAt, - }) - .where(inArray(taskRuns.id, activeJobIds)); - - await Promise.all( - activeJobIds.map((runId) => - markTaskStartParallelCountEndedAt(tx, { - runId, - endedAt, - }), - ), - ); - }); - } - - return { success: true as const }; -} - -export async function resetSetupNewSelectionCommand(auth: UserAuthSuccess) { - assertAdmin(auth); - - const { userId } = auth; - - return db.transaction(async (tx) => { - const currentState = await getPersistedSetupNewState(tx); - const setupNewState = normalizeSetupNewState({ - ...currentState, - selectedRepositoryIds: [], - setupGuidance: null, - onboardingTaskId: null, - onboardingTaskStartedAt: null, - slackTeamId: null, - slackChannel: null, - slackThreadTs: null, - chatHandoffProvider: null, - chatHandoffChannelId: null, - chatHandoffThreadId: null, - chatHandoffServiceUrl: null, - suggestionTaskId: null, - suggestionTaskStartedAt: null, - suggestionGenerationTriggeredAt: null, - lastInteractedByUserId: userId, - }); - - await savePersistedSetupNewState(setupNewState, tx); - await clearTaskSuggestions(currentState.suggestionTaskId, tx); - await clearQueuedSetupTasks(tx); - - return { - setupNewState, - }; - }); -} - -export async function saveSetupNewQueuedTasksCommand( - auth: UserAuthSuccess, - input: { - selectedSuggestionIds: string[]; - customTaskPrompt?: string; - }, -) { - assertAdmin(auth); - await assertSetupQualificationNotBlocked(auth); - - const setupNewState = await getPersistedSetupNewState(); - - if (!setupNewState.onboardingTaskId) { - throw new Error('Start setup before queuing onboarding tasks.'); - } - - const customTaskPrompt = input.customTaskPrompt?.trim() || null; - const selectedSuggestionIds = [...new Set(input.selectedSuggestionIds)]; - - const { selectedRepositories } = await resolveSelectedRepositories( - setupNewState.selectedRepositoryIds, - ).catch(() => ({ - normalizedRepositoryIds: setupNewState.selectedRepositoryIds, - selectedRepositories: [] as SelectedRepositorySummary[], - })); - - const matchingEnvironment = await getMatchingEnvironmentSummary({ - selectedRepositoryFullNames: selectedRepositories.map( - (repository) => repository.fullName, - ), - onboardingTaskStartedAt: setupNewState.onboardingTaskStartedAt, - }); - const onboardingTask = await getOnboardingTaskState( - setupNewState.onboardingTaskId, - ); - const onboardingTaskStatus = onboardingTask.status; - const onboardingTaskPhase = onboardingTask.taskPhase; - const onboardingSucceeded = - isSetupNewOnboardingSuccessStatus( - onboardingTaskStatus, - onboardingTaskPhase, - ) && matchingEnvironment !== null; - - await replaceQueuedSetupTasks({ - setupOnboardingTaskId: setupNewState.onboardingTaskId, - selectedByUserId: auth.userId, - selectedSuggestionIds, - customTaskPrompt, - }); - - await launchQueuedSetupTasksIfReady({ - setupOnboardingTaskId: setupNewState.onboardingTaskId, - matchingEnvironmentId: onboardingSucceeded ? matchingEnvironment.id : null, - slackTeamId: setupNewState.slackTeamId, - slackChannel: setupNewState.slackChannel, - slackThreadTs: setupNewState.slackThreadTs, - chatHandoffProvider: setupNewState.chatHandoffProvider, - chatHandoffChannelId: setupNewState.chatHandoffChannelId, - chatHandoffThreadId: setupNewState.chatHandoffThreadId, - chatHandoffServiceUrl: setupNewState.chatHandoffServiceUrl, - }); - - return { - queuedOnboardingTasks: await getPersistedQueuedSetupTasks( - setupNewState.onboardingTaskId, - ), - }; -} - -export async function ensureSetupNewDefaultAgentsCommand( - auth: UserAuthSuccess, -) { - return ensureDefaultSetupAgents(auth); -} +export { getSetupNewStatusCommand } from './status'; + +export { + saveSetupNewModelConfigCommand, + saveSetupNewComputeProviderChoiceCommand, + saveSetupNewComputeConfigCommand, + saveSetupNewAuthProviderChoiceCommand, + saveSetupNewAuthConfigCommand, + saveSetupNewSourceControlProviderChoiceCommand, + saveSetupNewSourceControlConfigCommand, + getSetupBootstrapStatusCommand, + saveSetupBootstrapAuthProviderChoiceCommand, + saveSetupBootstrapAuthConfigCommand, +} from './config'; + +export { + saveSetupNewSelectionCommand, + startSetupNewOnboardingTaskCommand, + cancelSetupNewOnboardingTaskCommand, + resetSetupNewSelectionCommand, + ensureSetupNewDefaultAgentsCommand, +} from './onboarding'; + +export { saveSetupNewQueuedTasksCommand } from './queued-tasks'; diff --git a/apps/web/src/trpc/commands/setup-new/onboarding.test.ts b/apps/web/src/trpc/commands/setup-new/onboarding.test.ts new file mode 100644 index 00000000..f66ec541 --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/onboarding.test.ts @@ -0,0 +1,727 @@ +import type { FeatureFlag } from '@roomote/feature-flags'; + +import type { UserAuthSuccess } from '@/types'; + +const { + mockTxSelect, + mockDbTransaction, + mockUpsertDeploymentEnvironmentVariables, + mockGetPersistedEnvironmentVariableNames, + mockGetPersistedEnvironmentVariableValues, + mockGetSetupBootstrapState, + mockSetupTokenState, + mockRunComputeProvisioning, + mockResolveSavedWorkerImage, + mockResolveGiteaBaseUrl, + mockValidateGiteaToken, +} = vi.hoisted(() => ({ + mockTxSelect: vi.fn(), + mockDbTransaction: vi.fn(), + mockUpsertDeploymentEnvironmentVariables: vi.fn(), + mockGetPersistedEnvironmentVariableNames: vi.fn().mockResolvedValue([]), + mockGetPersistedEnvironmentVariableValues: vi.fn().mockResolvedValue({}), + mockGetSetupBootstrapState: vi.fn(), + mockSetupTokenState: { + requiredToken: undefined as string | undefined, + inviteCookieToken: null as string | null, + }, + mockRunComputeProvisioning: vi.fn().mockResolvedValue(undefined), + mockResolveSavedWorkerImage: vi.fn().mockResolvedValue(null), + mockResolveGiteaBaseUrl: vi + .fn() + .mockResolvedValue('https://gitea.example.com'), + mockValidateGiteaToken: vi.fn().mockResolvedValue({ status: 'valid' }), +})); + +vi.mock('../compute/compute-provisioning', async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + runComputeProvisioning: mockRunComputeProvisioning, + }; +}); + +vi.mock('@roomote/github', () => ({ + getRepositoryEmptyStates: vi.fn(async () => new Map()), +})); + +vi.mock('@roomote/gitea', () => ({ + resolveGiteaBaseUrl: mockResolveGiteaBaseUrl, + validateGiteaToken: mockValidateGiteaToken, +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + enqueueCloudTask: vi.fn(), +})); + +vi.mock('@roomote/slack', () => ({ + SlackNotifier: vi.fn(), +})); + +vi.mock('@roomote/communication/telegram-provider', () => ({ + TelegramCommunicationProvider: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', () => ({ + createTeamsCommunicationProviderFromRuntimeCredentials: vi.fn( + async () => null, + ), + findTelegramPrimaryChatId: vi.fn(async () => null), + findTeamsPrimaryConversation: vi.fn(async () => null), + recordSlackConversationMessageBestEffort: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + and: vi.fn(), + asc: vi.fn(), + taskRuns: {}, + db: { + select: mockTxSelect, + transaction: mockDbTransaction, + }, + deploymentSettings: { + id: 'deployment_settings.id', + setupNewState: 'deployment_settings.setup_new_state', + runtimeModelConfig: 'deployment_settings.runtime_model_config', + }, + environmentVariables: { + name: 'environment_variables.name', + }, + environments: {}, + eq: vi.fn(), + inArray: vi.fn(), + isNull: vi.fn(), + markTaskStartParallelCountEndedAt: vi.fn(), + resolveSavedWorkerImage: mockResolveSavedWorkerImage, + purgeSavedDeploymentWorkerImage: vi.fn(async () => undefined), + resolveTelegramRuntimeCredentials: vi.fn(async () => ({ + botToken: null, + webhookSecret: null, + botUsername: null, + })), + slackInstallations: {}, + slackUserMappings: {}, + sql: vi.fn(), + syncSetupQualificationBlock: vi.fn(), + users: {}, + workItems: {}, +})); + +vi.mock('@/lib/server', () => ({ + getLatestCloudJobsByTaskId: vi.fn(), + getRepositories: vi.fn(), + getRequestInviteToken: vi.fn( + async () => mockSetupTokenState.inviteCookieToken, + ), + isSetupTokenRequired: () => mockSetupTokenState.requiredToken != null, + isSetupTokenValid: (setupToken: string | undefined) => + mockSetupTokenState.requiredToken == null || + setupToken === mockSetupTokenState.requiredToken, + assertSetupTokenValid: (setupToken: string | undefined) => { + if ( + mockSetupTokenState.requiredToken != null && + setupToken !== mockSetupTokenState.requiredToken + ) { + throw new Error('A valid setup token is required.'); + } + }, +})); + +vi.mock('@/lib/repositories', () => ({ + areAllRepositoriesEmpty: vi.fn(), +})); + +vi.mock('@/lib/setup-new', () => ({ + appendEnvironmentDefinitionGuidance: vi.fn(), + buildSetupNewKickoffPrompt: vi.fn(), + buildSetupNewWorkspacePayload: vi.fn(), + findMatchingSetupNewEnvironment: vi.fn(), + isSetupNewOnboardingFailureStatus: vi.fn(), + isSetupNewOnboardingSuccessStatus: vi.fn(), + isSetupNewOnboardingTerminalSuccessStatus: vi.fn(), + normalizeRepositorySelection: vi.fn(), +})); + +vi.mock('../environment-variables', () => ({ + upsertDeploymentEnvironmentVariables: + mockUpsertDeploymentEnvironmentVariables, + getPersistedEnvironmentVariableNames: + mockGetPersistedEnvironmentVariableNames, + getPersistedEnvironmentVariableValues: + mockGetPersistedEnvironmentVariableValues, +})); + +vi.mock('../task-suggestions', () => ({ + triggerTaskSuggestionsCommand: vi.fn(), +})); + +vi.mock('../setup/shared', () => ({ + assertAdmin: (auth: UserAuthSuccess) => { + if (!auth.isAdmin) { + throw new Error('Unauthorized'); + } + }, + ensureDefaultSetupAgents: vi.fn(), + getSetupBaseStatus: vi.fn(), + getSetupBootstrapState: mockGetSetupBootstrapState, +})); + +import { startSetupNewOnboardingTaskCommand } from './onboarding'; +import { TaskPayloadKind } from '@roomote/types'; +import { enqueueCloudTask } from '@roomote/cloud-agents/server'; +import { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; +import { resolveTelegramRuntimeCredentials } from '@roomote/db/server'; +import { + createTeamsCommunicationProviderFromRuntimeCredentials, + findTelegramPrimaryChatId, + findTeamsPrimaryConversation, +} from '@roomote/sdk/server'; +import { SlackNotifier } from '@roomote/slack'; +import { getRepositories } from '@/lib/server'; +import { + appendEnvironmentDefinitionGuidance, + buildSetupNewKickoffPrompt, + buildSetupNewWorkspacePayload, + normalizeRepositorySelection, +} from '@/lib/setup-new'; + +function buildMockAuth( + overrides: Partial = {}, +): UserAuthSuccess { + return { + success: true, + userType: 'user', + userId: 'setup-test-user', + isAdmin: true, + name: 'Setup Tester', + primaryEmail: 'setup@example.com', + featureFlags: {} as Record, + resource: { + username: 'setup-tester', + fullName: 'Setup Tester', + firstName: 'Setup', + lastName: 'Tester', + primaryEmailAddress: { id: '1', emailAddress: 'setup@example.com' }, + emailAddresses: [{ id: '1', emailAddress: 'setup@example.com' }], + imageUrl: 'https://example.com/avatar.png', + createdAt: new Date(), + }, + ...overrides, + } as UserAuthSuccess; +} + +function createSelectChain(result: unknown) { + return { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(async () => result), + })), + })), + }; +} + +describe('setup-new onboarding task start command', () => { + const insertOnConflictMock = vi.fn(async () => undefined); + const insertValuesMock = vi.fn(() => ({ + onConflictDoUpdate: insertOnConflictMock, + })); + const txExecuteMock = vi.fn(async () => undefined); + + function mockOnboardingTransaction({ + slackInstallation, + slackUserMapping, + setupNewState, + }: { + slackInstallation: { botAccessToken: string; teamId: string } | null; + slackUserMapping?: { slackUserId: string } | null; + setupNewState?: { + selectedModelId?: string | null; + selectedRepositoryIds?: string[]; + version?: number; + }; + }) { + mockDbTransaction.mockImplementation(async (callback) => { + const tx = { + execute: txExecuteMock, + select: mockTxSelect, + insert: vi.fn(() => ({ values: insertValuesMock })), + }; + + mockTxSelect.mockReset(); + mockTxSelect.mockReturnValueOnce( + createSelectChain([ + { + setupNewState: { + version: 1, + selectedRepositoryIds: ['repo-1'], + ...setupNewState, + }, + }, + ]), + ); + mockTxSelect.mockReturnValueOnce( + createSelectChain(slackInstallation ? [slackInstallation] : []), + ); + + if (slackInstallation) { + mockTxSelect.mockReturnValueOnce( + createSelectChain(slackUserMapping ? [slackUserMapping] : []), + ); + } + + return callback(tx); + }); + } + + beforeEach(() => { + vi.clearAllMocks(); + mockTxSelect.mockReset(); + + vi.mocked(getRepositories).mockResolvedValue([ + { id: 'repo-1', fullName: 'acme/api' }, + ] as Awaited>); + vi.mocked(normalizeRepositorySelection).mockReturnValue(['repo-1']); + vi.mocked(buildSetupNewWorkspacePayload).mockReturnValue({ + repo: 'acme/api', + }); + vi.mocked(buildSetupNewKickoffPrompt).mockReturnValue('kickoff prompt'); + vi.mocked(appendEnvironmentDefinitionGuidance).mockReturnValue( + 'kickoff prompt', + ); + vi.mocked(resolveTelegramRuntimeCredentials).mockResolvedValue({ + botToken: null, + webhookSecret: null, + botUsername: null, + } as Awaited>); + vi.mocked(findTelegramPrimaryChatId).mockResolvedValue(null); + vi.mocked(findTeamsPrimaryConversation).mockResolvedValue(null); + vi.mocked( + createTeamsCommunicationProviderFromRuntimeCredentials, + ).mockResolvedValue(null); + vi.mocked(enqueueCloudTask).mockResolvedValue({ + taskId: 'task-onboarding-1', + id: 'cloud-job-1', + } as unknown as Awaited>); + }); + + it('launches a web onboarding task when no Slack workspace is connected', async () => { + mockOnboardingTransaction({ slackInstallation: null }); + + const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(result.taskId).toBe('task-onboarding-1'); + expect(enqueueCloudTask).toHaveBeenCalledTimes(1); + expect(enqueueCloudTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + type: TaskPayloadKind.StandardTask, + payload: expect.objectContaining({ + repo: 'acme/api', + description: 'kickoff prompt', + visibleInTranscript: false, + }), + }), + initiator: { kind: 'user', userId: 'setup-test-user' }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + }), + ); + expect(SlackNotifier).not.toHaveBeenCalled(); + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewState: expect.objectContaining({ + onboardingTaskId: 'task-onboarding-1', + slackTeamId: null, + slackChannel: null, + slackThreadTs: null, + }), + }), + ); + }); + + it('falls back to a Telegram kickoff when no Slack workspace is connected but a primary chat exists', async () => { + mockOnboardingTransaction({ slackInstallation: null }); + + const telegramPostMessage = vi.fn(async () => ({ + provider: 'telegram' as const, + channelId: '8846357662', + messageId: '900', + })); + + vi.mocked(resolveTelegramRuntimeCredentials).mockResolvedValue({ + botToken: 'bot-token', + webhookSecret: null, + botUsername: null, + } as Awaited>); + vi.mocked(findTelegramPrimaryChatId).mockResolvedValue('8846357662'); + vi.mocked(TelegramCommunicationProvider).mockImplementation(function ( + this: unknown, + ) { + return { + postMessage: telegramPostMessage, + } as unknown as TelegramCommunicationProvider; + }); + + const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(result.taskId).toBe('task-onboarding-1'); + expect(telegramPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ channelId: '8846357662' }), + ); + expect(SlackNotifier).not.toHaveBeenCalled(); + expect(enqueueCloudTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + type: TaskPayloadKind.StandardTask, + payload: expect.objectContaining({ + description: 'kickoff prompt', + visibleInTranscript: false, + communicationProvider: 'telegram', + communicationChannelId: '8846357662', + communicationMessageId: '900', + }), + }), + initiator: { kind: 'user', userId: 'setup-test-user' }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + }), + ); + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewState: expect.objectContaining({ + onboardingTaskId: 'task-onboarding-1', + slackChannel: null, + chatHandoffProvider: 'telegram', + chatHandoffChannelId: '8846357662', + chatHandoffThreadId: '900', + chatHandoffServiceUrl: null, + }), + }), + ); + }); + + it('falls back to a Teams kickoff when no Slack or Telegram destination exists but a primary conversation was captured', async () => { + mockOnboardingTransaction({ slackInstallation: null }); + + const teamsPostMessage = vi.fn(async () => ({ + provider: 'teams' as const, + channelId: '19:channel@thread.tacv2', + messageId: '1751000000000', + })); + + vi.mocked(findTeamsPrimaryConversation).mockResolvedValue({ + conversationId: '19:channel@thread.tacv2', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + conversationType: 'channel', + }); + vi.mocked( + createTeamsCommunicationProviderFromRuntimeCredentials, + ).mockResolvedValue({ + postMessage: teamsPostMessage, + } as unknown as Awaited< + ReturnType + >); + + const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(result.taskId).toBe('task-onboarding-1'); + expect(teamsPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: '19:channel@thread.tacv2', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + }), + ); + expect(SlackNotifier).not.toHaveBeenCalled(); + expect(enqueueCloudTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + type: TaskPayloadKind.StandardTask, + payload: expect.objectContaining({ + description: 'kickoff prompt', + visibleInTranscript: false, + communicationProvider: 'teams', + communicationChannelId: '19:channel@thread.tacv2', + communicationMessageId: '1751000000000', + communicationThreadId: '1751000000000', + communicationServiceUrl: 'https://smba.trafficmanager.net/amer/', + }), + }), + initiator: { kind: 'user', userId: 'setup-test-user' }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + }), + ); + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewState: expect.objectContaining({ + onboardingTaskId: 'task-onboarding-1', + slackChannel: null, + chatHandoffProvider: 'teams', + chatHandoffChannelId: '19:channel@thread.tacv2', + chatHandoffThreadId: '1751000000000', + chatHandoffServiceUrl: 'https://smba.trafficmanager.net/amer/', + }), + }), + ); + }); + + it('falls back to a web onboarding task when a Teams conversation exists but bot credentials are missing', async () => { + mockOnboardingTransaction({ slackInstallation: null }); + + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + vi.mocked(findTeamsPrimaryConversation).mockResolvedValue({ + conversationId: '19:channel@thread.tacv2', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + conversationType: 'channel', + }); + + try { + const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(result.taskId).toBe('task-onboarding-1'); + expect(enqueueCloudTask).toHaveBeenCalledTimes(1); + + const enqueueInput = vi.mocked(enqueueCloudTask).mock.calls[0]?.[0]; + expect(enqueueInput?.task.payload).toBeDefined(); + expect(enqueueInput?.task.payload).not.toHaveProperty( + 'communicationProvider', + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Teams bot credentials could not be resolved'), + ); + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewState: expect.objectContaining({ + onboardingTaskId: 'task-onboarding-1', + chatHandoffProvider: null, + chatHandoffChannelId: null, + chatHandoffThreadId: null, + chatHandoffServiceUrl: null, + }), + }), + ); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it('falls back to a web onboarding task when the Teams kickoff post fails', async () => { + mockOnboardingTransaction({ slackInstallation: null }); + + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const teamsPostMessage = vi.fn(async () => { + throw new Error('Teams post failed'); + }); + + vi.mocked(findTeamsPrimaryConversation).mockResolvedValue({ + conversationId: '19:channel@thread.tacv2', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + conversationType: 'channel', + }); + vi.mocked( + createTeamsCommunicationProviderFromRuntimeCredentials, + ).mockResolvedValue({ + postMessage: teamsPostMessage, + } as unknown as Awaited< + ReturnType + >); + + try { + const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(result.taskId).toBe('task-onboarding-1'); + expect(teamsPostMessage).toHaveBeenCalled(); + expect(enqueueCloudTask).toHaveBeenCalledTimes(1); + + const enqueueInput = vi.mocked(enqueueCloudTask).mock.calls[0]?.[0]; + expect(enqueueInput?.task.payload).toBeDefined(); + expect(enqueueInput?.task.payload).not.toHaveProperty( + 'communicationProvider', + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to post the Teams setup kickoff'), + expect.any(Error), + ); + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewState: expect.objectContaining({ + onboardingTaskId: 'task-onboarding-1', + chatHandoffProvider: null, + chatHandoffChannelId: null, + chatHandoffThreadId: null, + chatHandoffServiceUrl: null, + }), + }), + ); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it('falls back to a web onboarding task when the Teams kickoff post returns no message id', async () => { + mockOnboardingTransaction({ slackInstallation: null }); + + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const teamsPostMessage = vi.fn(async () => ({ + provider: 'teams' as const, + channelId: '19:channel@thread.tacv2', + messageId: '', + })); + + vi.mocked(findTeamsPrimaryConversation).mockResolvedValue({ + conversationId: '19:channel@thread.tacv2', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + conversationType: 'channel', + }); + vi.mocked( + createTeamsCommunicationProviderFromRuntimeCredentials, + ).mockResolvedValue({ + postMessage: teamsPostMessage, + } as unknown as Awaited< + ReturnType + >); + + try { + const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(result.taskId).toBe('task-onboarding-1'); + expect(teamsPostMessage).toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('returned no message id'), + ); + + const enqueueInput = vi.mocked(enqueueCloudTask).mock.calls[0]?.[0]; + expect(enqueueInput?.task.payload).toBeDefined(); + expect(enqueueInput?.task.payload).not.toHaveProperty( + 'communicationProvider', + ); + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewState: expect.objectContaining({ + chatHandoffProvider: null, + chatHandoffChannelId: null, + }), + }), + ); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it('applies the selected setup model to web onboarding tasks', async () => { + mockOnboardingTransaction({ + slackInstallation: null, + setupNewState: { + selectedModelId: 'openrouter/z-ai/glm-5.2', + }, + }); + + await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(enqueueCloudTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + harness: 'opencode-server', + type: TaskPayloadKind.StandardTask, + payload: expect.objectContaining({ + harnessModelOverrides: { + 'opencode-server': 'openrouter/z-ai/glm-5.2', + }, + }), + }), + initiator: { kind: 'user', userId: 'setup-test-user' }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + }), + ); + }); + + it('falls back to a web onboarding task when the admin has no Slack user mapping', async () => { + mockOnboardingTransaction({ + slackInstallation: { botAccessToken: 'xoxb-token', teamId: 'T1' }, + slackUserMapping: null, + }); + + const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(result.taskId).toBe('task-onboarding-1'); + expect(SlackNotifier).not.toHaveBeenCalled(); + expect(enqueueCloudTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + type: TaskPayloadKind.StandardTask, + }), + initiator: { kind: 'user', userId: 'setup-test-user' }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + }), + ); + }); + + it('keeps the Slack DM handoff when Slack is connected and mapped', async () => { + mockOnboardingTransaction({ + slackInstallation: { botAccessToken: 'xoxb-token', teamId: 'T1' }, + slackUserMapping: { slackUserId: 'U1' }, + }); + + const openConversationMock = vi.fn(async () => 'D1'); + const postMessageMock = vi.fn(async () => '171.0001'); + vi.mocked(SlackNotifier).mockImplementation(function (this: unknown) { + return { + openConversation: openConversationMock, + postMessage: postMessageMock, + deleteMessage: vi.fn(), + } as unknown as InstanceType; + }); + + const result = await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(result.taskId).toBe('task-onboarding-1'); + expect(openConversationMock).toHaveBeenCalledWith('U1'); + expect(enqueueCloudTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + type: TaskPayloadKind.SlackAppMention, + payload: expect.objectContaining({ + channel: 'D1', + user: 'U1', + ts: '171.0001', + thread_ts: '171.0001', + }), + }), + initiator: { kind: 'user', userId: 'setup-test-user' }, + workflow: 'setup_onboarding', + surface: 'slack', + trigger: 'manual', + channels: { + slackChannelId: 'D1', + slackThreadTs: '171.0001', + }, + }), + ); + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewState: expect.objectContaining({ + onboardingTaskId: 'task-onboarding-1', + slackTeamId: 'T1', + slackChannel: 'D1', + slackThreadTs: '171.0001', + }), + }), + ); + }); +}); diff --git a/apps/web/src/trpc/commands/setup-new/onboarding.ts b/apps/web/src/trpc/commands/setup-new/onboarding.ts new file mode 100644 index 00000000..87a85dfb --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/onboarding.ts @@ -0,0 +1,564 @@ +import { enqueueCloudTask } from '@roomote/cloud-agents/server'; +import { resolveSingleSourceControlProvider } from '@/lib/server/source-control-provider'; +import { buildSetupKickoffText } from '@roomote/communication/chat-messages'; +import { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; +import { SlackNotifier } from '@roomote/slack'; +import { + db, + taskRuns, + eq, + inArray, + sql, + markTaskStartParallelCountEndedAt, +} from '@roomote/db/server'; +import { recordSlackConversationMessageBestEffort } from '@roomote/sdk/server'; +import { + CloudTaskStatus, + TaskPayloadKind, + resolveEvalHarnessSelection, + isExitedCloudTaskStatus, + normalizeSetupNewState, +} from '@roomote/types'; + +import type { UserAuthSuccess } from '@/types'; +import { + appendEnvironmentDefinitionGuidance, + buildSetupNewKickoffPrompt, + buildSetupNewWorkspacePayload, +} from '@/lib/setup-new'; + +import { assertAdmin, ensureDefaultSetupAgents } from '../setup/shared'; +import { triggerTaskSuggestionsCommand } from '../task-suggestions'; +import { + assertHasCommittedRepositorySelection, + assertSetupQualificationNotBlocked, + clearQueuedSetupTasks, + clearTaskSuggestions, + didSuggestionSourceChange, + getPersistedSetupNewState, + resolveSelectedRepositories, + savePersistedSetupNewState, +} from './shared'; +import { + resolveSetupChatFallbackHandoffTarget, + resolveSetupSlackHandoffTarget, +} from './handoff'; + +export async function saveSetupNewSelectionCommand( + auth: UserAuthSuccess, + input: { + repositoryIds: string[]; + setupGuidance?: string; + selectedModelId?: string; + }, +) { + assertAdmin(auth); + await assertSetupQualificationNotBlocked(auth); + + const { userId } = auth; + + if (input.repositoryIds.length === 0) { + throw new Error('Select at least one repository to continue.'); + } + + const { normalizedRepositoryIds } = await resolveSelectedRepositories( + input.repositoryIds, + ); + await assertHasCommittedRepositorySelection(normalizedRepositoryIds); + const nextSetupGuidance = input.setupGuidance?.trim() || null; + const nextSelectedModelId = input.selectedModelId?.trim() || null; + + return db.transaction(async (tx) => { + const currentState = await getPersistedSetupNewState(tx); + const setupNewState = normalizeSetupNewState({ + ...currentState, + selectedRepositoryIds: normalizedRepositoryIds, + setupGuidance: nextSetupGuidance, + selectedModelId: nextSelectedModelId, + onboardingTaskId: null, + onboardingTaskStartedAt: null, + slackTeamId: null, + slackChannel: null, + slackThreadTs: null, + chatHandoffProvider: null, + chatHandoffChannelId: null, + chatHandoffThreadId: null, + chatHandoffServiceUrl: null, + suggestionTaskId: null, + suggestionTaskStartedAt: null, + suggestionGenerationTriggeredAt: null, + lastInteractedByUserId: userId, + }); + + await savePersistedSetupNewState(setupNewState, tx); + await clearQueuedSetupTasks(tx); + + if ( + didSuggestionSourceChange({ + currentState, + nextRepositoryIds: normalizedRepositoryIds, + nextSetupGuidance, + }) + ) { + await clearTaskSuggestions(currentState.suggestionTaskId, tx); + } + + return { + setupNewState, + }; + }); +} + +export async function startSetupNewOnboardingTaskCommand( + auth: UserAuthSuccess, +) { + assertAdmin(auth); + await assertSetupQualificationNotBlocked(auth); + + const { userId } = auth; + const startResult = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('setup-new'))`); + + const currentState = await getPersistedSetupNewState(tx); + + if (currentState.onboardingTaskId) { + return { + taskId: currentState.onboardingTaskId, + startedAt: currentState.onboardingTaskStartedAt, + launchedNewOnboardingTask: false as const, + }; + } + + const { normalizedRepositoryIds, selectedRepositories } = + await resolveSelectedRepositories(currentState.selectedRepositoryIds); + await assertHasCommittedRepositorySelection(normalizedRepositoryIds); + + if (selectedRepositories.length === 0) { + throw new Error('Select at least one repository before starting setup.'); + } + + const selectedRepositoryFullNames = selectedRepositories.map( + (repository) => repository.fullName, + ); + const workspacePayload = buildSetupNewWorkspacePayload( + selectedRepositoryFullNames, + ); + // Stamp the provider explicitly: dequeue defaults to GitHub when the + // payload omits it, which breaks non-GitHub deployments. + const setupSourceControlProvider = resolveSingleSourceControlProvider( + selectedRepositories.map( + (repository) => repository.sourceControlProvider, + ), + ); + const prompt = appendEnvironmentDefinitionGuidance( + buildSetupNewKickoffPrompt(selectedRepositoryFullNames), + currentState.setupGuidance, + ); + const modelSelection = resolveEvalHarnessSelection({ + model: currentState.selectedModelId ?? undefined, + }); + + if (!modelSelection.ok) { + throw new Error(modelSelection.error); + } + + const handoffTarget = await resolveSetupSlackHandoffTarget( + { + userId, + }, + tx, + ); + + if (!handoffTarget) { + // No connected Slack workspace (or the admin never linked their Slack + // account). Fall back to the next chat surface (Telegram, then Teams) + // so the kickoff still gets a real conversation thread; only when no + // chat surface exists does onboarding run as a web-only task whose + // progress stays visible in the setup wizard's task panel. + const fallbackTarget = await resolveSetupChatFallbackHandoffTarget(); + + if (fallbackTarget) { + const kickoffMessage = buildSetupKickoffText(); + let kickoffMessageId: string | null = null; + let kickoffChannelId: string | null = null; + + if (fallbackTarget.provider === 'telegram') { + const telegram = new TelegramCommunicationProvider({ + botToken: fallbackTarget.botToken, + }); + const posted = await telegram.postMessage({ + channelId: fallbackTarget.chatId, + text: kickoffMessage, + textFormat: 'markdown', + }); + + if (!posted.messageId) { + throw new Error( + 'Roomote could not post the Telegram setup kickoff.', + ); + } + + kickoffMessageId = posted.messageId; + kickoffChannelId = fallbackTarget.chatId; + } else { + // Teams is best-effort: when the kickoff post fails, onboarding + // continues as a web-only task instead of failing setup, and the + // persisted handoff fields stay null so Teams never looks + // connected without a delivered kickoff. + try { + const posted = await fallbackTarget.teams.postMessage({ + channelId: fallbackTarget.conversationId, + serviceUrl: fallbackTarget.serviceUrl, + text: kickoffMessage, + textFormat: 'markdown', + }); + + if (posted.messageId) { + kickoffMessageId = posted.messageId; + kickoffChannelId = fallbackTarget.conversationId; + } else { + console.warn( + '[setup-new] The Teams setup kickoff post returned no message id; onboarding continues as a web-only task.', + ); + } + } catch (error) { + console.warn( + '[setup-new] Failed to post the Teams setup kickoff; onboarding continues as a web-only task.', + error, + ); + } + } + + if (kickoffMessageId && kickoffChannelId) { + const startedAt = new Date().toISOString(); + const launchResult = await enqueueCloudTask({ + task: { + ...(modelSelection.harness + ? { harness: modelSelection.harness } + : {}), + type: TaskPayloadKind.StandardTask, + payload: { + ...workspacePayload, + ...(setupSourceControlProvider + ? { sourceControlProvider: setupSourceControlProvider } + : {}), + description: prompt, + visibleInTranscript: false, + communicationProvider: fallbackTarget.provider, + communicationChannelId: kickoffChannelId, + communicationMessageId: kickoffMessageId, + ...(fallbackTarget.provider === 'teams' + ? { + communicationThreadId: kickoffMessageId, + communicationServiceUrl: fallbackTarget.serviceUrl, + } + : {}), + ...(modelSelection.harnessModelOverrides + ? { + harnessModelOverrides: + modelSelection.harnessModelOverrides, + } + : {}), + }, + }, + initiator: { kind: 'user', userId }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + }); + + await savePersistedSetupNewState( + normalizeSetupNewState({ + ...currentState, + selectedRepositoryIds: normalizedRepositoryIds, + setupGuidance: currentState.setupGuidance ?? null, + onboardingTaskId: launchResult.taskId, + onboardingTaskStartedAt: startedAt, + slackTeamId: null, + slackChannel: null, + slackThreadTs: null, + chatHandoffProvider: fallbackTarget.provider, + chatHandoffChannelId: kickoffChannelId, + chatHandoffThreadId: kickoffMessageId, + chatHandoffServiceUrl: + fallbackTarget.provider === 'teams' + ? fallbackTarget.serviceUrl + : null, + lastInteractedByUserId: userId, + }), + tx, + ); + + return { + taskId: launchResult.taskId, + startedAt, + launchedNewOnboardingTask: true as const, + }; + } + } + + const startedAt = new Date().toISOString(); + const launchResult = await enqueueCloudTask({ + task: { + ...(modelSelection.harness + ? { harness: modelSelection.harness } + : {}), + type: TaskPayloadKind.StandardTask, + payload: { + ...workspacePayload, + ...(setupSourceControlProvider + ? { sourceControlProvider: setupSourceControlProvider } + : {}), + description: prompt, + visibleInTranscript: false, + ...(modelSelection.harnessModelOverrides + ? { + harnessModelOverrides: modelSelection.harnessModelOverrides, + } + : {}), + }, + }, + initiator: { kind: 'user', userId }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + }); + + await savePersistedSetupNewState( + normalizeSetupNewState({ + ...currentState, + selectedRepositoryIds: normalizedRepositoryIds, + setupGuidance: currentState.setupGuidance ?? null, + onboardingTaskId: launchResult.taskId, + onboardingTaskStartedAt: startedAt, + slackTeamId: null, + slackChannel: null, + slackThreadTs: null, + chatHandoffProvider: null, + chatHandoffChannelId: null, + chatHandoffThreadId: null, + chatHandoffServiceUrl: null, + lastInteractedByUserId: userId, + }), + tx, + ); + + return { + taskId: launchResult.taskId, + startedAt, + launchedNewOnboardingTask: true as const, + }; + } + + const slack = new SlackNotifier(handoffTarget.botAccessToken); + const slackChannel = await slack.openConversation( + handoffTarget.slackUserId, + ); + + if (!slackChannel) { + throw new Error('Roomote could not open a Slack DM for setup.'); + } + + const kickoffMessage = buildSetupKickoffText({ + userMention: `<@${handoffTarget.slackUserId}>`, + }); + const slackThreadTs = await slack.postMessage({ + channel: slackChannel, + text: kickoffMessage, + }); + + if (!slackThreadTs) { + throw new Error('Roomote could not post the Slack setup kickoff.'); + } + + const startedAt = new Date().toISOString(); + let launchResult: Awaited>; + + try { + launchResult = await enqueueCloudTask({ + task: { + ...(modelSelection.harness + ? { harness: modelSelection.harness } + : {}), + type: TaskPayloadKind.SlackAppMention, + payload: { + ...workspacePayload, + ...(setupSourceControlProvider + ? { sourceControlProvider: setupSourceControlProvider } + : {}), + channel: slackChannel, + user: handoffTarget.slackUserId, + text: prompt, + ts: slackThreadTs, + thread_ts: slackThreadTs, + webPath: '/setup', + visibleInTranscript: false, + ...(modelSelection.harnessModelOverrides + ? { + harnessModelOverrides: modelSelection.harnessModelOverrides, + } + : {}), + }, + }, + initiator: { kind: 'user', userId }, + workflow: 'setup_onboarding', + surface: 'slack', + trigger: 'manual', + channels: { + slackChannelId: slackChannel, + slackThreadTs, + }, + }); + } catch (error) { + await slack.deleteMessage({ channel: slackChannel, ts: slackThreadTs }); + throw error; + } + + await savePersistedSetupNewState( + normalizeSetupNewState({ + ...currentState, + selectedRepositoryIds: normalizedRepositoryIds, + setupGuidance: currentState.setupGuidance ?? null, + onboardingTaskId: launchResult.taskId, + onboardingTaskStartedAt: startedAt, + slackTeamId: handoffTarget.slackTeamId, + slackChannel, + slackThreadTs, + chatHandoffProvider: 'slack', + chatHandoffChannelId: slackChannel, + chatHandoffThreadId: slackThreadTs, + chatHandoffServiceUrl: null, + lastInteractedByUserId: userId, + }), + tx, + ); + + await recordSlackConversationMessageBestEffort({ + logContext: 'setupNew.startOnboardingTask', + subjectUserId: userId, + slackTeamId: handoffTarget.slackTeamId, + subjectSlackUserId: handoffTarget.slackUserId, + slackChannelId: slackChannel, + conversationKind: 'dm', + messageTs: slackThreadTs, + direction: 'outbound', + authorKind: 'roomote', + source: 'setup_dm', + text: kickoffMessage, + taskId: launchResult.taskId, + cloudJobId: launchResult.id, + }); + + return { + taskId: launchResult.taskId, + startedAt, + launchedNewOnboardingTask: true as const, + }; + }); + + try { + await triggerTaskSuggestionsCommand(auth); + } catch (error) { + console.error( + `[startSetupNewOnboardingTaskCommand] Failed to trigger task suggestions: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + return { + taskId: startResult.taskId, + startedAt: startResult.startedAt, + }; +} + +export async function cancelSetupNewOnboardingTaskCommand( + auth: UserAuthSuccess, +) { + assertAdmin(auth); + + const currentState = await getPersistedSetupNewState(); + + if (!currentState.onboardingTaskId) { + return { success: true as const }; + } + + const jobs = await db + .select({ + id: taskRuns.id, + status: taskRuns.status, + }) + .from(taskRuns) + .where(eq(taskRuns.taskId, currentState.onboardingTaskId)); + + const activeJobIds = jobs + .filter((job) => !isExitedCloudTaskStatus(job.status)) + .map((job) => job.id); + + if (activeJobIds.length > 0) { + const endedAt = new Date(); + + await db.transaction(async (tx) => { + await tx + .update(taskRuns) + .set({ + status: CloudTaskStatus.Canceled, + canceledAt: endedAt, + }) + .where(inArray(taskRuns.id, activeJobIds)); + + await Promise.all( + activeJobIds.map((runId) => + markTaskStartParallelCountEndedAt(tx, { + runId, + endedAt, + }), + ), + ); + }); + } + + return { success: true as const }; +} + +export async function resetSetupNewSelectionCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const { userId } = auth; + + return db.transaction(async (tx) => { + const currentState = await getPersistedSetupNewState(tx); + const setupNewState = normalizeSetupNewState({ + ...currentState, + selectedRepositoryIds: [], + setupGuidance: null, + onboardingTaskId: null, + onboardingTaskStartedAt: null, + slackTeamId: null, + slackChannel: null, + slackThreadTs: null, + chatHandoffProvider: null, + chatHandoffChannelId: null, + chatHandoffThreadId: null, + chatHandoffServiceUrl: null, + suggestionTaskId: null, + suggestionTaskStartedAt: null, + suggestionGenerationTriggeredAt: null, + lastInteractedByUserId: userId, + }); + + await savePersistedSetupNewState(setupNewState, tx); + await clearTaskSuggestions(currentState.suggestionTaskId, tx); + await clearQueuedSetupTasks(tx); + + return { + setupNewState, + }; + }); +} + +export async function ensureSetupNewDefaultAgentsCommand( + auth: UserAuthSuccess, +) { + return ensureDefaultSetupAgents(auth); +} diff --git a/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts b/apps/web/src/trpc/commands/setup-new/queued-tasks.test.ts similarity index 99% rename from apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts rename to apps/web/src/trpc/commands/setup-new/queued-tasks.test.ts index ac507b46..0d4cf50d 100644 --- a/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts +++ b/apps/web/src/trpc/commands/setup-new/queued-tasks.test.ts @@ -150,7 +150,7 @@ import { WORK_ITEM_LAUNCH_STALE_CLAIM_MS, } from '@roomote/db/server'; -import { launchQueuedSetupTasksIfReady } from './index'; +import { launchQueuedSetupTasksIfReady } from './queued-tasks'; const STALE_CLAIM_AT = new Date( Date.now() - WORK_ITEM_LAUNCH_STALE_CLAIM_MS - 60_000, diff --git a/apps/web/src/trpc/commands/setup-new/queued-tasks.ts b/apps/web/src/trpc/commands/setup-new/queued-tasks.ts new file mode 100644 index 00000000..ce18323c --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/queued-tasks.ts @@ -0,0 +1,644 @@ +import { enqueueCloudTask } from '@roomote/cloud-agents/server'; +import { resolveEnvironmentSourceControlProvider } from '@/lib/server/source-control-provider'; +import { + db, + workItems, + asc, + eq, + and, + inArray, + cancelTaskRunDirect, + claimWorkItem, + finalizeWorkItemLaunched, + releaseWorkItemClaim, + WORK_ITEM_LAUNCH_STALE_CLAIM_MS, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import { TaskPayloadKind } from '@roomote/types'; + +import type { UserAuthSuccess } from '@/types'; +import { isSetupNewOnboardingSuccessStatus } from '@/lib/setup-new'; +import { + SETUP_BOOTSTRAP_USER_ID, + assertSetupQualificationNotBlocked, + getMatchingEnvironmentSummary, + getOnboardingTaskState, + getPersistedSetupNewState, + resolveSelectedRepositories, + type MutableQueuedSetupTask, + type PersistedQueuedSetupTask, + type SelectedRepositorySummary, +} from './shared'; +import { assertAdmin } from '../setup/shared'; + +async function getPersistedTaskSuggestionRows(suggestionIds?: string[]) { + if (suggestionIds && suggestionIds.length === 0) { + return []; + } + + return db + .select({ + id: workItems.id, + title: workItems.title, + brief: workItems.brief, + sortOrder: workItems.sortOrder, + }) + .from(workItems) + .where( + suggestionIds + ? and( + eq(workItems.kind, 'suggestion'), + inArray(workItems.id, suggestionIds), + ) + : eq(workItems.kind, 'suggestion'), + ) + .orderBy(asc(workItems.sortOrder)); +} + +export async function getPersistedQueuedSetupTasks( + setupOnboardingTaskId: string | null, + executor: DatabaseOrTransaction = db, +): Promise { + if (!setupOnboardingTaskId) { + return []; + } + + return executor + .select({ + id: workItems.id, + suggestionId: workItems.sourceWorkItemId, + title: workItems.title, + prompt: workItems.executionPrompt, + sortOrder: workItems.sortOrder, + launchedTaskId: workItems.launchedTaskId, + launchedAt: workItems.launchedAt, + environmentId: workItems.targetEnvironmentId, + }) + .from(workItems) + .where( + and( + eq(workItems.sourceTaskId, setupOnboardingTaskId), + eq(workItems.kind, 'onboarding'), + ), + ) + .orderBy(asc(workItems.sortOrder)); +} + +async function getMutableQueuedSetupTasks( + setupOnboardingTaskId: string, + executor: DatabaseOrTransaction = db, +): Promise { + return executor + .select({ + id: workItems.id, + suggestionId: workItems.sourceWorkItemId, + title: workItems.title, + prompt: workItems.executionPrompt, + sortOrder: workItems.sortOrder, + launchedTaskId: workItems.launchedTaskId, + launchedAt: workItems.launchedAt, + environmentId: workItems.targetEnvironmentId, + launchClaimedAt: workItems.launchClaimedAt, + }) + .from(workItems) + .where( + and( + eq(workItems.sourceTaskId, setupOnboardingTaskId), + eq(workItems.kind, 'onboarding'), + ), + ) + .orderBy(asc(workItems.sortOrder)); +} + +/** + * Mirrors a launched onboarding copy back onto its source suggestion so the + * suggestions UI shows the suggestion as launched once its queued onboarding + * copy launches. This is a status *mirror*, not a launch of its own, so it must + * never stomp a live claim held by another surface. + * + * It goes through the shared fenced CAS: `claimWorkItem` the suggestion, and + * only `finalizeWorkItemLaunched` when the claim succeeded. When another surface + * (e.g. web-implement) already holds a fresh claim, or the suggestion is already + * `launched`, the claim returns null and the mirror is skipped — never + * overwriting that surface's status/token. + * + * `dismissed` is opted into the claimable set (matching the web-implement + * surface in task-suggestions/implement.ts) with `clearDismissedAt`, so a + * suggestion that was dismissed after being queued still flips to `launched` + * when its onboarding copy launches. That is the least-surprising state: the + * work genuinely launched, so the suggestions UI should reflect it rather than + * showing the row as merely dismissed. + * + * The claim and finalize run in one transaction so a finalize failure rolls the + * claim back rather than stranding the suggestion in `launching` with a live + * claim until the 10-minute stale-claim recovery. + */ +async function markSuggestionWorkItemLaunched( + input: { + suggestionId: string; + launchedTaskId: string; + }, + executor: DatabaseOrTransaction = db, +) { + await executor.transaction(async (tx) => { + const claimedSuggestion = await claimWorkItem(tx, { + id: input.suggestionId, + additionalClaimableStatuses: ['dismissed'], + extraConditions: [eq(workItems.kind, 'suggestion')], + }); + + if (!claimedSuggestion) { + // Another surface holds a fresh claim, the suggestion already launched, or + // it is terminally failed. Skip the mirror rather than overwrite it. + console.warn( + `[setup-new] Skipped mirroring launched state onto suggestion ${input.suggestionId}: it is already launched or another surface holds a fresh claim.`, + ); + return; + } + + const finalized = await finalizeWorkItemLaunched(tx, { + id: input.suggestionId, + taskId: input.launchedTaskId, + claimedAt: claimedSuggestion.launchClaimedAt, + clearDismissedAt: true, + }); + + if (!finalized) { + // Our claim token was superseded between claim and finalize; leave the new + // claimant's state untouched. + console.warn( + `[setup-new] Suggestion ${input.suggestionId} mirror lost the fencing guard after claiming; another surface reclaimed it. Leaving its state untouched.`, + ); + } + }); +} + +function stripMutableQueuedSetupTask( + queuedTask: MutableQueuedSetupTask, +): PersistedQueuedSetupTask { + const { launchClaimedAt: _launchClaimedAt, ...persistedQueuedTask } = + queuedTask; + + return persistedQueuedTask; +} + +function buildCustomQueuedTaskTitle(prompt: string) { + const firstMeaningfulLine = prompt + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + + if (!firstMeaningfulLine) { + return 'Custom task'; + } + + // Persist a fuller title in storage; the pill strip truncates further for display. + return firstMeaningfulLine.length <= 120 + ? firstMeaningfulLine + : `${firstMeaningfulLine.slice(0, 117)}...`; +} + +async function replaceQueuedSetupTasks({ + setupOnboardingTaskId, + selectedByUserId, + selectedSuggestionIds, + customTaskPrompt, +}: { + setupOnboardingTaskId: string; + selectedByUserId: string; + selectedSuggestionIds: string[]; + customTaskPrompt: string | null; +}): Promise { + const suggestionRows = await getPersistedTaskSuggestionRows( + selectedSuggestionIds, + ); + const suggestionIdsBySortOrder = new Set(suggestionRows.map(({ id }) => id)); + + if (suggestionIdsBySortOrder.size !== selectedSuggestionIds.length) { + throw new Error( + 'One or more selected suggestions are no longer available.', + ); + } + + return db.transaction(async (tx) => { + const existingRows = await getMutableQueuedSetupTasks( + setupOnboardingTaskId, + tx, + ); + + // Refuse to replace the queue while a launch is genuinely in flight or an + // item already launched. A *stale* `launchClaimedAt` (older than the shared + // stale-claim window) is left by a crash between claim and finalize; with + // per-item stale-claim recovery now in place that row is recoverable, so it + // must not block re-queuing forever. Only a launched row or a fresh claim + // blocks replacement. + const staleClaimThreshold = new Date( + Date.now() - WORK_ITEM_LAUNCH_STALE_CLAIM_MS, + ); + + if ( + existingRows.some( + (queuedTask) => + queuedTask.launchedAt !== null || + (queuedTask.launchClaimedAt !== null && + queuedTask.launchClaimedAt > staleClaimThreshold), + ) + ) { + return existingRows.map(stripMutableQueuedSetupTask); + } + + await tx + .delete(workItems) + .where( + and( + eq(workItems.sourceTaskId, setupOnboardingTaskId), + eq(workItems.kind, 'onboarding'), + ), + ); + + const nextRows: Array<{ + kind: 'onboarding'; + sourceTaskId: string; + selectedByUserId: string; + sourceWorkItemId: string | null; + title: string; + executionPrompt: string; + sortOrder: number; + }> = suggestionRows.map((suggestion, index) => ({ + kind: 'onboarding', + sourceTaskId: setupOnboardingTaskId, + selectedByUserId, + sourceWorkItemId: suggestion.id, + title: suggestion.title, + executionPrompt: suggestion.brief ?? '', + sortOrder: index, + })); + + if (customTaskPrompt) { + nextRows.push({ + kind: 'onboarding', + sourceTaskId: setupOnboardingTaskId, + selectedByUserId, + sourceWorkItemId: null, + title: buildCustomQueuedTaskTitle(customTaskPrompt), + executionPrompt: customTaskPrompt, + sortOrder: nextRows.length, + }); + } + + if (nextRows.length === 0) { + return []; + } + + return tx.insert(workItems).values(nextRows).returning({ + id: workItems.id, + suggestionId: workItems.sourceWorkItemId, + title: workItems.title, + prompt: workItems.executionPrompt, + sortOrder: workItems.sortOrder, + launchedTaskId: workItems.launchedTaskId, + launchedAt: workItems.launchedAt, + environmentId: workItems.targetEnvironmentId, + }); + }); +} + +type ClaimedQueuedSetupTask = { + id: string; + suggestionId: string | null; + selectedByUserId: string | null; + prompt: string | null; + /** The `launchClaimedAt` fencing token for this claim. */ + claimedAt: Date; +}; + +/** + * Claims the queued onboarding items for a setup task through the shared fenced + * CAS, preserving the previous batch semantics: fetch the candidate onboarding + * rows for the setup task in queue order, attempt to claim each, and return + * whichever claims succeeded (each carrying its own fencing token). + * + * Migrated off the single batch UPDATE gated on `launchedAt IS NULL AND + * launchClaimedAt IS NULL`, which had no stale-claim recovery: a crash between + * claim and finalize left an item `launching` forever. `claimWorkItem` instead + * claims `open` OR a stale `launching` row (older than the shared stale-claim + * window) and guards `launched_task_id IS NULL`, so a crashed launch recovers, + * a fresh in-flight claim on one item is skipped (only that item), and an + * already-launched item is never re-claimed. Onboarding rows are inserted with + * the `work_items.status` default of `open`, so the claim predicate matches the + * rows produced by `replaceQueuedSetupTasks`. Each claim is its own atomic CAS, + * so no wrapping transaction is needed. + */ +async function claimQueuedSetupTasksForLaunch( + setupOnboardingTaskId: string, +): Promise { + const candidates = await db + .select({ id: workItems.id }) + .from(workItems) + .where( + and( + eq(workItems.sourceTaskId, setupOnboardingTaskId), + eq(workItems.kind, 'onboarding'), + ), + ) + .orderBy(asc(workItems.sortOrder)); + + const claimedTasks: ClaimedQueuedSetupTask[] = []; + + for (const candidate of candidates) { + const claimed = await claimWorkItem(db, { + id: candidate.id, + extraConditions: [eq(workItems.kind, 'onboarding')], + }); + + if (!claimed) { + continue; + } + + claimedTasks.push({ + id: claimed.id, + suggestionId: claimed.sourceWorkItemId, + selectedByUserId: claimed.selectedByUserId, + prompt: claimed.executionPrompt, + claimedAt: claimed.launchClaimedAt, + }); + } + + return claimedTasks; +} + +// Exported for the DB-backed launch-lifecycle tests, which exercise the fenced +// claim/finalize/release flow directly. Not part of the tRPC command surface. +export async function launchQueuedSetupTasksIfReady({ + setupOnboardingTaskId, + matchingEnvironmentId, + slackTeamId, + slackChannel, + slackThreadTs, + chatHandoffProvider, + chatHandoffChannelId, + chatHandoffThreadId, + chatHandoffServiceUrl, +}: { + setupOnboardingTaskId: string | null; + matchingEnvironmentId: string | null; + slackTeamId?: string | null; + slackChannel?: string | null; + slackThreadTs?: string | null; + chatHandoffProvider?: string | null; + chatHandoffChannelId?: string | null; + chatHandoffThreadId?: string | null; + chatHandoffServiceUrl?: string | null; +}) { + if (!setupOnboardingTaskId || !matchingEnvironmentId) { + return; + } + + const claimedTasks = await claimQueuedSetupTasksForLaunch( + setupOnboardingTaskId, + ); + + if (claimedTasks.length === 0) { + return; + } + + // Non-Slack kickoffs (Telegram, Teams) carry provider-neutral + // communication metadata so the launched starter tasks reply into the same + // chat that hosted the setup kickoff. + const nonSlackChatHandoffProvider = + chatHandoffProvider === 'telegram' || chatHandoffProvider === 'teams' + ? chatHandoffProvider + : null; + const communicationMetadata = + nonSlackChatHandoffProvider && chatHandoffChannelId + ? { + communicationProvider: nonSlackChatHandoffProvider, + communicationChannelId: chatHandoffChannelId, + // Telegram treats communicationThreadId as a forum-topic + // message_thread_id, which the private primary chat does not have, + // so only Teams threads starter-task replies under the kickoff + // message. + ...(nonSlackChatHandoffProvider === 'teams' && chatHandoffThreadId + ? { communicationThreadId: chatHandoffThreadId } + : {}), + ...(chatHandoffServiceUrl + ? { communicationServiceUrl: chatHandoffServiceUrl } + : {}), + } + : {}; + + const queuedSourceControlProvider = + await resolveEnvironmentSourceControlProvider(matchingEnvironmentId); + + await Promise.allSettled( + claimedTasks.map(async (queuedTask) => { + let launchResult: Awaited>; + + try { + launchResult = await enqueueCloudTask({ + task: { + type: TaskPayloadKind.StandardTask, + payload: { + repo: '', + environmentId: matchingEnvironmentId, + ...(queuedSourceControlProvider + ? { sourceControlProvider: queuedSourceControlProvider } + : {}), + ...(slackTeamId ? { teamId: slackTeamId } : {}), + ...(slackChannel ? { slackChannel } : {}), + ...(slackThreadTs ? { slackThreadTs } : {}), + ...communicationMetadata, + description: queuedTask.prompt ?? '', + }, + }, + initiator: { + kind: 'user', + userId: queuedTask.selectedByUserId ?? SETUP_BOOTSTRAP_USER_ID, + }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + ...(slackChannel || slackThreadTs + ? { + channels: { + slackChannelId: slackChannel ?? null, + slackThreadTs: slackThreadTs ?? null, + }, + } + : {}), + }); + } catch (error) { + // The enqueue never succeeded, so no run exists: release our claim back + // to `open` so a later trigger can retry promptly. The fenced release + // never reverts a `launched` item and never reverts a claim already + // reclaimed by another launcher. Only a pre-enqueue failure may release; + // see the post-enqueue invariant below. + await releaseWorkItemClaim(db, { + id: queuedTask.id, + claimedAt: queuedTask.claimedAt, + extraConditions: [eq(workItems.kind, 'onboarding')], + }); + + console.warn( + `[setup-new] enqueue failed for onboarding work item ${queuedTask.id}; released its claim back to open — ${ + error instanceof Error ? error.message : String(error) + }.`, + ); + return; + } + + // Fenced finalize: `launching` -> `launched` only when our claim token + // still matches, stamping `targetEnvironmentId` in the same guarded write. + // Runs directly against `db` (not a transaction): it is a single fenced + // UPDATE, and the suggestion mirror below deliberately no longer shares + // its atomicity so a mirror failure can never roll back a healthy launch. + // + // Invariant: once the task is enqueued, a failure of unknown cause must + // never release the claim. Releasing would let the next readiness pass + // re-claim and launch a duplicate immediately, while leaving the claim in + // place lets stale-claim recovery retry safely only after the shared + // window. So a throw here is treated exactly like a lost finalize + // (`finalized = false`), which drives the orphan-cancel branch. In the + // rare ambiguous case where the finalize committed but its ack was lost, + // the cancel may kill a healthy linked run; that trade is intentional — a + // visibly canceled task beats a silent duplicate. + let finalized: boolean; + + try { + finalized = await finalizeWorkItemLaunched(db, { + id: queuedTask.id, + taskId: launchResult.taskId, + claimedAt: queuedTask.claimedAt, + targetEnvironmentId: matchingEnvironmentId, + }); + } catch (error) { + console.warn( + `[setup-new] finalize threw for onboarding work item ${queuedTask.id} after enqueuing task ${launchResult.taskId} (run ${launchResult.id}); treating as a lost finalize and leaving the claim for stale-claim recovery — ${ + error instanceof Error ? error.message : String(error) + }.`, + ); + finalized = false; + } + + if (!finalized) { + // The task is already enqueued but the finalize did not commit (the + // fencing guard rejected it, or it threw), so the run is orphaned from + // this work item. Best-effort cancel it while it is still pre-sandbox, + // and log loudly either way with the cancel outcome (matches the + // implement.ts orphan handling). Return before the mirror: a lost or + // failed finalize must never mirror a launch that did not link. + let cancelNote = 'orphaned run left running'; + + try { + const canceled = await cancelTaskRunDirect({ + runId: launchResult.id, + error: + 'Canceled: setup-new queued task launch finalize lost the claim fencing guard', + }); + cancelNote = canceled + ? 'orphaned run canceled' + : 'orphaned run cancel did not apply (already started or terminal)'; + } catch (cancelError) { + cancelNote = `orphaned run cancel failed: ${ + cancelError instanceof Error + ? cancelError.message + : String(cancelError) + }`; + } + + console.warn( + `[setup-new] finalize lost the fencing guard for onboarding work item ${queuedTask.id}; orphaned task ${launchResult.taskId} (run ${launchResult.id}) runs unlinked — ${cancelNote}.`, + ); + return; + } + + // Mirror the launched state onto the source suggestion only after a + // committed finalize, outside any transaction and best-effort: the launch + // link is already finalized and must stay finalized, so a mirror throw is + // logged and swallowed rather than allowed to undo the launch. + if (queuedTask.suggestionId) { + try { + await markSuggestionWorkItemLaunched({ + suggestionId: queuedTask.suggestionId, + launchedTaskId: launchResult.taskId, + }); + } catch (error) { + console.warn( + `[setup-new] failed to mirror launched state onto suggestion ${queuedTask.suggestionId} for task ${launchResult.taskId}; the onboarding launch stays finalized — ${ + error instanceof Error ? error.message : String(error) + }.`, + ); + } + } + }), + ); +} + +export async function saveSetupNewQueuedTasksCommand( + auth: UserAuthSuccess, + input: { + selectedSuggestionIds: string[]; + customTaskPrompt?: string; + }, +) { + assertAdmin(auth); + await assertSetupQualificationNotBlocked(auth); + + const setupNewState = await getPersistedSetupNewState(); + + if (!setupNewState.onboardingTaskId) { + throw new Error('Start setup before queuing onboarding tasks.'); + } + + const customTaskPrompt = input.customTaskPrompt?.trim() || null; + const selectedSuggestionIds = [...new Set(input.selectedSuggestionIds)]; + + const { selectedRepositories } = await resolveSelectedRepositories( + setupNewState.selectedRepositoryIds, + ).catch(() => ({ + normalizedRepositoryIds: setupNewState.selectedRepositoryIds, + selectedRepositories: [] as SelectedRepositorySummary[], + })); + + const matchingEnvironment = await getMatchingEnvironmentSummary({ + selectedRepositoryFullNames: selectedRepositories.map( + (repository) => repository.fullName, + ), + onboardingTaskStartedAt: setupNewState.onboardingTaskStartedAt, + }); + const onboardingTask = await getOnboardingTaskState( + setupNewState.onboardingTaskId, + ); + const onboardingTaskStatus = onboardingTask.status; + const onboardingTaskPhase = onboardingTask.taskPhase; + const onboardingSucceeded = + isSetupNewOnboardingSuccessStatus( + onboardingTaskStatus, + onboardingTaskPhase, + ) && matchingEnvironment !== null; + + await replaceQueuedSetupTasks({ + setupOnboardingTaskId: setupNewState.onboardingTaskId, + selectedByUserId: auth.userId, + selectedSuggestionIds, + customTaskPrompt, + }); + + await launchQueuedSetupTasksIfReady({ + setupOnboardingTaskId: setupNewState.onboardingTaskId, + matchingEnvironmentId: onboardingSucceeded ? matchingEnvironment.id : null, + slackTeamId: setupNewState.slackTeamId, + slackChannel: setupNewState.slackChannel, + slackThreadTs: setupNewState.slackThreadTs, + chatHandoffProvider: setupNewState.chatHandoffProvider, + chatHandoffChannelId: setupNewState.chatHandoffChannelId, + chatHandoffThreadId: setupNewState.chatHandoffThreadId, + chatHandoffServiceUrl: setupNewState.chatHandoffServiceUrl, + }); + + return { + queuedOnboardingTasks: await getPersistedQueuedSetupTasks( + setupNewState.onboardingTaskId, + ), + }; +} diff --git a/apps/web/src/trpc/commands/setup-new/shared.ts b/apps/web/src/trpc/commands/setup-new/shared.ts new file mode 100644 index 00000000..d1fb5cc3 --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/shared.ts @@ -0,0 +1,374 @@ +import * as GitHub from '@roomote/github'; +import { + db, + deploymentSettings, + environments, + users, + workItems, + eq, + and, + syncSetupQualificationBlock, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import { + createEmptySetupNewState, + normalizeDeploymentModelConfig, + normalizeSetupNewState, + type DeploymentModelConfig, + type SourceControlProvider, + type TaskModelSettings, +} from '@roomote/types'; + +import type { UserAuthSuccess } from '@/types'; +import { getLatestCloudJobsByTaskId, getRepositories } from '@/lib/server'; +import { areAllRepositoriesEmpty } from '@/lib/repositories'; +import { + findMatchingSetupNewEnvironment, + normalizeRepositorySelection, +} from '@/lib/setup-new'; +import type { QueuedOnboardingTask } from './types'; +import { getSetupBootstrapState } from '../setup/shared'; + +type PersistedSetupNewState = ReturnType; +type PersistedRuntimeModelConfig = DeploymentModelConfig; + +export type SelectedRepositorySummary = { + id: string; + fullName: string; + sourceControlProvider: SourceControlProvider; +}; + +export type PersistedQueuedSetupTask = QueuedOnboardingTask; + +export type MutableQueuedSetupTask = PersistedQueuedSetupTask & { + launchClaimedAt: Date | null; +}; + +type ActiveSetupQualificationBlock = { + reason: 'github_organization_required'; + email: string | null; + emailDomain: string | null; + githubAccountLogin: string | null; + githubAccountType: string | null; + lastBlockedAt: Date; +}; + +export const SETUP_BOOTSTRAP_USER_ID = 'setup-bootstrap-user'; + +export async function ensureSetupBootstrapAuditUser( + executor: DatabaseOrTransaction, +): Promise { + const [existingUser] = await executor + .select({ id: users.id }) + .from(users) + .where(eq(users.id, SETUP_BOOTSTRAP_USER_ID)) + .limit(1); + + if (existingUser) { + return existingUser.id; + } + + await executor.insert(users).values({ + id: SETUP_BOOTSTRAP_USER_ID, + name: 'Setup Bootstrap', + email: 'setup-bootstrap@roomote.local', + imageUrl: '', + entity: { + id: SETUP_BOOTSTRAP_USER_ID, + name: 'Setup Bootstrap', + email: 'setup-bootstrap@roomote.local', + imageUrl: '', + }, + metadata: { + system: true, + }, + }); + + return SETUP_BOOTSTRAP_USER_ID; +} + +export async function assertSetupBootstrapOpen() { + const bootstrapState = await getSetupBootstrapState(); + + if (!bootstrapState.setupOpen) { + throw new Error('Initial setup is no longer open.'); + } +} + +function getSetupQualificationBlockErrorMessage() { + return 'Setup is currently limited to work email addresses. Use another work email or contact the team if this seems wrong.'; +} + +export async function getPersistedSetupNewState( + executor: DatabaseOrTransaction = db, +): Promise { + const [settings] = await executor + .select({ setupNewState: deploymentSettings.setupNewState }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .limit(1); + + return normalizeSetupNewState(settings?.setupNewState ?? {}); +} + +export async function getPersistedRuntimeModelConfig( + executor: DatabaseOrTransaction = db, +): Promise { + const [settings] = await executor + .select({ runtimeModelConfig: deploymentSettings.runtimeModelConfig }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .limit(1); + + return normalizeDeploymentModelConfig(settings?.runtimeModelConfig); +} + +export async function savePersistedSetupNewState( + setupNewState: PersistedSetupNewState, + executor: DatabaseOrTransaction = db, +) { + await executor + .insert(deploymentSettings) + .values({ + id: 'default', + setupNewState, + }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { + setupNewState, + updatedAt: new Date(), + }, + }); + + return setupNewState; +} + +export async function savePersistedRuntimeModelConfig( + runtimeModelConfig: PersistedRuntimeModelConfig, + executor: DatabaseOrTransaction = db, +) { + await executor + .insert(deploymentSettings) + .values({ + id: 'default', + runtimeModelConfig, + }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { + runtimeModelConfig, + updatedAt: new Date(), + }, + }); + + return runtimeModelConfig; +} + +export async function savePersistedTaskModelSettings( + taskModelSettings: TaskModelSettings, + executor: DatabaseOrTransaction = db, +) { + await executor + .insert(deploymentSettings) + .values({ + id: 'default', + taskModelSettings, + }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { + taskModelSettings, + updatedAt: new Date(), + }, + }); + + return taskModelSettings; +} + +// Persisted runtime compute config helpers are shared with the compute +// settings commands and imported from '../compute'. + +export async function resolveSelectedRepositories( + repositoryIds: string[], +): Promise<{ + normalizedRepositoryIds: string[]; + selectedRepositories: SelectedRepositorySummary[]; +}> { + const uniqueRepositoryIds = [...new Set(repositoryIds)]; + const availableRepositories = await getRepositories(); + const availableRepositoriesById = new Map( + availableRepositories.map((repository) => [repository.id, repository]), + ); + const selectedRepositories: SelectedRepositorySummary[] = []; + + for (const repositoryId of uniqueRepositoryIds) { + const repository = availableRepositoriesById.get(repositoryId); + + if (!repository) { + throw new Error('Selected repositories are no longer available.'); + } + + selectedRepositories.push({ + id: repository.id, + fullName: repository.fullName, + sourceControlProvider: repository.sourceControlProvider, + }); + } + + return { + normalizedRepositoryIds: normalizeRepositorySelection(selectedRepositories), + selectedRepositories: selectedRepositories.sort((left, right) => + left.fullName.localeCompare(right.fullName), + ), + }; +} + +export async function assertHasCommittedRepositorySelection( + repositoryIds: string[], +) { + const emptyStates = await GitHub.getRepositoryEmptyStates({ + repositoryIds, + }); + + if ( + emptyStates.size === repositoryIds.length && + areAllRepositoriesEmpty( + [...emptyStates.values()].map((isEmpty) => ({ isEmpty })), + ) + ) { + throw new Error( + emptyStates.size === 1 + ? 'The selected repository has no commits yet. Push an initial commit first, or choose a different repository.' + : 'All selected repositories have no commits yet. Push an initial commit first, or choose different repositories.', + ); + } +} + +export async function clearTaskSuggestions( + sourceTaskId: string | null, + executor: DatabaseOrTransaction = db, +) { + if (!sourceTaskId) { + return; + } + + await executor + .delete(workItems) + .where( + and( + eq(workItems.sourceTaskId, sourceTaskId), + eq(workItems.kind, 'suggestion'), + ), + ); +} + +export async function clearQueuedSetupTasks( + executor: DatabaseOrTransaction = db, +) { + await executor.delete(workItems).where(eq(workItems.kind, 'onboarding')); +} + +export function didSuggestionSourceChange({ + currentState, + nextRepositoryIds, + nextSetupGuidance, +}: { + currentState: PersistedSetupNewState; + nextRepositoryIds: string[]; + nextSetupGuidance: string | null; +}): boolean { + return ( + currentState.setupGuidance !== nextSetupGuidance || + currentState.selectedRepositoryIds.length !== nextRepositoryIds.length || + currentState.selectedRepositoryIds.some( + (repositoryId, index) => repositoryId !== nextRepositoryIds[index], + ) + ); +} + +export async function getOnboardingTaskState(taskId: string | null) { + if (!taskId) { + return { + status: null, + taskPhase: null, + firstAssistantOutputAt: null, + }; + } + + const latestCloudJobs = await getLatestCloudJobsByTaskId([taskId]); + const latestJob = latestCloudJobs[taskId]; + + return { + status: latestJob?.status ?? null, + taskPhase: latestJob?.taskPhase ?? null, + firstAssistantOutputAt: latestJob?.firstAssistantOutputAt ?? null, + }; +} + +export async function getMatchingEnvironmentSummary({ + selectedRepositoryFullNames, + onboardingTaskStartedAt, +}: { + selectedRepositoryFullNames: string[]; + onboardingTaskStartedAt: string | null; +}) { + if ( + selectedRepositoryFullNames.length === 0 || + onboardingTaskStartedAt === null + ) { + return null; + } + + const environmentList = await db.query.environments.findMany({ + where: eq(environments.isEval, false), + columns: { + id: true, + name: true, + config: true, + createdAt: true, + }, + }); + + const matchingEnvironment = findMatchingSetupNewEnvironment( + environmentList, + selectedRepositoryFullNames, + onboardingTaskStartedAt, + ); + + if (matchingEnvironment) { + return { + id: matchingEnvironment.id, + name: matchingEnvironment.name, + }; + } + + return null; +} + +export async function getActiveSetupQualificationBlock( + auth: UserAuthSuccess, +): Promise { + await Promise.all([ + syncSetupQualificationBlock({ + userId: auth.userId, + reason: 'github_organization_required', + blocked: false, + }), + ]); + + return null; +} + +export async function assertSetupQualificationNotBlocked( + auth: UserAuthSuccess, +) { + const activeSetupQualificationBlock = + await getActiveSetupQualificationBlock(auth); + + if (!activeSetupQualificationBlock) { + return; + } + + throw new Error(getSetupQualificationBlockErrorMessage()); +} diff --git a/apps/web/src/trpc/commands/setup-new/status.ts b/apps/web/src/trpc/commands/setup-new/status.ts new file mode 100644 index 00000000..5bb813e7 --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/status.ts @@ -0,0 +1,211 @@ +import { + purgeSavedDeploymentWorkerImage, + isChatGptSubscriptionConnected, + resolveDeploymentEnvVar, +} from '@roomote/db/server'; +import { + buildSetupAuthStatus, + buildSetupComputeStatus, + buildSetupModelStatus, + buildSetupSourceControlStatus, + hasSetupChatHandoffDestination, + normalizeSetupNewState, + presentSetupNewComputeProvisioning, + NON_SECRET_COMPUTE_ENV_VAR_NAMES, +} from '@roomote/types'; + +import type { UserAuthSuccess } from '@/types'; +import { getSourceControlConnectionSummary } from '@/lib/server'; +import { + isSetupNewOnboardingFailureStatus, + isSetupNewOnboardingSuccessStatus, + isSetupNewOnboardingTerminalSuccessStatus, +} from '@/lib/setup-new'; + +import { assertAdmin, getSetupBaseStatus } from '../setup/shared'; +import { + getPersistedEnvironmentVariableNames, + getPersistedEnvironmentVariableValues, +} from '../environment-variables'; +import { getPersistedRuntimeComputeConfig } from '../compute'; +import { triggerTaskSuggestionsCommand } from '../task-suggestions'; +import { + getActiveSetupQualificationBlock, + getMatchingEnvironmentSummary, + getOnboardingTaskState, + getPersistedRuntimeModelConfig, + getPersistedSetupNewState, + resolveSelectedRepositories, + type SelectedRepositorySummary, +} from './shared'; +import { getSetupSlackAccessStatus } from './handoff'; +import { + getPersistedQueuedSetupTasks, + launchQueuedSetupTasksIfReady, +} from './queued-tasks'; + +export async function getSetupNewStatusCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const { userId } = auth; + await purgeSavedDeploymentWorkerImage(); + + const [ + baseStatus, + slackAccessStatus, + persistedRuntimeModelConfig, + persistedRuntimeComputeConfig, + envVarNames, + nonSecretComputeEnvValues, + chatgptConnected, + ] = await Promise.all([ + getSetupBaseStatus(auth), + getSetupSlackAccessStatus({ userId }), + getPersistedRuntimeModelConfig(), + getPersistedRuntimeComputeConfig(), + getPersistedEnvironmentVariableNames(), + getPersistedEnvironmentVariableValues([ + ...NON_SECRET_COMPUTE_ENV_VAR_NAMES, + ]), + isChatGptSubscriptionConnected(), + ]); + const activeSetupQualificationBlock = + await getActiveSetupQualificationBlock(auth); + let setupNewState = normalizeSetupNewState(baseStatus.setupNewState); + + if ( + setupNewState.onboardingTaskId && + setupNewState.suggestionTaskId === null && + hasSetupChatHandoffDestination(setupNewState) + ) { + try { + await triggerTaskSuggestionsCommand(auth); + setupNewState = await getPersistedSetupNewState(); + } catch (error) { + console.error( + `[getSetupNewStatusCommand] Failed to trigger task suggestions: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + const { selectedRepositories } = await resolveSelectedRepositories( + setupNewState.selectedRepositoryIds, + ).catch(() => ({ + normalizedRepositoryIds: setupNewState.selectedRepositoryIds, + selectedRepositories: [] as SelectedRepositorySummary[], + })); + + const selectedRepositoryFullNames = selectedRepositories.map( + (repository) => repository.fullName, + ); + + const [onboardingTask, matchingEnvironment] = await Promise.all([ + getOnboardingTaskState(setupNewState.onboardingTaskId), + getMatchingEnvironmentSummary({ + selectedRepositoryFullNames, + onboardingTaskStartedAt: setupNewState.onboardingTaskStartedAt, + }), + ]); + const onboardingTaskStatus = onboardingTask.status; + const onboardingTaskPhase = onboardingTask.taskPhase; + const onboardingTaskFirstAssistantOutputAt = + onboardingTask.firstAssistantOutputAt; + + const onboardingSucceeded = + isSetupNewOnboardingSuccessStatus( + onboardingTaskStatus, + onboardingTaskPhase, + ) && matchingEnvironment !== null; + const onboardingEndedWithoutEnvironment = + isSetupNewOnboardingTerminalSuccessStatus( + onboardingTaskStatus, + onboardingTaskPhase, + ) && matchingEnvironment === null; + const onboardingFailed = + (isSetupNewOnboardingFailureStatus(onboardingTaskStatus) || + onboardingEndedWithoutEnvironment) && + !onboardingSucceeded; + + await launchQueuedSetupTasksIfReady({ + setupOnboardingTaskId: setupNewState.onboardingTaskId, + matchingEnvironmentId: onboardingSucceeded ? matchingEnvironment.id : null, + slackTeamId: setupNewState.slackTeamId, + slackChannel: setupNewState.slackChannel, + slackThreadTs: setupNewState.slackThreadTs, + chatHandoffProvider: setupNewState.chatHandoffProvider, + chatHandoffChannelId: setupNewState.chatHandoffChannelId, + chatHandoffThreadId: setupNewState.chatHandoffThreadId, + chatHandoffServiceUrl: setupNewState.chatHandoffServiceUrl, + }); + + const queuedOnboardingTasks = await getPersistedQueuedSetupTasks( + setupNewState.onboardingTaskId, + ); + const authSetup = buildSetupAuthStatus({ + runtimeEnv: process.env, + persistedEnvVarNames: envVarNames, + selectedProvider: setupNewState.authProvider, + }); + const modelSetup = buildSetupModelStatus({ + runtimeEnv: process.env, + persistedModelConfig: persistedRuntimeModelConfig, + persistedEnvVarNames: envVarNames, + selectedProvider: setupNewState.modelProvider, + chatgptConnected, + }); + const computeSetup = buildSetupComputeStatus({ + runtimeEnv: process.env, + persistedEnvVarNames: envVarNames, + persistedEnvVarValues: nonSecretComputeEnvValues, + persistedComputeConfig: persistedRuntimeComputeConfig, + selectedProvider: setupNewState.computeProvider, + }); + // Present stale in-flight provisioning runs as failed so the wizard + // offers a retry instead of polling forever after a web-process restart. + setupNewState = { + ...setupNewState, + e2bTemplateBuild: presentSetupNewComputeProvisioning( + setupNewState.e2bTemplateBuild, + ), + daytonaSnapshotBuild: presentSetupNewComputeProvisioning( + setupNewState.daytonaSnapshotBuild, + ), + }; + + const sourceControlConnection = await getSourceControlConnectionSummary(); + const gitlabBaseUrl = await resolveDeploymentEnvVar('GITLAB_BASE_URL'); + const sourceControlSetup = buildSetupSourceControlStatus({ + runtimeEnv: process.env, + persistedEnvVarNames: envVarNames, + selectedProvider: setupNewState.sourceControlProvider, + connectedProviders: sourceControlConnection.connectedProviders, + repositoryCounts: sourceControlConnection.repositoryCounts, + gitlabBaseUrl, + }); + + return { + hasGitHub: baseStatus.hasGitHub, + hasSlack: slackAccessStatus.hasSlackUserMapping, + hasSlackInstallation: slackAccessStatus.hasSlackInstallation, + hasLinear: baseStatus.hasLinear, + setupCompletedAt: baseStatus.setupCompletedAt, + setupNewState, + selectedRepositories, + onboardingTaskStatus, + onboardingTaskPhase, + onboardingTaskFirstAssistantOutputAt, + onboardingSucceeded, + onboardingFailed, + matchingEnvironment, + queuedOnboardingTasks, + authSetup, + modelSetup, + computeSetup, + sourceControlSetup, + setupQualification: { + activeBlock: activeSetupQualificationBlock, + }, + }; +}