From 3a94aa9b5ad41a7059707da78060b5f8d7424994 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:35:27 +0000 Subject: [PATCH 1/3] feat: unify automation destination defaults --- .../custom-automations-routes.test.ts | 16 + .../src/handlers/custom-automations/index.ts | 16 +- ...AutomationsSettings.render.client.test.tsx | 43 ++- .../automations/CustomAutomationsSection.tsx | 54 ++-- .../custom-automations-telemetry.test.ts | 7 + .../automations/custom-automations.ts | 18 +- .../src/trpc/commands/setup-new/index.test.ts | 173 +++++++++++ apps/web/src/trpc/commands/setup-new/index.ts | 48 ++- packages/sdk/src/server/index.ts | 5 + .../default-automation-destination.test.ts | 238 ++++++++++++++ .../lib/default-automation-destination.ts | 291 ++++++++++++++++++ .../src/manage-custom-automations-tool.ts | 3 + 12 files changed, 868 insertions(+), 44 deletions(-) create mode 100644 packages/sdk/src/server/lib/default-automation-destination.test.ts create mode 100644 packages/sdk/src/server/lib/default-automation-destination.ts diff --git a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts index 4decb93773..63e0972e03 100644 --- a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts +++ b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts @@ -32,6 +32,7 @@ const { mockListConnectedCommunicationProviders, mockCanStartAgentMailConversationWithUser, mockListAvailableAgentMailOutboundIdentities, + mockResolveDefaultAutomationTarget, mockResolveCustomAutomationSchedule, mockRunCustomAutomationNow, mockCaptureActivationCustomAutomationChanged, @@ -47,6 +48,7 @@ const { mockListConnectedCommunicationProviders: vi.fn(), mockCanStartAgentMailConversationWithUser: vi.fn(), mockListAvailableAgentMailOutboundIdentities: vi.fn(), + mockResolveDefaultAutomationTarget: vi.fn(), mockResolveCustomAutomationSchedule: vi.fn(), mockRunCustomAutomationNow: vi.fn(), mockCaptureActivationCustomAutomationChanged: vi.fn(), @@ -67,11 +69,16 @@ vi.mock('@roomote/db/server', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES: { + chatProviders: ['slack', 'teams', 'telegram', 'discord'], + email: true, + }, listConnectedCommunicationProviders: mockListConnectedCommunicationProviders, canStartAgentMailConversationWithUser: mockCanStartAgentMailConversationWithUser, listAvailableAgentMailOutboundIdentities: mockListAvailableAgentMailOutboundIdentities, + resolveDefaultAutomationTarget: mockResolveDefaultAutomationTarget, resolveCustomAutomationSchedule: mockResolveCustomAutomationSchedule, runCustomAutomationNow: mockRunCustomAutomationNow, })); @@ -189,6 +196,7 @@ describe('custom-automations MCP routes', () => { kind: 'verified', }, ]); + mockResolveDefaultAutomationTarget.mockResolvedValue(null); mockGetDeploymentTaskModelOptions.mockResolvedValue({ models: ENABLED_MODELS, defaultModelId: 'openai/gpt-5.6-luna', @@ -851,6 +859,13 @@ describe('custom-automations MCP routes', () => { it('lists and stores only a server-verified Email identity', async () => { const { app } = createApp(); + const defaultTarget = { + provider: 'email' as const, + targetKind: 'email_user' as const, + externalRef: 'admin-1', + metadata: { emailIdentityId: 'verified:admin-1:digest' }, + }; + mockResolveDefaultAutomationTarget.mockResolvedValue(defaultTarget); mockResolveCustomAutomationSchedule.mockResolvedValue({ status: 'resolved', scheduleMode: 'daily', @@ -870,6 +885,7 @@ describe('custom-automations MCP routes', () => { kind: 'verified', }, ], + defaultTarget, }); const res = await postCreate( app, diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index f547309ddb..3932346a17 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -16,10 +16,12 @@ import { users, } from '@roomote/db/server'; import { + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, listConnectedCommunicationProviders, listAvailableAgentMailOutboundIdentities, canStartAgentMailConversationWithUser, resolveCustomAutomationSchedule, + resolveDefaultAutomationTarget, runCustomAutomationNow, } from '@roomote/sdk/server'; import { @@ -397,16 +399,26 @@ customAutomationsRouter.get('/models', async (c) => customAutomationsRouter.get('/destinations', async (c) => { const automationId = c.req.query('automationId'); let ownerUserId = actorId(c); + let existingTarget: OptionalAutomationTarget | null = null; if (automationId) { const automation = await getCustomAutomationById(automationId); if (!automation || !canManage(c, automation)) { return c.json({ error: 'Custom automation was not found.' }, 404); } ownerUserId = automation.createdByUserId ?? ownerUserId; + existingTarget = automation.target; } + const [emailIdentities, defaultTarget] = await Promise.all([ + listAvailableAgentMailOutboundIdentities(ownerUserId), + resolveDefaultAutomationTarget({ + ownerUserId, + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + existingTarget, + }), + ]); return c.json({ - emailIdentities: - await listAvailableAgentMailOutboundIdentities(ownerUserId), + emailIdentities, + defaultTarget, }); }); diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 499d376756..bd9fd2d60e 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -18,6 +18,15 @@ const state = vi.hoisted(() => ({ customAutomationsPending: false, customAutomationRunPendingId: null as string | null, customAutomationTimeZone: 'UTC' as string | undefined, + customAutomationDefaultTarget: undefined as + | { + provider: 'slack' | 'discord' | 'teams' | 'telegram' | 'email'; + targetKind: string; + externalRef: string; + metadata?: Record; + } + | null + | undefined, customAutomations: [] as Array<{ id: string; name: string; @@ -336,14 +345,34 @@ vi.mock('@tanstack/react-query', () => ({ state.queriedKeys.push(queryOptions.queryKey); const key1 = queryOptions.queryKey?.[1]; if (key1 === 'getCustomAutomationOptions') { + const managerSlackChannelId = + state.settingsQuery.data.settings.managerSlackChannelId; + const managerDiscordChannelId = + state.settingsQuery.data.settings.managerDiscordChannelId; return { isPending: state.settingsQuery.isPending, data: { capabilities: state.settingsQuery.data.capabilities, - managerSlackChannelId: - state.settingsQuery.data.settings.managerSlackChannelId, - managerDiscordChannelId: - state.settingsQuery.data.settings.managerDiscordChannelId, + managerSlackChannelId, + managerDiscordChannelId, + defaultTarget: + state.customAutomationDefaultTarget !== undefined + ? state.customAutomationDefaultTarget + : managerSlackChannelId && + state.settingsQuery.data.capabilities.slackConnected + ? { + provider: 'slack', + targetKind: 'slack_channel', + externalRef: managerSlackChannelId, + } + : managerDiscordChannelId && + state.settingsQuery.data.capabilities.discordConnected + ? { + provider: 'discord', + targetKind: 'discord_channel', + externalRef: managerDiscordChannelId, + } + : null, effectiveTimeZone: state.customAutomationTimeZone, }, }; @@ -708,6 +737,7 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.reviewer.relayUsers = []; state.customAutomations = []; state.customAutomationTimeZone = 'UTC'; + state.customAutomationDefaultTarget = undefined; state.customAutomationsPending = false; state.settingsQuery.isPending = false; state.environments = []; @@ -1878,6 +1908,11 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.capabilities.discordConnected = true; state.settingsQuery.data.capabilities.teamsConnected = true; state.settingsQuery.data.settings.managerSlackChannelId = null as never; + state.customAutomationDefaultTarget = { + provider: 'discord', + targetKind: 'discord_user', + externalRef: 'user-1', + }; render(); diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index e8c6a872b7..ea56f9bf10 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -20,6 +20,7 @@ import { isBackgroundAutomationUserTargetKind, MAX_CUSTOM_AUTOMATIONS, type CustomAutomationScheduleMode, + type OptionalAutomationTarget, type ReasoningEffort, } from '@roomote/types'; @@ -260,12 +261,12 @@ function CustomAutomationRunButton({ ); } -function targetFromRow(row: CustomAutomationListItem): { +function targetFromAutomationTarget(target: OptionalAutomationTarget): { provider: CustomAutomationFormState['targetProvider']; mode: CustomAutomationFormState['targetMode']; channelId: string; } { - if (!row.target.provider || !row.target.externalRef) { + if (!target.provider || !target.externalRef) { return { provider: 'none', mode: 'channel', @@ -274,26 +275,30 @@ function targetFromRow(row: CustomAutomationListItem): { } const provider = - row.target.provider === 'discord' || - row.target.provider === 'teams' || - row.target.provider === 'telegram' || - row.target.provider === 'email' - ? row.target.provider + target.provider === 'discord' || + target.provider === 'teams' || + target.provider === 'telegram' || + target.provider === 'email' + ? target.provider : 'slack'; return { provider, - mode: isBackgroundAutomationUserTargetKind(row.target.targetKind) + mode: isBackgroundAutomationUserTargetKind(target.targetKind) ? 'direct_message' : 'channel', channelId: - row.target.provider === 'email' - ? (getAutomationTargetEmailIdentityId(row.target) ?? '') - : isBackgroundAutomationUserTargetKind(row.target.targetKind) + target.provider === 'email' + ? (getAutomationTargetEmailIdentityId(target) ?? '') + : isBackgroundAutomationUserTargetKind(target.targetKind) ? '' - : (row.target.externalRef ?? ''), + : (target.externalRef ?? ''), }; } +function targetFromRow(row: CustomAutomationListItem) { + return targetFromAutomationTarget(row.target); +} + function formFromRow( row: CustomAutomationListItem, connectedProviders: readonly ConnectedDestinationProvider[] | null, @@ -1125,29 +1130,16 @@ export function CustomAutomationsSection({ size="sm" disabled={busy || atCap || !capabilitiesLoaded} onClick={() => { - const managerProvider = - managerSlackChannelId && capabilities?.slackConnected - ? 'slack' - : managerDiscordChannelId && capabilities?.discordConnected - ? 'discord' - : null; - const targetProvider = - managerProvider ?? connectedDestinationOptions[0]?.value ?? 'none'; + const target = targetFromAutomationTarget( + optionsQuery.data?.defaultTarget ?? {}, + ); setIsCreating(true); setEditingId(null); setForm({ ...EMPTY_FORM, - targetProvider, - targetMode: - targetProvider === 'email' ? 'direct_message' : 'channel', - targetChannelId: - targetProvider === 'slack' - ? managerSlackChannelId - : targetProvider === 'discord' - ? managerDiscordChannelId - : targetProvider === 'email' - ? (emailOptions[0]?.id ?? '') - : '', + targetProvider: target.provider, + targetMode: target.mode, + targetChannelId: target.channelId, }); setResolvedCron(null); setScheduleSummary(null); diff --git a/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts b/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts index 19513fd772..50154fba6c 100644 --- a/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts +++ b/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ listConnectedCommunicationProviders: vi.fn(), canStartAgentMailConversationWithUser: vi.fn(), listAvailableAgentMailOutboundIdentities: vi.fn(), + resolveDefaultAutomationTarget: vi.fn(), captureActivationCustomAutomationChanged: vi.fn(), })); @@ -45,6 +46,7 @@ vi.mock('@roomote/sdk/server', async (importOriginal) => ({ mocks.canStartAgentMailConversationWithUser, listAvailableAgentMailOutboundIdentities: mocks.listAvailableAgentMailOutboundIdentities, + resolveDefaultAutomationTarget: mocks.resolveDefaultAutomationTarget, runCustomAutomationNow: mocks.runCustomAutomationNow, resolveDeploymentTimeZone: mocks.resolveDeploymentTimeZone, })); @@ -103,6 +105,7 @@ describe('custom automation activation telemetry', () => { mocks.listConnectedCommunicationProviders.mockResolvedValue(['slack']); mocks.canStartAgentMailConversationWithUser.mockResolvedValue(false); mocks.listAvailableAgentMailOutboundIdentities.mockResolvedValue([]); + mocks.resolveDefaultAutomationTarget.mockResolvedValue(null); }); it('tracks creation with only the destination provider classification', async () => { @@ -272,6 +275,7 @@ describe('custom automation ownership', () => { vi.clearAllMocks(); mocks.canStartAgentMailConversationWithUser.mockResolvedValue(false); mocks.listAvailableAgentMailOutboundIdentities.mockResolvedValue([]); + mocks.resolveDefaultAutomationTarget.mockResolvedValue(null); }); it('returns only member-safe connection flags and timezone without reading admin settings', async () => { @@ -294,6 +298,7 @@ describe('custom automation ownership', () => { }, managerSlackChannelId: null, managerDiscordChannelId: null, + defaultTarget: null, emailIdentities: [], effectiveTimeZone: 'America/New_York', }); @@ -355,6 +360,7 @@ describe('custom automation ownership', () => { ], managerSlackChannelId: null, managerDiscordChannelId: null, + defaultTarget: null, effectiveTimeZone: 'UTC', }); }); @@ -381,6 +387,7 @@ describe('custom automation ownership', () => { }, managerSlackChannelId: 'private-slack', managerDiscordChannelId: 'private-discord', + defaultTarget: null, emailIdentities: [], effectiveTimeZone: 'UTC', }, diff --git a/apps/web/src/trpc/commands/automations/custom-automations.ts b/apps/web/src/trpc/commands/automations/custom-automations.ts index af3b528b9c..8cbab116b7 100644 --- a/apps/web/src/trpc/commands/automations/custom-automations.ts +++ b/apps/web/src/trpc/commands/automations/custom-automations.ts @@ -15,8 +15,10 @@ import { type CustomAutomation, } from '@roomote/db/server'; import { + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, listConnectedCommunicationProviders, listAvailableAgentMailOutboundIdentities, + resolveDefaultAutomationTarget, resolveCustomAutomationSchedule, resolveDeploymentTimeZone, runCustomAutomationNow, @@ -327,16 +329,21 @@ export async function getCustomAutomationOptionsCommand( // Email identities belong to the automation owner (runs execute as the // creator), so editing someone else's automation lists the owner's // identities rather than the viewer's. - const ownerUserId = input.automationId - ? ((await getOwnedAutomation(auth, input.automationId)).createdByUserId ?? - auth.userId) - : auth.userId; - const [providers, emailIdentities, { timeZone }, settings] = + const automation = input.automationId + ? await getOwnedAutomation(auth, input.automationId) + : null; + const ownerUserId = automation?.createdByUserId ?? auth.userId; + const [providers, emailIdentities, { timeZone }, settings, defaultTarget] = await Promise.all([ listConnectedCommunicationProviders(), listAvailableAgentMailOutboundIdentities(ownerUserId), resolveDeploymentTimeZone(), auth.isAdmin ? getBackgroundAgentSettingsForDeployment() : null, + resolveDefaultAutomationTarget({ + ownerUserId, + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + existingTarget: automation?.target, + }), ]); return { @@ -351,6 +358,7 @@ export async function getCustomAutomationOptionsCommand( // Channel catalogs are bot-scoped, not evidence of a member's access. managerSlackChannelId: settings?.managerSlackChannelId ?? null, managerDiscordChannelId: settings?.managerDiscordChannelId ?? null, + defaultTarget, effectiveTimeZone: timeZone, }; } 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 46a2fa1c1f..3ca47f848e 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -1,4 +1,5 @@ import type { UserAuthSuccess } from '@/types'; +import type { AutomationTarget } from '@roomote/types'; const { mockTxSelect, @@ -23,6 +24,10 @@ const { mockEnqueueAutomationRecommendations, mockEnqueueAutomationRecommendationInitialRun, mockUpsertAutomation, + mockCreateCustomAutomation, + mockUpdateCustomAutomation, + mockGetCustomAutomationById, + mockResolveDefaultAutomationTarget, mockCaptureActivationAutomationChanged, mockTriggerAutomationCommand, mockTriggerCustomAutomationCommand, @@ -56,6 +61,20 @@ const { mockEnqueueAutomationRecommendations: vi.fn(async () => undefined), mockEnqueueAutomationRecommendationInitialRun: vi.fn(async () => undefined), mockUpsertAutomation: vi.fn(async () => undefined), + mockCreateCustomAutomation: vi.fn(async (input) => ({ + id: 'custom-automation-1', + ...input, + })), + mockUpdateCustomAutomation: vi.fn(async (id, input) => ({ id, ...input })), + mockGetCustomAutomationById: vi.fn< + (...args: unknown[]) => Promise<{ + id: string; + target: AutomationTarget; + } | null> + >(async () => null), + mockResolveDefaultAutomationTarget: vi.fn< + (...args: unknown[]) => Promise + >(async () => null), mockCaptureActivationAutomationChanged: vi.fn(async () => undefined), mockTriggerAutomationCommand: vi.fn(async () => ({ outcome: 'launched' as const, @@ -142,6 +161,10 @@ vi.mock('../automations/custom-automations', () => ({ vi.mock('@roomote/sdk/server', () => ({ AUTOMATION_RECOMMENDATION_REPOSITORY_CAP: 10, + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES: { + chatProviders: ['slack', 'teams', 'telegram', 'discord'], + email: true, + }, buildAutomationRecommendationFingerprint: vi.fn( (repositoryIds: string[], provider: string | null) => `${provider ?? 'none'}:${repositoryIds.join(',')}`, @@ -149,6 +172,7 @@ vi.mock('@roomote/sdk/server', () => ({ enqueueAutomationRecommendations: mockEnqueueAutomationRecommendations, enqueueAutomationRecommendationInitialRun: mockEnqueueAutomationRecommendationInitialRun, + resolveDefaultAutomationTarget: mockResolveDefaultAutomationTarget, })); vi.mock('@roomote/db/server', () => ({ @@ -164,6 +188,7 @@ vi.mock('@roomote/db/server', () => ({ setupNewState: 'deployment_settings.setup_new_state', runtimeModelConfig: 'deployment_settings.runtime_model_config', }, + automations: { key: 'automations.key' }, environmentVariables: { name: 'environment_variables.name', }, @@ -188,6 +213,9 @@ vi.mock('@roomote/db/server', () => ({ updatedAtRemote: 'pull_request_facts.updated_at_remote', }, upsertAutomation: mockUpsertAutomation, + createCustomAutomation: mockCreateCustomAutomation, + updateCustomAutomation: mockUpdateCustomAutomation, + getCustomAutomationById: mockGetCustomAutomationById, isChatGptSubscriptionConnected: vi.fn(async () => false), isGitHubCopilotSubscriptionConnected: vi.fn(async () => false), isXaiSubscriptionConnected: vi.fn(async () => false), @@ -1179,6 +1207,9 @@ describe('setup recommendation commands', () => { execute: txExecuteMock, select: mockTxSelect, insert: vi.fn(() => ({ values: insertValuesMock })), + query: { + automations: { findFirst: vi.fn(async () => null) }, + }, }; mockTxSelect.mockReset(); @@ -1200,6 +1231,8 @@ describe('setup recommendation commands', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetCustomAutomationById.mockResolvedValue(null); + mockResolveDefaultAutomationTarget.mockResolvedValue(null); mockTxSelect.mockReset(); mockTxSelect.mockReturnValue(createGroupBySelectChain([])); vi.mocked(getRepositories).mockResolvedValue([ @@ -1326,6 +1359,146 @@ describe('setup recommendation commands', () => { expect(result?.applicationState).toBe('applied'); }); + it('defaults a built-in report only within its declared capabilities', async () => { + const reportTarget = { + provider: 'slack' as const, + targetKind: 'slack_channel' as const, + externalRef: 'C123', + }; + mockResolveDefaultAutomationTarget.mockResolvedValue(reportTarget); + mockRecommendationTransaction({ + 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.ci-failure-triage:1', + candidateId: 'built-in.ci-failure-triage', + rank: 1, + score: 1, + explanation: 'Fix broken builds.', + enabled: true, + lastRunTaskId: null, + automationId: null, + }, + ], + }, + }); + + await applySetupRecommendationsCommand(buildMockAuth()); + + expect(mockResolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: expect.objectContaining({ email: false }), + }), + ); + expect(mockUpsertAutomation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ targets: [reportTarget] }), + ); + }); + + it('uses the shared default for a recommended custom automation', async () => { + const reportTarget = { + provider: 'email' as const, + targetKind: 'email_user' as const, + externalRef: 'setup-test-user', + metadata: { emailIdentityId: 'verified:setup-test-user:hash' }, + }; + mockResolveDefaultAutomationTarget.mockResolvedValue(reportTarget); + mockRecommendationTransaction({ + 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: 'cookbook.scheduled-housekeeping:1', + candidateId: 'cookbook.scheduled-housekeeping', + rank: 1, + score: 1, + explanation: 'Review maintenance opportunities.', + enabled: true, + lastRunTaskId: null, + automationId: null, + }, + ], + }, + }); + + await applySetupRecommendationsCommand(buildMockAuth()); + + expect(mockResolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ + ownerUserId: 'setup-test-user', + includeSetupHandoff: true, + }), + ); + expect(mockCreateCustomAutomation).toHaveBeenCalledWith( + expect.objectContaining({ target: reportTarget }), + expect.anything(), + ); + }); + + it('preserves an existing explicit destination without resolving a default', async () => { + const existingTarget = { + provider: 'teams' as const, + targetKind: 'teams_channel' as const, + externalRef: 'conversation-1', + }; + mockGetCustomAutomationById.mockResolvedValue({ + id: 'custom-automation-1', + target: existingTarget, + }); + mockRecommendationTransaction({ + 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: 'cookbook.scheduled-housekeeping:1', + candidateId: 'cookbook.scheduled-housekeeping', + rank: 1, + score: 1, + explanation: 'Review maintenance opportunities.', + enabled: true, + lastRunTaskId: null, + automationId: 'custom-automation-1', + }, + ], + }, + }); + + await applySetupRecommendationsCommand(buildMockAuth()); + + expect(mockResolveDefaultAutomationTarget).not.toHaveBeenCalled(); + expect(mockUpdateCustomAutomation).toHaveBeenCalledWith( + 'custom-automation-1', + expect.objectContaining({ target: existingTarget }), + expect.anything(), + ); + }); + it('keeps a skipped pending batch unapplied and disabled', async () => { mockRecommendationTransaction({ automationRecommendations: { diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 4499a81987..6a9e81e02c 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -7,6 +7,7 @@ import { } from '@roomote/telemetry/server'; import { db, + automations, deploymentSettings, environments, environmentVariables, @@ -42,8 +43,10 @@ import { import { AUTOMATION_RECOMMENDATION_REPOSITORY_CAP, buildAutomationRecommendationFingerprint, + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, enqueueAutomationRecommendationInitialRun, enqueueAutomationRecommendations, + resolveDefaultAutomationTarget, } from '@roomote/sdk/server'; import { buildRecommendedDeploymentModelConfig, @@ -106,6 +109,9 @@ import { AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, AUTOMATION_RECOMMENDATION_CATALOG, ALL_REPOSITORIES, + getTriggerableBackgroundAutomationDescriptorByKey, + isAutomationDestinationTarget, + isConfiguredAutomationTarget, } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; @@ -2598,12 +2604,39 @@ async function applySetupRecommendationInTx( candidate: (typeof AUTOMATION_RECOMMENDATION_CATALOG)[number], ): Promise { if (candidate.source === 'built_in') { + const descriptor = getTriggerableBackgroundAutomationDescriptorByKey( + candidate.automationKey, + ); + const existing = descriptor?.usesManagerChannel + ? await tx.query.automations.findFirst({ + where: eq(automations.key, candidate.automationKey), + columns: { targets: true }, + }) + : null; + const existingTarget = existing?.targets.find( + isAutomationDestinationTarget, + ); + const defaultTarget = + enabled && descriptor?.usesManagerChannel && !existingTarget + ? await resolveDefaultAutomationTarget({ + ownerUserId: auth.userId, + capabilities: { + chatProviders: descriptor.supportedCommunicationProviders, + email: false, + }, + includeSetupHandoff: true, + client: tx, + }) + : null; await upsertAutomation(tx, { key: candidate.automationKey, enabled, schedule: { mode: enabled ? candidate.defaultScheduleMode : 'off', }, + ...(defaultTarget + ? { targets: [...(existing?.targets ?? []), defaultTarget] } + : {}), }); return null; } @@ -2611,6 +2644,15 @@ async function applySetupRecommendationInTx( const existing = recommendation.automationId ? await getCustomAutomationById(recommendation.automationId, tx) : null; + const reportTarget = + enabled && !isConfiguredAutomationTarget(existing?.target) + ? await resolveDefaultAutomationTarget({ + ownerUserId: auth.userId, + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + includeSetupHandoff: true, + client: tx, + }) + : null; const automation = existing ? await updateCustomAutomation( existing.id, @@ -2620,7 +2662,9 @@ async function applySetupRecommendationInTx( enabled, scheduleMode: candidate.template.scheduleMode, environmentId: ALL_REPOSITORIES, - target: {}, + target: isConfiguredAutomationTarget(existing.target) + ? existing.target + : (reportTarget ?? {}), }, tx, ) @@ -2631,7 +2675,7 @@ async function applySetupRecommendationInTx( enabled, scheduleMode: candidate.template.scheduleMode, environmentId: ALL_REPOSITORIES, - target: {}, + target: reportTarget ?? {}, createdByUserId: auth.userId, }, tx, diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 7d7893f796..3a02a7d51f 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -39,6 +39,11 @@ export { type AutomationRecommendationInitialRunJob, type AutomationSignalPrefetchJob, } from './lib/automation-recommendations'; +export { + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + resolveDefaultAutomationTarget, + type AutomationDestinationCapabilities, +} from './lib/default-automation-destination'; export { recordLlmUsage, type RecordLlmUsageInput, diff --git a/packages/sdk/src/server/lib/default-automation-destination.test.ts b/packages/sdk/src/server/lib/default-automation-destination.test.ts new file mode 100644 index 0000000000..ecf691e12c --- /dev/null +++ b/packages/sdk/src/server/lib/default-automation-destination.test.ts @@ -0,0 +1,238 @@ +import type { DatabaseOrTransaction } from '@roomote/db/server'; + +const mocks = vi.hoisted(() => ({ + settings: vi.fn(), + installations: vi.fn(), + membership: vi.fn(), + discord: vi.fn(), + discordPrimary: vi.fn(), + teams: vi.fn(), + teamsPrimary: vi.fn(), + telegramPrimary: vi.fn(), + teamsCredentials: vi.fn(), + telegramCredentials: vi.fn(), + discordCredentials: vi.fn(), + directMessage: vi.fn(), + emailIdentities: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { deploymentSettings: { findFirst: mocks.settings } }, + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ limit: mocks.installations }), + }), + }), + }), + }, + eq: (...args: unknown[]) => args, + and: (...args: unknown[]) => args, + deploymentSettings: { id: 'settings.id' }, + slackInstallations: { + id: 'installation.id', + botAccessToken: 'installation.token', + isActive: 'installation.active', + teamId: 'installation.team', + }, + slackInstallationChannels: { + slackInstallationId: 'channel.installation', + channelId: 'channel.id', + }, + resolveTeamsBotRuntimeCredentials: mocks.teamsCredentials, + resolveTelegramRuntimeCredentials: mocks.telegramCredentials, + resolveDiscordRuntimeCredentials: mocks.discordCredentials, +})); + +vi.mock('@roomote/slack', () => ({ + SlackNotifier: class { + isAppInChannel = mocks.membership; + }, +})); +vi.mock('./discord-persistence', () => ({ + findDiscordDestinationByChannelId: mocks.discord, + findDiscordDefaultDestination: mocks.discordPrimary, +})); +vi.mock('./teams-primary-conversation', () => ({ + findTeamsPrimaryConversation: mocks.teamsPrimary, +})); +vi.mock('./telegram-primary-chat', () => ({ + findTelegramPrimaryChatId: mocks.telegramPrimary, +})); +vi.mock('../automations/destination', () => ({ + findTeamsConversationRoute: mocks.teams, +})); +vi.mock('./agentmail/outbound', () => ({ + listAvailableAgentMailOutboundIdentities: mocks.emailIdentities, +})); +vi.mock('./user-direct-message', () => ({ + findUserDirectMessageDestination: mocks.directMessage, +})); + +import { + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + resolveDefaultAutomationTarget, +} from './default-automation-destination'; + +describe('resolveDefaultAutomationTarget', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.settings.mockResolvedValue({ setupNewState: {} }); + mocks.installations.mockResolvedValue([]); + mocks.membership.mockResolvedValue(false); + mocks.discord.mockResolvedValue(null); + mocks.discordPrimary.mockResolvedValue(null); + mocks.discordCredentials.mockResolvedValue({ botToken: 'token' }); + mocks.directMessage.mockResolvedValue(null); + mocks.teams.mockResolvedValue(null); + mocks.teamsPrimary.mockResolvedValue(null); + mocks.telegramPrimary.mockResolvedValue(null); + mocks.teamsCredentials.mockResolvedValue({ + botAppId: 'app', + botAppPassword: 'password', + }); + mocks.telegramCredentials.mockResolvedValue({ botToken: 'token' }); + mocks.emailIdentities.mockResolvedValue([]); + }); + + it('prefers a usable configured channel over owner DM and Email', async () => { + mocks.settings.mockResolvedValue({ + managerSlackChannelId: ' C12345678 ', + setupNewState: {}, + }); + mocks.installations.mockResolvedValue([ + { botAccessToken: 'token', teamId: 'T123' }, + ]); + mocks.membership.mockResolvedValue(true); + mocks.directMessage.mockResolvedValue({ channelId: 'D123' }); + mocks.emailIdentities.mockResolvedValue([{ id: 'verified:user:hash' }]); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toEqual({ + provider: 'slack', + targetKind: 'slack_channel', + externalRef: 'C12345678', + metadata: { slackTeamId: 'T123' }, + }); + expect(mocks.directMessage).not.toHaveBeenCalled(); + expect(mocks.emailIdentities).not.toHaveBeenCalled(); + }); + + it('continues after stale channels and unavailable DM providers', async () => { + mocks.settings.mockResolvedValue({ + managerSlackChannelId: 'CSTALE123', + managerDiscordChannelId: 'discord-stale', + setupNewState: {}, + }); + mocks.directMessage + .mockRejectedValueOnce(new Error('stale Slack link')) + .mockResolvedValueOnce({ + channelId: 'teams-conversation', + serviceUrl: 'https://teams.example.test', + }); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toEqual({ + provider: 'teams', + targetKind: 'teams_user', + externalRef: 'user-1', + }); + }); + + it('uses a primary conversation before an owner DM', async () => { + mocks.teamsPrimary.mockResolvedValue({ + conversationId: 'teams-channel', + serviceUrl: 'https://teams.example.test', + }); + mocks.teams.mockResolvedValue({ + workspaceId: 'tenant-1', + serviceUrl: 'https://teams.example.test', + }); + mocks.directMessage.mockResolvedValue({ channelId: 'D123' }); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toEqual({ + provider: 'teams', + targetKind: 'teams_channel', + externalRef: 'teams-channel', + metadata: { serviceUrl: 'https://teams.example.test' }, + }); + expect(mocks.directMessage).not.toHaveBeenCalled(); + }); + + it('preserves a supported explicit target without probing defaults', async () => { + const explicit = { + provider: 'discord' as const, + targetKind: 'discord_channel' as const, + externalRef: 'explicit-channel', + }; + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + existingTarget: explicit, + }), + ).resolves.toBe(explicit); + expect(mocks.settings).not.toHaveBeenCalled(); + expect(mocks.directMessage).not.toHaveBeenCalled(); + }); + + it('does not select Email when the runner does not support it', async () => { + mocks.emailIdentities.mockResolvedValue([{ id: 'verified:user:hash' }]); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: { chatProviders: ['slack'], email: false }, + }), + ).resolves.toBeNull(); + expect(mocks.emailIdentities).not.toHaveBeenCalled(); + }); + + it('uses verified Email only after chat candidates are exhausted', async () => { + mocks.emailIdentities.mockResolvedValue([{ id: 'verified:user:hash' }]); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toEqual({ + provider: 'email', + targetKind: 'email_user', + externalRef: 'user-1', + metadata: { emailIdentityId: 'verified:user:hash' }, + }); + }); + + it('returns null when no supported destination is usable', async () => { + const findFirst = vi.fn().mockResolvedValue({ setupNewState: {} }); + const client = { + query: { deploymentSettings: { findFirst } }, + } as unknown as DatabaseOrTransaction; + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + client, + }), + ).resolves.toBeNull(); + expect(findFirst).toHaveBeenCalled(); + expect(mocks.settings).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/server/lib/default-automation-destination.ts b/packages/sdk/src/server/lib/default-automation-destination.ts new file mode 100644 index 0000000000..e8919afdc8 --- /dev/null +++ b/packages/sdk/src/server/lib/default-automation-destination.ts @@ -0,0 +1,291 @@ +import { + and, + db, + deploymentSettings, + eq, + resolveDiscordRuntimeCredentials, + resolveTeamsBotRuntimeCredentials, + resolveTelegramRuntimeCredentials, + slackInstallationChannels, + slackInstallations, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import { SlackNotifier } from '@roomote/slack'; +import { + AUTOMATION_TARGET_EMAIL_IDENTITY_KEY, + getAutomationTargetKind, + hasSetupChatHandoffDestination, + isConfiguredAutomationTarget, + normalizeSetupNewState, + type AutomationCapableCommunicationProvider, + type AutomationTarget, + type OptionalAutomationTarget, +} from '@roomote/types'; + +import { findTeamsConversationRoute } from '../automations/destination'; +import { listAvailableAgentMailOutboundIdentities } from './agentmail/outbound'; +import { + findDiscordDefaultDestination, + findDiscordDestinationByChannelId, +} from './discord-persistence'; +import { findTeamsPrimaryConversation } from './teams-primary-conversation'; +import { findTelegramPrimaryChatId } from './telegram-primary-chat'; +import { findUserDirectMessageDestination } from './user-direct-message'; + +export type AutomationDestinationCapabilities = { + chatProviders: readonly AutomationCapableCommunicationProvider[]; + email: boolean; +}; + +export const CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES = { + chatProviders: ['slack', 'teams', 'telegram', 'discord'], + email: true, +} as const satisfies AutomationDestinationCapabilities; + +type DefaultAutomationTargetParams = { + ownerUserId: string; + capabilities: AutomationDestinationCapabilities; + existingTarget?: OptionalAutomationTarget | null; + includeSetupHandoff?: boolean; + client?: DatabaseOrTransaction; +}; + +/** + * Selects a persisted default report target without replacing an existing + * explicit target. Defaults follow one shared waterfall: usable configured + * channels, a resolvable owner DM, supported Email, then no destination. + */ +export async function resolveDefaultAutomationTarget({ + ownerUserId, + capabilities, + existingTarget, + includeSetupHandoff = false, + client = db, +}: DefaultAutomationTargetParams): Promise { + if (isConfiguredAutomationTarget(existingTarget)) { + const supported = + existingTarget.provider === 'email' + ? capabilities.email + : capabilities.chatProviders.includes( + existingTarget.provider as AutomationCapableCommunicationProvider, + ); + return supported ? existingTarget : null; + } + + const settings = await client.query.deploymentSettings + .findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { + managerSlackChannelId: true, + managerDiscordChannelId: true, + setupNewState: true, + }, + }) + .catch(() => null); + + const channelCandidates: AutomationTarget[] = []; + if (settings?.managerSlackChannelId?.trim()) { + channelCandidates.push({ + provider: 'slack', + targetKind: 'slack_channel', + externalRef: settings.managerSlackChannelId.trim(), + }); + } + if (settings?.managerDiscordChannelId?.trim()) { + channelCandidates.push({ + provider: 'discord', + targetKind: 'discord_channel', + externalRef: settings.managerDiscordChannelId.trim(), + }); + } + + if (includeSetupHandoff) { + const state = normalizeSetupNewState(settings?.setupNewState ?? {}); + if (hasSetupChatHandoffDestination(state)) { + const provider = state.chatHandoffProvider ?? 'slack'; + const channelId = ( + state.chatHandoffProvider + ? state.chatHandoffChannelId + : state.slackChannel + )?.trim(); + if ( + provider !== 'agentmail' && + channelId && + (provider !== 'slack' || state.slackTeamId?.trim()) + ) { + channelCandidates.push({ + provider, + targetKind: getAutomationTargetKind(provider, 'channel'), + externalRef: channelId, + ...(provider === 'slack' + ? { metadata: { slackTeamId: state.slackTeamId!.trim() } } + : {}), + }); + } + } + } + + // These persisted primary conversations are channel-level defaults, so they + // precede owner DMs just like an explicitly configured manager channel. + if (capabilities.chatProviders.includes('teams')) { + try { + const primary = await findTeamsPrimaryConversation(); + if (primary) { + channelCandidates.push({ + provider: 'teams', + targetKind: 'teams_channel', + externalRef: primary.conversationId, + }); + } + } catch { + // Continue to the next configured channel convention. + } + } + if (capabilities.chatProviders.includes('telegram')) { + try { + const chatId = await findTelegramPrimaryChatId(); + if (chatId) { + channelCandidates.push({ + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: chatId, + }); + } + } catch { + // Continue to the next configured channel convention. + } + } + if (capabilities.chatProviders.includes('discord')) { + try { + const primary = await findDiscordDefaultDestination(); + if (primary) { + channelCandidates.push({ + provider: 'discord', + targetKind: 'discord_channel', + externalRef: primary.channelId, + }); + } + } catch { + // Continue to owner DMs when no primary channel is usable. + } + } + + for (const candidate of channelCandidates) { + if ( + !capabilities.chatProviders.includes( + candidate.provider as AutomationCapableCommunicationProvider, + ) + ) { + continue; + } + const resolved = await resolveUsableChannelTarget(candidate, client); + if (resolved) return resolved; + } + + for (const provider of capabilities.chatProviders) { + try { + if (await findUserDirectMessageDestination(provider, ownerUserId)) { + return { + provider, + targetKind: getAutomationTargetKind(provider, 'direct_message'), + externalRef: ownerUserId, + }; + } + } catch { + // A stale provider link must not block later providers or Email. + } + } + + if (!capabilities.email) return null; + try { + const [identity] = + await listAvailableAgentMailOutboundIdentities(ownerUserId); + return identity + ? { + provider: 'email', + targetKind: 'email_user', + externalRef: ownerUserId, + metadata: { [AUTOMATION_TARGET_EMAIL_IDENTITY_KEY]: identity.id }, + } + : null; + } catch { + return null; + } +} + +async function resolveUsableChannelTarget( + target: AutomationTarget, + client: DatabaseOrTransaction, +): Promise { + try { + if (target.provider === 'slack') { + const teamId = + typeof target.metadata?.slackTeamId === 'string' + ? target.metadata.slackTeamId + : null; + if (!/^[CG][A-Z0-9]{8,}$/i.test(target.externalRef)) return null; + const installations = await client + .select({ + botAccessToken: slackInstallations.botAccessToken, + teamId: slackInstallations.teamId, + }) + .from(slackInstallations) + .innerJoin( + slackInstallationChannels, + and( + eq( + slackInstallationChannels.slackInstallationId, + slackInstallations.id, + ), + eq(slackInstallationChannels.channelId, target.externalRef), + ), + ) + .where( + and( + eq(slackInstallations.isActive, true), + ...(teamId ? [eq(slackInstallations.teamId, teamId)] : []), + ), + ) + .limit(2); + const installation = installations.length === 1 ? installations[0] : null; + if ( + installation?.botAccessToken && + installation.teamId && + (await new SlackNotifier(installation.botAccessToken).isAppInChannel( + target.externalRef, + )) === true + ) { + return { + ...target, + metadata: { ...target.metadata, slackTeamId: installation.teamId }, + }; + } + return null; + } + + if (target.provider === 'discord') { + return (await resolveDiscordRuntimeCredentials()).botToken && + (await findDiscordDestinationByChannelId(target.externalRef)) + ? target + : null; + } + + if (target.provider === 'teams') { + const credentials = await resolveTeamsBotRuntimeCredentials(); + if (!credentials.botAppId || !credentials.botAppPassword) return null; + const route = await findTeamsConversationRoute(target.externalRef); + return route?.serviceUrl.trim() + ? { ...target, metadata: { serviceUrl: route.serviceUrl } } + : null; + } + + if (target.provider === 'telegram') { + return (await resolveTelegramRuntimeCredentials()).botToken + ? target + : null; + } + } catch { + // Continue the waterfall when a configured candidate is stale. + } + return null; +} diff --git a/packages/types/src/manage-custom-automations-tool.ts b/packages/types/src/manage-custom-automations-tool.ts index b94d88ff3b..6c97b740e7 100644 --- a/packages/types/src/manage-custom-automations-tool.ts +++ b/packages/types/src/manage-custom-automations-tool.ts @@ -251,6 +251,9 @@ export function compactManageCustomAutomationsResult( : {}; }) : [], + defaultTarget: asRecord(result.defaultTarget) + ? compactAutomation({ target: result.defaultTarget }) + : null, }; case 'resolve_schedule': return compactScheduleResolution(result); From 3731773de8b14e5dbe26e368320b6f3e8de202df Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:59:39 +0000 Subject: [PATCH 2/3] fix: keep member automation defaults private --- .../custom-automations-routes.test.ts | 14 ++++++++ .../src/handlers/custom-automations/index.ts | 1 + .../custom-automations-telemetry.test.ts | 6 ++++ .../automations/custom-automations.ts | 1 + .../default-automation-destination.test.ts | 26 +++++++++++++++ .../lib/default-automation-destination.ts | 33 +++++++++++-------- .../manage-custom-automations-tool.test.ts | 1 + 7 files changed, 69 insertions(+), 13 deletions(-) diff --git a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts index 63e0972e03..4e0b55bcac 100644 --- a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts +++ b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts @@ -505,6 +505,20 @@ describe('custom-automations MCP routes', () => { }); }); + it('excludes shared channel defaults from destination discovery', async () => { + const { app } = createApp(); + + const response = await app.request('/custom-automations/destinations'); + + expect(response.status).toBe(200); + expect(mockResolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ + ownerUserId: 'member-1', + includeSharedChannels: false, + }), + ); + }); + it.each(['other', null])( 'denies every ID operation for owner %s without side effects', async (createdByUserId) => { diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index db88d96d2f..ed1da952d7 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -417,6 +417,7 @@ customAutomationsRouter.get('/destinations', async (c) => { ownerUserId, capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, existingTarget, + includeSharedChannels: c.get('customAutomationUser').role === 'admin', }), ]); return c.json({ diff --git a/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts b/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts index 50154fba6c..51e4cbdf6b 100644 --- a/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts +++ b/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts @@ -305,6 +305,12 @@ describe('custom automation ownership', () => { expect( mocks.getBackgroundAgentSettingsForDeployment, ).not.toHaveBeenCalled(); + expect(mocks.resolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ + ownerUserId: 'member-1', + includeSharedChannels: false, + }), + ); }); it("lists the automation owner's Email identities when an admin edits on their behalf", async () => { diff --git a/apps/web/src/trpc/commands/automations/custom-automations.ts b/apps/web/src/trpc/commands/automations/custom-automations.ts index f86406993d..1f08446983 100644 --- a/apps/web/src/trpc/commands/automations/custom-automations.ts +++ b/apps/web/src/trpc/commands/automations/custom-automations.ts @@ -364,6 +364,7 @@ export async function getCustomAutomationOptionsCommand( ownerUserId, capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, existingTarget: automation?.target, + includeSharedChannels: auth.isAdmin, }), ]); diff --git a/packages/sdk/src/server/lib/default-automation-destination.test.ts b/packages/sdk/src/server/lib/default-automation-destination.test.ts index ecf691e12c..d7c50deebe 100644 --- a/packages/sdk/src/server/lib/default-automation-destination.test.ts +++ b/packages/sdk/src/server/lib/default-automation-destination.test.ts @@ -173,6 +173,32 @@ describe('resolveDefaultAutomationTarget', () => { expect(mocks.directMessage).not.toHaveBeenCalled(); }); + it('skips shared channel defaults for member-owned automation options', async () => { + mocks.settings.mockResolvedValue({ + managerSlackChannelId: 'C12345678', + setupNewState: {}, + }); + mocks.teamsPrimary.mockResolvedValue({ + conversationId: 'teams-channel', + serviceUrl: 'https://teams.example.test', + }); + mocks.directMessage.mockResolvedValue({ channelId: 'D123' }); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + includeSharedChannels: false, + }), + ).resolves.toEqual({ + provider: 'slack', + targetKind: 'slack_user', + externalRef: 'user-1', + }); + expect(mocks.settings).not.toHaveBeenCalled(); + expect(mocks.teamsPrimary).not.toHaveBeenCalled(); + }); + it('preserves a supported explicit target without probing defaults', async () => { const explicit = { provider: 'discord' as const, diff --git a/packages/sdk/src/server/lib/default-automation-destination.ts b/packages/sdk/src/server/lib/default-automation-destination.ts index e8919afdc8..e222cf0956 100644 --- a/packages/sdk/src/server/lib/default-automation-destination.ts +++ b/packages/sdk/src/server/lib/default-automation-destination.ts @@ -46,6 +46,7 @@ type DefaultAutomationTargetParams = { ownerUserId: string; capabilities: AutomationDestinationCapabilities; existingTarget?: OptionalAutomationTarget | null; + includeSharedChannels?: boolean; includeSetupHandoff?: boolean; client?: DatabaseOrTransaction; }; @@ -59,6 +60,7 @@ export async function resolveDefaultAutomationTarget({ ownerUserId, capabilities, existingTarget, + includeSharedChannels = true, includeSetupHandoff = false, client = db, }: DefaultAutomationTargetParams): Promise { @@ -72,16 +74,18 @@ export async function resolveDefaultAutomationTarget({ return supported ? existingTarget : null; } - const settings = await client.query.deploymentSettings - .findFirst({ - where: eq(deploymentSettings.id, 'default'), - columns: { - managerSlackChannelId: true, - managerDiscordChannelId: true, - setupNewState: true, - }, - }) - .catch(() => null); + const settings = includeSharedChannels + ? await client.query.deploymentSettings + .findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { + managerSlackChannelId: true, + managerDiscordChannelId: true, + setupNewState: true, + }, + }) + .catch(() => null) + : null; const channelCandidates: AutomationTarget[] = []; if (settings?.managerSlackChannelId?.trim()) { @@ -127,7 +131,7 @@ export async function resolveDefaultAutomationTarget({ // These persisted primary conversations are channel-level defaults, so they // precede owner DMs just like an explicitly configured manager channel. - if (capabilities.chatProviders.includes('teams')) { + if (includeSharedChannels && capabilities.chatProviders.includes('teams')) { try { const primary = await findTeamsPrimaryConversation(); if (primary) { @@ -141,7 +145,10 @@ export async function resolveDefaultAutomationTarget({ // Continue to the next configured channel convention. } } - if (capabilities.chatProviders.includes('telegram')) { + if ( + includeSharedChannels && + capabilities.chatProviders.includes('telegram') + ) { try { const chatId = await findTelegramPrimaryChatId(); if (chatId) { @@ -155,7 +162,7 @@ export async function resolveDefaultAutomationTarget({ // Continue to the next configured channel convention. } } - if (capabilities.chatProviders.includes('discord')) { + if (includeSharedChannels && capabilities.chatProviders.includes('discord')) { try { const primary = await findDiscordDefaultDestination(); if (primary) { diff --git a/packages/types/src/manage-custom-automations-tool.test.ts b/packages/types/src/manage-custom-automations-tool.test.ts index 094def4b31..08c74276f9 100644 --- a/packages/types/src/manage-custom-automations-tool.test.ts +++ b/packages/types/src/manage-custom-automations-tool.test.ts @@ -430,6 +430,7 @@ describe('manage custom automations tool contract', () => { kind: 'verified', }, ], + defaultTarget: null, }); expect( buildManageCustomAutomationsRequest({ From 54542d578bcd4a11bdebdc2fd167689ccdf25835 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:45:29 +0000 Subject: [PATCH 3/3] fix: validate automation fallback ownership --- .../src/trpc/commands/setup-new/index.test.ts | 47 ++++++++++++++++++- apps/web/src/trpc/commands/setup-new/index.ts | 2 +- .../default-automation-destination.test.ts | 23 +++++++++ .../lib/default-automation-destination.ts | 8 +++- 4 files changed, 76 insertions(+), 4 deletions(-) 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 3ca47f848e..98f71df10d 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -1,5 +1,8 @@ import type { UserAuthSuccess } from '@/types'; -import type { AutomationTarget } from '@roomote/types'; +import type { + AutomationTarget, + OptionalAutomationTarget, +} from '@roomote/types'; const { mockTxSelect, @@ -69,7 +72,8 @@ const { mockGetCustomAutomationById: vi.fn< (...args: unknown[]) => Promise<{ id: string; - target: AutomationTarget; + target: OptionalAutomationTarget; + createdByUserId?: string | null; } | null> >(async () => null), mockResolveDefaultAutomationTarget: vi.fn< @@ -1499,6 +1503,45 @@ describe('setup recommendation commands', () => { ); }); + it('resolves a reapplied recommendation for its persisted owner', async () => { + mockGetCustomAutomationById.mockResolvedValue({ + id: 'custom-automation-1', + target: {}, + createdByUserId: 'original-owner', + }); + mockRecommendationTransaction({ + 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: 'cookbook.scheduled-housekeeping:1', + candidateId: 'cookbook.scheduled-housekeeping', + rank: 1, + score: 1, + explanation: 'Review maintenance opportunities.', + enabled: true, + lastRunTaskId: null, + automationId: 'custom-automation-1', + }, + ], + }, + }); + + await applySetupRecommendationsCommand(buildMockAuth()); + + expect(mockResolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ ownerUserId: 'original-owner' }), + ); + }); + it('keeps a skipped pending batch unapplied and disabled', async () => { mockRecommendationTransaction({ automationRecommendations: { diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 6a9e81e02c..0adb5a75a7 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -2647,7 +2647,7 @@ async function applySetupRecommendationInTx( const reportTarget = enabled && !isConfiguredAutomationTarget(existing?.target) ? await resolveDefaultAutomationTarget({ - ownerUserId: auth.userId, + ownerUserId: existing?.createdByUserId ?? auth.userId, capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, includeSetupHandoff: true, client: tx, diff --git a/packages/sdk/src/server/lib/default-automation-destination.test.ts b/packages/sdk/src/server/lib/default-automation-destination.test.ts index d7c50deebe..1d9870bbd6 100644 --- a/packages/sdk/src/server/lib/default-automation-destination.test.ts +++ b/packages/sdk/src/server/lib/default-automation-destination.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ teamsCredentials: vi.fn(), telegramCredentials: vi.fn(), discordCredentials: vi.fn(), + connectedProviders: vi.fn(), directMessage: vi.fn(), emailIdentities: vi.fn(), })); @@ -62,6 +63,7 @@ vi.mock('./telegram-primary-chat', () => ({ })); vi.mock('../automations/destination', () => ({ findTeamsConversationRoute: mocks.teams, + listConnectedCommunicationProviders: mocks.connectedProviders, })); vi.mock('./agentmail/outbound', () => ({ listAvailableAgentMailOutboundIdentities: mocks.emailIdentities, @@ -84,6 +86,12 @@ describe('resolveDefaultAutomationTarget', () => { mocks.discord.mockResolvedValue(null); mocks.discordPrimary.mockResolvedValue(null); mocks.discordCredentials.mockResolvedValue({ botToken: 'token' }); + mocks.connectedProviders.mockResolvedValue([ + 'slack', + 'teams', + 'telegram', + 'discord', + ]); mocks.directMessage.mockResolvedValue(null); mocks.teams.mockResolvedValue(null); mocks.teamsPrimary.mockResolvedValue(null); @@ -148,6 +156,21 @@ describe('resolveDefaultAutomationTarget', () => { }); }); + it('skips stale DM mappings for disconnected providers', async () => { + mocks.connectedProviders.mockResolvedValue(['slack']); + mocks.directMessage.mockResolvedValue(null); + mocks.emailIdentities.mockResolvedValue([{ id: 'verified:user:hash' }]); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toMatchObject({ provider: 'email' }); + expect(mocks.directMessage).toHaveBeenCalledTimes(1); + expect(mocks.directMessage).toHaveBeenCalledWith('slack', 'user-1'); + }); + it('uses a primary conversation before an owner DM', async () => { mocks.teamsPrimary.mockResolvedValue({ conversationId: 'teams-channel', diff --git a/packages/sdk/src/server/lib/default-automation-destination.ts b/packages/sdk/src/server/lib/default-automation-destination.ts index e222cf0956..cb7ddae52b 100644 --- a/packages/sdk/src/server/lib/default-automation-destination.ts +++ b/packages/sdk/src/server/lib/default-automation-destination.ts @@ -22,7 +22,10 @@ import { type OptionalAutomationTarget, } from '@roomote/types'; -import { findTeamsConversationRoute } from '../automations/destination'; +import { + findTeamsConversationRoute, + listConnectedCommunicationProviders, +} from '../automations/destination'; import { listAvailableAgentMailOutboundIdentities } from './agentmail/outbound'; import { findDiscordDefaultDestination, @@ -189,7 +192,10 @@ export async function resolveDefaultAutomationTarget({ if (resolved) return resolved; } + const connectedProviders: AutomationCapableCommunicationProvider[] = + await listConnectedCommunicationProviders().catch(() => []); for (const provider of capabilities.chatProviders) { + if (!connectedProviders.includes(provider)) continue; try { if (await findUserDirectMessageDestination(provider, ownerUserId)) { return {