From 581e9503653de9d4c1f98c62c2f9960f40294326 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sun, 2 Aug 2026 23:39:54 +0000 Subject: [PATCH] refactor: unify custom automation writes --- .../custom-automations-routes.test.ts | 359 +++++---------- .../src/handlers/custom-automations/index.ts | 312 +++---------- .../automations/custom-automations.test.ts | 123 +++++ .../automations/custom-automations.ts | 137 ++---- apps/web/src/trpc/routers/_app.ts | 50 +- .../__tests__/custom-automations.test.ts | 40 +- .../roomote-mcp-server/custom-automations.ts | 5 - .../lib/__tests__/custom-automations.test.ts | 49 +- packages/db/src/lib/custom-automations.ts | 149 +----- .../custom-automation-writes.test.ts | 261 +++++++++++ .../automations/custom-automation-errors.ts | 27 ++ .../automations/custom-automation-schedule.ts | 24 +- .../automations/custom-automation-writes.ts | 435 ++++++++++++++++++ packages/sdk/src/server/automations/index.ts | 2 + 14 files changed, 1141 insertions(+), 832 deletions(-) create mode 100644 apps/web/src/trpc/commands/automations/custom-automations.test.ts create mode 100644 packages/sdk/src/server/automations/__tests__/custom-automation-writes.test.ts create mode 100644 packages/sdk/src/server/automations/custom-automation-errors.ts create mode 100644 packages/sdk/src/server/automations/custom-automation-writes.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 abd02df75..dc4ebcc0b 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 @@ -4,34 +4,41 @@ import type { AuthTokenContext } from '@roomote/types'; import type { Variables } from '../../../types'; import type { McpAuth } from '../../mcp/middleware'; -import { - customAutomationsRouter, - DUPLICATE_AUTOMATION_NAME_ERROR, -} from '../index'; +import { customAutomationsRouter } from '../index'; const { - mockUsersFindFirst, - mockResolveActingUserIdOrNull, - mockCreateCustomAutomation, - mockUpdateCustomAutomation, + MockCustomAutomationWriteError, + mockCreateCustomAutomationWrite, + mockDeleteCustomAutomation, mockGetCustomAutomationById, mockListCustomAutomations, - mockDeleteCustomAutomation, - mockListConnectedCommunicationProviders, + mockResolveActingUserIdOrNull, mockResolveCustomAutomationSchedule, mockRunCustomAutomationNow, -} = vi.hoisted(() => ({ - mockUsersFindFirst: vi.fn(), - mockResolveActingUserIdOrNull: vi.fn(), - mockCreateCustomAutomation: vi.fn(), - mockUpdateCustomAutomation: vi.fn(), - mockGetCustomAutomationById: vi.fn(), - mockListCustomAutomations: vi.fn(), - mockDeleteCustomAutomation: vi.fn(), - mockListConnectedCommunicationProviders: vi.fn(), - mockResolveCustomAutomationSchedule: vi.fn(), - mockRunCustomAutomationNow: vi.fn(), -})); + mockUpdateCustomAutomationWrite, + mockUsersFindFirst, +} = vi.hoisted(() => { + class MockCustomAutomationWriteError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } + } + return { + MockCustomAutomationWriteError, + mockCreateCustomAutomationWrite: vi.fn(), + mockDeleteCustomAutomation: vi.fn(), + mockGetCustomAutomationById: vi.fn(), + mockListCustomAutomations: vi.fn(), + mockResolveActingUserIdOrNull: vi.fn(), + mockResolveCustomAutomationSchedule: vi.fn(), + mockRunCustomAutomationNow: vi.fn(), + mockUpdateCustomAutomationWrite: vi.fn(), + mockUsersFindFirst: vi.fn(), + }; +}); vi.mock('@roomote/db/server', () => ({ and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), @@ -39,17 +46,19 @@ vi.mock('@roomote/db/server', () => ({ isNull: vi.fn((arg: unknown) => ({ type: 'isNull', arg })), users: { id: 'users.id', role: 'users.role', deletedAt: 'users.deletedAt' }, db: { query: { users: { findFirst: mockUsersFindFirst } } }, - createCustomAutomation: mockCreateCustomAutomation, - updateCustomAutomation: mockUpdateCustomAutomation, deleteCustomAutomation: mockDeleteCustomAutomation, getCustomAutomationById: mockGetCustomAutomationById, listCustomAutomations: mockListCustomAutomations, })); vi.mock('@roomote/sdk/server', () => ({ - listConnectedCommunicationProviders: mockListConnectedCommunicationProviders, + createCustomAutomationWrite: mockCreateCustomAutomationWrite, + CustomAutomationWriteError: MockCustomAutomationWriteError, + DUPLICATE_CUSTOM_AUTOMATION_NAME_MESSAGE: + 'A custom automation with this name already exists.', resolveCustomAutomationSchedule: mockResolveCustomAutomationSchedule, runCustomAutomationNow: mockRunCustomAutomationNow, + updateCustomAutomationWrite: mockUpdateCustomAutomationWrite, })); vi.mock('../../mcp/proxy-utils', () => ({ @@ -60,15 +69,10 @@ const ENVIRONMENT_ID = '00000000-0000-0000-0000-000000000001'; function createApp() { const app = new Hono<{ Variables: Variables & { mcpAuth: McpAuth } }>(); - - // Mirrors the generic-error branch of `app.onError` in - // apps/api/src/server.ts: routes rethrow unexpected errors so the app-level - // handler logs them and returns an opaque 500. const onError = vi.fn((_error: Error, c: Context) => c.json({ error: 'internal_server_error' }, 500), ); app.onError(onError); - app.use('*', async (c, next) => { const authContext: AuthTokenContext = { userId: 'admin-1', @@ -79,240 +83,119 @@ function createApp() { await next(); }); app.route('/custom-automations', customAutomationsRouter); - return { app, onError }; } -function createBody(overrides: Record = {}) { - return { - name: 'Nightly report', - prompt: 'Summarize yesterday.', - schedule: 'daily', - environmentId: ENVIRONMENT_ID, - ...overrides, - }; -} - -function postCreate( - app: ReturnType['app'], - body: Record, -) { - return app.request('/custom-automations', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }); -} - -describe('custom-automations MCP routes', () => { +describe('custom-automations MCP write routes', () => { beforeEach(() => { vi.clearAllMocks(); mockResolveActingUserIdOrNull.mockResolvedValue('admin-1'); mockUsersFindFirst.mockResolvedValue({ id: 'admin-1' }); - mockListConnectedCommunicationProviders.mockResolvedValue(['slack']); - }); - - describe('POST / (create)', () => { - it('returns 400 with the message when the environment does not exist', async () => { - const { app } = createApp(); - mockCreateCustomAutomation.mockRejectedValue( - new Error('Selected environment was not found.'), - ); - - const res = await postCreate(app, createBody()); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: 'Selected environment was not found.', - }); + mockCreateCustomAutomationWrite.mockResolvedValue({ + status: 'saved', + automation: { id: 'automation-1' }, + resolution: null, }); - - it('returns 400 with a friendly message for a duplicate name', async () => { - const { app } = createApp(); - const dbError = Object.assign( - new Error( - 'duplicate key value violates unique constraint "custom_automations_name_unique_idx"', - ), - { code: '23505', constraint: 'custom_automations_name_unique_idx' }, - ); - mockCreateCustomAutomation.mockRejectedValue(dbError); - - const res = await postCreate(app, createBody()); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: DUPLICATE_AUTOMATION_NAME_ERROR, - }); + mockUpdateCustomAutomationWrite.mockResolvedValue({ + status: 'saved', + automation: { id: 'automation-1' }, + resolution: null, }); + }); - it('detects a duplicate name when drizzle wraps the driver error', async () => { - const { app } = createApp(); - const wrapped = new Error('Failed query: insert into custom_automations'); - (wrapped as { cause?: unknown }).cause = Object.assign( - new Error('duplicate key value violates unique constraint'), - { code: '23505', constraint: 'custom_automations_name_unique_idx' }, - ); - mockCreateCustomAutomation.mockRejectedValue(wrapped); - - const res = await postCreate(app, createBody()); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: DUPLICATE_AUTOMATION_NAME_ERROR, - }); - }); - - it('rethrows a 23505 on an unrelated constraint instead of mislabeling it as a duplicate name', async () => { - const { app, onError } = createApp(); - const unrelatedUniqueViolation = Object.assign( - new Error( - 'duplicate key value violates unique constraint "environments_name_unique"', - ), - { code: '23505', constraint: 'environments_name_unique' }, - ); - mockCreateCustomAutomation.mockRejectedValue(unrelatedUniqueViolation); - - const res = await postCreate(app, createBody()); - - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ error: 'internal_server_error' }); - expect(onError).toHaveBeenCalledWith( - unrelatedUniqueViolation, - expect.anything(), - ); - }); - - it('returns 400 with the message when the automation cap is reached', async () => { - const { app } = createApp(); - mockCreateCustomAutomation.mockRejectedValue( - new Error('You can create at most 25 custom automations.'), - ); - - const res = await postCreate(app, createBody()); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: 'You can create at most 25 custom automations.', - }); - }); - - it('rethrows unexpected errors so the app-level handler returns 500', async () => { - const { app, onError } = createApp(); - const unexpected = new Error('connection refused'); - mockCreateCustomAutomation.mockRejectedValue(unexpected); - - const res = await postCreate(app, createBody()); - - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ error: 'internal_server_error' }); - expect(onError).toHaveBeenCalledWith(unexpected, expect.anything()); + it('adapts create input to the owning write service', async () => { + const { app } = createApp(); + const response = await app.request('/custom-automations', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Nightly report', + prompt: 'Summarize yesterday.', + schedule: 'daily', + model: 'anthropic/claude-sonnet-5', + environmentId: ENVIRONMENT_ID, + targetProvider: 'slack', + targetChannelId: 'C123', + }), }); - }); - describe('PATCH /:id (update)', () => { - const existing = { - id: 'automation-1', + expect(response.status).toBe(201); + expect(mockCreateCustomAutomationWrite).toHaveBeenCalledWith({ name: 'Nightly report', prompt: 'Summarize yesterday.', enabled: true, - scheduleMode: 'daily', - cronExpression: null, + model: 'anthropic/claude-sonnet-5', environmentId: ENVIRONMENT_ID, - target: {}, - }; - - it('returns 400 with the message for a known validation failure', async () => { - const { app } = createApp(); - mockGetCustomAutomationById.mockResolvedValue(existing); - mockUpdateCustomAutomation.mockRejectedValue( - new Error('Selected environment was not found.'), - ); - - const res = await app.request('/custom-automations/automation-1', { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ environmentId: ENVIRONMENT_ID }), - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: 'Selected environment was not found.', - }); + schedule: { schedule: 'daily', userId: 'admin-1' }, + target: { provider: 'slack', channelId: 'C123' }, + createdByUserId: 'admin-1', }); + }); - it('returns 400 with a friendly message for a duplicate name', async () => { - const { app } = createApp(); - mockGetCustomAutomationById.mockResolvedValue(existing); - mockUpdateCustomAutomation.mockRejectedValue( - Object.assign(new Error('duplicate key value'), { - code: '23505', - constraint: 'custom_automations_name_unique_idx', - }), - ); - - const res = await app.request('/custom-automations/automation-1', { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: 'Taken name' }), - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: DUPLICATE_AUTOMATION_NAME_ERROR, - }); + it('preserves omitted update fields and explicit destination clearing', async () => { + const { app } = createApp(); + const response = await app.request('/custom-automations/automation-1', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'Updated prompt', targetProvider: null }), }); - it('rethrows unexpected errors so the app-level handler returns 500', async () => { - const { app, onError } = createApp(); - mockGetCustomAutomationById.mockResolvedValue(existing); - const unexpected = new Error('connection refused'); - mockUpdateCustomAutomation.mockRejectedValue(unexpected); + expect(response.status).toBe(200); + expect(mockUpdateCustomAutomationWrite).toHaveBeenCalledWith( + 'automation-1', + expect.objectContaining({ + prompt: 'Updated prompt', + schedule: undefined, + target: null, + }), + ); + }); - const res = await app.request('/custom-automations/automation-1', { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: 'Renamed' }), - }); + it.each([ + ['invalid_input', 400], + ['duplicate_name', 400], + ['not_found', 404], + ])('maps the stable %s error code to HTTP %i', async (code, status) => { + const { app } = createApp(); + mockCreateCustomAutomationWrite.mockRejectedValue( + new MockCustomAutomationWriteError(code, 'Expected failure.'), + ); + + const response = await app.request('/custom-automations', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Nightly report', + prompt: 'Summarize yesterday.', + schedule: 'daily', + environmentId: ENVIRONMENT_ID, + }), + }); - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ error: 'internal_server_error' }); - expect(onError).toHaveBeenCalledWith(unexpected, expect.anything()); + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ + error: 'Expected failure.', + code, }); }); - describe('POST /resolve-schedule', () => { - it('returns 400 with the message for a known schedule validation failure', async () => { - const { app } = createApp(); - mockResolveCustomAutomationSchedule.mockRejectedValue( - new Error('Use a standard five-field cron expression.'), - ); - - const res = await app.request('/custom-automations/resolve-schedule', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ schedule: 'every day at noon' }), - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: 'Use a standard five-field cron expression.', - }); + it('rethrows unexpected failures to the logged 500 path', async () => { + const { app, onError } = createApp(); + const unexpected = new Error('connection refused'); + mockCreateCustomAutomationWrite.mockRejectedValue(unexpected); + + const response = await app.request('/custom-automations', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Nightly report', + prompt: 'Summarize yesterday.', + schedule: 'daily', + environmentId: ENVIRONMENT_ID, + }), }); - it('rethrows unexpected resolution failures so the app-level handler returns 500', async () => { - const { app, onError } = createApp(); - const unexpected = new Error('LLM request failed'); - mockResolveCustomAutomationSchedule.mockRejectedValue(unexpected); - - const res = await app.request('/custom-automations/resolve-schedule', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ schedule: 'every day at noon' }), - }); - - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ error: 'internal_server_error' }); - expect(onError).toHaveBeenCalledWith(unexpected, expect.anything()); - }); + expect(response.status).toBe(500); + expect(onError).toHaveBeenCalledWith(unexpected, expect.anything()); }); }); diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index b1eea1085..709402fc8 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -4,27 +4,22 @@ import { z } from 'zod'; import { and, - createCustomAutomation, db, deleteCustomAutomation, eq, getCustomAutomationById, isNull, listCustomAutomations, - updateCustomAutomation, users, } from '@roomote/db/server'; import { - listConnectedCommunicationProviders, + createCustomAutomationWrite, + CustomAutomationWriteError, resolveCustomAutomationSchedule, runCustomAutomationNow, + updateCustomAutomationWrite, + type CustomAutomationTargetWriteInput, } from '@roomote/sdk/server'; -import type { - BackgroundAutomationProvider, - BackgroundAutomationTargetKind, - CustomAutomationScheduleMode, - OptionalAutomationTarget, -} from '@roomote/types'; import type { Variables } from '../../types'; import type { McpAuth } from '../mcp/middleware'; @@ -35,142 +30,48 @@ type CustomAutomationVariables = Variables & { customAutomationAdminId: string; }; -const modelSchema = z - .string() - .trim() - .min(1) - .max(200) - .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.'); +const modelSchema = z.string(); const writeSchema = z.object({ - name: z.string().trim().min(1).max(100), - prompt: z.string().trim().min(1).max(8_000), + name: z.string(), + prompt: z.string(), enabled: z.boolean().default(true), - schedule: z.string().trim().min(1).max(500), + schedule: z.string(), model: modelSchema.optional(), environmentId: z.string().uuid(), targetProvider: z.enum(['slack', 'discord', 'teams', 'telegram']).optional(), - targetChannelId: z.string().trim().min(1).max(160).optional(), - targetServiceUrl: z.string().trim().min(1).max(500).optional(), + targetChannelId: z.string().optional(), + targetServiceUrl: z.string().optional(), }); const updateSchema = z.object({ - name: z.string().trim().min(1).max(100).optional(), - prompt: z.string().trim().min(1).max(8_000).optional(), + name: z.string().optional(), + prompt: z.string().optional(), enabled: z.boolean().optional(), - schedule: z.string().trim().min(1).max(500).optional(), + schedule: z.string().optional(), model: modelSchema.nullable().optional(), environmentId: z.string().uuid().optional(), targetProvider: z .enum(['slack', 'discord', 'teams', 'telegram']) .nullable() .optional(), - targetChannelId: z.string().trim().min(1).max(160).optional(), - targetServiceUrl: z.string().trim().min(1).max(500).optional(), + targetChannelId: z.string().optional(), + targetServiceUrl: z.string().nullable().optional(), }); -const UNIQUE_VIOLATION_CODE = '23505'; -const NAME_UNIQUE_INDEX = 'custom_automations_name_unique_idx'; -export const DUPLICATE_AUTOMATION_NAME_ERROR = - 'A custom automation with this name already exists.'; - -/** - * Whether the error (or anything in its cause chain — drizzle wraps the - * driver error in a DrizzleQueryError) is the Postgres unique violation for - * the custom automation name index specifically. Both signals are required: - * a 23505 on some other constraint is not a duplicate name and must rethrow - * to the logged 500 path instead of being mislabeled. The two signals may - * live on different levels of the cause chain (wrapper message vs. driver - * error fields), so they are accumulated across the walk. - */ -function isDuplicateNameViolation(error: unknown): boolean { - let sawUniqueViolationCode = false; - let sawNameUniqueIndex = false; - - for ( - let current = error, depth = 0; - current !== null && current !== undefined && depth < 10; - depth += 1 - ) { - const candidate = current as { - code?: unknown; - constraint?: unknown; - message?: unknown; - cause?: unknown; - }; - - if (candidate.code === UNIQUE_VIOLATION_CODE) { - sawUniqueViolationCode = true; - } - - if ( - candidate.constraint === NAME_UNIQUE_INDEX || - (typeof candidate.message === 'string' && - candidate.message.includes(NAME_UNIQUE_INDEX)) - ) { - sawNameUniqueIndex = true; - } - - if (sawUniqueViolationCode && sawNameUniqueIndex) { - return true; - } - - current = candidate.cause; - } - - return false; -} - -/** - * Expected validation failures thrown as plain Errors by - * `createCustomAutomation` / `updateCustomAutomation` (packages/db), - * `buildTarget`, and schedule validation (packages/sdk). These are safe to - * echo to the admin-only MCP client so the calling agent can self-correct; - * the web tRPC surface already shows the same messages to admins. Anything - * not matched here is rethrown so the app-level onError handler logs it and - * returns a generic 500. - */ -const VALIDATION_ERROR_PATTERNS: RegExp[] = [ - /^Name is required\.$/, - /^Name must be at most \d+ characters\.$/, - /^Prompt is required\.$/, - /^Prompt must be at most \d+ characters\.$/, - /^Invalid schedule mode: /, - /^Cron expression is required for a cron schedule\.$/, - /^Cron expression is only valid for a cron schedule\.$/, - /^Cron expression must be at most \d+ characters\.$/, - /^Cron expression must be between 1 and \d+ characters\.$/, - /^Use a standard five-field cron expression\.$/, - /^Model must be at most \d+ characters\.$/, - /^Model must use provider\/model format\.$/, - /^Environment is required\.$/, - /^Selected environment was not found\.$/, - /^Custom automation was not found\.$/, - /^Report destination must include a provider, target kind, and channel\.$/, - /^You can create at most \d+ custom automations\.$/, - /^targetChannelId is required when targetProvider is set\.$/, - /^Timezone is required\.$/, - /^Choose a valid IANA timezone\.$/, -]; - /** - * Translate an expected validation failure into a 400 response with the - * message, or return null when the error is not a known validation failure - * (callers rethrow those so onError logs them as unexpected 500s). + * Translate stable domain failures while leaving unexpected failures on the + * app-level logged 500 path. */ function knownErrorResponse( c: Pick, error: unknown, ): Response | null { - if (isDuplicateNameViolation(error)) { - return c.json({ error: DUPLICATE_AUTOMATION_NAME_ERROR }, 400); - } - - if ( - error instanceof Error && - VALIDATION_ERROR_PATTERNS.some((pattern) => pattern.test(error.message)) - ) { - return c.json({ error: error.message }, 400); + if (error instanceof CustomAutomationWriteError) { + return c.json( + { error: error.message, code: error.code }, + error.code === 'not_found' ? 404 : 400, + ); } return null; @@ -200,61 +101,28 @@ async function requireAdmin(auth: McpAuth): Promise { return user?.id ?? null; } -function buildTarget( +function targetInput( input: Pick< - z.infer, + z.infer, 'targetProvider' | 'targetChannelId' | 'targetServiceUrl' >, -): OptionalAutomationTarget { - if (!input.targetProvider) return {}; - if (!input.targetChannelId) { - throw new Error('targetChannelId is required when targetProvider is set.'); - } - - const kinds: Record = { - slack: 'slack_channel', - discord: 'discord_channel', - teams: 'teams_channel', - telegram: 'telegram_chat', - }; - return { - provider: input.targetProvider as BackgroundAutomationProvider, - targetKind: kinds[input.targetProvider]!, - externalRef: input.targetChannelId, - ...(input.targetServiceUrl - ? { metadata: { serviceUrl: input.targetServiceUrl } } - : {}), - }; -} - -async function resolveWriteSchedule(schedule: string, userId: string) { +): CustomAutomationTargetWriteInput | null | undefined { + if (input.targetProvider === null) return null; if ( - ['off', 'every_hour', 'every_6_hours', 'daily', 'weekly'].includes(schedule) + input.targetProvider === undefined && + input.targetChannelId === undefined && + input.targetServiceUrl === undefined ) { - return { - status: 'resolved' as const, - scheduleMode: schedule as CustomAutomationScheduleMode, - cronExpression: null, - resolution: null, - }; - } - - const resolution = await resolveCustomAutomationSchedule({ - schedule, - userId, - }); - if (resolution.status === 'ambiguous' || !resolution.cronExpression) { - return { - status: 'ambiguous' as const, - clarification: resolution.clarification, - resolution, - }; + return undefined; } return { - status: 'resolved' as const, - scheduleMode: 'cron' as const, - cronExpression: resolution.cronExpression, - resolution, + ...(input.targetProvider ? { provider: input.targetProvider } : {}), + ...(input.targetChannelId !== undefined + ? { channelId: input.targetChannelId } + : {}), + ...(input.targetServiceUrl !== undefined + ? { serviceUrl: input.targetServiceUrl } + : {}), }; } @@ -302,34 +170,21 @@ customAutomationsRouter.post('/', async (c) => { const parsed = writeSchema.safeParse(await c.req.json()); if (!parsed.success) return c.json({ error: parsed.error.message }, 400); try { - const schedule = await resolveWriteSchedule( - parsed.data.schedule, - adminId(c), - ); - if (schedule.status === 'ambiguous') return c.json(schedule, 409); - - if (parsed.data.targetProvider) { - const connected = await listConnectedCommunicationProviders(); - if (!connected.includes(parsed.data.targetProvider)) { - return c.json( - { error: `${parsed.data.targetProvider} is not connected.` }, - 400, - ); - } - } - - const automation = await createCustomAutomation({ + const result = await createCustomAutomationWrite({ name: parsed.data.name, prompt: parsed.data.prompt, enabled: parsed.data.enabled, - scheduleMode: schedule.scheduleMode, - cronExpression: schedule.cronExpression, model: parsed.data.model ?? null, environmentId: parsed.data.environmentId, - target: buildTarget(parsed.data), + schedule: { schedule: parsed.data.schedule, userId: adminId(c) }, + target: targetInput(parsed.data) ?? null, createdByUserId: adminId(c), }); - return c.json({ automation, resolution: schedule.resolution }, 201); + if (result.status === 'ambiguous') return c.json(result, 409); + return c.json( + { automation: result.automation, resolution: result.resolution }, + 201, + ); } catch (error) { const known = knownErrorResponse(c, error); if (known) return known; @@ -340,69 +195,24 @@ customAutomationsRouter.post('/', async (c) => { customAutomationsRouter.patch('/:id', async (c) => { const parsed = updateSchema.safeParse(await c.req.json()); if (!parsed.success) return c.json({ error: parsed.error.message }, 400); - const existing = await getCustomAutomationById(c.req.param('id')); - if (!existing) { - return c.json({ error: 'Custom automation was not found.' }, 404); - } try { - const schedule = parsed.data.schedule - ? await resolveWriteSchedule(parsed.data.schedule, adminId(c)) - : { - status: 'resolved' as const, - scheduleMode: existing.scheduleMode as CustomAutomationScheduleMode, - cronExpression: existing.cronExpression, - resolution: null, - }; - if (schedule.status === 'ambiguous') return c.json(schedule, 409); - if (parsed.data.targetProvider) { - const connected = await listConnectedCommunicationProviders(); - if (!connected.includes(parsed.data.targetProvider)) { - return c.json( - { error: `${parsed.data.targetProvider} is not connected.` }, - 400, - ); - } - } - const existingTarget = existing.target; - const clearTarget = parsed.data.targetProvider === null; - const targetProvider = - parsed.data.targetProvider ?? - (existingTarget.provider === 'slack' || - existingTarget.provider === 'discord' || - existingTarget.provider === 'teams' || - existingTarget.provider === 'telegram' - ? existingTarget.provider - : undefined); - const targetChannelId = - parsed.data.targetChannelId ?? existingTarget.externalRef ?? undefined; - const existingServiceUrl = - typeof existingTarget.metadata?.serviceUrl === 'string' - ? existingTarget.metadata.serviceUrl - : undefined; - const automation = await updateCustomAutomation(c.req.param('id'), { - name: parsed.data.name ?? existing.name, - prompt: parsed.data.prompt ?? existing.prompt, - enabled: parsed.data.enabled ?? existing.enabled, - scheduleMode: schedule.scheduleMode, - cronExpression: schedule.cronExpression, - // Explicit null clears the override; omitted keeps the existing value. - model: - parsed.data.model === null - ? null - : (parsed.data.model ?? existing.model), - environmentId: parsed.data.environmentId ?? existing.environmentId ?? '', - target: clearTarget - ? {} - : targetProvider && targetChannelId - ? buildTarget({ - targetProvider, - targetChannelId, - targetServiceUrl: - parsed.data.targetServiceUrl ?? existingServiceUrl, - }) - : existingTarget, + const result = await updateCustomAutomationWrite(c.req.param('id'), { + name: parsed.data.name, + prompt: parsed.data.prompt, + enabled: parsed.data.enabled, + model: parsed.data.model, + environmentId: parsed.data.environmentId, + schedule: + parsed.data.schedule !== undefined + ? { schedule: parsed.data.schedule, userId: adminId(c) } + : undefined, + target: targetInput(parsed.data), + }); + if (result.status === 'ambiguous') return c.json(result, 409); + return c.json({ + automation: result.automation, + resolution: result.resolution, }); - return c.json({ automation, resolution: schedule.resolution }); } catch (error) { const known = knownErrorResponse(c, error); if (known) return known; diff --git a/apps/web/src/trpc/commands/automations/custom-automations.test.ts b/apps/web/src/trpc/commands/automations/custom-automations.test.ts new file mode 100644 index 000000000..d2edd599a --- /dev/null +++ b/apps/web/src/trpc/commands/automations/custom-automations.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + createCustomAutomationWrite, + updateCustomAutomationWrite, + assertAdmin, +} = vi.hoisted(() => ({ + createCustomAutomationWrite: vi.fn(), + updateCustomAutomationWrite: vi.fn(), + assertAdmin: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + deleteCustomAutomation: vi.fn(), + getCustomAutomationById: vi.fn(), + listCustomAutomations: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', () => ({ + createCustomAutomationWrite, + resolveCustomAutomationSchedule: vi.fn(), + runCustomAutomationNow: vi.fn(), + updateCustomAutomationWrite, +})); + +vi.mock('./feature-gates', () => ({ assertAdmin })); + +import { + createCustomAutomationCommand, + updateCustomAutomationCommand, +} from './custom-automations'; + +const automation = { + id: 'automation-1', + name: 'Daily scan', + prompt: 'Scan the repository.', + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + model: null, + environmentId: '00000000-0000-0000-0000-000000000001', + target: {}, + lastRunAt: null, + lastSucceededAt: null, + lastFailedAt: null, + lastError: null, + lastLaunchedTaskId: null, + createdByUserId: 'user-1', + launchClaimedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +const input = { + name: automation.name, + prompt: automation.prompt, + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + model: null, + environmentId: automation.environmentId, + targetProvider: 'slack' as const, + targetChannelId: 'C123', +}; + +describe('custom automation commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + createCustomAutomationWrite.mockResolvedValue({ + status: 'saved', + automation, + resolution: null, + }); + updateCustomAutomationWrite.mockResolvedValue({ + status: 'saved', + automation, + resolution: null, + }); + }); + + it('adapts web create input to the owning write service', async () => { + await createCustomAutomationCommand({ userId: 'user-1' } as never, input); + + expect(createCustomAutomationWrite).toHaveBeenCalledWith({ + name: automation.name, + prompt: automation.prompt, + enabled: true, + model: null, + environmentId: automation.environmentId, + schedule: { scheduleMode: 'daily', cronExpression: null }, + target: { provider: 'slack', channelId: 'C123' }, + createdByUserId: 'user-1', + }); + }); + + it('adapts a destination-free web update as an explicit clear', async () => { + await updateCustomAutomationCommand({ userId: 'user-1' } as never, { + ...input, + id: automation.id, + targetProvider: undefined, + }); + + expect(updateCustomAutomationWrite).toHaveBeenCalledWith( + automation.id, + expect.objectContaining({ target: null }), + ); + }); + + it('preserves typed write errors from the owning service', async () => { + const error = Object.assign( + new Error('Model must use provider/model format.'), + { code: 'invalid_input' }, + ); + createCustomAutomationWrite.mockRejectedValue(error); + + await expect( + createCustomAutomationCommand({ userId: 'user-1' } as never, { + ...input, + model: 'no-provider-prefix', + }), + ).rejects.toBe(error); + }); +}); diff --git a/apps/web/src/trpc/commands/automations/custom-automations.ts b/apps/web/src/trpc/commands/automations/custom-automations.ts index 64e19da8e..d6968386f 100644 --- a/apps/web/src/trpc/commands/automations/custom-automations.ts +++ b/apps/web/src/trpc/commands/automations/custom-automations.ts @@ -1,24 +1,18 @@ import { - createCustomAutomation, deleteCustomAutomation, getCustomAutomationById, listCustomAutomations, - updateCustomAutomation, type CustomAutomation, } from '@roomote/db/server'; import { - listConnectedCommunicationProviders, + createCustomAutomationWrite, resolveCustomAutomationSchedule, - resolveDeploymentTimeZone, runCustomAutomationNow, - validateCronExpression, + updateCustomAutomationWrite, type AutomationRunNowResult, } from '@roomote/sdk/server'; import { isScheduleOnlyBackgroundAutomationFrequency, - type AutomationTarget, - type BackgroundAutomationProvider, - type BackgroundAutomationTargetKind, type CustomAutomationScheduleMode, type OptionalAutomationTarget, } from '@roomote/types'; @@ -95,65 +89,6 @@ function toListItem( }; } -function buildTarget( - input: CustomAutomationWriteInput, -): OptionalAutomationTarget { - if (!input.targetProvider) { - return {}; - } - - const externalRef = input.targetChannelId?.trim() ?? ''; - if (!externalRef) { - throw new Error( - 'Choose a destination channel for the selected provider, or set the destination to None.', - ); - } - - const targetKindByProvider: Record< - NonNullable, - BackgroundAutomationTargetKind - > = { - slack: 'slack_channel', - discord: 'discord_channel', - teams: 'teams_channel', - telegram: 'telegram_chat', - }; - - const provider = input.targetProvider as BackgroundAutomationProvider; - const target: AutomationTarget = { - provider, - targetKind: targetKindByProvider[input.targetProvider], - externalRef, - }; - - const serviceUrl = input.targetServiceUrl?.trim(); - if (serviceUrl) { - target.metadata = { serviceUrl }; - } - - return target; -} - -function assertScheduleMode( - value: string, -): asserts value is CustomAutomationScheduleMode { - if (!isScheduleOnlyBackgroundAutomationFrequency(value)) { - if (value === 'cron') return; - throw new Error(`Invalid schedule mode: ${value}`); - } -} - -async function assertDestinationConnected( - provider: NonNullable, -): Promise { - const connected = await listConnectedCommunicationProviders(); - if (!connected.includes(provider)) { - throw new Error( - `Connect ${provider} before saving a ${provider} report destination.`, - ); - } -} - export async function listCustomAutomationsCommand( auth: UserAuthSuccess, ): Promise { @@ -167,31 +102,29 @@ export async function createCustomAutomationCommand( input: CustomAutomationWriteInput, ): Promise { assertAdmin(auth); - assertScheduleMode(input.scheduleMode); - const cronExpression = - input.scheduleMode === 'cron' - ? validateCronExpression( - input.cronExpression ?? '', - (await resolveDeploymentTimeZone()).timeZone, - ) - : null; - if (input.targetProvider) { - await assertDestinationConnected(input.targetProvider); - } - - const created = await createCustomAutomation({ + const result = await createCustomAutomationWrite({ name: input.name, prompt: input.prompt, enabled: input.enabled, - scheduleMode: input.scheduleMode, - cronExpression, model: input.model ?? null, environmentId: input.environmentId, - target: buildTarget(input), + schedule: { + scheduleMode: input.scheduleMode, + cronExpression: input.cronExpression, + }, + target: input.targetProvider + ? { + provider: input.targetProvider, + channelId: input.targetChannelId, + serviceUrl: input.targetServiceUrl, + } + : null, createdByUserId: auth.userId, }); - - return toListItem(created); + if (result.status === 'ambiguous') { + throw new Error(result.clarification ?? 'Schedule needs clarification.'); + } + return toListItem(result.automation); } export async function updateCustomAutomationCommand( @@ -199,30 +132,28 @@ export async function updateCustomAutomationCommand( input: CustomAutomationWriteInput & { id: string }, ): Promise { assertAdmin(auth); - assertScheduleMode(input.scheduleMode); - const cronExpression = - input.scheduleMode === 'cron' - ? validateCronExpression( - input.cronExpression ?? '', - (await resolveDeploymentTimeZone()).timeZone, - ) - : null; - if (input.targetProvider) { - await assertDestinationConnected(input.targetProvider); - } - - const updated = await updateCustomAutomation(input.id, { + const result = await updateCustomAutomationWrite(input.id, { name: input.name, prompt: input.prompt, enabled: input.enabled, - scheduleMode: input.scheduleMode, - cronExpression, model: input.model ?? null, environmentId: input.environmentId, - target: buildTarget(input), + schedule: { + scheduleMode: input.scheduleMode, + cronExpression: input.cronExpression, + }, + target: input.targetProvider + ? { + provider: input.targetProvider, + channelId: input.targetChannelId, + serviceUrl: input.targetServiceUrl, + } + : null, }); - - return toListItem(updated); + if (result.status === 'ambiguous') { + throw new Error(result.clarification ?? 'Schedule needs clarification.'); + } + return toListItem(result.automation); } export async function deleteCustomAutomationCommand( diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index ed79a5ce9..a0d09b4a7 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -660,8 +660,8 @@ const automationsRouter = createRouter({ createCustomAutomation: protectedProcedure .input( z.object({ - name: z.string().trim().min(1).max(100), - prompt: z.string().trim().min(1).max(8_000), + name: z.string(), + prompt: z.string(), enabled: z.boolean(), scheduleMode: z.enum([ 'off', @@ -671,27 +671,14 @@ const automationsRouter = createRouter({ 'weekly', 'cron', ]), - cronExpression: z.string().trim().max(200).nullable().optional(), - model: z - .string() - .trim() - .min(1) - .max(200) - .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.') - .nullable() - .optional(), + cronExpression: z.string().nullable().optional(), + model: z.string().nullable().optional(), environmentId: z.string().uuid(), targetProvider: z .enum(['slack', 'discord', 'teams', 'telegram']) .optional(), - targetChannelId: z.string().trim().min(1).max(160).optional(), - targetServiceUrl: z - .string() - .trim() - .min(1) - .max(500) - .nullable() - .optional(), + targetChannelId: z.string().optional(), + targetServiceUrl: z.string().nullable().optional(), }), ) .mutation(({ ctx: { auth }, input }) => @@ -702,8 +689,8 @@ const automationsRouter = createRouter({ .input( z.object({ id: z.string().uuid(), - name: z.string().trim().min(1).max(100), - prompt: z.string().trim().min(1).max(8_000), + name: z.string(), + prompt: z.string(), enabled: z.boolean(), scheduleMode: z.enum([ 'off', @@ -713,27 +700,14 @@ const automationsRouter = createRouter({ 'weekly', 'cron', ]), - cronExpression: z.string().trim().max(200).nullable().optional(), - model: z - .string() - .trim() - .min(1) - .max(200) - .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.') - .nullable() - .optional(), + cronExpression: z.string().nullable().optional(), + model: z.string().nullable().optional(), environmentId: z.string().uuid(), targetProvider: z .enum(['slack', 'discord', 'teams', 'telegram']) .optional(), - targetChannelId: z.string().trim().min(1).max(160).optional(), - targetServiceUrl: z - .string() - .trim() - .min(1) - .max(500) - .nullable() - .optional(), + targetChannelId: z.string().optional(), + targetServiceUrl: z.string().nullable().optional(), }), ) .mutation(({ ctx: { auth }, input }) => diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/custom-automations.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/custom-automations.test.ts index b451a758c..a95927078 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/custom-automations.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/custom-automations.test.ts @@ -75,17 +75,7 @@ describe('handleManageCustomAutomations', () => { }); }); - it('still requires all create fields and defaults enabled to true', async () => { - const missing = await handleManageCustomAutomations( - { action: 'create', name: 'Incomplete' }, - config, - ); - expect(JSON.parse(missing.content[0]?.text ?? '{}')).toMatchObject({ - success: false, - error: 'prompt is required', - }); - expect(fetchMock).not.toHaveBeenCalled(); - + it('leaves create validation to the API and defaults enabled to true', async () => { await handleManageCustomAutomations( { action: 'create', @@ -99,4 +89,32 @@ describe('handleManageCustomAutomations', () => { const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(JSON.parse(request.body as string)).toMatchObject({ enabled: true }); }); + + it('returns the API stable validation error to the MCP caller', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + error: 'Model must use provider/model format.', + code: 'invalid_input', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await handleManageCustomAutomations( + { + action: 'create', + name: 'Daily scan', + prompt: 'Scan the repository.', + schedule: 'daily', + model: 'no-provider-prefix', + environmentId: 'environment-1', + }, + config, + ); + + expect(result.content[0]?.text).toContain( + 'Model must use provider/model format.', + ); + }); }); diff --git a/apps/worker/src/mcp/roomote-mcp-server/custom-automations.ts b/apps/worker/src/mcp/roomote-mcp-server/custom-automations.ts index aaf527430..d2498b70e 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/custom-automations.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/custom-automations.ts @@ -40,11 +40,6 @@ export async function handleManageCustomAutomations( method = 'POST'; body = { schedule: params.schedule }; } else if (params.action === 'create' || params.action === 'update') { - const required = ['name', 'prompt', 'schedule', 'environmentId'] as const; - if (params.action === 'create') { - const missing = required.find((key) => !params[key]); - if (missing) return errorResult(`${missing} is required`); - } if (params.action === 'update' && !params.automationId) { return errorResult('automationId is required for update'); } diff --git a/packages/db/src/lib/__tests__/custom-automations.test.ts b/packages/db/src/lib/__tests__/custom-automations.test.ts index 0f5e2093b..ed30746b1 100644 --- a/packages/db/src/lib/__tests__/custom-automations.test.ts +++ b/packages/db/src/lib/__tests__/custom-automations.test.ts @@ -65,9 +65,9 @@ describe('custom automations helpers', () => { }, }); - expect(updated.enabled).toBe(false); - expect(updated.scheduleMode).toBe('weekly'); - expect(updated.prompt).toContain('updated'); + expect(updated?.enabled).toBe(false); + expect(updated?.scheduleMode).toBe('weekly'); + expect(updated?.prompt).toContain('updated'); await deleteCustomAutomation(created.id); expect(await getCustomAutomationById(created.id)).toBeNull(); @@ -99,7 +99,7 @@ describe('custom automations helpers', () => { await deleteCustomAutomation(created.id); }); - it('persists canonical cron schedules and rejects invalid mode combinations', async () => { + it('persists canonical cron schedules', async () => { const [environment] = await db .insert(environments) .values({ @@ -120,22 +120,10 @@ describe('custom automations helpers', () => { expect(created.scheduleMode).toBe('cron'); expect(created.cronExpression).toBe('0 9 * * 1-5'); - await expect( - updateCustomAutomation(created.id, { - name: created.name, - prompt: created.prompt, - enabled: true, - scheduleMode: 'daily', - cronExpression: '0 9 * * *', - environmentId: environment!.id, - target: {}, - }), - ).rejects.toThrow('only valid for a cron schedule'); - await deleteCustomAutomation(created.id); }); - it('persists a model override and rejects malformed model ids', async () => { + it('persists and clears a model override', async () => { const [environment] = await db .insert(environments) .values({ @@ -164,19 +152,7 @@ describe('custom automations helpers', () => { environmentId: environment!.id, target: {}, }); - expect(cleared.model).toBeNull(); - - await expect( - updateCustomAutomation(created.id, { - name: created.name, - prompt: created.prompt, - enabled: true, - scheduleMode: 'daily', - model: 'no-provider-prefix', - environmentId: environment!.id, - target: {}, - }), - ).rejects.toThrow('provider/model format'); + expect(cleared?.model).toBeNull(); await deleteCustomAutomation(created.id); }); @@ -242,17 +218,4 @@ describe('custom automations helpers', () => { await releaseCustomAutomationLaunchClaim(created.id, nextClaim!); await deleteCustomAutomation(created.id); }); - - it('rejects a partially specified report destination', async () => { - await expect( - createCustomAutomation({ - name: `Partial target ${Date.now()}`, - prompt: 'Scan for flaky tests.', - enabled: true, - scheduleMode: 'daily', - environmentId: 'ignored-by-early-validation', - target: { provider: 'slack' } as never, - }), - ).rejects.toThrow('Report destination'); - }); }); diff --git a/packages/db/src/lib/custom-automations.ts b/packages/db/src/lib/custom-automations.ts index 1621ab7d4..89f83aab9 100644 --- a/packages/db/src/lib/custom-automations.ts +++ b/packages/db/src/lib/custom-automations.ts @@ -1,20 +1,17 @@ import { and, asc, count, eq, isNull, lt, or } from 'drizzle-orm'; import { - isConfiguredAutomationTarget, isScheduleOnlyBackgroundAutomationFrequency, type CustomAutomationScheduleMode, type OptionalAutomationTarget, type ScheduleOnlyBackgroundAutomationFrequency, CUSTOM_AUTOMATION_NAME_MAX_LENGTH, CUSTOM_AUTOMATION_PROMPT_MAX_LENGTH, - CUSTOM_AUTOMATION_CRON_MAX_LENGTH, - CUSTOM_AUTOMATION_MODEL_MAX_LENGTH, MAX_CUSTOM_AUTOMATIONS, } from '@roomote/types'; import { type DatabaseOrTransaction, db } from '../db'; -import { customAutomations, environments, tasks } from '../schema'; +import { customAutomations, tasks } from '../schema'; import type { CustomAutomation } from '../types'; import type { AutomationRunOutcomeStatus } from './automations'; @@ -41,92 +38,6 @@ export type CustomAutomationWriteInput = { createdByUserId?: string | null; }; -function normalizeName(name: string): string { - return name.trim().replace(/\s+/g, ' '); -} - -function assertValidWriteInput(input: CustomAutomationWriteInput): { - name: string; - prompt: string; - cronExpression: string | null; - model: string | null; -} { - const name = normalizeName(input.name); - const prompt = input.prompt.trim(); - - if (!name) { - throw new Error('Name is required.'); - } - - if (name.length > CUSTOM_AUTOMATION_NAME_MAX_LENGTH) { - throw new Error( - `Name must be at most ${CUSTOM_AUTOMATION_NAME_MAX_LENGTH} characters.`, - ); - } - - if (!prompt) { - throw new Error('Prompt is required.'); - } - - if (prompt.length > CUSTOM_AUTOMATION_PROMPT_MAX_LENGTH) { - throw new Error( - `Prompt must be at most ${CUSTOM_AUTOMATION_PROMPT_MAX_LENGTH} characters.`, - ); - } - - if ( - input.scheduleMode !== 'cron' && - !isScheduleOnlyBackgroundAutomationFrequency(input.scheduleMode) - ) { - throw new Error(`Invalid schedule mode: ${input.scheduleMode}`); - } - - const cronExpression = input.cronExpression?.trim() || null; - if (input.scheduleMode === 'cron' && !cronExpression) { - throw new Error('Cron expression is required for a cron schedule.'); - } - if (input.scheduleMode !== 'cron' && cronExpression) { - throw new Error('Cron expression is only valid for a cron schedule.'); - } - if ( - cronExpression && - cronExpression.length > CUSTOM_AUTOMATION_CRON_MAX_LENGTH - ) { - throw new Error( - `Cron expression must be at most ${CUSTOM_AUTOMATION_CRON_MAX_LENGTH} characters.`, - ); - } - - const model = input.model?.trim() || null; - if (model) { - if (model.length > CUSTOM_AUTOMATION_MODEL_MAX_LENGTH) { - throw new Error( - `Model must be at most ${CUSTOM_AUTOMATION_MODEL_MAX_LENGTH} characters.`, - ); - } - if (!/^[^/\s]+\/.+$/u.test(model)) { - throw new Error('Model must use provider/model format.'); - } - } - - if (!input.environmentId) { - throw new Error('Environment is required.'); - } - - const hasAnyTargetField = Boolean( - input.target?.provider || - input.target?.targetKind || - input.target?.externalRef, - ); - if (hasAnyTargetField && !isConfiguredAutomationTarget(input.target)) { - throw new Error( - 'Report destination must include a provider, target kind, and channel.', - ); - } - - return { name, prompt, cronExpression, model }; -} - export type CustomAutomationWithCreator = CustomAutomation & { createdByUser: { id: string; name: string; email: string } | null; }; @@ -174,33 +85,15 @@ export async function createCustomAutomation( input: CustomAutomationWriteInput, client: DatabaseOrTransaction = db, ): Promise { - const { name, prompt, cronExpression, model } = assertValidWriteInput(input); - - const existingCount = await countCustomAutomations(client); - if (existingCount >= MAX_CUSTOM_AUTOMATIONS) { - throw new Error( - `You can create at most ${MAX_CUSTOM_AUTOMATIONS} custom automations.`, - ); - } - - const environment = await client.query.environments.findFirst({ - columns: { id: true }, - where: eq(environments.id, input.environmentId), - }); - - if (!environment) { - throw new Error('Selected environment was not found.'); - } - const [created] = await client .insert(customAutomations) .values({ - name, - prompt, + name: input.name, + prompt: input.prompt, enabled: input.enabled, scheduleMode: input.scheduleMode, - cronExpression, - model, + cronExpression: input.cronExpression ?? null, + model: input.model ?? null, environmentId: input.environmentId, target: input.target, createdByUserId: input.createdByUserId ?? null, @@ -218,32 +111,16 @@ export async function updateCustomAutomation( id: string, input: CustomAutomationWriteInput, client: DatabaseOrTransaction = db, -): Promise { - const { name, prompt, cronExpression, model } = assertValidWriteInput(input); - - const existing = await getCustomAutomationById(id, client); - if (!existing) { - throw new Error('Custom automation was not found.'); - } - - const environment = await client.query.environments.findFirst({ - columns: { id: true }, - where: eq(environments.id, input.environmentId), - }); - - if (!environment) { - throw new Error('Selected environment was not found.'); - } - +): Promise { const [updated] = await client .update(customAutomations) .set({ - name, - prompt, + name: input.name, + prompt: input.prompt, enabled: input.enabled, scheduleMode: input.scheduleMode, - cronExpression, - model, + cronExpression: input.cronExpression ?? null, + model: input.model ?? null, environmentId: input.environmentId, target: input.target, updatedAt: new Date(), @@ -251,11 +128,7 @@ export async function updateCustomAutomation( .where(eq(customAutomations.id, id)) .returning(); - if (!updated) { - throw new Error('Failed to update custom automation.'); - } - - return updated; + return updated ?? null; } export async function deleteCustomAutomation( diff --git a/packages/sdk/src/server/automations/__tests__/custom-automation-writes.test.ts b/packages/sdk/src/server/automations/__tests__/custom-automation-writes.test.ts new file mode 100644 index 000000000..31bec4a6a --- /dev/null +++ b/packages/sdk/src/server/automations/__tests__/custom-automation-writes.test.ts @@ -0,0 +1,261 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + countCustomAutomations, + createCustomAutomation, + environmentFindFirst, + getCustomAutomationById, + listConnectedCommunicationProviders, + resolveCustomAutomationSchedule, + resolveDeploymentTimeZone, + updateCustomAutomation, + validateCronExpression, +} = vi.hoisted(() => ({ + countCustomAutomations: vi.fn(), + createCustomAutomation: vi.fn(), + environmentFindFirst: vi.fn(), + getCustomAutomationById: vi.fn(), + listConnectedCommunicationProviders: vi.fn(), + resolveCustomAutomationSchedule: vi.fn(), + resolveDeploymentTimeZone: vi.fn(), + updateCustomAutomation: vi.fn(), + validateCronExpression: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + countCustomAutomations, + createCustomAutomation, + db: { query: { environments: { findFirst: environmentFindFirst } } }, + environments: { id: 'environments.id' }, + eq: vi.fn((...args: unknown[]) => args), + getCustomAutomationById, + updateCustomAutomation, +})); + +vi.mock('../custom-automation-schedule', () => ({ + resolveCustomAutomationSchedule, + resolveDeploymentTimeZone, + validateCronExpression, +})); + +vi.mock('../destination', () => ({ listConnectedCommunicationProviders })); + +import { + createCustomAutomationWrite, + updateCustomAutomationWrite, + type CreateCustomAutomationWriteInput, +} from '../custom-automation-writes'; +import { + CustomAutomationWriteError, + DUPLICATE_CUSTOM_AUTOMATION_NAME_MESSAGE, +} from '../custom-automation-errors'; + +const existing = { + id: 'automation-1', + name: 'Daily scan', + prompt: 'Scan the repository.', + enabled: false, + scheduleMode: 'daily', + cronExpression: null, + model: 'anthropic/claude-sonnet-5', + environmentId: 'environment-1', + target: { + provider: 'slack', + targetKind: 'slack_channel', + externalRef: 'C123', + }, + createdByUserId: 'user-1', + lastRunAt: null, + lastSucceededAt: null, + lastFailedAt: null, + lastError: null, + lastLaunchedTaskId: null, + launchClaimedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +function createInput( + schedule: CreateCustomAutomationWriteInput['schedule'], +): CreateCustomAutomationWriteInput { + return { + name: ' Daily scan ', + prompt: ' Scan the repository. ', + enabled: true, + schedule, + model: ' anthropic/claude-sonnet-5 ', + environmentId: 'environment-1', + target: { + provider: 'slack', + channelId: ' C123 ', + }, + createdByUserId: 'user-1', + }; +} + +describe('custom automation writes', () => { + beforeEach(() => { + vi.clearAllMocks(); + countCustomAutomations.mockResolvedValue(0); + environmentFindFirst.mockResolvedValue({ id: 'environment-1' }); + listConnectedCommunicationProviders.mockResolvedValue(['slack']); + resolveDeploymentTimeZone.mockResolvedValue({ timeZone: 'UTC' }); + validateCronExpression.mockImplementation((value: string) => + value.trim().replace(/\s+/g, ' '), + ); + createCustomAutomation.mockImplementation(async (input) => ({ + ...existing, + ...input, + id: 'created-1', + })); + getCustomAutomationById.mockResolvedValue(existing); + updateCustomAutomation.mockImplementation(async (_id, input) => ({ + ...existing, + ...input, + })); + }); + + it.each([ + ['REST schedule text', { schedule: 'daily', userId: 'user-1' }], + ['web resolved schedule', { scheduleMode: 'daily' }], + ])('normalizes equivalent create input from %s', async (_label, schedule) => { + const result = await createCustomAutomationWrite(createInput(schedule)); + + expect(result.status).toBe('saved'); + expect(createCustomAutomation).toHaveBeenCalledWith({ + name: 'Daily scan', + prompt: 'Scan the repository.', + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + model: 'anthropic/claude-sonnet-5', + environmentId: 'environment-1', + target: { + provider: 'slack', + targetKind: 'slack_channel', + externalRef: 'C123', + }, + createdByUserId: 'user-1', + }); + }); + + it('owns partial update merging and explicit target clearing', async () => { + await updateCustomAutomationWrite(existing.id, { + prompt: ' Updated prompt ', + target: null, + }); + + expect(updateCustomAutomation).toHaveBeenCalledWith(existing.id, { + name: existing.name, + prompt: 'Updated prompt', + enabled: false, + scheduleMode: 'daily', + cronExpression: null, + model: existing.model, + environmentId: existing.environmentId, + target: {}, + }); + }); + + it('validates cron schedules and supports clearing a model override', async () => { + await updateCustomAutomationWrite(existing.id, { + schedule: { + scheduleMode: 'cron', + cronExpression: ' 0 9 * * 1-5 ', + }, + model: null, + }); + + expect(validateCronExpression).toHaveBeenCalledWith( + ' 0 9 * * 1-5 ', + 'UTC', + ); + expect(updateCustomAutomation).toHaveBeenCalledWith( + existing.id, + expect.objectContaining({ + scheduleMode: 'cron', + cronExpression: '0 9 * * 1-5', + model: null, + }), + ); + }); + + it('returns an ambiguous natural-language schedule without writing', async () => { + resolveCustomAutomationSchedule.mockResolvedValue({ + status: 'ambiguous', + cronExpression: null, + summary: 'Needs a time', + clarification: 'What time should this run?', + timeZone: 'UTC', + nextRunAt: null, + }); + + const result = await createCustomAutomationWrite( + createInput({ schedule: 'every weekday' }), + ); + + expect(result).toMatchObject({ + status: 'ambiguous', + clarification: 'What time should this run?', + }); + expect(createCustomAutomation).not.toHaveBeenCalled(); + }); + + it('maps only the name uniqueness constraint to a stable domain error', async () => { + createCustomAutomation.mockRejectedValue( + Object.assign(new Error('duplicate key'), { + code: '23505', + constraint: 'custom_automations_name_unique_idx', + }), + ); + + await expect( + createCustomAutomationWrite(createInput({ scheduleMode: 'daily' })), + ).rejects.toMatchObject({ + code: 'duplicate_name', + message: DUPLICATE_CUSTOM_AUTOMATION_NAME_MESSAGE, + }); + }); + + it.each([ + ['REST schedule text', { schedule: 'daily', userId: 'user-1' }], + ['web resolved schedule', { scheduleMode: 'daily' }], + ])( + 'rejects the same invalid model contract from %s', + async (_label, schedule) => { + await expect( + createCustomAutomationWrite({ + ...createInput(schedule), + model: 'no-provider-prefix', + }), + ).rejects.toMatchObject({ + code: 'invalid_input', + message: 'Model must use provider/model format.', + }); + }, + ); + + it.each([ + [ + 'missing cron', + { schedule: { scheduleMode: 'cron' } }, + 'Cron expression is required for a cron schedule.', + ], + [ + 'missing destination channel', + { target: { provider: 'slack' } }, + 'Choose a destination channel', + ], + ])( + 'rejects %s with a typed validation error', + async (_label, patch, message) => { + const promise = createCustomAutomationWrite({ + ...createInput({ scheduleMode: 'daily' }), + ...(patch as Partial), + }); + + await expect(promise).rejects.toBeInstanceOf(CustomAutomationWriteError); + await expect(promise).rejects.toThrow(message); + }, + ); +}); diff --git a/packages/sdk/src/server/automations/custom-automation-errors.ts b/packages/sdk/src/server/automations/custom-automation-errors.ts new file mode 100644 index 000000000..75668d015 --- /dev/null +++ b/packages/sdk/src/server/automations/custom-automation-errors.ts @@ -0,0 +1,27 @@ +export const DUPLICATE_CUSTOM_AUTOMATION_NAME_MESSAGE = + 'A custom automation with this name already exists.'; + +export type CustomAutomationWriteErrorCode = + | 'duplicate_name' + | 'environment_not_found' + | 'invalid_input' + | 'limit_reached' + | 'not_found'; + +export class CustomAutomationWriteError extends Error { + constructor( + readonly code: CustomAutomationWriteErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'CustomAutomationWriteError'; + } +} + +export function customAutomationValidationError( + message: string, + options?: ErrorOptions, +): CustomAutomationWriteError { + return new CustomAutomationWriteError('invalid_input', message, options); +} diff --git a/packages/sdk/src/server/automations/custom-automation-schedule.ts b/packages/sdk/src/server/automations/custom-automation-schedule.ts index ff23c0447..c7a14bdc6 100644 --- a/packages/sdk/src/server/automations/custom-automation-schedule.ts +++ b/packages/sdk/src/server/automations/custom-automation-schedule.ts @@ -17,6 +17,10 @@ import { DAILY_WEEKLY_SCHEDULE_HOUR_LOCAL, resolveSlackWorkspaceTimezone, } from './scheduling-utils'; +import { + customAutomationValidationError, + CustomAutomationWriteError, +} from './custom-automation-errors'; const DEFAULT_DEPLOYMENT_SETTINGS_ID = 'default'; const LOG_PREFIX = '[custom-automation-schedule]'; @@ -32,7 +36,7 @@ export type ResolvedDeploymentTimeZone = { export function normalizeTimeZone(value: string): string { const trimmed = value.trim(); if (!trimmed) { - throw new Error('Timezone is required.'); + throw customAutomationValidationError('Timezone is required.'); } try { @@ -40,7 +44,7 @@ export function normalizeTimeZone(value: string): string { timeZone: trimmed, }).resolvedOptions().timeZone; } catch { - throw new Error('Choose a valid IANA timezone.'); + throw customAutomationValidationError('Choose a valid IANA timezone.'); } } @@ -86,15 +90,25 @@ export function validateCronExpression( ): string { const expression = value.trim().replace(/\s+/g, ' '); if (!expression || expression.length > CUSTOM_AUTOMATION_CRON_MAX_LENGTH) { - throw new Error( + throw customAutomationValidationError( `Cron expression must be between 1 and ${CUSTOM_AUTOMATION_CRON_MAX_LENGTH} characters.`, ); } if (expression.split(' ').length !== 5) { - throw new Error('Use a standard five-field cron expression.'); + throw customAutomationValidationError( + 'Use a standard five-field cron expression.', + ); } - CronExpressionParser.parse(expression, { tz: normalizeTimeZone(timeZone) }); + try { + CronExpressionParser.parse(expression, { tz: normalizeTimeZone(timeZone) }); + } catch (error) { + if (error instanceof CustomAutomationWriteError) throw error; + throw customAutomationValidationError( + 'Use a valid standard five-field cron expression.', + { cause: error }, + ); + } return expression; } diff --git a/packages/sdk/src/server/automations/custom-automation-writes.ts b/packages/sdk/src/server/automations/custom-automation-writes.ts new file mode 100644 index 000000000..07f6e47d9 --- /dev/null +++ b/packages/sdk/src/server/automations/custom-automation-writes.ts @@ -0,0 +1,435 @@ +import { + countCustomAutomations, + createCustomAutomation, + db, + environments, + eq, + getCustomAutomationById, + updateCustomAutomation, + type CustomAutomation, +} from '@roomote/db/server'; +import { + CUSTOM_AUTOMATION_MODEL_MAX_LENGTH, + CUSTOM_AUTOMATION_NAME_MAX_LENGTH, + CUSTOM_AUTOMATION_PROMPT_MAX_LENGTH, + MAX_CUSTOM_AUTOMATIONS, + isConfiguredAutomationTarget, + isScheduleOnlyBackgroundAutomationFrequency, + resolveEvalHarnessSelection, + type AutomationTarget, + type BackgroundAutomationTargetKind, + type CustomAutomationScheduleMode, + type OptionalAutomationTarget, +} from '@roomote/types'; + +import { + customAutomationValidationError, + CustomAutomationWriteError, + DUPLICATE_CUSTOM_AUTOMATION_NAME_MESSAGE, +} from './custom-automation-errors'; +import { + resolveCustomAutomationSchedule, + resolveDeploymentTimeZone, + validateCronExpression, + type CustomAutomationScheduleResolution, +} from './custom-automation-schedule'; +import { listConnectedCommunicationProviders } from './destination'; + +export type CustomAutomationResolvedScheduleInput = { + scheduleMode: string; + cronExpression?: string | null; +}; + +export type CustomAutomationScheduleTextInput = { + schedule: string; + userId?: string | null; +}; + +export type CustomAutomationWriteScheduleInput = + | CustomAutomationResolvedScheduleInput + | CustomAutomationScheduleTextInput; + +export type CustomAutomationTargetWriteInput = { + provider?: 'slack' | 'discord' | 'teams' | 'telegram'; + channelId?: string; + serviceUrl?: string | null; +}; + +type CustomAutomationWriteFields = { + name: string; + prompt: string; + enabled: boolean; + model?: string | null; + environmentId: string; + schedule: CustomAutomationWriteScheduleInput; + target?: CustomAutomationTargetWriteInput | null; +}; + +export type CreateCustomAutomationWriteInput = CustomAutomationWriteFields & { + createdByUserId?: string | null; +}; + +export type UpdateCustomAutomationWriteInput = Partial< + Omit +> & { + schedule?: CustomAutomationWriteScheduleInput; +}; + +export type CustomAutomationWriteResult = + | { + status: 'saved'; + automation: CustomAutomation; + resolution: CustomAutomationScheduleResolution | null; + } + | { + status: 'ambiguous'; + clarification: string | null; + resolution: CustomAutomationScheduleResolution; + }; + +const TARGET_KIND_BY_PROVIDER: Record< + CustomAutomationTargetWriteInput['provider'] & string, + BackgroundAutomationTargetKind +> = { + slack: 'slack_channel', + discord: 'discord_channel', + teams: 'teams_channel', + telegram: 'telegram_chat', +}; + +const UNIQUE_VIOLATION_CODE = '23505'; +const NAME_UNIQUE_INDEX = 'custom_automations_name_unique_idx'; +const SCHEDULE_TEXT_MAX_LENGTH = 500; +const TARGET_CHANNEL_MAX_LENGTH = 160; +const TARGET_SERVICE_URL_MAX_LENGTH = 500; + +function validationError(message: string): CustomAutomationWriteError { + return customAutomationValidationError(message); +} + +function normalizeName(value: string): string { + const name = value.trim().replace(/\s+/g, ' '); + if (!name) throw validationError('Name is required.'); + if (name.length > CUSTOM_AUTOMATION_NAME_MAX_LENGTH) { + throw validationError( + `Name must be at most ${CUSTOM_AUTOMATION_NAME_MAX_LENGTH} characters.`, + ); + } + return name; +} + +function normalizePrompt(value: string): string { + const prompt = value.trim(); + if (!prompt) throw validationError('Prompt is required.'); + if (prompt.length > CUSTOM_AUTOMATION_PROMPT_MAX_LENGTH) { + throw validationError( + `Prompt must be at most ${CUSTOM_AUTOMATION_PROMPT_MAX_LENGTH} characters.`, + ); + } + return prompt; +} + +function normalizeModel(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const model = value.trim(); + if (!model) throw validationError('Model must use provider/model format.'); + if (model.length > CUSTOM_AUTOMATION_MODEL_MAX_LENGTH) { + throw validationError( + `Model must be at most ${CUSTOM_AUTOMATION_MODEL_MAX_LENGTH} characters.`, + ); + } + if (!resolveEvalHarnessSelection({ model }).ok) { + throw validationError('Model must use provider/model format.'); + } + return model; +} + +async function resolveWriteSchedule( + input: CustomAutomationWriteScheduleInput, +): Promise< + | { + status: 'resolved'; + scheduleMode: CustomAutomationScheduleMode; + cronExpression: string | null; + resolution: CustomAutomationScheduleResolution | null; + } + | { + status: 'ambiguous'; + clarification: string | null; + resolution: CustomAutomationScheduleResolution; + } +> { + if ('schedule' in input) { + const schedule = input.schedule.trim(); + if (!schedule) throw validationError('Schedule is required.'); + if (schedule.length > SCHEDULE_TEXT_MAX_LENGTH) { + throw validationError( + `Schedule must be at most ${SCHEDULE_TEXT_MAX_LENGTH} characters.`, + ); + } + if (isScheduleOnlyBackgroundAutomationFrequency(schedule)) { + return { + status: 'resolved', + scheduleMode: schedule, + cronExpression: null, + resolution: null, + }; + } + + const resolution = await resolveCustomAutomationSchedule({ + schedule, + userId: input.userId, + }); + if (resolution.status === 'ambiguous' || !resolution.cronExpression) { + return { + status: 'ambiguous', + clarification: resolution.clarification, + resolution, + }; + } + return { + status: 'resolved', + scheduleMode: 'cron', + cronExpression: resolution.cronExpression, + resolution, + }; + } + + if ( + input.scheduleMode !== 'cron' && + !isScheduleOnlyBackgroundAutomationFrequency(input.scheduleMode) + ) { + throw validationError(`Invalid schedule mode: ${input.scheduleMode}`); + } + if (input.scheduleMode !== 'cron') { + if (input.cronExpression?.trim()) { + throw validationError( + 'Cron expression is only valid for a cron schedule.', + ); + } + return { + status: 'resolved', + scheduleMode: input.scheduleMode, + cronExpression: null, + resolution: null, + }; + } + + if (!input.cronExpression?.trim()) { + throw validationError('Cron expression is required for a cron schedule.'); + } + const { timeZone } = await resolveDeploymentTimeZone(); + return { + status: 'resolved', + scheduleMode: 'cron', + cronExpression: validateCronExpression(input.cronExpression, timeZone), + resolution: null, + }; +} + +async function buildTarget( + input: CustomAutomationTargetWriteInput | null | undefined, + existing: OptionalAutomationTarget, +): Promise { + if (input === undefined) return existing; + if (input === null) return {}; + + const existingTarget = isConfiguredAutomationTarget(existing) + ? existing + : null; + const existingProvider = existingTarget?.provider; + const provider = + input.provider ?? + (existingProvider && existingProvider in TARGET_KIND_BY_PROVIDER + ? (existingProvider as keyof typeof TARGET_KIND_BY_PROVIDER) + : undefined); + const channelId = + input.channelId === undefined + ? existingTarget?.externalRef + : input.channelId.trim(); + if (!provider || !(provider in TARGET_KIND_BY_PROVIDER)) { + throw validationError('Choose a report destination provider.'); + } + if (!channelId) { + throw validationError( + 'Choose a destination channel for the selected provider, or set the destination to None.', + ); + } + if (channelId.length > TARGET_CHANNEL_MAX_LENGTH) { + throw validationError( + `Destination channel must be at most ${TARGET_CHANNEL_MAX_LENGTH} characters.`, + ); + } + + const connected = await listConnectedCommunicationProviders(); + if (!connected.includes(provider)) { + throw validationError( + `Connect ${provider} before saving a ${provider} report destination.`, + ); + } + + const existingServiceUrl = + typeof existingTarget?.metadata?.serviceUrl === 'string' + ? existingTarget.metadata.serviceUrl + : undefined; + const serviceUrl = + input.serviceUrl === null + ? undefined + : input.serviceUrl === undefined + ? existingServiceUrl + : input.serviceUrl.trim(); + if ( + input.serviceUrl !== undefined && + input.serviceUrl !== null && + !serviceUrl + ) { + throw validationError('Destination service URL cannot be empty.'); + } + if (serviceUrl && serviceUrl.length > TARGET_SERVICE_URL_MAX_LENGTH) { + throw validationError( + `Destination service URL must be at most ${TARGET_SERVICE_URL_MAX_LENGTH} characters.`, + ); + } + return { + provider, + targetKind: TARGET_KIND_BY_PROVIDER[provider], + externalRef: channelId, + ...(serviceUrl ? { metadata: { serviceUrl } } : {}), + } satisfies AutomationTarget; +} + +async function assertEnvironmentExists(environmentId: string): Promise { + if (!environmentId) throw validationError('Environment is required.'); + const environment = await db.query.environments.findFirst({ + columns: { id: true }, + where: eq(environments.id, environmentId), + }); + if (!environment) { + throw new CustomAutomationWriteError( + 'environment_not_found', + 'Selected environment was not found.', + ); + } +} + +function isDuplicateNameViolation(error: unknown): boolean { + let sawUniqueViolationCode = false; + let sawNameUniqueIndex = false; + for ( + let current = error, depth = 0; + current !== null && current !== undefined && depth < 10; + depth += 1 + ) { + const candidate = current as { + code?: unknown; + constraint?: unknown; + message?: unknown; + cause?: unknown; + }; + sawUniqueViolationCode ||= candidate.code === UNIQUE_VIOLATION_CODE; + sawNameUniqueIndex ||= + candidate.constraint === NAME_UNIQUE_INDEX || + (typeof candidate.message === 'string' && + candidate.message.includes(NAME_UNIQUE_INDEX)); + if (sawUniqueViolationCode && sawNameUniqueIndex) return true; + current = candidate.cause; + } + return false; +} + +async function persist(write: () => Promise): Promise { + try { + return await write(); + } catch (error) { + if (isDuplicateNameViolation(error)) { + throw new CustomAutomationWriteError( + 'duplicate_name', + DUPLICATE_CUSTOM_AUTOMATION_NAME_MESSAGE, + { cause: error }, + ); + } + throw error; + } +} + +export async function createCustomAutomationWrite( + input: CreateCustomAutomationWriteInput, +): Promise { + const name = normalizeName(input.name); + const prompt = normalizePrompt(input.prompt); + const model = normalizeModel(input.model); + const schedule = await resolveWriteSchedule(input.schedule); + if (schedule.status === 'ambiguous') return schedule; + const target = await buildTarget(input.target ?? null, {}); + await assertEnvironmentExists(input.environmentId); + if ((await countCustomAutomations()) >= MAX_CUSTOM_AUTOMATIONS) { + throw new CustomAutomationWriteError( + 'limit_reached', + `You can create at most ${MAX_CUSTOM_AUTOMATIONS} custom automations.`, + ); + } + + const automation = await persist(() => + createCustomAutomation({ + name, + prompt, + enabled: input.enabled, + scheduleMode: schedule.scheduleMode, + cronExpression: schedule.cronExpression, + model, + environmentId: input.environmentId, + target, + createdByUserId: input.createdByUserId ?? null, + }), + ); + return { status: 'saved', automation, resolution: schedule.resolution }; +} + +export async function updateCustomAutomationWrite( + id: string, + input: UpdateCustomAutomationWriteInput, +): Promise { + const existing = await getCustomAutomationById(id); + if (!existing) { + throw new CustomAutomationWriteError( + 'not_found', + 'Custom automation was not found.', + ); + } + + const schedule = input.schedule + ? await resolveWriteSchedule(input.schedule) + : { + status: 'resolved' as const, + scheduleMode: existing.scheduleMode as CustomAutomationScheduleMode, + cronExpression: existing.cronExpression, + resolution: null, + }; + if (schedule.status === 'ambiguous') return schedule; + + const environmentId = input.environmentId ?? existing.environmentId ?? ''; + const target = await buildTarget(input.target, existing.target); + await assertEnvironmentExists(environmentId); + const automation = await persist(() => + updateCustomAutomation(id, { + name: normalizeName(input.name ?? existing.name), + prompt: normalizePrompt(input.prompt ?? existing.prompt), + enabled: input.enabled ?? existing.enabled, + scheduleMode: schedule.scheduleMode, + cronExpression: schedule.cronExpression, + model: + input.model === undefined + ? existing.model + : normalizeModel(input.model), + environmentId, + target, + }), + ); + if (!automation) { + throw new CustomAutomationWriteError( + 'not_found', + 'Custom automation was not found.', + ); + } + return { status: 'saved', automation, resolution: schedule.resolution }; +} diff --git a/packages/sdk/src/server/automations/index.ts b/packages/sdk/src/server/automations/index.ts index 65509a013..69199c5b9 100644 --- a/packages/sdk/src/server/automations/index.ts +++ b/packages/sdk/src/server/automations/index.ts @@ -3,6 +3,8 @@ export { customAutomationsJob, runCustomAutomationNow, } from './custom-automations'; +export * from './custom-automation-errors'; +export * from './custom-automation-writes'; export * from './custom-automation-schedule'; export { ciFailureTriageJob } from './ci-failure-triage'; export {