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;