diff --git a/apps/web/src/app/(onboarding)/setup/StepAutomationRecommendations.tsx b/apps/web/src/app/(onboarding)/setup/StepAutomationRecommendations.tsx index 2ffd589be..ed1745f27 100644 --- a/apps/web/src/app/(onboarding)/setup/StepAutomationRecommendations.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepAutomationRecommendations.tsx @@ -2,10 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { - AUTOMATION_RECOMMENDATION_CATALOG, - type AutomationRecommendationBatch, -} from '@roomote/types'; +import type { AutomationRecommendationBatch } from '@roomote/types'; import { Alert, AlertDescription, @@ -23,12 +20,10 @@ import { getSetupStepDefinition } from './types'; const STEP = getSetupStepDefinition('automation-recommendations'); -function candidateTitle(candidateId: string) { - return ( - AUTOMATION_RECOMMENDATION_CATALOG.find( - (candidate) => candidate.id === candidateId, - )?.title ?? candidateId - ); +function candidateTitle( + recommendation: AutomationRecommendationBatch['recommendations'][number], +) { + return recommendation.title ?? recommendation.candidateId; } export function StepAutomationRecommendations({ @@ -194,7 +189,7 @@ export function StepAutomationRecommendations({ />

- {candidateTitle(recommendation.candidateId)} + {candidateTitle(recommendation)}

{recommendation.explanation} diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index f119d67c8..77519e332 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -22,6 +22,7 @@ const { mockValidateSetupModelProviderCredentials, mockEnqueueAutomationRecommendations, mockEnqueueAutomationRecommendationInitialRun, + mockResolveConnectedAutomationRecommendationRepositories, mockUpsertAutomation, mockCaptureActivationAutomationChanged, mockTriggerAutomationCommand, @@ -53,8 +54,20 @@ const { mockValidateSetupModelProviderCredentials: vi .fn() .mockResolvedValue(undefined), - mockEnqueueAutomationRecommendations: vi.fn(async () => undefined), + mockEnqueueAutomationRecommendations: vi.fn( + async (_input?: unknown) => undefined, + ), mockEnqueueAutomationRecommendationInitialRun: vi.fn(async () => undefined), + mockResolveConnectedAutomationRecommendationRepositories: vi.fn(async () => ({ + normalizedRepositoryIds: ['repo-1'], + connectedRepositories: [ + { + id: 'repo-1', + fullName: 'acme/api', + sourceControlProvider: 'github' as const, + }, + ], + })), mockUpsertAutomation: vi.fn(async () => undefined), mockCaptureActivationAutomationChanged: vi.fn(async () => undefined), mockTriggerAutomationCommand: vi.fn(async () => ({ @@ -146,6 +159,50 @@ vi.mock('@roomote/sdk/server', () => ({ enqueueAutomationRecommendationInitialRun: mockEnqueueAutomationRecommendationInitialRun, enqueueAutomationSignalPrefetch: vi.fn(async () => undefined), + resolveConnectedAutomationRecommendationRepositories: + mockResolveConnectedAutomationRecommendationRepositories, + prepareSetupAutomationRecommendationInput: vi.fn(async () => { + const result = + await mockResolveConnectedAutomationRecommendationRepositories(); + return { + fingerprint: `${result.connectedRepositories[0]?.sourceControlProvider ?? 'none'}:${result.normalizedRepositoryIds.join(',')}`, + repositoryIds: result.normalizedRepositoryIds, + }; + }), + dispatchSetupAutomationRecommendationBatch: vi.fn( + async ({ batch, repositoryIds }) => + mockEnqueueAutomationRecommendations({ + fingerprint: batch.inputFingerprint, + repositoryIds, + }), + ), + createPendingAutomationRecommendationBatch: vi.fn( + ( + inputFingerprint: string, + previousBatch: Record | null, + ) => ({ + version: 1, + inputFingerprint, + catalogVersion: 1, + status: 'pending', + startedAt: new Date().toISOString(), + completedAt: null, + partial: false, + errorCode: null, + dismissed: false, + applicationState: 'pending', + recommendations: previousBatch?.recommendations ?? [], + }), + ), + markAutomationRecommendationBatchFailed: vi.fn(async () => undefined), + prefetchSetupAutomationRecommendationSignals: vi.fn(), + setSetupAutomationRecommendationEnabled: vi.fn(), + applySetupAutomationRecommendations: vi.fn(), + skipSetupAutomationRecommendations: vi.fn(), + listSetupAutomationRecommendations: vi.fn(), + startSetupAutomationRecommendations: vi.fn(), + runSetupAutomationRecommendationNow: vi.fn(), + dismissSetupAutomationRecommendations: vi.fn(), createTeamsCommunicationProviderFromRuntimeCredentials: vi.fn( async () => null, ), @@ -307,9 +364,6 @@ import { saveSetupNewComputeProviderChoiceCommand, saveSetupNewSourceControlConfigCommand, saveSetupNewSourceControlProviderChoiceCommand, - startSetupRecommendationsCommand, - applySetupRecommendationsCommand, - skipSetupRecommendationsCommand, startSetupNewOnboardingTaskCommand, trackSetupBootstrapWelcomeSeenCommand, trackSetupCommsStateCommand, @@ -1383,25 +1437,33 @@ describe('setup-new onboarding task start command', () => { }); it('scores all connected repositories independently of environment selection', async () => { - vi.mocked(getRepositories).mockResolvedValue([ - ...Array.from({ length: 11 }, (_, index) => ({ - id: `repo-${index + 1}`, - fullName: `acme/repo-${String(index + 1).padStart(2, '0')}`, - })), - ] as Awaited>); - vi.mocked(normalizeRepositorySelection).mockImplementation((repositories) => - repositories.map((repository) => repository.id), + mockResolveConnectedAutomationRecommendationRepositories.mockResolvedValueOnce( + { + normalizedRepositoryIds: [ + 'repo-11', + 'repo-10', + 'repo-1', + 'repo-2', + 'repo-3', + 'repo-4', + 'repo-5', + 'repo-6', + 'repo-7', + 'repo-8', + ], + connectedRepositories: [ + { + id: 'repo-11', + fullName: 'acme/repo-11', + sourceControlProvider: 'github', + }, + ], + }, ); mockOnboardingTransaction({ slackInstallation: null, setupNewState: { selectedRepositoryIds: ['repo-1'] }, }); - mockTxSelect.mockReturnValueOnce( - createGroupBySelectChain([ - { repositoryId: 'repo-11', activity: 10 }, - { repositoryId: 'repo-10', activity: 5 }, - ]), - ); await startSetupNewOnboardingTaskCommand(buildMockAuth()); @@ -1423,174 +1485,6 @@ describe('setup-new onboarding task start command', () => { ); }); - it('starts recommendations from connected repositories without environment selection', async () => { - mockOnboardingTransaction({ - slackInstallation: null, - setupNewState: { selectedRepositoryIds: [] }, - }); - - const result = await startSetupRecommendationsCommand(buildMockAuth()); - - expect(result.status).toBe('pending'); - expect(mockEnqueueAutomationRecommendations).toHaveBeenCalledWith( - expect.objectContaining({ - repositoryIds: ['repo-1'], - }), - ); - }); - - it('applies the enabled recommendation selection before continuing setup', async () => { - mockOnboardingTransaction({ - slackInstallation: null, - setupNewState: { - automationRecommendations: { - version: 1, - inputFingerprint: 'recommendation-fingerprint', - catalogVersion: 1, - status: 'ready', - startedAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - partial: false, - errorCode: null, - dismissed: false, - recommendations: [ - { - id: 'built-in.review-code:1', - candidateId: 'built-in.review-code', - rank: 1, - score: 1, - explanation: 'Review PRs automatically.', - enabled: true, - lastRunTaskId: null, - automationId: null, - }, - { - id: 'built-in.ci-failure-triage:2', - candidateId: 'built-in.ci-failure-triage', - rank: 2, - score: 1, - explanation: 'Fix broken builds.', - enabled: true, - lastRunTaskId: null, - automationId: null, - }, - { - id: 'built-in.codeql-triage:3', - candidateId: 'built-in.codeql-triage', - rank: 3, - score: 1, - explanation: 'Triage security alerts.', - enabled: false, - lastRunTaskId: null, - automationId: null, - }, - ], - }, - }, - }); - - const result = await applySetupRecommendationsCommand(buildMockAuth()); - - expect(mockUpsertAutomation).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - key: 'review_code', - enabled: true, - }), - ); - expect(mockUpsertAutomation).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - key: 'ci_failure_triage', - enabled: true, - }), - ); - expect(mockUpsertAutomation).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - key: 'codeql_triage', - enabled: false, - }), - ); - expect(mockCaptureActivationAutomationChanged).toHaveBeenCalledWith( - 'enabled', - 'review_code', - ); - expect(mockCaptureActivationAutomationChanged).toHaveBeenCalledWith( - 'enabled', - 'ci_failure_triage', - ); - expect(mockCaptureActivationAutomationChanged).not.toHaveBeenCalledWith( - 'enabled', - 'codeql_triage', - ); - expect(mockTriggerAutomationCommand).not.toHaveBeenCalled(); - expect(mockEnqueueAutomationRecommendationInitialRun).toHaveBeenCalledWith( - { - fingerprint: 'recommendation-fingerprint', - recommendationId: 'built-in.ci-failure-triage:2', - }, - 5 * 60 * 1_000, - ); - expect(mockTriggerAutomationCommand).not.toHaveBeenCalledWith( - expect.objectContaining({ userId: 'setup-test-user' }), - { automationKey: 'review_code' }, - ); - expect(result?.recommendations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - candidateId: 'built-in.review-code', - enabled: true, - }), - expect.objectContaining({ - candidateId: 'built-in.ci-failure-triage', - enabled: true, - }), - ]), - ); - }); - - it('keeps a skipped pending batch unapplied and disabled', async () => { - mockOnboardingTransaction({ - slackInstallation: null, - setupNewState: { - automationRecommendations: { - version: 1, - inputFingerprint: 'recommendation-fingerprint', - catalogVersion: 1, - status: 'pending', - startedAt: new Date().toISOString(), - completedAt: null, - partial: false, - errorCode: null, - dismissed: false, - applicationState: 'pending', - recommendations: [ - { - id: 'built-in.ci-failure-triage:1', - candidateId: 'built-in.ci-failure-triage', - rank: 1, - score: 1, - explanation: 'Fix broken builds.', - enabled: true, - lastRunTaskId: null, - automationId: null, - }, - ], - }, - }, - }); - - const result = await skipSetupRecommendationsCommand(buildMockAuth()); - - expect(result).toMatchObject({ - applicationState: 'skipped', - recommendations: [ - expect.objectContaining({ enabled: false, applied: false }), - ], - }); - }); - it('uses the first workspace provider when setup repositories are mixed', async () => { vi.mocked(getRepositories).mockResolvedValue([ { diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index dcb032b08..802585bf8 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -7,10 +7,7 @@ import { DiscordCommunicationProvider } from '@roomote/communication/discord-pro import type { TeamsCommunicationProvider } from '@roomote/communication/teams-provider'; import { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; import { SlackNotifier } from '@roomote/slack'; -import { - captureActivationAutomationChanged, - captureTaskSettled, -} from '@roomote/telemetry/server'; +import { captureTaskSettled } from '@roomote/telemetry/server'; import { db, deploymentSettings, @@ -18,12 +15,10 @@ import { environmentVariables, taskRuns, workItems, - pullRequestFacts, slackInstallations, slackUserMappings, asc, eq, - gte, and, inArray, isNull, @@ -44,10 +39,6 @@ import { isGitHubCopilotSubscriptionConnected, isXaiSubscriptionConnected, type DatabaseOrTransaction, - upsertAutomation, - createCustomAutomation, - updateCustomAutomation, - getCustomAutomationById, } from '@roomote/db/server'; import { createTeamsCommunicationProviderFromRuntimeCredentials, @@ -56,11 +47,9 @@ import { findDiscordUserMappingByRoomoteUserId, findTeamsPrimaryConversation, recordSlackConversationMessageBestEffort, - AUTOMATION_RECOMMENDATION_REPOSITORY_CAP, - buildAutomationRecommendationFingerprint, - enqueueAutomationRecommendationInitialRun, - enqueueAutomationRecommendations, - enqueueAutomationSignalPrefetch, + createPendingAutomationRecommendationBatch, + dispatchSetupAutomationRecommendationBatch, + prepareSetupAutomationRecommendationInput, } from '@roomote/sdk/server'; import { buildRecommendedDeploymentModelConfig, @@ -114,16 +103,12 @@ import { SETUP_COMPUTE_PROVISIONING_STATE_FIELDS, SHARED_WORKER_IMAGE_ENV_VAR, type SetupAuthProviderId, - type AutomationRecommendationBatch, type SetupComputeStatus, type SetupModelProviderId, type SetupProvisionableComputeProvider, type SourceControlProvider, type TaskModelSettings, WAITING_FOR_SANDBOX_PROVIDER_TASK_PHASE, - AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, - AUTOMATION_RECOMMENDATION_CATALOG, - ALL_REPOSITORIES, } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; @@ -195,12 +180,9 @@ import { } from '../task-models/auto-add-models'; import { validateSetupModelProviderCredentials } from '../task-models/provider-validation'; import { triggerTaskSuggestionsCommand } from '../task-suggestions'; -import { triggerAutomationCommand } from '../automations/trigger-agent'; -import { triggerCustomAutomationCommand } from '../automations/custom-automations'; type PersistedSetupNewState = ReturnType; type PersistedRuntimeModelConfig = DeploymentModelConfig; -const AUTOMATION_RECOMMENDATION_TRIGGER_DELAY_MS = 5 * 60 * 1_000; type SelectedRepositorySummary = { id: string; @@ -234,58 +216,6 @@ async function getPersistedSetupNewState( return normalizeSetupNewState(settings?.setupNewState ?? {}); } -async function markAutomationRecommendationBatchFailed( - inputFingerprint: string, - errorCode: string, -) { - await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const currentState = await getPersistedSetupNewState(tx); - if ( - currentState.automationRecommendations?.inputFingerprint !== - inputFingerprint - ) { - return; - } - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...currentState, - automationRecommendations: { - ...currentState.automationRecommendations, - status: 'failed', - completedAt: new Date().toISOString(), - errorCode, - }, - }), - tx, - ); - }); -} - -function createPendingAutomationRecommendationBatch( - inputFingerprint: string, - previousBatch: AutomationRecommendationBatch | null | undefined, -): AutomationRecommendationBatch { - const sameInput = previousBatch?.inputFingerprint === inputFingerprint; - return { - version: 1, - inputFingerprint, - catalogVersion: AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, - status: 'pending', - startedAt: new Date().toISOString(), - completedAt: null, - partial: false, - errorCode: null, - dismissed: sameInput ? previousBatch.dismissed : false, - applicationState: sameInput - ? (previousBatch.applicationState ?? 'pending') - : 'pending', - recommendations: sameInput ? previousBatch.recommendations : [], - }; -} - async function getPersistedRuntimeModelConfig( executor: DatabaseOrTransaction = db, ): Promise { @@ -413,54 +343,6 @@ async function resolveSelectedRepositories(repositoryIds: string[]): Promise<{ }; } -async function resolveConnectedRecommendationRepositories(): Promise<{ - normalizedRepositoryIds: string[]; - connectedRepositories: SelectedRepositorySummary[]; -}> { - const availableRepositories = await getRepositories(); - const connectedRepositories = availableRepositories.map((repository) => ({ - id: repository.id, - fullName: repository.fullName, - sourceControlProvider: repository.sourceControlProvider, - })); - const activitySince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1_000); - const activityRows = - connectedRepositories.length > 0 - ? await db - .select({ - repositoryId: pullRequestFacts.repositoryId, - activity: sql`count(*)::int`, - }) - .from(pullRequestFacts) - .where( - and( - inArray( - pullRequestFacts.repositoryId, - connectedRepositories.map((repository) => repository.id), - ), - gte(pullRequestFacts.updatedAtRemote, activitySince), - ), - ) - .groupBy(pullRequestFacts.repositoryId) - : []; - const activityByRepositoryId = new Map( - activityRows.map((row) => [row.repositoryId, row.activity]), - ); - const rankedRepositories = [...connectedRepositories] - .sort((left, right) => { - const activityDifference = - (activityByRepositoryId.get(right.id) ?? 0) - - (activityByRepositoryId.get(left.id) ?? 0); - return activityDifference || left.fullName.localeCompare(right.fullName); - }) - .slice(0, AUTOMATION_RECOMMENDATION_REPOSITORY_CAP); - - return { - normalizedRepositoryIds: normalizeRepositorySelection(rankedRepositories), - connectedRepositories: rankedRepositories, - }; -} - /** * Look up which selected repositories have no commits yet. Empty repos are a * supported onboarding target (the environment-setup agent bootstraps them @@ -2671,30 +2553,14 @@ export async function saveSetupNewSelectionCommand( return result; } -export async function prefetchSetupRecommendationSignalsCommand( - auth: UserAuthSuccess, - _input: { repositoryIds: string[] }, -) { - assertAdmin(auth); - const { normalizedRepositoryIds } = - await resolveConnectedRecommendationRepositories(); - await enqueueAutomationSignalPrefetch(normalizedRepositoryIds); - return { repositoryIds: normalizedRepositoryIds }; -} - export async function startSetupNewOnboardingTaskCommand( auth: UserAuthSuccess, ) { assertAdmin(auth); const { userId } = auth; - const { - normalizedRepositoryIds: recommendationRepositoryIds, - connectedRepositories, - } = await resolveConnectedRecommendationRepositories(); - const recommendationFingerprint = buildAutomationRecommendationFingerprint( - recommendationRepositoryIds, - connectedRepositories[0]?.sourceControlProvider ?? null, - ); + const recommendationInput = await prepareSetupAutomationRecommendationInput(); + const recommendationRepositoryIds = recommendationInput.repositoryIds; + const recommendationFingerprint = recommendationInput.fingerprint; const startResult = await db.transaction(async (tx) => { await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('setup-new'))`); @@ -3205,21 +3071,11 @@ export async function startSetupNewOnboardingTaskCommand( }); if (startResult.recommendationBatch?.status === 'pending') { - try { - await enqueueAutomationRecommendations({ - fingerprint: startResult.recommendationBatch.inputFingerprint, - repositoryIds: startResult.repositoryIds, - }); - } catch (error) { - console.error( - '[startSetupNewOnboardingTaskCommand] Failed to enqueue recommendation scoring:', - error, - ); - await markAutomationRecommendationBatchFailed( - startResult.recommendationBatch.inputFingerprint, - 'recommendation_queue_unavailable', - ); - } + await dispatchSetupAutomationRecommendationBatch({ + batch: startResult.recommendationBatch, + repositoryIds: startResult.repositoryIds, + logContext: 'startSetupNewOnboardingTaskCommand', + }); } try { @@ -3241,445 +3097,6 @@ export async function startSetupNewOnboardingTaskCommand( }; } -export async function setSetupRecommendationEnabledCommand( - auth: UserAuthSuccess, - input: { id: string; enabled: boolean }, -) { - assertAdmin(auth); - const recommendation = await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const state = await getPersistedSetupNewState(tx); - const batch = state.automationRecommendations; - const recommendation = batch?.recommendations.find( - (item) => item.id === input.id, - ); - if (!batch || !recommendation) - throw new Error('Recommendation was not found.'); - const candidate = AUTOMATION_RECOMMENDATION_CATALOG.find( - (item) => item.id === recommendation.candidateId, - ); - if (!candidate) throw new Error('Recommendation candidate was not found.'); - - const automationId = await applySetupRecommendationInTx( - tx, - auth, - recommendation, - input.enabled, - candidate, - ); - - const nextBatch = { - ...batch, - recommendations: batch.recommendations.map((item) => - item.id === input.id - ? { - ...item, - enabled: input.enabled, - applied: true, - ...(automationId ? { automationId } : {}), - } - : item, - ), - }; - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...state, - automationRecommendations: nextBatch, - }), - tx, - ); - return nextBatch.recommendations.find((item) => item.id === input.id); - }); - const candidate = recommendation - ? AUTOMATION_RECOMMENDATION_CATALOG.find( - (item) => item.id === recommendation.candidateId, - ) - : null; - if (recommendation?.enabled && candidate?.source === 'built_in') { - void captureActivationAutomationChanged('enabled', candidate.automationKey); - } - return recommendation; -} - -async function applySetupRecommendationInTx( - tx: DatabaseOrTransaction, - auth: UserAuthSuccess, - recommendation: AutomationRecommendationBatch['recommendations'][number], - enabled: boolean, - candidate: (typeof AUTOMATION_RECOMMENDATION_CATALOG)[number], -): Promise { - if (candidate.source === 'built_in') { - await upsertAutomation(tx, { - key: candidate.automationKey, - enabled, - schedule: { - mode: enabled ? candidate.defaultScheduleMode : 'off', - }, - }); - return null; - } - - const existing = recommendation.automationId - ? await getCustomAutomationById(recommendation.automationId, tx) - : null; - const automation = existing - ? await updateCustomAutomation( - existing.id, - { - name: candidate.template.name, - prompt: candidate.template.prompt, - enabled, - scheduleMode: candidate.template.scheduleMode, - environmentId: ALL_REPOSITORIES, - target: {}, - }, - tx, - ) - : await createCustomAutomation( - { - name: candidate.template.name, - prompt: candidate.template.prompt, - enabled, - scheduleMode: candidate.template.scheduleMode, - environmentId: ALL_REPOSITORIES, - target: {}, - createdByUserId: auth.userId, - }, - tx, - ); - return automation.id; -} - -export async function applySetupRecommendationsCommand(auth: UserAuthSuccess) { - assertAdmin(auth); - const batch = await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const state = await getPersistedSetupNewState(tx); - const batch = state.automationRecommendations; - if (!batch || batch.status !== 'ready') return batch; - - const recommendations = []; - for (const recommendation of batch.recommendations) { - const candidate = AUTOMATION_RECOMMENDATION_CATALOG.find( - (item) => item.id === recommendation.candidateId, - ); - if (!candidate) { - throw new Error('Recommendation candidate was not found.'); - } - const automationId = await applySetupRecommendationInTx( - tx, - auth, - recommendation, - recommendation.enabled, - candidate, - ); - recommendations.push({ - ...recommendation, - applied: true, - ...(automationId ? { automationId } : {}), - }); - } - - const nextBatch = { ...batch, recommendations }; - nextBatch.applicationState = 'applied'; - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...state, - automationRecommendations: nextBatch, - }), - tx, - ); - return nextBatch; - }); - for (const recommendation of batch?.recommendations ?? []) { - if (!recommendation.enabled) continue; - const candidate = AUTOMATION_RECOMMENDATION_CATALOG.find( - (item) => item.id === recommendation.candidateId, - ); - if (candidate?.source === 'built_in') { - void captureActivationAutomationChanged( - 'enabled', - candidate.automationKey, - ); - } - } - await Promise.all( - (batch?.recommendations ?? []) - .filter((recommendation) => recommendation.enabled) - .filter((recommendation) => { - const candidate = AUTOMATION_RECOMMENDATION_CATALOG.find( - (item) => item.id === recommendation.candidateId, - ); - if (!candidate) return false; - return !( - candidate?.source === 'built_in' && - candidate.automationKey === 'review_code' - ); - }) - .map(async (recommendation) => { - try { - await enqueueAutomationRecommendationInitialRun( - { - fingerprint: batch!.inputFingerprint, - recommendationId: recommendation.id, - }, - AUTOMATION_RECOMMENDATION_TRIGGER_DELAY_MS, - ); - } catch (error) { - console.error( - `[applySetupRecommendationsCommand] Failed to schedule ${recommendation.id}:`, - error, - ); - } - }), - ); - return batch; -} - -export async function skipSetupRecommendationsCommand(auth: UserAuthSuccess) { - assertAdmin(auth); - return db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const state = await getPersistedSetupNewState(tx); - const batch = state.automationRecommendations; - if (!batch || (batch.applicationState ?? 'pending') !== 'pending') { - return batch ?? null; - } - - const nextBatch = { - ...batch, - applicationState: 'skipped' as const, - recommendations: batch.recommendations.map((recommendation) => ({ - ...recommendation, - enabled: false, - applied: false, - })), - }; - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...state, - automationRecommendations: nextBatch, - }), - tx, - ); - return nextBatch; - }); -} - -export async function listSetupRecommendationsCommand(auth: UserAuthSuccess) { - assertAdmin(auth); - const state = await getPersistedSetupNewState(); - return state.automationRecommendations; -} - -export async function startSetupRecommendationsCommand(auth: UserAuthSuccess) { - assertAdmin(auth); - const { - normalizedRepositoryIds: recommendationRepositoryIds, - connectedRepositories, - } = await resolveConnectedRecommendationRepositories(); - const result = await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const state = await getPersistedSetupNewState(tx); - const fingerprint = buildAutomationRecommendationFingerprint( - recommendationRepositoryIds, - connectedRepositories[0]?.sourceControlProvider ?? null, - ); - const existingBatch = state.automationRecommendations; - const batch = { - ...(existingBatch?.inputFingerprint === fingerprint - ? existingBatch - : { - version: 1 as const, - inputFingerprint: fingerprint, - catalogVersion: AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, - completedAt: null, - partial: false, - dismissed: false, - applicationState: 'pending' as const, - recommendations: [], - }), - status: 'pending' as const, - startedAt: new Date().toISOString(), - completedAt: null, - errorCode: null, - }; - await savePersistedSetupNewState( - normalizeSetupNewState({ ...state, automationRecommendations: batch }), - tx, - ); - return { batch, repositoryIds: recommendationRepositoryIds }; - }); - - try { - await enqueueAutomationRecommendations({ - fingerprint: result.batch.inputFingerprint, - repositoryIds: result.repositoryIds, - }); - } catch (error) { - console.error( - '[startSetupRecommendationsCommand] Failed to enqueue recommendation scoring:', - error, - ); - await db.transaction(async (tx) => { - const state = await getPersistedSetupNewState(tx); - if ( - state.automationRecommendations?.inputFingerprint !== - result.batch.inputFingerprint - ) { - return; - } - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...state, - automationRecommendations: { - ...state.automationRecommendations, - status: 'failed', - completedAt: new Date().toISOString(), - errorCode: 'recommendation_queue_unavailable', - }, - }), - tx, - ); - }); - } - - return result.batch; -} - -async function runSetupRecommendationNowForCandidate( - auth: UserAuthSuccess, - recommendation: AutomationRecommendationBatch['recommendations'][number], - candidate: (typeof AUTOMATION_RECOMMENDATION_CATALOG)[number], -) { - if ( - candidate.source === 'built_in' && - candidate.automationKey === 'review_code' - ) { - throw new Error('Review Code runs from pull-request events.'); - } - - let automationId = recommendation.automationId; - if (!recommendation.enabled) { - const updatedRecommendation = await setSetupRecommendationEnabledCommand( - auth, - { - id: recommendation.id, - enabled: true, - }, - ); - automationId = updatedRecommendation?.automationId ?? null; - } - - if (candidate.source === 'cookbook') { - if (!automationId) { - const refreshed = await listSetupRecommendationsCommand(auth); - automationId = - refreshed?.recommendations.find((item) => item.id === recommendation.id) - ?.automationId ?? null; - } - if (!automationId) - throw new Error('Recommendation automation was not created.'); - return triggerCustomAutomationCommand(auth, { id: automationId }); - } - - if (candidate.automationKey === 'review_code') { - throw new Error('Review Code runs from pull-request events.'); - } - - return triggerAutomationCommand(auth, { - automationKey: candidate.automationKey, - }); -} - -async function recordSetupRecommendationLaunch( - recommendationId: string, - taskId: string, -) { - await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const state = await getPersistedSetupNewState(tx); - const nextBatch = state.automationRecommendations - ? { - ...state.automationRecommendations, - recommendations: state.automationRecommendations.recommendations.map( - (item) => - item.id === recommendationId - ? { ...item, enabled: true, lastRunTaskId: taskId } - : item, - ), - } - : null; - if (nextBatch) { - await savePersistedSetupNewState( - normalizeSetupNewState({ - ...state, - automationRecommendations: nextBatch, - }), - tx, - ); - } - }); -} - -export async function runSetupRecommendationNowCommand( - auth: UserAuthSuccess, - input: { id: string }, -) { - assertAdmin(auth); - const batch = await listSetupRecommendationsCommand(auth); - const recommendation = batch?.recommendations.find( - (item) => item.id === input.id, - ); - const candidate = recommendation - ? AUTOMATION_RECOMMENDATION_CATALOG.find( - (item) => item.id === recommendation.candidateId, - ) - : null; - if (!batch || !recommendation || !candidate) { - throw new Error('Recommendation was not found.'); - } - - const result = await runSetupRecommendationNowForCandidate( - auth, - recommendation, - candidate, - ); - if (result.outcome === 'launched') { - await recordSetupRecommendationLaunch(recommendation.id, result.taskId); - } - return result; -} - -export async function dismissSetupRecommendationsCardCommand( - auth: UserAuthSuccess, -) { - assertAdmin(auth); - return db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const state = await getPersistedSetupNewState(tx); - if (!state.automationRecommendations) return null; - const batch = { ...state.automationRecommendations, dismissed: true }; - await savePersistedSetupNewState( - normalizeSetupNewState({ ...state, automationRecommendations: batch }), - tx, - ); - return batch; - }); -} - export async function cancelSetupNewOnboardingTaskCommand( auth: UserAuthSuccess, ) { @@ -3845,3 +3262,14 @@ export async function ensureSetupNewDefaultAgentsCommand( ) { return ensureDefaultSetupAgents(auth); } + +export { + applySetupRecommendationsCommand, + dismissSetupRecommendationsCardCommand, + listSetupRecommendationsCommand, + prefetchSetupRecommendationSignalsCommand, + runSetupRecommendationNowCommand, + setSetupRecommendationEnabledCommand, + skipSetupRecommendationsCommand, + startSetupRecommendationsCommand, +} from './recommendations'; diff --git a/apps/web/src/trpc/commands/setup-new/recommendations.test.ts b/apps/web/src/trpc/commands/setup-new/recommendations.test.ts new file mode 100644 index 000000000..6bb6cd7e8 --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/recommendations.test.ts @@ -0,0 +1,119 @@ +import type { UserAuthSuccess } from '@/types'; + +const { + mockApply, + mockDismiss, + mockList, + mockPrefetch, + mockRunNow, + mockSetEnabled, + mockSkip, + mockStart, + mockTriggerBuiltIn, + mockTriggerCustom, +} = vi.hoisted(() => ({ + mockApply: vi.fn(), + mockDismiss: vi.fn(), + mockList: vi.fn(), + mockPrefetch: vi.fn(), + mockRunNow: vi.fn(), + mockSetEnabled: vi.fn(), + mockSkip: vi.fn(), + mockStart: vi.fn(), + mockTriggerBuiltIn: vi.fn(), + mockTriggerCustom: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', () => ({ + applySetupAutomationRecommendations: mockApply, + dismissSetupAutomationRecommendations: mockDismiss, + listSetupAutomationRecommendations: mockList, + prefetchSetupAutomationRecommendationSignals: mockPrefetch, + runSetupAutomationRecommendationNow: mockRunNow, + setSetupAutomationRecommendationEnabled: mockSetEnabled, + skipSetupAutomationRecommendations: mockSkip, + startSetupAutomationRecommendations: mockStart, +})); + +vi.mock('../setup/shared', () => ({ + assertAdmin: (auth: UserAuthSuccess) => { + if (!auth.isAdmin) throw new Error('Unauthorized'); + }, +})); + +vi.mock('../automations/trigger-agent', () => ({ + triggerAutomationCommand: mockTriggerBuiltIn, +})); + +vi.mock('../automations/custom-automations', () => ({ + triggerCustomAutomationCommand: mockTriggerCustom, +})); + +import { + applySetupRecommendationsCommand, + dismissSetupRecommendationsCardCommand, + listSetupRecommendationsCommand, + prefetchSetupRecommendationSignalsCommand, + runSetupRecommendationNowCommand, + setSetupRecommendationEnabledCommand, + skipSetupRecommendationsCommand, + startSetupRecommendationsCommand, +} from './recommendations'; + +const auth = { + userId: 'user-1', + isAdmin: true, +} as UserAuthSuccess; + +describe('setup recommendation command adapters', () => { + beforeEach(() => vi.clearAllMocks()); + + it('delegates setup lifecycle operations to the SDK domain', async () => { + await prefetchSetupRecommendationSignalsCommand(auth, { + repositoryIds: ['ignored'], + }); + await setSetupRecommendationEnabledCommand(auth, { + id: 'recommendation-1', + enabled: true, + }); + await applySetupRecommendationsCommand(auth); + await skipSetupRecommendationsCommand(auth); + await listSetupRecommendationsCommand(auth); + await startSetupRecommendationsCommand(auth); + await dismissSetupRecommendationsCardCommand(auth); + + expect(mockPrefetch).toHaveBeenCalledOnce(); + expect(mockSetEnabled).toHaveBeenCalledWith({ + userId: 'user-1', + id: 'recommendation-1', + enabled: true, + }); + expect(mockApply).toHaveBeenCalledWith('user-1'); + expect(mockSkip).toHaveBeenCalledOnce(); + expect(mockList).toHaveBeenCalledOnce(); + expect(mockStart).toHaveBeenCalledOnce(); + expect(mockDismiss).toHaveBeenCalledOnce(); + }); + + it('keeps manual-run validation in the web trigger adapters', async () => { + mockRunNow.mockImplementationOnce(async (input) => { + await input.runBuiltIn('ci_failure_triage'); + await input.runCustom('custom-1'); + return { outcome: 'completed' }; + }); + + await runSetupRecommendationNowCommand(auth, { id: 'recommendation-1' }); + + expect(mockTriggerBuiltIn).toHaveBeenCalledWith(auth, { + automationKey: 'ci_failure_triage', + }); + expect(mockTriggerCustom).toHaveBeenCalledWith(auth, { id: 'custom-1' }); + }); + + it('rejects non-admin callers before entering the SDK domain', async () => { + await expect( + listSetupRecommendationsCommand({ ...auth, isAdmin: false }), + ).rejects.toThrow('Unauthorized'); + expect(mockList).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/trpc/commands/setup-new/recommendations.ts b/apps/web/src/trpc/commands/setup-new/recommendations.ts new file mode 100644 index 000000000..5cdd9f873 --- /dev/null +++ b/apps/web/src/trpc/commands/setup-new/recommendations.ts @@ -0,0 +1,75 @@ +import { + applySetupAutomationRecommendations, + dismissSetupAutomationRecommendations, + listSetupAutomationRecommendations, + prefetchSetupAutomationRecommendationSignals, + runSetupAutomationRecommendationNow, + setSetupAutomationRecommendationEnabled, + skipSetupAutomationRecommendations, + startSetupAutomationRecommendations, +} from '@roomote/sdk/server'; + +import type { UserAuthSuccess } from '@/types'; +import { assertAdmin } from '../setup/shared'; +import { triggerAutomationCommand } from '../automations/trigger-agent'; +import { triggerCustomAutomationCommand } from '../automations/custom-automations'; + +export async function prefetchSetupRecommendationSignalsCommand( + auth: UserAuthSuccess, + _input: { repositoryIds: string[] }, +) { + assertAdmin(auth); + return prefetchSetupAutomationRecommendationSignals(); +} + +export async function setSetupRecommendationEnabledCommand( + auth: UserAuthSuccess, + input: { id: string; enabled: boolean }, +) { + assertAdmin(auth); + return setSetupAutomationRecommendationEnabled({ + userId: auth.userId, + ...input, + }); +} + +export async function applySetupRecommendationsCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + return applySetupAutomationRecommendations(auth.userId); +} + +export async function skipSetupRecommendationsCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + return skipSetupAutomationRecommendations(); +} + +export async function listSetupRecommendationsCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + return listSetupAutomationRecommendations(); +} + +export async function startSetupRecommendationsCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + return startSetupAutomationRecommendations(); +} + +export async function runSetupRecommendationNowCommand( + auth: UserAuthSuccess, + input: { id: string }, +) { + assertAdmin(auth); + return runSetupAutomationRecommendationNow({ + userId: auth.userId, + id: input.id, + runBuiltIn: (automationKey) => + triggerAutomationCommand(auth, { automationKey }), + runCustom: (id) => triggerCustomAutomationCommand(auth, { id }), + }); +} + +export async function dismissSetupRecommendationsCardCommand( + auth: UserAuthSuccess, +) { + assertAdmin(auth); + return dismissSetupAutomationRecommendations(); +} diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index fd4927ef9..330a43f8c 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -27,11 +27,31 @@ export { enqueueAutomationRecommendationInitialRun, enqueueAutomationSignalPrefetch, processAutomationRecommendationsJob, - runAutomationRecommendationInitialRunJob, type AutomationRecommendationJob, type AutomationRecommendationInitialRunJob, type AutomationSignalPrefetchJob, } from './lib/automation-recommendations'; +export { + canRecoverAutomationRecommendationInitialRunClaim, + runAutomationRecommendationInitialRunJob, +} from './lib/automation-recommendation-initial-runs'; +export { + applySetupAutomationRecommendations, + createPendingAutomationRecommendationBatch, + dispatchSetupAutomationRecommendationBatch, + dismissSetupAutomationRecommendations, + listSetupAutomationRecommendations, + markAutomationRecommendationBatchFailed, + prepareSetupAutomationRecommendationInput, + prefetchSetupAutomationRecommendationSignals, + resolveConnectedAutomationRecommendationRepositories, + runSetupAutomationRecommendationNow, + setSetupAutomationRecommendationEnabled, + skipSetupAutomationRecommendations, + startSetupAutomationRecommendations, + isSetupAutomationRecommendationFingerprintCurrent, + updateSetupAutomationRecommendationBatchIfCurrent, +} from './lib/setup-automation-recommendations'; export { recordLlmUsage, type RecordLlmUsageInput, diff --git a/packages/sdk/src/server/lib/automation-recommendation-initial-runs.ts b/packages/sdk/src/server/lib/automation-recommendation-initial-runs.ts new file mode 100644 index 000000000..0ae51c0d8 --- /dev/null +++ b/packages/sdk/src/server/lib/automation-recommendation-initial-runs.ts @@ -0,0 +1,256 @@ +import { db, deploymentSettings, eq, sql } from '@roomote/db/server'; +import { + normalizeSetupNewState, + type AutomationRecommendationBatch, +} from '@roomote/types'; + +import { runCustomAutomationNow } from '../automations/custom-automations'; +import { runAutomationNow } from '../automations/run-now'; +import { + automationRecommendationInitialRunJobSchema, + type AutomationRecommendationInitialRunJob, +} from './automation-recommendation-queues'; +import { AUTOMATION_RECOMMENDATION_CATALOG } from './automation-recommendations-policy'; + +const AUTOMATION_RECOMMENDATION_INITIAL_RUN_CLAIM_TIMEOUT_MS = 15 * 60 * 1_000; + +function recommendationApplicationState( + batch: AutomationRecommendationBatch, +): 'pending' | 'applied' | 'skipped' { + return ( + batch.applicationState ?? (batch.status === 'ready' ? 'applied' : 'pending') + ); +} + +export function canRecoverAutomationRecommendationInitialRunClaim( + recommendation: Pick< + AutomationRecommendationBatch['recommendations'][number], + 'initialRunClaimedAt' | 'initialRunDispatchAttemptedAt' + >, + now = Date.now(), +): boolean { + if ( + !recommendation.initialRunClaimedAt || + recommendation.initialRunDispatchAttemptedAt + ) { + return false; + } + + const claimedAt = Date.parse(recommendation.initialRunClaimedAt); + return ( + !Number.isFinite(claimedAt) || + now - claimedAt >= AUTOMATION_RECOMMENDATION_INITIAL_RUN_CLAIM_TIMEOUT_MS + ); +} + +async function claimAutomationRecommendationInitialRun( + request: AutomationRecommendationInitialRunJob, +) { + return db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, + ); + const [settings] = await tx + .select({ setupNewState: deploymentSettings.setupNewState }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .limit(1); + const state = normalizeSetupNewState(settings?.setupNewState ?? {}); + const batch = state.automationRecommendations; + const recommendation = batch?.recommendations.find( + (item) => item.id === request.recommendationId, + ); + if ( + !batch || + batch.inputFingerprint !== request.fingerprint || + recommendationApplicationState(batch) !== 'applied' || + !recommendation?.enabled || + recommendation.lastRunTaskId || + recommendation.initialRunTerminalAt + ) { + return null; + } + + if ( + recommendation.initialRunClaimedAt && + !canRecoverAutomationRecommendationInitialRunClaim(recommendation) + ) { + return null; + } + + const claimedAt = new Date().toISOString(); + const nextBatch = { + ...batch, + recommendations: batch.recommendations.map((item) => + item.id === request.recommendationId + ? { + ...item, + initialRunClaimedAt: claimedAt, + initialRunDispatchAttemptedAt: null, + } + : item, + ), + }; + await tx + .update(deploymentSettings) + .set({ + setupNewState: normalizeSetupNewState({ + ...state, + automationRecommendations: nextBatch, + }), + updatedAt: new Date(), + }) + .where(eq(deploymentSettings.id, 'default')); + + return { + claimedAt, + candidateId: recommendation.candidateId, + automationId: recommendation.automationId, + }; + }); +} + +async function markAutomationRecommendationInitialRunDispatchAttempted( + request: AutomationRecommendationInitialRunJob, + claimedAt: string, +): Promise { + let dispatchMarked = false; + await updateAutomationRecommendationInitialRun(request, (item) => { + if ( + item.initialRunClaimedAt !== claimedAt || + item.initialRunDispatchAttemptedAt || + item.initialRunTerminalAt + ) { + return item; + } + + dispatchMarked = true; + return { + ...item, + initialRunDispatchAttemptedAt: new Date().toISOString(), + }; + }); + return dispatchMarked; +} + +async function updateAutomationRecommendationInitialRun( + request: AutomationRecommendationInitialRunJob, + update: ( + recommendation: AutomationRecommendationBatch['recommendations'][number], + ) => AutomationRecommendationBatch['recommendations'][number], +) { + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, + ); + const [settings] = await tx + .select({ setupNewState: deploymentSettings.setupNewState }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .limit(1); + const state = normalizeSetupNewState(settings?.setupNewState ?? {}); + const batch = state.automationRecommendations; + if (!batch || batch.inputFingerprint !== request.fingerprint) return; + const nextBatch = { + ...batch, + recommendations: batch.recommendations.map((item) => + item.id === request.recommendationId ? update(item) : item, + ), + }; + await tx + .update(deploymentSettings) + .set({ + setupNewState: normalizeSetupNewState({ + ...state, + automationRecommendations: nextBatch, + }), + updatedAt: new Date(), + }) + .where(eq(deploymentSettings.id, 'default')); + }); +} + +export async function runAutomationRecommendationInitialRunJob( + input: AutomationRecommendationInitialRunJob, +): Promise { + const request = automationRecommendationInitialRunJobSchema.parse(input); + const claimed = await claimAutomationRecommendationInitialRun(request); + if (!claimed) return; + + const candidate = AUTOMATION_RECOMMENDATION_CATALOG.find( + (item) => item.id === claimed.candidateId, + ); + if (!candidate) { + await updateAutomationRecommendationInitialRun(request, (item) => ({ + ...item, + initialRunClaimedAt: null, + initialRunDispatchAttemptedAt: null, + })); + throw new Error( + `Recommendation candidate was not found: ${claimed.candidateId}`, + ); + } + + let launched = false; + try { + const dispatchMarked = + await markAutomationRecommendationInitialRunDispatchAttempted( + request, + claimed.claimedAt, + ); + if (!dispatchMarked) return; + + const result = + candidate.source === 'built_in' + ? candidate.automationKey === 'review_code' + ? { + outcome: 'skipped' as const, + reason: 'Review Code runs from pull-request events.', + } + : await runAutomationNow(candidate.automationKey) + : claimed.automationId + ? await runCustomAutomationNow(claimed.automationId) + : { + outcome: 'failed' as const, + error: 'Recommendation automation was not created.', + }; + + if (result.outcome === 'failed') { + throw new Error(result.error); + } + + launched = result.outcome === 'launched'; + await updateAutomationRecommendationInitialRun(request, (item) => ({ + ...item, + initialRunClaimedAt: null, + initialRunDispatchAttemptedAt: null, + initialRunTerminalAt: new Date().toISOString(), + ...(result.outcome === 'launched' + ? { lastRunTaskId: result.taskId } + : {}), + })); + } catch (error) { + if (launched) { + try { + await updateAutomationRecommendationInitialRun(request, (item) => ({ + ...item, + initialRunClaimedAt: null, + initialRunDispatchAttemptedAt: null, + initialRunTerminalAt: new Date().toISOString(), + })); + } catch (terminalError) { + console.error( + `[automation-recommendations] Initial run launched for ${request.recommendationId}, but recording its terminal state failed:`, + terminalError, + ); + } + return; + } + await updateAutomationRecommendationInitialRun(request, (item) => ({ + ...item, + initialRunClaimedAt: null, + initialRunDispatchAttemptedAt: null, + })); + throw error; + } +} diff --git a/packages/sdk/src/server/lib/automation-recommendation-queues.ts b/packages/sdk/src/server/lib/automation-recommendation-queues.ts new file mode 100644 index 000000000..f52b129d0 --- /dev/null +++ b/packages/sdk/src/server/lib/automation-recommendation-queues.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto'; +import { Queue } from 'bullmq'; +import { z } from 'zod'; + +import { getRedis } from '@roomote/redis'; +import type { SourceControlProvider } from '@roomote/types'; + +import { AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION } from './automation-recommendations-policy'; + +export const AUTOMATION_RECOMMENDATIONS_QUEUE_NAME = + 'automation-recommendations'; +export const AUTOMATION_SIGNAL_PREFETCH_QUEUE_NAME = + 'automation-signal-prefetch'; +export const AUTOMATION_RECOMMENDATION_INITIAL_RUN_QUEUE_NAME = + 'automation-recommendation-initial-runs'; +export const AUTOMATION_SIGNALS_VERSION = 2; +export const AUTOMATION_RECOMMENDATION_REPOSITORY_CAP = 10; +const AUTOMATION_SIGNAL_PREFETCH_CAP = AUTOMATION_RECOMMENDATION_REPOSITORY_CAP; + +export const automationRecommendationJobSchema = z.object({ + fingerprint: z.string().min(1), + repositoryIds: z.array(z.string().uuid()), +}); +export type AutomationRecommendationJob = z.infer< + typeof automationRecommendationJobSchema +>; + +export const automationSignalPrefetchJobSchema = z.object({ + repositoryId: z.string().uuid(), + signalsVersion: z.number().int().positive(), +}); +export type AutomationSignalPrefetchJob = z.infer< + typeof automationSignalPrefetchJobSchema +>; + +export const automationRecommendationInitialRunJobSchema = z.object({ + fingerprint: z.string().min(1), + recommendationId: z.string().min(1), +}); +export type AutomationRecommendationInitialRunJob = z.infer< + typeof automationRecommendationInitialRunJobSchema +>; + +let recommendationQueue: Queue | null = null; +let signalPrefetchQueue: Queue | null = null; +let recommendationInitialRunQueue: Queue | null = + null; + +function getRecommendationQueue() { + recommendationQueue ??= new Queue( + AUTOMATION_RECOMMENDATIONS_QUEUE_NAME, + { + connection: getRedis(), + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 1_000 }, + removeOnComplete: { age: 3_600, count: 100 }, + removeOnFail: { age: 24 * 3_600 }, + }, + }, + ); + return recommendationQueue; +} + +function getSignalPrefetchQueue() { + signalPrefetchQueue ??= new Queue( + AUTOMATION_SIGNAL_PREFETCH_QUEUE_NAME, + { + connection: getRedis(), + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 2_000 }, + removeOnComplete: { age: 24 * 3_600, count: 500 }, + removeOnFail: { age: 7 * 24 * 3_600 }, + }, + }, + ); + return signalPrefetchQueue; +} + +function getRecommendationInitialRunQueue() { + recommendationInitialRunQueue ??= + new Queue( + AUTOMATION_RECOMMENDATION_INITIAL_RUN_QUEUE_NAME, + { + connection: getRedis(), + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 5_000 }, + removeOnComplete: { age: 24 * 3_600, count: 500 }, + removeOnFail: { age: 7 * 24 * 3_600 }, + }, + }, + ); + return recommendationInitialRunQueue; +} + +export function buildAutomationRecommendationFingerprint( + repositoryIds: readonly string[], + provider: SourceControlProvider | null, +): string { + return createHash('sha256') + .update( + JSON.stringify({ + repositoryIds: [...repositoryIds].sort(), + provider, + catalogVersion: AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, + }), + ) + .digest('hex'); +} + +export async function enqueueAutomationRecommendations( + input: AutomationRecommendationJob, +): Promise { + const request = automationRecommendationJobSchema.parse(input); + const queue = getRecommendationQueue(); + const jobId = `automation-recommendations-${request.fingerprint}`; + const existing = await queue.getJob(jobId); + if (existing) { + const state = await existing.getState(); + if (state === 'completed' || state === 'failed') { + await existing.remove(); + } + } + console.info( + `[automation-recommendations] Enqueuing recommendation scoring for ${request.repositoryIds.length} repositories`, + ); + await queue.add('score-automation-recommendations', request, { jobId }); +} + +export async function enqueueAutomationSignalPrefetch( + repositoryIds: readonly string[], +): Promise { + const queue = getSignalPrefetchQueue(); + const collectionDay = new Date().toISOString().slice(0, 10); + const cappedIds = [...new Set(repositoryIds)].slice( + 0, + AUTOMATION_SIGNAL_PREFETCH_CAP, + ); + await Promise.all( + cappedIds.map((repositoryId) => + queue.add( + 'collect-automation-signals', + { repositoryId, signalsVersion: AUTOMATION_SIGNALS_VERSION }, + { + jobId: `automation-signals-${repositoryId}-${AUTOMATION_SIGNALS_VERSION}-${collectionDay}`, + }, + ), + ), + ); +} + +export async function enqueueAutomationRecommendationInitialRun( + input: AutomationRecommendationInitialRunJob, + delay: number, +): Promise { + const request = automationRecommendationInitialRunJobSchema.parse(input); + const queue = getRecommendationInitialRunQueue(); + const jobId = `automation-recommendation-initial-run-${request.fingerprint}-${request.recommendationId}`; + const existing = await queue.getJob(jobId); + if (existing) { + const state = await existing.getState(); + if (state === 'completed' || state === 'failed') { + await existing.remove(); + } else { + return; + } + } + await queue.add('run-automation-recommendation', request, { jobId, delay }); +} diff --git a/packages/types/src/automation-recommendations.test.ts b/packages/sdk/src/server/lib/automation-recommendations-policy.test.ts similarity index 99% rename from packages/types/src/automation-recommendations.test.ts rename to packages/sdk/src/server/lib/automation-recommendations-policy.test.ts index 21add7a52..1cf411532 100644 --- a/packages/types/src/automation-recommendations.test.ts +++ b/packages/sdk/src/server/lib/automation-recommendations-policy.test.ts @@ -6,7 +6,7 @@ import { AUTOMATION_RECOMMENDATION_CATALOG, scoreAutomationRecommendations, type MergedAutomationRecommendationSignals, -} from './automation-recommendations'; +} from './automation-recommendations-policy'; const signals: MergedAutomationRecommendationSignals = { repositoryCount: 2, diff --git a/packages/sdk/src/server/lib/automation-recommendations-policy.ts b/packages/sdk/src/server/lib/automation-recommendations-policy.ts new file mode 100644 index 000000000..2e3ef2b3f --- /dev/null +++ b/packages/sdk/src/server/lib/automation-recommendations-policy.ts @@ -0,0 +1,391 @@ +import type { + CustomAutomationScheduleMode, + RepositoryAutomationSignals, + SourceControlProvider, + TriggerableBackgroundAutomationKey, +} from '@roomote/types'; +import { getTriggerableBackgroundAutomationDescriptorByKey } from '@roomote/types'; +import { sourceControlProviders } from '@roomote/types'; + +export const AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION = 1; + +export type RecommendationCategory = + | 'quality' + | 'security' + | 'maintenance' + | 'delivery' + | 'communication'; + +type RecommendationSignal = + | 'active_pr_flow' + | 'merged_prs' + | 'open_prs' + | 'conflicts' + | 'ci_failures' + | 'dependabot_alerts' + | 'codeql_alerts' + | 'dependency_manifests' + | 'docs'; + +export type RecommendationScoringRule = { + signal: RecommendationSignal; + weight: number; + explanation: (value: number, repositoryCount: number) => string; +}; + +export type AutomationRecommendationCandidate = + | { + id: string; + source: 'built_in'; + automationKey: TriggerableBackgroundAutomationKey | 'review_code'; + title: string; + defaultScheduleMode: string; + environmentPolicy: 'not_required' | 'optional' | 'required'; + category: RecommendationCategory; + alwaysRecommend?: boolean; + scoringRules: RecommendationScoringRule[]; + } + | { + id: string; + source: 'cookbook'; + cookbookSlug: string; + title: string; + template: { + name: string; + prompt: string; + scheduleMode: CustomAutomationScheduleMode; + workspace: 'all_repositories'; + destination: 'none'; + }; + environmentPolicy: 'not_required' | 'optional'; + category: RecommendationCategory; + alwaysRecommend?: boolean; + scoringRules: RecommendationScoringRule[]; + }; + +export type MergedAutomationRecommendationSignals = Omit< + RepositoryAutomationSignals, + 'repositoryId' | 'repositoryName' | 'sourceControlProvider' +> & { + repositoryCount: number; + sourceControlProviders: SourceControlProvider[]; +}; + +const signalValue = ( + signals: MergedAutomationRecommendationSignals, + signal: RecommendationSignal, +) => { + const values: Record = { + active_pr_flow: signals.openPrs + signals.mergedPrs30d, + merged_prs: signals.mergedPrs30d, + open_prs: signals.openPrs, + conflicts: signals.conflicts, + ci_failures: signals.ciFailures30d, + dependabot_alerts: signals.dependabotAlerts, + codeql_alerts: signals.codeqlAlerts, + dependency_manifests: signals.dependencyManifests, + docs: signals.docs, + }; + return values[signal]; +}; + +const formatCount = (value: number, noun: string) => `${value} ${noun}`; + +const activePrRule = (weight: number): RecommendationScoringRule => ({ + signal: 'active_pr_flow', + weight, + explanation: (value, repositoryCount) => + `Your repos have active PR flow (${formatCount(value, 'recent PRs')} across ${repositoryCount} repos), so Roomote can help keep the work moving.`, +}); + +const mergedPrRule = (weight: number): RecommendationScoringRule => ({ + signal: 'merged_prs', + weight, + explanation: (value, repositoryCount) => + `You merged ${formatCount(value, 'PRs')} across ${repositoryCount} repos in the last 30 days, so Roomote can help keep up with the pace of change.`, +}); + +const openPrRule = (weight: number): RecommendationScoringRule => ({ + signal: 'open_prs', + weight, + explanation: (value) => + `Your repos have ${formatCount(value, 'open PRs')}, and Roomote can help keep them moving.`, +}); + +function fallbackRecommendationExplanation( + candidate: AutomationRecommendationCandidate, +): string { + switch (candidate.id) { + case 'built-in.review-code': + return 'Strongly recommended. Have Roomote review use a separate run to review PRs it creates.'; + case 'built-in.code-quality-auditor': + return 'As your repositories evolve, Roomote can run regular code quality checks and surface actionable fixes.'; + case 'built-in.security-auditor': + return 'Roomote can regularly check your repositories for security issues and surface focused fixes.'; + case 'built-in.resolve-pr-conflicts': + return 'Roomote can watch for merge conflicts and resolve safe conflicts in open pull requests.'; + case 'built-in.dependabot-triage': + return 'Your repos seem to have Dependabot alerts, and Roomote can handle those for you.'; + case 'built-in.codeql-triage': + return 'Your repos seem to have CodeQL alerts, and Roomote can handle those for you.'; + case 'built-in.ci-failure-triage': + return 'Your CI setup can lead to default branch failures. Enable this to automatically fix broken builds.'; + case 'cookbook.scheduled-housekeeping': + return 'Roomote can regularly check your repositories for dependency drift, stale flags, and flaky-test maintenance work.'; + default: + return `Your repositories are connected, so Roomote can help with ${candidate.title.toLowerCase()}.`; + } +} + +export const AUTOMATION_RECOMMENDATION_CATALOG: readonly AutomationRecommendationCandidate[] = + [ + { + id: 'built-in.review-code', + source: 'built_in', + automationKey: 'review_code', + title: 'Review Code', + defaultScheduleMode: 'off', + environmentPolicy: 'not_required', + category: 'quality', + alwaysRecommend: true, + scoringRules: [openPrRule(5), activePrRule(2)], + }, + { + id: 'built-in.code-quality-auditor', + source: 'built_in', + automationKey: 'code_quality_auditor', + title: 'Code Quality Auditor', + defaultScheduleMode: 'weekly', + environmentPolicy: 'not_required', + category: 'quality', + scoringRules: [mergedPrRule(4), activePrRule(2)], + }, + { + id: 'built-in.security-auditor', + source: 'built_in', + automationKey: 'security_auditor', + title: 'Security Auditor', + defaultScheduleMode: 'weekly', + environmentPolicy: 'not_required', + category: 'security', + scoringRules: [mergedPrRule(3), activePrRule(1)], + }, + { + id: 'built-in.resolve-pr-conflicts', + source: 'built_in', + automationKey: 'conflict_resolver', + title: 'Resolve PR Conflicts', + defaultScheduleMode: 'daily', + environmentPolicy: 'not_required', + category: 'delivery', + alwaysRecommend: true, + scoringRules: [ + { + signal: 'conflicts', + weight: 12, + explanation: (value) => + `Your repos have at least ${formatCount(value, 'open PR conflicts')}, and Roomote can resolve the safe ones automatically.`, + }, + openPrRule(2), + ], + }, + { + id: 'built-in.dependabot-triage', + source: 'built_in', + automationKey: 'dependabot_triage', + title: 'Triage Dependabot Alerts', + defaultScheduleMode: 'weekly', + environmentPolicy: 'not_required', + category: 'maintenance', + scoringRules: [ + { + signal: 'dependabot_alerts', + weight: 10, + explanation: (value) => + `Your repos have ${formatCount(value, 'open Dependabot alerts')}, and Roomote can handle those for you.`, + }, + { + signal: 'dependency_manifests', + weight: 2, + explanation: (value) => + `${formatCount(value, 'of your repos')} include dependency manifests, which Roomote can keep up-to-date.`, + }, + ], + }, + { + id: 'built-in.codeql-triage', + source: 'built_in', + automationKey: 'codeql_triage', + title: 'Triage CodeQL Alerts', + defaultScheduleMode: 'weekly', + environmentPolicy: 'not_required', + category: 'security', + scoringRules: [ + { + signal: 'codeql_alerts', + weight: 10, + explanation: (value) => + `Your repos have ${formatCount(value, 'open CodeQL alerts')}, and Roomote can handle those for you.`, + }, + ], + }, + { + id: 'built-in.ci-failure-triage', + source: 'built_in', + automationKey: 'ci_failure_triage', + title: 'CI Failure Triage', + defaultScheduleMode: 'daily', + environmentPolicy: 'optional', + category: 'delivery', + alwaysRecommend: true, + scoringRules: [ + { + signal: 'ci_failures', + weight: 9, + explanation: (value) => + `Roomote found ${formatCount(value, 'recent CI failures')}, and it can automatically open PRs to fix broken builds.`, + }, + ], + }, + { + id: 'cookbook.scheduled-housekeeping', + source: 'cookbook', + cookbookSlug: 'scheduled-housekeeping', + title: 'Schedule maintenance', + template: { + name: 'Repository maintenance review', + prompt: + 'Review these repositories for dependency drift, stale feature flags, and flaky-test maintenance opportunities. Report only concrete, actionable findings with file paths and concise next steps.', + scheduleMode: 'weekly', + workspace: 'all_repositories', + destination: 'none', + }, + environmentPolicy: 'not_required', + category: 'maintenance', + scoringRules: [mergedPrRule(3), activePrRule(1)], + }, + ] as const; + +type ScoredAutomationRecommendation = { + candidate: AutomationRecommendationCandidate; + score: number; + explanation: string; +}; + +export function scoreAutomationRecommendations( + signals: MergedAutomationRecommendationSignals, + options: { + enabledCandidateIds?: ReadonlySet; + catalog?: readonly AutomationRecommendationCandidate[]; + minScore?: number; + } = {}, +): ScoredAutomationRecommendation[] { + const catalog = options.catalog ?? AUTOMATION_RECOMMENDATION_CATALOG; + const enabled = options.enabledCandidateIds ?? new Set(); + // Recommendations should still be useful immediately after a repository is + // connected, before provider signal collection has produced rich data. Once + // collection is complete, only recommend candidates backed by real signals. + const allowFallbackCandidates = signals.partial !== false; + const scored = catalog + .filter((candidate) => !enabled.has(candidate.id)) + .filter((candidate) => { + if (candidate.source !== 'built_in') return true; + const descriptor = getTriggerableBackgroundAutomationDescriptorByKey( + candidate.automationKey === 'review_code' + ? 'conflict_resolver' + : candidate.automationKey, + ); + return candidate.automationKey === 'review_code' + ? signals.sourceControlProviders.some((provider) => + sourceControlProviders.includes(provider), + ) + : (descriptor?.supportedSourceControlProviders.some((provider) => + signals.sourceControlProviders.includes(provider), + ) ?? false); + }) + .map((candidate) => { + const matches = candidate.scoringRules + .map((rule) => ({ rule, value: signalValue(signals, rule.signal) })) + .filter(({ value }) => value > 0); + const score = matches.reduce( + (total, { rule, value }) => total + rule.weight * Math.min(value, 20), + 0, + ); + const explanation = matches[0]?.rule.explanation( + matches[0].value, + signals.repositoryCount, + ); + return { + candidate, + score: Math.max( + score, + candidate.alwaysRecommend || allowFallbackCandidates ? 1 : 0, + ), + explanation: + explanation ?? fallbackRecommendationExplanation(candidate), + }; + }) + .filter(({ score }) => score >= (options.minScore ?? 1)) + .sort( + (left, right) => + right.score - left.score || + left.candidate.id.localeCompare(right.candidate.id), + ); + + const categories = new Map(); + const selected: ScoredAutomationRecommendation[] = []; + for (const recommendation of scored) { + const count = categories.get(recommendation.candidate.category) ?? 0; + if (count >= 2) continue; + categories.set(recommendation.candidate.category, count + 1); + selected.push(recommendation); + if (selected.length === 6) break; + } + + for (const recommendation of scored.filter( + ({ candidate }) => candidate.alwaysRecommend, + )) { + if ( + selected.some( + ({ candidate }) => candidate.id === recommendation.candidate.id, + ) + ) { + continue; + } + + const replacementIndex = [...selected] + .map((item, index) => ({ item, index })) + .reverse() + .find(({ item }) => !item.candidate.alwaysRecommend)?.index; + if (replacementIndex !== undefined) { + selected.splice(replacementIndex, 1, recommendation); + } else { + selected.push(recommendation); + } + } + + if (selected.length < 3 && allowFallbackCandidates) { + for (const recommendation of scored) { + if ( + selected.some( + (item) => item.candidate.id === recommendation.candidate.id, + ) + ) + continue; + selected.push(recommendation); + if (selected.length === 3) break; + } + } + + const reviewCode = selected.find( + ({ candidate }) => candidate.id === 'built-in.review-code', + ); + if (!reviewCode) return selected; + + return [ + reviewCode, + ...selected.filter( + ({ candidate }) => candidate.id !== 'built-in.review-code', + ), + ]; +} diff --git a/packages/sdk/src/server/lib/automation-recommendations.test.ts b/packages/sdk/src/server/lib/automation-recommendations.test.ts index 1d10caad5..5f88f90e7 100644 --- a/packages/sdk/src/server/lib/automation-recommendations.test.ts +++ b/packages/sdk/src/server/lib/automation-recommendations.test.ts @@ -1,4 +1,4 @@ -import { canRecoverAutomationRecommendationInitialRunClaim } from './automation-recommendations'; +import { canRecoverAutomationRecommendationInitialRunClaim } from './automation-recommendation-initial-runs'; describe('canRecoverAutomationRecommendationInitialRunClaim', () => { const now = Date.parse('2026-08-14T18:00:00.000Z'); diff --git a/packages/sdk/src/server/lib/automation-recommendations.ts b/packages/sdk/src/server/lib/automation-recommendations.ts index e41aff37b..270baa837 100644 --- a/packages/sdk/src/server/lib/automation-recommendations.ts +++ b/packages/sdk/src/server/lib/automation-recommendations.ts @@ -1,7 +1,3 @@ -import { createHash } from 'node:crypto'; -import { Queue } from 'bullmq'; -import { z } from 'zod'; - import { getInstallationOctokit } from '@roomote/github'; import { getLatestAdoBuild, resolveAdoInstanceHost } from '@roomote/ado'; import { @@ -20,17 +16,12 @@ import { resolveGitLabInstanceHost, } from '@roomote/gitlab'; import { - AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, - AUTOMATION_RECOMMENDATION_CATALOG, - scoreAutomationRecommendations, type AutomationRecommendationBatch, type RepositoryAutomationSignals, type SourceControlProvider, - normalizeSetupNewState, } from '@roomote/types'; import { db, - deploymentSettings, githubInstallations, pullRequestFacts, repositories, @@ -39,25 +30,42 @@ import { eq, gte, inArray, - sql, } from '@roomote/db/server'; -import { getRedis } from '@roomote/redis'; - -import { runCustomAutomationNow } from '../automations/custom-automations'; -import { runAutomationNow } from '../automations/run-now'; - -export const AUTOMATION_RECOMMENDATIONS_QUEUE_NAME = - 'automation-recommendations'; -export const AUTOMATION_SIGNAL_PREFETCH_QUEUE_NAME = - 'automation-signal-prefetch'; -export const AUTOMATION_RECOMMENDATION_INITIAL_RUN_QUEUE_NAME = - 'automation-recommendation-initial-runs'; -export const AUTOMATION_SIGNALS_VERSION = 2; -export const AUTOMATION_RECOMMENDATION_REPOSITORY_CAP = 10; -const AUTOMATION_SIGNAL_PREFETCH_CAP = AUTOMATION_RECOMMENDATION_REPOSITORY_CAP; +import { + AUTOMATION_SIGNALS_VERSION, + automationRecommendationJobSchema, + automationSignalPrefetchJobSchema, + type AutomationRecommendationJob, + type AutomationSignalPrefetchJob, +} from './automation-recommendation-queues'; +export { + AUTOMATION_RECOMMENDATIONS_QUEUE_NAME, + AUTOMATION_RECOMMENDATION_INITIAL_RUN_QUEUE_NAME, + AUTOMATION_RECOMMENDATION_REPOSITORY_CAP, + AUTOMATION_SIGNAL_PREFETCH_QUEUE_NAME, + AUTOMATION_SIGNALS_VERSION, + automationRecommendationInitialRunJobSchema, + automationRecommendationJobSchema, + automationSignalPrefetchJobSchema, + buildAutomationRecommendationFingerprint, + enqueueAutomationRecommendationInitialRun, + enqueueAutomationRecommendations, + enqueueAutomationSignalPrefetch, + type AutomationRecommendationInitialRunJob, + type AutomationRecommendationJob, + type AutomationSignalPrefetchJob, +} from './automation-recommendation-queues'; +import { + AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, + AUTOMATION_RECOMMENDATION_CATALOG, + scoreAutomationRecommendations, +} from './automation-recommendations-policy'; +import { + isSetupAutomationRecommendationFingerprintCurrent, + updateSetupAutomationRecommendationBatchIfCurrent, +} from './setup-automation-recommendations'; const AUTOMATION_SIGNAL_LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000; -const AUTOMATION_RECOMMENDATION_INITIAL_RUN_CLAIM_TIMEOUT_MS = 15 * 60 * 1_000; const DEPENDENCY_MANIFEST_NAMES = new Set([ 'bun.lock', 'bun.lockb', @@ -83,165 +91,6 @@ const DEPENDENCY_MANIFEST_NAMES = new Set([ type GitHubOctokit = Awaited>; type GitHubOctokitCache = Map>; -export const automationRecommendationJobSchema = z.object({ - fingerprint: z.string().min(1), - repositoryIds: z.array(z.string().uuid()), -}); -export type AutomationRecommendationJob = z.infer< - typeof automationRecommendationJobSchema ->; - -export const automationSignalPrefetchJobSchema = z.object({ - repositoryId: z.string().uuid(), - signalsVersion: z.number().int().positive(), -}); -export type AutomationSignalPrefetchJob = z.infer< - typeof automationSignalPrefetchJobSchema ->; - -export const automationRecommendationInitialRunJobSchema = z.object({ - fingerprint: z.string().min(1), - recommendationId: z.string().min(1), -}); -export type AutomationRecommendationInitialRunJob = z.infer< - typeof automationRecommendationInitialRunJobSchema ->; - -let recommendationQueue: Queue | null = null; -let signalPrefetchQueue: Queue | null = null; -let recommendationInitialRunQueue: Queue | null = - null; - -function getRecommendationQueue() { - recommendationQueue ??= new Queue( - AUTOMATION_RECOMMENDATIONS_QUEUE_NAME, - { - connection: getRedis(), - defaultJobOptions: { - attempts: 3, - backoff: { type: 'exponential', delay: 1_000 }, - removeOnComplete: { age: 3_600, count: 100 }, - removeOnFail: { age: 24 * 3_600 }, - }, - }, - ); - return recommendationQueue; -} - -function getSignalPrefetchQueue() { - signalPrefetchQueue ??= new Queue( - AUTOMATION_SIGNAL_PREFETCH_QUEUE_NAME, - { - connection: getRedis(), - defaultJobOptions: { - attempts: 3, - backoff: { type: 'exponential', delay: 2_000 }, - removeOnComplete: { age: 24 * 3_600, count: 500 }, - removeOnFail: { age: 7 * 24 * 3_600 }, - }, - }, - ); - return signalPrefetchQueue; -} - -function getRecommendationInitialRunQueue() { - recommendationInitialRunQueue ??= - new Queue( - AUTOMATION_RECOMMENDATION_INITIAL_RUN_QUEUE_NAME, - { - connection: getRedis(), - defaultJobOptions: { - attempts: 3, - backoff: { type: 'exponential', delay: 5_000 }, - removeOnComplete: { age: 24 * 3_600, count: 500 }, - removeOnFail: { age: 7 * 24 * 3_600 }, - }, - }, - ); - return recommendationInitialRunQueue; -} - -export function buildAutomationRecommendationFingerprint( - repositoryIds: readonly string[], - provider: SourceControlProvider | null, -): string { - return createHash('sha256') - .update( - JSON.stringify({ - repositoryIds: [...repositoryIds].sort(), - provider, - catalogVersion: AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, - }), - ) - .digest('hex'); -} - -export async function enqueueAutomationRecommendations( - input: AutomationRecommendationJob, -): Promise { - const request = automationRecommendationJobSchema.parse(input); - const queue = getRecommendationQueue(); - const jobId = `automation-recommendations-${request.fingerprint}`; - const existing = await queue.getJob(jobId); - if (existing) { - const state = await existing.getState(); - if (state === 'completed' || state === 'failed') { - await existing.remove(); - } - } - console.info( - `[automation-recommendations] Enqueuing recommendation scoring for ${request.repositoryIds.length} repositories`, - ); - await queue.add('score-automation-recommendations', request, { - jobId, - }); -} - -export async function enqueueAutomationSignalPrefetch( - repositoryIds: readonly string[], -): Promise { - const queue = getSignalPrefetchQueue(); - const collectionDay = new Date().toISOString().slice(0, 10); - const cappedIds = [...new Set(repositoryIds)].slice( - 0, - AUTOMATION_SIGNAL_PREFETCH_CAP, - ); - - await Promise.all( - cappedIds.map((repositoryId) => - queue.add( - 'collect-automation-signals', - { repositoryId, signalsVersion: AUTOMATION_SIGNALS_VERSION }, - { - jobId: `automation-signals-${repositoryId}-${AUTOMATION_SIGNALS_VERSION}-${collectionDay}`, - }, - ), - ), - ); -} - -export async function enqueueAutomationRecommendationInitialRun( - input: AutomationRecommendationInitialRunJob, - delay: number, -): Promise { - const request = automationRecommendationInitialRunJobSchema.parse(input); - const queue = getRecommendationInitialRunQueue(); - const jobId = `automation-recommendation-initial-run-${request.fingerprint}-${request.recommendationId}`; - const existing = await queue.getJob(jobId); - if (existing) { - const state = await existing.getState(); - if (state === 'completed' || state === 'failed') { - await existing.remove(); - } else { - return; - } - } - await queue.add('run-automation-recommendation', request, { - jobId, - delay, - }); -} - async function getCachedGitHubOctokit( installationId: string, cache: GitHubOctokitCache, @@ -827,6 +676,7 @@ async function buildRecommendationBatch( recommendations: scored.map(({ candidate, score, explanation }, index) => ({ id: `${candidate.id}:${index + 1}`, candidateId: candidate.id, + title: candidate.title, rank: index + 1, score, explanation, @@ -849,14 +699,10 @@ export async function processAutomationRecommendationsJob( console.info( `[automation-recommendations] Started recommendation scoring for ${request.repositoryIds.length} repositories`, ); - const [settings] = await db - .select({ setupNewState: deploymentSettings.setupNewState }) - .from(deploymentSettings) - .where(eq(deploymentSettings.id, 'default')) - .limit(1); - const state = normalizeSetupNewState(settings?.setupNewState ?? {}); if ( - state.automationRecommendations?.inputFingerprint !== request.fingerprint + !(await isSetupAutomationRecommendationFingerprintCurrent( + request.fingerprint, + )) ) { console.info( '[automation-recommendations] Skipped recommendation scoring because the request is stale', @@ -869,31 +715,11 @@ export async function processAutomationRecommendationsJob( request.repositoryIds, request.fingerprint, ); - const latest = await db - .select({ setupNewState: deploymentSettings.setupNewState }) - .from(deploymentSettings) - .where(eq(deploymentSettings.id, 'default')) - .limit(1); - const latestState = normalizeSetupNewState( - latest?.[0]?.setupNewState ?? {}, + const persisted = await updateSetupAutomationRecommendationBatchIfCurrent( + request.fingerprint, + (current) => mergeRecommendationState(batch, current), ); - if ( - latestState.automationRecommendations?.inputFingerprint !== - request.fingerprint - ) { - return; - } - const nextState = normalizeSetupNewState({ - ...latestState, - automationRecommendations: mergeRecommendationState( - batch, - latestState.automationRecommendations, - ), - }); - await db - .update(deploymentSettings) - .set({ setupNewState: nextState, updatedAt: new Date() }) - .where(eq(deploymentSettings.id, 'default')); + if (!persisted) return; console.info( `[automation-recommendations] Completed recommendation scoring for ${request.repositoryIds.length} repositories in ${Date.now() - startedAt}ms`, { @@ -902,283 +728,18 @@ export async function processAutomationRecommendationsJob( }, ); } catch (error) { - const current = await db - .select({ setupNewState: deploymentSettings.setupNewState }) - .from(deploymentSettings) - .where(eq(deploymentSettings.id, 'default')) - .limit(1); - const currentState = normalizeSetupNewState( - current[0]?.setupNewState ?? {}, + await updateSetupAutomationRecommendationBatchIfCurrent( + request.fingerprint, + (current) => ({ + ...current, + status: 'failed', + completedAt: new Date().toISOString(), + errorCode: 'recommendation_generation_failed', + }), ); - if ( - currentState.automationRecommendations?.inputFingerprint !== - request.fingerprint - ) { - return; - } - const failedBatch = { - ...currentState.automationRecommendations, - status: 'failed' as const, - completedAt: new Date().toISOString(), - errorCode: 'recommendation_generation_failed', - }; - await db - .update(deploymentSettings) - .set({ - setupNewState: normalizeSetupNewState({ - ...currentState, - automationRecommendations: failedBatch, - }), - updatedAt: new Date(), - }) - .where(eq(deploymentSettings.id, 'default')); console.warn( `[automation-recommendations] Recommendation scoring failed after ${Date.now() - startedAt}ms`, ); throw error; } } - -function recommendationApplicationState( - batch: AutomationRecommendationBatch, -): 'pending' | 'applied' | 'skipped' { - return ( - batch.applicationState ?? (batch.status === 'ready' ? 'applied' : 'pending') - ); -} - -export function canRecoverAutomationRecommendationInitialRunClaim( - recommendation: Pick< - AutomationRecommendationBatch['recommendations'][number], - 'initialRunClaimedAt' | 'initialRunDispatchAttemptedAt' - >, - now = Date.now(), -): boolean { - if ( - !recommendation.initialRunClaimedAt || - recommendation.initialRunDispatchAttemptedAt - ) { - return false; - } - - const claimedAt = Date.parse(recommendation.initialRunClaimedAt); - return ( - !Number.isFinite(claimedAt) || - now - claimedAt >= AUTOMATION_RECOMMENDATION_INITIAL_RUN_CLAIM_TIMEOUT_MS - ); -} - -async function claimAutomationRecommendationInitialRun( - request: AutomationRecommendationInitialRunJob, -) { - return db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const [settings] = await tx - .select({ setupNewState: deploymentSettings.setupNewState }) - .from(deploymentSettings) - .where(eq(deploymentSettings.id, 'default')) - .limit(1); - const state = normalizeSetupNewState(settings?.setupNewState ?? {}); - const batch = state.automationRecommendations; - const recommendation = batch?.recommendations.find( - (item) => item.id === request.recommendationId, - ); - if ( - !batch || - batch.inputFingerprint !== request.fingerprint || - recommendationApplicationState(batch) !== 'applied' || - !recommendation?.enabled || - recommendation.lastRunTaskId || - recommendation.initialRunTerminalAt - ) { - return null; - } - - if ( - recommendation.initialRunClaimedAt && - !canRecoverAutomationRecommendationInitialRunClaim(recommendation) - ) { - return null; - } - - const claimedAt = new Date().toISOString(); - const nextBatch = { - ...batch, - recommendations: batch.recommendations.map((item) => - item.id === request.recommendationId - ? { - ...item, - initialRunClaimedAt: claimedAt, - initialRunDispatchAttemptedAt: null, - } - : item, - ), - }; - await tx - .update(deploymentSettings) - .set({ - setupNewState: normalizeSetupNewState({ - ...state, - automationRecommendations: nextBatch, - }), - updatedAt: new Date(), - }) - .where(eq(deploymentSettings.id, 'default')); - - return { - claimedAt, - candidateId: recommendation.candidateId, - automationId: recommendation.automationId, - }; - }); -} - -async function markAutomationRecommendationInitialRunDispatchAttempted( - request: AutomationRecommendationInitialRunJob, - claimedAt: string, -): Promise { - let dispatchMarked = false; - await updateAutomationRecommendationInitialRun(request, (item) => { - if ( - item.initialRunClaimedAt !== claimedAt || - item.initialRunDispatchAttemptedAt || - item.initialRunTerminalAt - ) { - return item; - } - - dispatchMarked = true; - return { - ...item, - initialRunDispatchAttemptedAt: new Date().toISOString(), - }; - }); - return dispatchMarked; -} - -async function updateAutomationRecommendationInitialRun( - request: AutomationRecommendationInitialRunJob, - update: ( - recommendation: AutomationRecommendationBatch['recommendations'][number], - ) => AutomationRecommendationBatch['recommendations'][number], -) { - await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, - ); - const [settings] = await tx - .select({ setupNewState: deploymentSettings.setupNewState }) - .from(deploymentSettings) - .where(eq(deploymentSettings.id, 'default')) - .limit(1); - const state = normalizeSetupNewState(settings?.setupNewState ?? {}); - const batch = state.automationRecommendations; - if (!batch || batch.inputFingerprint !== request.fingerprint) return; - const nextBatch = { - ...batch, - recommendations: batch.recommendations.map((item) => - item.id === request.recommendationId ? update(item) : item, - ), - }; - await tx - .update(deploymentSettings) - .set({ - setupNewState: normalizeSetupNewState({ - ...state, - automationRecommendations: nextBatch, - }), - updatedAt: new Date(), - }) - .where(eq(deploymentSettings.id, 'default')); - }); -} - -export async function runAutomationRecommendationInitialRunJob( - input: AutomationRecommendationInitialRunJob, -): Promise { - const request = automationRecommendationInitialRunJobSchema.parse(input); - const claimed = await claimAutomationRecommendationInitialRun(request); - if (!claimed) return; - - const candidate = AUTOMATION_RECOMMENDATION_CATALOG.find( - (item) => item.id === claimed.candidateId, - ); - if (!candidate) { - await updateAutomationRecommendationInitialRun(request, (item) => ({ - ...item, - initialRunClaimedAt: null, - initialRunDispatchAttemptedAt: null, - })); - throw new Error( - `Recommendation candidate was not found: ${claimed.candidateId}`, - ); - } - - let launched = false; - try { - const dispatchMarked = - await markAutomationRecommendationInitialRunDispatchAttempted( - request, - claimed.claimedAt, - ); - if (!dispatchMarked) return; - - const result = - candidate.source === 'built_in' - ? candidate.automationKey === 'review_code' - ? { - outcome: 'skipped' as const, - reason: 'Review Code runs from pull-request events.', - } - : await runAutomationNow(candidate.automationKey) - : claimed.automationId - ? await runCustomAutomationNow(claimed.automationId) - : { - outcome: 'failed' as const, - error: 'Recommendation automation was not created.', - }; - - if (result.outcome === 'failed') { - throw new Error(result.error); - } - - launched = result.outcome === 'launched'; - await updateAutomationRecommendationInitialRun(request, (item) => ({ - ...item, - initialRunClaimedAt: null, - initialRunDispatchAttemptedAt: null, - initialRunTerminalAt: new Date().toISOString(), - ...(result.outcome === 'launched' - ? { lastRunTaskId: result.taskId } - : {}), - })); - } catch (error) { - if (launched) { - // The task has already been enqueued. Persist a terminal marker when - // possible; if this fallback write also fails, retain the claim. Claims - // are never stale-reclaimed, so a retry cannot launch a duplicate. - try { - await updateAutomationRecommendationInitialRun(request, (item) => ({ - ...item, - initialRunClaimedAt: null, - initialRunDispatchAttemptedAt: null, - initialRunTerminalAt: new Date().toISOString(), - })); - } catch (terminalError) { - console.error( - `[automation-recommendations] Initial run launched for ${request.recommendationId}, but recording its terminal state failed:`, - terminalError, - ); - } - return; - } - await updateAutomationRecommendationInitialRun(request, (item) => ({ - ...item, - initialRunClaimedAt: null, - initialRunDispatchAttemptedAt: null, - })); - throw error; - } -} diff --git a/packages/sdk/src/server/lib/setup-automation-recommendations.test.ts b/packages/sdk/src/server/lib/setup-automation-recommendations.test.ts new file mode 100644 index 000000000..3f3961eaa --- /dev/null +++ b/packages/sdk/src/server/lib/setup-automation-recommendations.test.ts @@ -0,0 +1,141 @@ +import { + createEmptySetupNewState, + type AutomationRecommendationBatch, +} from '@roomote/types'; +import { db, deploymentSettings, eq } from '@roomote/db/server'; + +import { + createPendingAutomationRecommendationBatch, + listSetupAutomationRecommendations, + skipSetupAutomationRecommendations, + updateSetupAutomationRecommendationBatchIfCurrent, +} from './setup-automation-recommendations'; + +const recommendation = { + id: 'built-in.ci-failure-triage:1', + candidateId: 'built-in.ci-failure-triage', + rank: 1, + score: 1, + explanation: 'Fix broken builds.', + enabled: true, + lastRunTaskId: null, + automationId: null, +}; + +function batch(): AutomationRecommendationBatch { + return { + version: 1, + inputFingerprint: 'recommendation-fingerprint', + catalogVersion: 1, + status: 'ready', + startedAt: '2026-08-14T00:00:00.000Z', + completedAt: '2026-08-14T00:00:01.000Z', + partial: false, + errorCode: null, + dismissed: false, + applicationState: 'pending', + recommendations: [recommendation], + }; +} + +describe('setup automation recommendation state', () => { + let originalSetupNewState: (typeof deploymentSettings.$inferSelect)['setupNewState']; + let hadSettings = false; + + beforeAll(async () => { + const [settings] = await db + .select({ setupNewState: deploymentSettings.setupNewState }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .limit(1); + hadSettings = settings !== undefined; + originalSetupNewState = settings?.setupNewState ?? null; + }); + + afterAll(async () => { + if (!hadSettings) { + await db + .delete(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')); + return; + } + await db + .update(deploymentSettings) + .set({ setupNewState: originalSetupNewState }) + .where(eq(deploymentSettings.id, 'default')); + }); + + beforeEach(async () => { + await db + .insert(deploymentSettings) + .values({ + id: 'default', + setupNewState: { + ...createEmptySetupNewState(), + automationRecommendations: batch(), + }, + }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { + setupNewState: { + ...createEmptySetupNewState(), + automationRecommendations: batch(), + }, + }, + }); + }); + + it('hydrates client-safe titles for batches persisted before titles existed', async () => { + const result = await listSetupAutomationRecommendations(); + + expect(result?.recommendations[0]).toMatchObject({ + candidateId: 'built-in.ci-failure-triage', + title: 'CI Failure Triage', + }); + }); + + it('persists skipped recommendations as disabled and unapplied', async () => { + await skipSetupAutomationRecommendations(); + + const [settings] = await db + .select({ setupNewState: deploymentSettings.setupNewState }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .limit(1); + expect(settings?.setupNewState?.automationRecommendations).toMatchObject({ + applicationState: 'skipped', + recommendations: [ + expect.objectContaining({ enabled: false, applied: false }), + ], + }); + }); + + it('preserves same-input choices while resetting generation state', () => { + const previous = { ...batch(), dismissed: true }; + + expect( + createPendingAutomationRecommendationBatch( + previous.inputFingerprint, + previous, + ), + ).toMatchObject({ + status: 'pending', + dismissed: true, + recommendations: previous.recommendations, + }); + }); + + it('rejects stale worker writes after the recommendation input changes', async () => { + const result = await updateSetupAutomationRecommendationBatchIfCurrent( + 'stale-fingerprint', + (current) => ({ ...current, status: 'failed' }), + ); + + expect(result).toBeNull(); + expect(await listSetupAutomationRecommendations()).toMatchObject({ + inputFingerprint: 'recommendation-fingerprint', + status: 'ready', + }); + }); +}); diff --git a/packages/sdk/src/server/lib/setup-automation-recommendations.ts b/packages/sdk/src/server/lib/setup-automation-recommendations.ts new file mode 100644 index 000000000..97cbc2f33 --- /dev/null +++ b/packages/sdk/src/server/lib/setup-automation-recommendations.ts @@ -0,0 +1,621 @@ +import { + ALL_REPOSITORIES, + normalizeRepositorySelection, + normalizeSetupNewState, + sourceControlTokenBackedProviders, + type AutomationRecommendationBatch, + type SourceControlProvider, + type TriggerableBackgroundAutomationKey, +} from '@roomote/types'; +import { + and, + createCustomAutomation, + db, + deploymentSettings, + eq, + getCustomAutomationById, + githubInstallations, + gte, + inArray, + isNull, + or, + pullRequestFacts, + repositories, + sql, + updateCustomAutomation, + upsertAutomation, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import { captureActivationAutomationChanged } from '@roomote/telemetry/server'; + +import type { AutomationRunNowResult } from '../automations/types'; +import { + AUTOMATION_RECOMMENDATION_REPOSITORY_CAP, + buildAutomationRecommendationFingerprint, + enqueueAutomationRecommendationInitialRun, + enqueueAutomationRecommendations, + enqueueAutomationSignalPrefetch, +} from './automation-recommendation-queues'; +import { + AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, + AUTOMATION_RECOMMENDATION_CATALOG, + type AutomationRecommendationCandidate, +} from './automation-recommendations-policy'; + +const AUTOMATION_RECOMMENDATION_TRIGGER_DELAY_MS = 5 * 60 * 1_000; + +type PersistedSetupNewState = ReturnType; + +async function getSetupState( + 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 saveSetupState( + setupNewState: PersistedSetupNewState, + executor: DatabaseOrTransaction = db, +) { + await executor + .insert(deploymentSettings) + .values({ id: 'default', setupNewState }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { setupNewState, updatedAt: new Date() }, + }); +} + +async function withRecommendationLock( + update: ( + state: PersistedSetupNewState, + tx: DatabaseOrTransaction, + ) => Promise, +): Promise { + return db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext('automation-recommendations'))`, + ); + return update(await getSetupState(tx), tx); + }); +} + +function candidateFor(candidateId: string) { + return AUTOMATION_RECOMMENDATION_CATALOG.find( + (candidate) => candidate.id === candidateId, + ); +} + +function hydrateRecommendationBatch( + batch: AutomationRecommendationBatch | null | undefined, +): AutomationRecommendationBatch | null { + if (!batch) return null; + return { + ...batch, + recommendations: batch.recommendations.map((recommendation) => ({ + ...recommendation, + title: + recommendation.title ?? + candidateFor(recommendation.candidateId)?.title ?? + recommendation.candidateId, + })), + }; +} + +export function createPendingAutomationRecommendationBatch( + inputFingerprint: string, + previousBatch: AutomationRecommendationBatch | null | undefined, +): AutomationRecommendationBatch { + const sameInput = previousBatch?.inputFingerprint === inputFingerprint; + return { + version: 1, + inputFingerprint, + catalogVersion: AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, + status: 'pending', + startedAt: new Date().toISOString(), + completedAt: null, + partial: false, + errorCode: null, + dismissed: sameInput ? previousBatch.dismissed : false, + applicationState: sameInput + ? (previousBatch.applicationState ?? 'pending') + : 'pending', + recommendations: sameInput ? previousBatch.recommendations : [], + }; +} + +export async function markAutomationRecommendationBatchFailed( + inputFingerprint: string, + errorCode: string, +) { + await updateSetupAutomationRecommendationBatchIfCurrent( + inputFingerprint, + (batch) => ({ + ...batch, + status: 'failed', + completedAt: new Date().toISOString(), + errorCode, + }), + ); +} + +export async function isSetupAutomationRecommendationFingerprintCurrent( + inputFingerprint: string, +) { + return ( + (await getSetupState()).automationRecommendations?.inputFingerprint === + inputFingerprint + ); +} + +export async function updateSetupAutomationRecommendationBatchIfCurrent( + inputFingerprint: string, + update: ( + batch: AutomationRecommendationBatch, + ) => AutomationRecommendationBatch, +) { + return withRecommendationLock(async (state, tx) => { + const current = state.automationRecommendations; + if (current?.inputFingerprint !== inputFingerprint) return null; + const batch = update(current); + await saveSetupState( + normalizeSetupNewState({ ...state, automationRecommendations: batch }), + tx, + ); + return batch; + }); +} + +export async function resolveConnectedAutomationRecommendationRepositories(): Promise<{ + normalizedRepositoryIds: string[]; + connectedRepositories: Array<{ + id: string; + fullName: string; + sourceControlProvider: SourceControlProvider; + }>; +}> { + const connectedRepositories = await db + .select({ + id: repositories.id, + fullName: repositories.fullName, + sourceControlProvider: repositories.sourceControlProvider, + }) + .from(repositories) + .leftJoin( + githubInstallations, + eq(repositories.installationId, githubInstallations.id), + ) + .where( + and( + eq(repositories.isActive, true), + or( + inArray( + repositories.sourceControlProvider, + sourceControlTokenBackedProviders, + ), + and( + eq(repositories.sourceControlProvider, 'github'), + isNull(githubInstallations.suspendedAt), + ), + ), + ), + ); + const activitySince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1_000); + const activityRows = + connectedRepositories.length > 0 + ? await db + .select({ + repositoryId: pullRequestFacts.repositoryId, + activity: sql`count(*)::int`, + }) + .from(pullRequestFacts) + .where( + and( + inArray( + pullRequestFacts.repositoryId, + connectedRepositories.map((repository) => repository.id), + ), + gte(pullRequestFacts.updatedAtRemote, activitySince), + ), + ) + .groupBy(pullRequestFacts.repositoryId) + : []; + const activityByRepositoryId = new Map( + activityRows.map((row) => [row.repositoryId, row.activity]), + ); + const rankedRepositories = [...connectedRepositories] + .sort( + (left, right) => + (activityByRepositoryId.get(right.id) ?? 0) - + (activityByRepositoryId.get(left.id) ?? 0) || + left.fullName.localeCompare(right.fullName), + ) + .slice(0, AUTOMATION_RECOMMENDATION_REPOSITORY_CAP); + return { + normalizedRepositoryIds: normalizeRepositorySelection(rankedRepositories), + connectedRepositories: rankedRepositories, + }; +} + +export async function prefetchSetupAutomationRecommendationSignals() { + const { normalizedRepositoryIds } = + await resolveConnectedAutomationRecommendationRepositories(); + await enqueueAutomationSignalPrefetch(normalizedRepositoryIds); + return { repositoryIds: normalizedRepositoryIds }; +} + +export async function prepareSetupAutomationRecommendationInput() { + const { normalizedRepositoryIds, connectedRepositories } = + await resolveConnectedAutomationRecommendationRepositories(); + return { + fingerprint: buildAutomationRecommendationFingerprint( + normalizedRepositoryIds, + connectedRepositories[0]?.sourceControlProvider ?? null, + ), + repositoryIds: normalizedRepositoryIds, + }; +} + +export async function dispatchSetupAutomationRecommendationBatch(input: { + batch: AutomationRecommendationBatch; + repositoryIds: string[]; + logContext: string; +}) { + try { + await enqueueAutomationRecommendations({ + fingerprint: input.batch.inputFingerprint, + repositoryIds: input.repositoryIds, + }); + } catch (error) { + console.error( + `[${input.logContext}] Failed to enqueue recommendation scoring:`, + error, + ); + await markAutomationRecommendationBatchFailed( + input.batch.inputFingerprint, + 'recommendation_queue_unavailable', + ); + } +} + +async function applyRecommendation( + tx: DatabaseOrTransaction, + userId: string, + recommendation: AutomationRecommendationBatch['recommendations'][number], + enabled: boolean, + candidate: AutomationRecommendationCandidate, +): Promise { + if (candidate.source === 'built_in') { + await upsertAutomation(tx, { + key: candidate.automationKey, + enabled, + schedule: { mode: enabled ? candidate.defaultScheduleMode : 'off' }, + }); + return null; + } + const existing = recommendation.automationId + ? await getCustomAutomationById(recommendation.automationId, tx) + : null; + const automation = existing + ? await updateCustomAutomation( + existing.id, + { + name: candidate.template.name, + prompt: candidate.template.prompt, + enabled, + scheduleMode: candidate.template.scheduleMode, + environmentId: ALL_REPOSITORIES, + target: {}, + }, + tx, + ) + : await createCustomAutomation( + { + name: candidate.template.name, + prompt: candidate.template.prompt, + enabled, + scheduleMode: candidate.template.scheduleMode, + environmentId: ALL_REPOSITORIES, + target: {}, + createdByUserId: userId, + }, + tx, + ); + return automation.id; +} + +export async function setSetupAutomationRecommendationEnabled(input: { + userId: string; + id: string; + enabled: boolean; +}) { + const result = await withRecommendationLock(async (state, tx) => { + const batch = state.automationRecommendations; + const recommendation = batch?.recommendations.find( + (item) => item.id === input.id, + ); + if (!batch || !recommendation) { + throw new Error('Recommendation was not found.'); + } + const candidate = candidateFor(recommendation.candidateId); + if (!candidate) throw new Error('Recommendation candidate was not found.'); + const automationId = await applyRecommendation( + tx, + input.userId, + recommendation, + input.enabled, + candidate, + ); + const nextBatch = { + ...batch, + recommendations: batch.recommendations.map((item) => + item.id === input.id + ? { + ...item, + title: candidate.title, + enabled: input.enabled, + applied: true, + ...(automationId ? { automationId } : {}), + } + : item, + ), + }; + await saveSetupState( + normalizeSetupNewState({ + ...state, + automationRecommendations: nextBatch, + }), + tx, + ); + return { + recommendation: nextBatch.recommendations.find( + (item) => item.id === input.id, + ), + candidate, + }; + }); + if ( + result.recommendation?.enabled && + result.candidate.source === 'built_in' + ) { + void captureActivationAutomationChanged( + 'enabled', + result.candidate.automationKey, + ); + } + return result.recommendation; +} + +export async function applySetupAutomationRecommendations(userId: string) { + const batch = await withRecommendationLock(async (state, tx) => { + const batch = state.automationRecommendations; + if (!batch || batch.status !== 'ready') return batch; + const recommendations = []; + for (const recommendation of batch.recommendations) { + const candidate = candidateFor(recommendation.candidateId); + if (!candidate) { + throw new Error('Recommendation candidate was not found.'); + } + const automationId = await applyRecommendation( + tx, + userId, + recommendation, + recommendation.enabled, + candidate, + ); + recommendations.push({ + ...recommendation, + title: candidate.title, + applied: true, + ...(automationId ? { automationId } : {}), + }); + } + const nextBatch = { + ...batch, + applicationState: 'applied' as const, + recommendations, + }; + await saveSetupState( + normalizeSetupNewState({ + ...state, + automationRecommendations: nextBatch, + }), + tx, + ); + return nextBatch; + }); + for (const recommendation of batch?.recommendations ?? []) { + if (!recommendation.enabled) continue; + const candidate = candidateFor(recommendation.candidateId); + if (candidate?.source === 'built_in') { + void captureActivationAutomationChanged( + 'enabled', + candidate.automationKey, + ); + } + } + await Promise.all( + (batch?.recommendations ?? []) + .filter((recommendation) => recommendation.enabled) + .filter((recommendation) => { + const candidate = candidateFor(recommendation.candidateId); + return !( + candidate?.source === 'built_in' && + candidate.automationKey === 'review_code' + ); + }) + .map(async (recommendation) => { + try { + await enqueueAutomationRecommendationInitialRun( + { + fingerprint: batch!.inputFingerprint, + recommendationId: recommendation.id, + }, + AUTOMATION_RECOMMENDATION_TRIGGER_DELAY_MS, + ); + } catch (error) { + console.error( + `[applySetupAutomationRecommendations] Failed to schedule ${recommendation.id}:`, + error, + ); + } + }), + ); + return hydrateRecommendationBatch(batch); +} + +export async function skipSetupAutomationRecommendations() { + return withRecommendationLock(async (state, tx) => { + const batch = state.automationRecommendations; + if (!batch || (batch.applicationState ?? 'pending') !== 'pending') { + return hydrateRecommendationBatch(batch); + } + const nextBatch = { + ...batch, + applicationState: 'skipped' as const, + recommendations: batch.recommendations.map((recommendation) => ({ + ...recommendation, + enabled: false, + applied: false, + })), + }; + await saveSetupState( + normalizeSetupNewState({ + ...state, + automationRecommendations: nextBatch, + }), + tx, + ); + return hydrateRecommendationBatch(nextBatch); + }); +} + +export async function listSetupAutomationRecommendations() { + return hydrateRecommendationBatch( + (await getSetupState()).automationRecommendations, + ); +} + +export async function startSetupAutomationRecommendations() { + const input = await prepareSetupAutomationRecommendationInput(); + const result = await withRecommendationLock(async (state, tx) => { + const existingBatch = state.automationRecommendations; + const batch = { + ...(existingBatch?.inputFingerprint === input.fingerprint + ? existingBatch + : { + version: 1 as const, + inputFingerprint: input.fingerprint, + catalogVersion: AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, + completedAt: null, + partial: false, + dismissed: false, + applicationState: 'pending' as const, + recommendations: [], + }), + status: 'pending' as const, + startedAt: new Date().toISOString(), + completedAt: null, + errorCode: null, + }; + await saveSetupState( + normalizeSetupNewState({ ...state, automationRecommendations: batch }), + tx, + ); + return { batch, repositoryIds: input.repositoryIds }; + }); + await dispatchSetupAutomationRecommendationBatch({ + ...result, + logContext: 'startSetupAutomationRecommendations', + }); + return hydrateRecommendationBatch(result.batch)!; +} + +export async function dismissSetupAutomationRecommendations() { + return withRecommendationLock(async (state, tx) => { + if (!state.automationRecommendations) return null; + const batch = { ...state.automationRecommendations, dismissed: true }; + await saveSetupState( + normalizeSetupNewState({ ...state, automationRecommendations: batch }), + tx, + ); + return hydrateRecommendationBatch(batch); + }); +} + +async function recordRecommendationLaunch( + recommendationId: string, + taskId: string, +) { + await withRecommendationLock(async (state, tx) => { + if (!state.automationRecommendations) return; + const batch = { + ...state.automationRecommendations, + recommendations: state.automationRecommendations.recommendations.map( + (item) => + item.id === recommendationId + ? { ...item, enabled: true, lastRunTaskId: taskId } + : item, + ), + }; + await saveSetupState( + normalizeSetupNewState({ ...state, automationRecommendations: batch }), + tx, + ); + }); +} + +export async function runSetupAutomationRecommendationNow(input: { + userId: string; + id: string; + runBuiltIn: ( + automationKey: TriggerableBackgroundAutomationKey, + ) => Promise; + runCustom: (automationId: string) => Promise; +}) { + const batch = await listSetupAutomationRecommendations(); + const recommendation = batch?.recommendations.find( + (item) => item.id === input.id, + ); + const candidate = recommendation + ? candidateFor(recommendation.candidateId) + : null; + if (!recommendation || !candidate) { + throw new Error('Recommendation was not found.'); + } + if ( + candidate.source === 'built_in' && + candidate.automationKey === 'review_code' + ) { + throw new Error('Review Code runs from pull-request events.'); + } + let automationId = recommendation.automationId; + if (!recommendation.enabled) { + const updated = await setSetupAutomationRecommendationEnabled({ + userId: input.userId, + id: recommendation.id, + enabled: true, + }); + automationId = updated?.automationId ?? null; + } + let result: AutomationRunNowResult; + if (candidate.source === 'cookbook') { + if (!automationId) { + throw new Error('Recommendation automation was not created.'); + } + result = await input.runCustom(automationId); + } else { + if (candidate.automationKey === 'review_code') { + throw new Error('Review Code runs from pull-request events.'); + } + result = await input.runBuiltIn(candidate.automationKey); + } + if (result.outcome === 'launched') { + await recordRecommendationLaunch(recommendation.id, result.taskId); + } + return result; +} diff --git a/packages/types/src/automation-recommendations.ts b/packages/types/src/automation-recommendations.ts index c24d2f8e0..8f5586859 100644 --- a/packages/types/src/automation-recommendations.ts +++ b/packages/types/src/automation-recommendations.ts @@ -1,67 +1,6 @@ -import type { CustomAutomationScheduleMode } from './background-agents'; -import type { TriggerableBackgroundAutomationKey } from './background-automation-registry'; -import { getTriggerableBackgroundAutomationDescriptorByKey } from './background-automation-registry'; -import { - sourceControlProviders, - type SourceControlProvider, -} from './source-control'; - -export const AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION = 1; - -export type RecommendationCategory = - | 'quality' - | 'security' - | 'maintenance' - | 'delivery' - | 'communication'; - -export type RecommendationSignal = - | 'active_pr_flow' - | 'merged_prs' - | 'open_prs' - | 'conflicts' - | 'ci_failures' - | 'dependabot_alerts' - | 'codeql_alerts' - | 'dependency_manifests' - | 'docs'; - -export type RecommendationScoringRule = { - signal: RecommendationSignal; - weight: number; - explanation: (value: number, repositoryCount: number) => string; -}; - -export type AutomationRecommendationCandidate = - | { - id: string; - source: 'built_in'; - automationKey: TriggerableBackgroundAutomationKey | 'review_code'; - title: string; - defaultScheduleMode: string; - environmentPolicy: 'not_required' | 'optional' | 'required'; - category: RecommendationCategory; - alwaysRecommend?: boolean; - scoringRules: RecommendationScoringRule[]; - } - | { - id: string; - source: 'cookbook'; - cookbookSlug: string; - title: string; - template: { - name: string; - prompt: string; - scheduleMode: CustomAutomationScheduleMode; - workspace: 'all_repositories'; - destination: 'none'; - }; - environmentPolicy: 'not_required' | 'optional'; - category: RecommendationCategory; - alwaysRecommend?: boolean; - scoringRules: RecommendationScoringRule[]; - }; +import type { SourceControlProvider } from './source-control'; +/** Persisted signal payload shared by collectors and database contracts. */ export type RepositoryAutomationSignals = { repositoryId: string; repositoryName: string; @@ -76,330 +15,3 @@ export type RepositoryAutomationSignals = { docs: number; partial?: boolean; }; - -export type MergedAutomationRecommendationSignals = Omit< - RepositoryAutomationSignals, - 'repositoryId' | 'repositoryName' | 'sourceControlProvider' -> & { - repositoryCount: number; - sourceControlProviders: SourceControlProvider[]; -}; - -const signalValue = ( - signals: MergedAutomationRecommendationSignals, - signal: RecommendationSignal, -) => { - const values: Record = { - active_pr_flow: signals.openPrs + signals.mergedPrs30d, - merged_prs: signals.mergedPrs30d, - open_prs: signals.openPrs, - conflicts: signals.conflicts, - ci_failures: signals.ciFailures30d, - dependabot_alerts: signals.dependabotAlerts, - codeql_alerts: signals.codeqlAlerts, - dependency_manifests: signals.dependencyManifests, - docs: signals.docs, - }; - return values[signal]; -}; - -const formatCount = (value: number, noun: string) => `${value} ${noun}`; - -const activePrRule = (weight: number): RecommendationScoringRule => ({ - signal: 'active_pr_flow', - weight, - explanation: (value, repositoryCount) => - `Your repos have active PR flow (${formatCount(value, 'recent PRs')} across ${repositoryCount} repos), so Roomote can help keep the work moving.`, -}); - -const mergedPrRule = (weight: number): RecommendationScoringRule => ({ - signal: 'merged_prs', - weight, - explanation: (value, repositoryCount) => - `You merged ${formatCount(value, 'PRs')} across ${repositoryCount} repos in the last 30 days, so Roomote can help keep up with the pace of change.`, -}); - -const openPrRule = (weight: number): RecommendationScoringRule => ({ - signal: 'open_prs', - weight, - explanation: (value) => - `Your repos have ${formatCount(value, 'open PRs')}, and Roomote can help keep them moving.`, -}); - -function fallbackRecommendationExplanation( - candidate: AutomationRecommendationCandidate, -): string { - switch (candidate.id) { - case 'built-in.review-code': - return 'Strongly recommended. Have Roomote review use a separate run to review PRs it creates.'; - case 'built-in.code-quality-auditor': - return 'As your repositories evolve, Roomote can run regular code quality checks and surface actionable fixes.'; - case 'built-in.security-auditor': - return 'Roomote can regularly check your repositories for security issues and surface focused fixes.'; - case 'built-in.resolve-pr-conflicts': - return 'Roomote can watch for merge conflicts and resolve safe conflicts in open pull requests.'; - case 'built-in.dependabot-triage': - return 'Your repos seem to have Dependabot alerts, and Roomote can handle those for you.'; - case 'built-in.codeql-triage': - return 'Your repos seem to have CodeQL alerts, and Roomote can handle those for you.'; - case 'built-in.ci-failure-triage': - return 'Your CI setup can lead to default branch failures. Enable this to automatically fix broken builds.'; - case 'cookbook.scheduled-housekeeping': - return 'Roomote can regularly check your repositories for dependency drift, stale flags, and flaky-test maintenance work.'; - default: - return `Your repositories are connected, so Roomote can help with ${candidate.title.toLowerCase()}.`; - } -} - -export const AUTOMATION_RECOMMENDATION_CATALOG: readonly AutomationRecommendationCandidate[] = - [ - { - id: 'built-in.review-code', - source: 'built_in', - automationKey: 'review_code', - title: 'Review Code', - defaultScheduleMode: 'off', - environmentPolicy: 'not_required', - category: 'quality', - alwaysRecommend: true, - scoringRules: [openPrRule(5), activePrRule(2)], - }, - { - id: 'built-in.code-quality-auditor', - source: 'built_in', - automationKey: 'code_quality_auditor', - title: 'Code Quality Auditor', - defaultScheduleMode: 'weekly', - environmentPolicy: 'not_required', - category: 'quality', - scoringRules: [mergedPrRule(4), activePrRule(2)], - }, - { - id: 'built-in.security-auditor', - source: 'built_in', - automationKey: 'security_auditor', - title: 'Security Auditor', - defaultScheduleMode: 'weekly', - environmentPolicy: 'not_required', - category: 'security', - scoringRules: [mergedPrRule(3), activePrRule(1)], - }, - { - id: 'built-in.resolve-pr-conflicts', - source: 'built_in', - automationKey: 'conflict_resolver', - title: 'Resolve PR Conflicts', - defaultScheduleMode: 'daily', - environmentPolicy: 'not_required', - category: 'delivery', - alwaysRecommend: true, - scoringRules: [ - { - signal: 'conflicts', - weight: 12, - explanation: (value) => - `Your repos have at least ${formatCount(value, 'open PR conflicts')}, and Roomote can resolve the safe ones automatically.`, - }, - openPrRule(2), - ], - }, - { - id: 'built-in.dependabot-triage', - source: 'built_in', - automationKey: 'dependabot_triage', - title: 'Triage Dependabot Alerts', - defaultScheduleMode: 'weekly', - environmentPolicy: 'not_required', - category: 'maintenance', - scoringRules: [ - { - signal: 'dependabot_alerts', - weight: 10, - explanation: (value) => - `Your repos have ${formatCount(value, 'open Dependabot alerts')}, and Roomote can handle those for you.`, - }, - { - signal: 'dependency_manifests', - weight: 2, - explanation: (value) => - `${formatCount(value, 'of your repos')} include dependency manifests, which Roomote can keep up-to-date.`, - }, - ], - }, - { - id: 'built-in.codeql-triage', - source: 'built_in', - automationKey: 'codeql_triage', - title: 'Triage CodeQL Alerts', - defaultScheduleMode: 'weekly', - environmentPolicy: 'not_required', - category: 'security', - scoringRules: [ - { - signal: 'codeql_alerts', - weight: 10, - explanation: (value) => - `Your repos have ${formatCount(value, 'open CodeQL alerts')}, and Roomote can handle those for you.`, - }, - ], - }, - { - id: 'built-in.ci-failure-triage', - source: 'built_in', - automationKey: 'ci_failure_triage', - title: 'CI Failure Triage', - defaultScheduleMode: 'daily', - environmentPolicy: 'optional', - category: 'delivery', - alwaysRecommend: true, - scoringRules: [ - { - signal: 'ci_failures', - weight: 9, - explanation: (value) => - `Roomote found ${formatCount(value, 'recent CI failures')}, and it can automatically open PRs to fix broken builds.`, - }, - ], - }, - { - id: 'cookbook.scheduled-housekeeping', - source: 'cookbook', - cookbookSlug: 'scheduled-housekeeping', - title: 'Schedule maintenance', - template: { - name: 'Repository maintenance review', - prompt: - 'Review these repositories for dependency drift, stale feature flags, and flaky-test maintenance opportunities. Report only concrete, actionable findings with file paths and concise next steps.', - scheduleMode: 'weekly', - workspace: 'all_repositories', - destination: 'none', - }, - environmentPolicy: 'not_required', - category: 'maintenance', - scoringRules: [mergedPrRule(3), activePrRule(1)], - }, - ] as const; - -export type ScoredAutomationRecommendation = { - candidate: AutomationRecommendationCandidate; - score: number; - explanation: string; -}; - -export function scoreAutomationRecommendations( - signals: MergedAutomationRecommendationSignals, - options: { - enabledCandidateIds?: ReadonlySet; - catalog?: readonly AutomationRecommendationCandidate[]; - minScore?: number; - } = {}, -): ScoredAutomationRecommendation[] { - const catalog = options.catalog ?? AUTOMATION_RECOMMENDATION_CATALOG; - const enabled = options.enabledCandidateIds ?? new Set(); - // Recommendations should still be useful immediately after a repository is - // connected, before provider signal collection has produced rich data. Once - // collection is complete, only recommend candidates backed by real signals. - const allowFallbackCandidates = signals.partial !== false; - const scored = catalog - .filter((candidate) => !enabled.has(candidate.id)) - .filter((candidate) => { - if (candidate.source !== 'built_in') return true; - const descriptor = getTriggerableBackgroundAutomationDescriptorByKey( - candidate.automationKey === 'review_code' - ? 'conflict_resolver' - : candidate.automationKey, - ); - return candidate.automationKey === 'review_code' - ? signals.sourceControlProviders.some((provider) => - sourceControlProviders.includes(provider), - ) - : (descriptor?.supportedSourceControlProviders.some((provider) => - signals.sourceControlProviders.includes(provider), - ) ?? false); - }) - .map((candidate) => { - const matches = candidate.scoringRules - .map((rule) => ({ rule, value: signalValue(signals, rule.signal) })) - .filter(({ value }) => value > 0); - const score = matches.reduce( - (total, { rule, value }) => total + rule.weight * Math.min(value, 20), - 0, - ); - const explanation = matches[0]?.rule.explanation( - matches[0].value, - signals.repositoryCount, - ); - return { - candidate, - score: Math.max( - score, - candidate.alwaysRecommend || allowFallbackCandidates ? 1 : 0, - ), - explanation: - explanation ?? fallbackRecommendationExplanation(candidate), - }; - }) - .filter(({ score }) => score >= (options.minScore ?? 1)) - .sort( - (left, right) => - right.score - left.score || - left.candidate.id.localeCompare(right.candidate.id), - ); - - const categories = new Map(); - const selected: ScoredAutomationRecommendation[] = []; - for (const recommendation of scored) { - const count = categories.get(recommendation.candidate.category) ?? 0; - if (count >= 2) continue; - categories.set(recommendation.candidate.category, count + 1); - selected.push(recommendation); - if (selected.length === 6) break; - } - - for (const recommendation of scored.filter( - ({ candidate }) => candidate.alwaysRecommend, - )) { - if ( - selected.some( - ({ candidate }) => candidate.id === recommendation.candidate.id, - ) - ) { - continue; - } - - const replacementIndex = [...selected] - .map((item, index) => ({ item, index })) - .reverse() - .find(({ item }) => !item.candidate.alwaysRecommend)?.index; - if (replacementIndex !== undefined) { - selected.splice(replacementIndex, 1, recommendation); - } else { - selected.push(recommendation); - } - } - - if (selected.length < 3 && allowFallbackCandidates) { - for (const recommendation of scored) { - if ( - selected.some( - (item) => item.candidate.id === recommendation.candidate.id, - ) - ) - continue; - selected.push(recommendation); - if (selected.length === 3) break; - } - } - - const reviewCode = selected.find( - ({ candidate }) => candidate.id === 'built-in.review-code', - ); - if (!reviewCode) return selected; - - return [ - reviewCode, - ...selected.filter( - ({ candidate }) => candidate.id !== 'built-in.review-code', - ), - ]; -} diff --git a/packages/types/src/setup-new.ts b/packages/types/src/setup-new.ts index f87856451..2a2ccce70 100644 --- a/packages/types/src/setup-new.ts +++ b/packages/types/src/setup-new.ts @@ -90,6 +90,8 @@ export const SETUP_COMPUTE_PROVISIONING_STATE_FIELDS = { export type AutomationRecommendation = { id: string; candidateId: string; + /** Client-safe display metadata. Older persisted batches are hydrated server-side. */ + title?: string; rank: number; score: number; explanation: string;