diff --git a/packages/control-plane/src/automation/automation-command-resolver.test.ts b/packages/control-plane/src/automation/automation-command-resolver.test.ts new file mode 100644 index 0000000000..dbfa79f369 --- /dev/null +++ b/packages/control-plane/src/automation/automation-command-resolver.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AutomationRow } from "../db/automation-store"; +import { + AutomationCommandResolver, + AutomationMutationResolutionError, + type AutomationCommandResolverDependencies, +} from "./automation-command-resolver"; + +const existing: AutomationRow = { + id: "auto-1", + name: "Daily review", + instructions: "Review changes", + trigger_type: "schedule", + schedule_cron: "0 9 * * *", + schedule_tz: "UTC", + model: "openai/gpt-5.4", + reasoning_effort: "high", + enabled: 1, + next_run_at: 1, + consecutive_failures: 0, + created_by: "user-1", + user_id: "canonical-1", + created_at: 1, + updated_at: 1, + deleted_at: null, + event_type: null, + trigger_config: null, + trigger_auth_data: null, +}; + +function dependencies( + overrides: Partial = {} +): AutomationCommandResolverDependencies { + return { + now: () => 123, + generateId: () => "auto-new", + resolveRepository: vi.fn().mockResolvedValue({ repoId: 42, defaultBranch: "main" }), + environmentExists: vi.fn().mockResolvedValue(true), + getRepositoryCount: vi.fn().mockResolvedValue(0), + getEnvironmentCount: vi.fn().mockResolvedValue(0), + resolveProviderSelections: vi.fn().mockImplementation(async (value) => value), + resolveCanonicalUserId: vi.fn().mockResolvedValue("canonical-1"), + generateWebhookApiKey: () => "plain-key", + hashWebhookApiKey: vi.fn().mockResolvedValue("hashed-key"), + encryptSentrySecret: vi.fn().mockResolvedValue("encrypted-secret"), + hasSentryEncryptionKey: true, + ...overrides, + }; +} + +describe("AutomationCommandResolver", () => { + it("returns a complete schedule update command", async () => { + const command = await new AutomationCommandResolver(dependencies()).resolveUpdate( + { scheduleCron: "0 12 * * *", scheduleTz: "America/New_York" }, + existing + ); + + expect(command).toMatchObject({ + id: "auto-1", + triggerType: "schedule", + scheduleCron: "0 12 * * *", + scheduleTz: "America/New_York", + nextRunAt: expect.any(Number), + now: 123, + }); + }); + + it("validates a one-sided target replacement against final aggregate state", async () => { + const deps = dependencies({ getRepositoryCount: vi.fn().mockResolvedValue(1) }); + + await expect( + new AutomationCommandResolver(deps).resolveUpdate( + { environmentIds: ["env_1"] }, + { ...existing, trigger_type: "webhook" } + ) + ).rejects.toEqual( + new AutomationMutationResolutionError( + "Multi-target selections require a schedule trigger", + 400 + ) + ); + }); + + it("rejects clearing required Slack trigger scoping", async () => { + await expect( + new AutomationCommandResolver(dependencies()).resolveUpdate( + { triggerConfig: null }, + { ...existing, trigger_type: "slack_event" } + ) + ).rejects.toEqual( + new AutomationMutationResolutionError( + "Cannot clear triggerConfig on slack_event automations; pause or delete instead", + 400 + ) + ); + }); + + it("resolves a complete webhook create command and one-time response secret", async () => { + const result = await new AutomationCommandResolver(dependencies()).resolveCreate( + { + name: "Webhook review", + instructions: "Review changes", + triggerType: "webhook", + }, + { createdBy: "user-1" } + ); + + expect(result).toEqual({ + command: expect.objectContaining({ + id: "auto-new", + triggerType: "webhook", + triggerAuthData: "hashed-key", + userId: "canonical-1", + now: 123, + }), + webhookApiKey: "plain-key", + }); + }); +}); diff --git a/packages/control-plane/src/automation/automation-command-resolver.ts b/packages/control-plane/src/automation/automation-command-resolver.ts new file mode 100644 index 0000000000..c41f9d0ec0 --- /dev/null +++ b/packages/control-plane/src/automation/automation-command-resolver.ts @@ -0,0 +1,411 @@ +import { cronIntervalMinutes, isValidCron, nextCronOccurrence } from "@open-inspect/shared/cron"; +import { + conditionRegistry, + TRIGGER_TYPE_TO_SOURCE, + validateConditions, + type AutomationTriggerType, + type TriggerConfig, +} from "@open-inspect/shared/triggers"; +import { + MAX_AUTOMATION_REPOSITORIES, + type AutomationRepositoryInput, +} from "@open-inspect/shared/types/automations"; +import { isEnvironmentId } from "@open-inspect/shared/types/environments"; +import { + getValidModelOrDefault, + isValidModel, + isValidReasoningEffort, +} from "@open-inspect/shared/models"; +import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts"; +import type { AutomationRepositoryInsert, AutomationRow } from "../db/automation-store"; +import type { CreateAutomationMutation, UpdateAutomationMutation } from "./automation-mutation"; + +const MIN_CRON_INTERVAL_MINUTES = 15; +const MAX_NAME_LENGTH = 200; +const MAX_INSTRUCTIONS_LENGTH = 15_000; + +declare const resolvedAutomationCommand: unique symbol; +type ResolvedAutomationCommand = { readonly [resolvedAutomationCommand]: true }; + +export type UpdateAutomationCommand = Readonly<{ + id: string; + triggerType: AutomationTriggerType; + name?: string; + instructions?: string; + scheduleCron?: string; + scheduleTz?: string; + model?: string; + reasoningEffort?: string | null; + nextRunAt?: number | null; + eventType?: string | null; + triggerConfig?: TriggerConfig | null; + repositories?: AutomationRepositoryInsert[]; + environmentIds?: string[]; + providerSelections?: ModelProviderSelections; + now: number; +}> & + ResolvedAutomationCommand; + +export type CreateAutomationCommand = Readonly<{ + id: string; + name: string; + instructions: string; + triggerType: AutomationTriggerType; + scheduleCron: string | null; + scheduleTz: string; + model: string; + reasoningEffort: string | null; + nextRunAt: number | null; + createdBy: string; + userId: string; + eventType: string | null; + triggerConfig: TriggerConfig | null; + triggerAuthData: string | null; + repositories: AutomationRepositoryInsert[]; + environmentIds: string[]; + providerSelections: ModelProviderSelections; + now: number; +}> & + ResolvedAutomationCommand; + +export interface AutomationCommandResolverDependencies { + now(): number; + generateId(): string; + resolveRepository(repository: AutomationRepositoryInput): Promise<{ + repoId: number; + defaultBranch: string; + }>; + environmentExists(id: string): Promise; + getRepositoryCount(automationId: string): Promise; + getEnvironmentCount(automationId: string): Promise; + resolveProviderSelections(value: unknown): Promise; + resolveCanonicalUserId(metadata: { + displayName?: string; + email?: string; + avatarUrl?: string; + }): Promise; + generateWebhookApiKey(): string; + hashWebhookApiKey(apiKey: string): Promise; + encryptSentrySecret(secret: string): Promise; + hasSentryEncryptionKey: boolean; +} + +export class AutomationMutationResolutionError extends Error { + constructor( + message: string, + readonly status: number = 400 + ) { + super(message); + this.name = "AutomationMutationResolutionError"; + } +} + +function fail(message: string, status = 400): never { + throw new AutomationMutationResolutionError(message, status); +} + +function resolveReasoningEffort( + model: string, + reasoningEffort: string | null | undefined +): string | null { + if (reasoningEffort === undefined || reasoningEffort === null) return null; + return isValidReasoningEffort(model, reasoningEffort) ? reasoningEffort : null; +} + +function isValidTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} + +function validateTargetCounts( + triggerType: AutomationTriggerType, + repositoryCount: number, + environmentCount: number +): void { + if (triggerType === "github_event" || triggerType === "linear_event") { + if (repositoryCount === 0) fail("Repository-scoped triggers require exactly one repository"); + if (environmentCount > 0) fail("Repository-scoped triggers cannot target environments"); + } + if (repositoryCount + environmentCount > 1 && triggerType !== "schedule") { + fail("Multi-target selections require a schedule trigger"); + } + if (repositoryCount + environmentCount > MAX_AUTOMATION_REPOSITORIES) { + fail(`At most ${MAX_AUTOMATION_REPOSITORIES} repositories and environments combined`); + } +} + +function validateSlackTriggerConfig(triggerConfig: TriggerConfig | null | undefined): void { + if (!(triggerConfig?.conditions ?? []).some((condition) => condition.type === "slack_channel")) { + fail("slack_event triggers require a slack_channel condition"); + } +} + +function validateConditionsForTrigger( + triggerType: AutomationTriggerType, + triggerConfig: TriggerConfig | null | undefined +): void { + if (!triggerConfig?.conditions) return; + const source = TRIGGER_TYPE_TO_SOURCE[triggerType]; + if (!source) return; + const errors = validateConditions(triggerConfig.conditions, source, conditionRegistry); + if (errors.length > 0) fail(errors.join("; ")); +} + +function validateEnvironmentIds(environmentIds: string[]): void { + if (environmentIds.some((id) => !isEnvironmentId(id))) { + fail("environmentIds must be an array of environment ids (env_…)"); + } + if (new Set(environmentIds).size !== environmentIds.length) { + fail("environmentIds must not contain duplicates"); + } +} + +function validateCron(cron: string): void { + if (!isValidCron(cron)) fail("scheduleCron must be a valid 5-field cron expression"); + const interval = cronIntervalMinutes(cron); + if (interval !== null && interval < MIN_CRON_INTERVAL_MINUTES) { + fail(`Schedule interval must be at least ${MIN_CRON_INTERVAL_MINUTES} minutes`); + } +} + +export class AutomationCommandResolver { + constructor(private readonly dependencies: AutomationCommandResolverDependencies) {} + + async resolveCreate( + input: CreateAutomationMutation, + identity: { createdBy: string } + ): Promise<{ command: CreateAutomationCommand; webhookApiKey?: string }> { + if (input.name.trim().length === 0) fail("name is required"); + if (input.name.length > MAX_NAME_LENGTH) { + fail(`name must be at most ${MAX_NAME_LENGTH} characters`); + } + if (input.instructions.trim().length === 0) fail("instructions is required"); + if (input.instructions.length > MAX_INSTRUCTIONS_LENGTH) { + fail(`instructions must be at most ${MAX_INSTRUCTIONS_LENGTH} characters`); + } + + const triggerType = input.triggerType ?? "schedule"; + const repositories = input.repositories ?? []; + const environmentIds = input.environmentIds ?? []; + validateEnvironmentIds(environmentIds); + validateTargetCounts(triggerType, repositories.length, environmentIds.length); + await this.resolveEnvironments(environmentIds); + + const isSchedule = triggerType === "schedule"; + if (isSchedule) { + if (!input.scheduleCron) fail("scheduleCron must be a valid 5-field cron expression"); + validateCron(input.scheduleCron); + if (!input.scheduleTz || !isValidTimezone(input.scheduleTz)) { + fail("scheduleTz must be a valid IANA timezone"); + } + } else if (input.scheduleCron || input.scheduleTz) { + fail("scheduleCron and scheduleTz are only valid for schedule triggers"); + } + + if (triggerType === "sentry" && !input.eventType) { + fail("eventType is required for sentry triggers"); + } + validateConditionsForTrigger(triggerType, input.triggerConfig); + if (triggerType === "slack_event") validateSlackTriggerConfig(input.triggerConfig); + + const model = getValidModelOrDefault(input.model ?? undefined); + const reasoningEffort = resolveReasoningEffort(model, input.reasoningEffort); + if (input.reasoningEffort !== undefined && input.reasoningEffort !== null && !reasoningEffort) { + fail("Invalid reasoning effort for selected model"); + } + + const resolvedRepositories = await this.resolveRepositories(repositories); + const providerSelections = await this.dependencies.resolveProviderSelections( + input.providerSelections ?? {} + ); + const nextRunAt = isSchedule + ? nextCronOccurrence(input.scheduleCron!, input.scheduleTz!).getTime() + : null; + const id = this.dependencies.generateId(); + const now = this.dependencies.now(); + + let webhookApiKey: string | undefined; + let triggerAuthData: string | null = null; + if (triggerType === "webhook") { + webhookApiKey = this.dependencies.generateWebhookApiKey(); + triggerAuthData = await this.dependencies.hashWebhookApiKey(webhookApiKey); + } else if (triggerType === "sentry") { + if (!input.sentryClientSecret?.trim()) { + fail("sentryClientSecret is required for sentry triggers"); + } + if (!this.dependencies.hasSentryEncryptionKey) fail("Encryption key not configured", 503); + triggerAuthData = await this.dependencies.encryptSentrySecret(input.sentryClientSecret); + } + + const userId = await this.dependencies.resolveCanonicalUserId({ + displayName: input.actorDisplayName, + email: input.actorEmail, + avatarUrl: input.actorAvatarUrl, + }); + return { + command: { + id, + name: input.name.trim(), + instructions: input.instructions, + triggerType, + scheduleCron: input.scheduleCron ?? null, + scheduleTz: input.scheduleTz ?? "UTC", + model, + reasoningEffort, + nextRunAt, + createdBy: identity.createdBy, + userId, + eventType: input.eventType ?? null, + triggerConfig: input.triggerConfig ?? null, + triggerAuthData, + repositories: resolvedRepositories, + environmentIds, + providerSelections, + now, + } as CreateAutomationCommand, + ...(webhookApiKey ? { webhookApiKey } : {}), + }; + } + + async resolveUpdate( + input: UpdateAutomationMutation, + existing: AutomationRow + ): Promise { + const providerSelections = + input.providerSelections !== undefined + ? await this.dependencies.resolveProviderSelections(input.providerSelections) + : undefined; + + if (input.name !== undefined) { + if (input.name.trim().length === 0) fail("name cannot be empty"); + if (input.name.length > MAX_NAME_LENGTH) { + fail(`name must be at most ${MAX_NAME_LENGTH} characters`); + } + } + if (input.instructions !== undefined) { + if (input.instructions.trim().length === 0) fail("instructions cannot be empty"); + if (input.instructions.length > MAX_INSTRUCTIONS_LENGTH) { + fail(`instructions must be at most ${MAX_INSTRUCTIONS_LENGTH} characters`); + } + } + if (input.scheduleCron !== undefined) validateCron(input.scheduleCron); + if (input.scheduleTz !== undefined && !isValidTimezone(input.scheduleTz)) { + fail("scheduleTz must be a valid IANA timezone"); + } + if (input.model !== undefined && !isValidModel(input.model)) fail("Invalid model"); + + const nextModel = + input.model !== undefined ? getValidModelOrDefault(input.model) : existing.model; + const requestedReasoningEffort = input.reasoningEffort; + const reasoningEffort = + requestedReasoningEffort !== undefined + ? resolveReasoningEffort(nextModel, requestedReasoningEffort) + : input.model !== undefined && existing.reasoning_effort !== null + ? resolveReasoningEffort(nextModel, existing.reasoning_effort) + : existing.reasoning_effort; + if ( + requestedReasoningEffort !== undefined && + requestedReasoningEffort !== null && + reasoningEffort === null + ) { + fail("Invalid reasoning effort for selected model"); + } + + const triggerType = existing.trigger_type as AutomationTriggerType; + let repositories: AutomationRepositoryInsert[] | undefined; + let environmentIds: string[] | undefined; + if (input.environmentIds !== undefined) { + validateEnvironmentIds(input.environmentIds); + environmentIds = input.environmentIds; + } + if (input.repositories !== undefined || environmentIds !== undefined) { + const repositoryCount = + input.repositories?.length ?? (await this.dependencies.getRepositoryCount(existing.id)); + const environmentCount = + environmentIds?.length ?? (await this.dependencies.getEnvironmentCount(existing.id)); + validateTargetCounts(triggerType, repositoryCount, environmentCount); + if (environmentIds !== undefined) await this.resolveEnvironments(environmentIds); + if (input.repositories !== undefined) { + repositories = await this.resolveRepositories(input.repositories); + } + } + + if (input.eventType !== undefined && triggerType === "schedule") { + fail("Cannot set eventType on schedule automations"); + } + if (input.triggerConfig !== undefined) { + if (triggerType === "schedule") { + fail("Cannot set triggerConfig on schedule automations"); + } + if (input.triggerConfig === null) { + if (triggerType === "slack_event") { + fail("Cannot clear triggerConfig on slack_event automations; pause or delete instead"); + } + } else { + if (triggerType === "slack_event") validateSlackTriggerConfig(input.triggerConfig); + validateConditionsForTrigger(triggerType, input.triggerConfig); + } + } + + let nextRunAt: number | undefined; + if ( + triggerType === "schedule" && + (input.scheduleCron !== undefined || input.scheduleTz !== undefined) + ) { + const cron = input.scheduleCron ?? existing.schedule_cron; + const timezone = input.scheduleTz ?? existing.schedule_tz; + if (!cron) fail("Cannot compute schedule: no cron expression"); + nextRunAt = nextCronOccurrence(cron, timezone).getTime(); + } + + return { + id: existing.id, + triggerType, + now: this.dependencies.now(), + ...(input.name !== undefined ? { name: input.name.trim() } : {}), + ...(input.instructions !== undefined ? { instructions: input.instructions } : {}), + ...(input.scheduleCron !== undefined ? { scheduleCron: input.scheduleCron } : {}), + ...(input.scheduleTz !== undefined ? { scheduleTz: input.scheduleTz } : {}), + ...(input.model !== undefined ? { model: nextModel } : {}), + ...(input.reasoningEffort !== undefined || input.model !== undefined + ? { reasoningEffort } + : {}), + ...(input.eventType !== undefined ? { eventType: input.eventType } : {}), + ...(input.triggerConfig !== undefined ? { triggerConfig: input.triggerConfig } : {}), + ...(nextRunAt !== undefined ? { nextRunAt } : {}), + ...(repositories !== undefined ? { repositories } : {}), + ...(environmentIds !== undefined ? { environmentIds } : {}), + ...(providerSelections !== undefined ? { providerSelections } : {}), + } as UpdateAutomationCommand; + } + + private async resolveEnvironments(environmentIds: string[]): Promise { + const found = await Promise.all( + environmentIds.map((id) => this.dependencies.environmentExists(id)) + ); + const missing = environmentIds.filter((_, index) => !found[index]); + if (missing.length > 0) fail(`Environment not found: ${missing.join(", ")}`); + } + + private async resolveRepositories( + repositories: AutomationRepositoryInput[] + ): Promise { + const settled = await Promise.allSettled( + repositories.map((repository) => this.dependencies.resolveRepository(repository)) + ); + const resolved = settled.map((result) => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); + return repositories.map((repository, index) => ({ + repo_owner: repository.repoOwner, + repo_name: repository.repoName, + repo_id: resolved[index].repoId, + base_branch: repository.baseBranch ?? resolved[index].defaultBranch, + })); + } +} diff --git a/packages/control-plane/src/automation/automation-mutation.test.ts b/packages/control-plane/src/automation/automation-mutation.test.ts new file mode 100644 index 0000000000..79dbdf479d --- /dev/null +++ b/packages/control-plane/src/automation/automation-mutation.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + AutomationMutationInputError, + parseCreateAutomationMutation, + parseUpdateAutomationMutation, +} from "./automation-mutation"; + +describe("automation mutation ingress", () => { + it("parses and normalizes a create request with canonical nested schemas", () => { + const input = parseCreateAutomationMutation({ + name: "Daily review", + instructions: "Review open changes", + triggerType: "slack_event", + triggerConfig: { + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C123"] }], + }, + repositories: [{ repoOwner: " Acme ", repoName: " API ", baseBranch: null }], + providerSelections: { openai: { mode: "provider_account", accountId: "a".repeat(32) } }, + actorDisplayName: "Automation Bot", + }); + + expect(input).toMatchObject({ + triggerType: "slack_event", + repositories: [{ repoOwner: "acme", repoName: "api", baseBranch: null }], + triggerConfig: { + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C123"] }], + }, + actorDisplayName: "Automation Bot", + }); + }); + + it("rejects invalid trigger-specific shapes at ingress", () => { + expect(() => + parseCreateAutomationMutation({ + name: "Slack review", + instructions: "Review", + triggerType: "slack_event", + triggerConfig: { conditions: "C123" }, + }) + ).toThrowError(new AutomationMutationInputError("triggerConfig.conditions must be an array")); + }); + + it("keeps create and update semantics distinct", () => { + expect(() => parseCreateAutomationMutation({ name: "Incomplete" })).toThrowError( + new AutomationMutationInputError("instructions is required") + ); + + expect(parseUpdateAutomationMutation({ name: "Renamed" })).toEqual({ name: "Renamed" }); + }); + + it("preserves update-specific scalar error messages", () => { + expect(() => parseUpdateAutomationMutation({ name: 42 })).toThrowError( + new AutomationMutationInputError("name cannot be empty") + ); + expect(() => parseUpdateAutomationMutation({ instructions: null })).toThrowError( + new AutomationMutationInputError("instructions cannot be empty") + ); + }); + + it("preserves omitted, cleared, and replaced update fields", () => { + const omitted = parseUpdateAutomationMutation({ name: "Renamed" }); + expect("triggerConfig" in omitted).toBe(false); + expect("reasoningEffort" in omitted).toBe(false); + expect("repositories" in omitted).toBe(false); + expect("environmentIds" in omitted).toBe(false); + expect("providerSelections" in omitted).toBe(false); + + const cleared = parseUpdateAutomationMutation({ + triggerConfig: null, + eventType: null, + reasoningEffort: null, + repositories: [], + environmentIds: [], + providerSelections: {}, + }); + expect(cleared).toEqual({ + triggerConfig: null, + eventType: null, + reasoningEffort: null, + repositories: [], + environmentIds: [], + providerSelections: {}, + }); + }); +}); diff --git a/packages/control-plane/src/automation/automation-mutation.ts b/packages/control-plane/src/automation/automation-mutation.ts new file mode 100644 index 0000000000..78dc4d22bb --- /dev/null +++ b/packages/control-plane/src/automation/automation-mutation.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; +import { + createAutomationRequestSchema, + updateAutomationRequestSchema, +} from "@open-inspect/shared/types/automations"; +import { triggerConfigSchema } from "@open-inspect/shared/triggers"; +import { modelProviderSelectionsSchema } from "@open-inspect/shared/types/provider-accounts"; + +const automationTriggerTypeSchema = createAutomationRequestSchema.shape.triggerType.unwrap(); + +const createAutomationMutationSchema = createAutomationRequestSchema + .extend({ + triggerType: automationTriggerTypeSchema.nullable().optional(), + scheduleCron: createAutomationRequestSchema.shape.scheduleCron.nullable(), + scheduleTz: createAutomationRequestSchema.shape.scheduleTz.nullable(), + model: createAutomationRequestSchema.shape.model.nullable(), + eventType: createAutomationRequestSchema.shape.eventType.nullable(), + providerSelections: modelProviderSelectionsSchema.nullable().optional(), + actorDisplayName: z.string().optional(), + actorEmail: z.string().optional(), + actorAvatarUrl: z.string().optional(), + }) + .transform(({ triggerType, providerSelections, ...input }) => ({ + ...input, + ...(triggerType ? { triggerType } : {}), + ...(providerSelections ? { providerSelections } : {}), + })); + +const updateAutomationMutationSchema = updateAutomationRequestSchema.extend({ + eventType: z.string().nullable().optional(), + triggerConfig: triggerConfigSchema.nullable().optional(), +}); + +export type CreateAutomationMutation = z.output; +export type UpdateAutomationMutation = z.output; + +export class AutomationMutationInputError extends Error { + constructor(message: string) { + super(message); + this.name = "AutomationMutationInputError"; + } +} + +function formatTriggerConfigIssue(value: unknown, issue: z.core.$ZodIssue): string { + if (issue.path.length === 1 && issue.path[0] === "conditions") { + return "triggerConfig.conditions must be an array"; + } + + const path = ["triggerConfig", ...issue.path].map(String).join("."); + const conditionIndex = issue.path[0] === "conditions" ? issue.path[1] : undefined; + const rawConditions = + typeof value === "object" && value !== null && "conditions" in value + ? (value as { conditions?: unknown }).conditions + : undefined; + const rawCondition = + typeof conditionIndex === "number" && Array.isArray(rawConditions) + ? rawConditions[conditionIndex] + : undefined; + const conditionType = + typeof rawCondition === "object" && + rawCondition !== null && + "type" in rawCondition && + typeof rawCondition.type === "string" + ? `${rawCondition.type}: ` + : ""; + return `${path}: ${conditionType}${issue.message}`; +} + +function formatMutationIssue( + value: unknown, + issue: z.core.$ZodIssue, + operation: "create" | "update" +): string { + const field = issue.path[0]; + if (field === "name") return operation === "create" ? "name is required" : "name cannot be empty"; + if (field === "instructions") { + return operation === "create" ? "instructions is required" : "instructions cannot be empty"; + } + if (field === "triggerType") { + return `triggerType must be one of: ${automationTriggerTypeSchema.options.join(", ")}`; + } + if (field === "triggerConfig") { + const triggerConfig = + typeof value === "object" && value !== null && "triggerConfig" in value + ? (value as { triggerConfig?: unknown }).triggerConfig + : undefined; + return formatTriggerConfigIssue(triggerConfig, { ...issue, path: issue.path.slice(1) }); + } + if (field === "repositories") { + const index = issue.path[1]; + return `repositories${typeof index === "number" ? `[${index}]` : ""}: ${issue.message}`; + } + if (field === "environmentIds") { + return "environmentIds must be an array of environment ids (env_…)"; + } + if (field === "providerSelections") { + return ["providerSelections", ...issue.path.slice(1)].join(".") + `: ${issue.message}`; + } + return issue.message; +} + +function parseMutation(schema: z.ZodType, value: unknown, operation: "create" | "update"): T { + const parsed = schema.safeParse(value); + if (parsed.success) return parsed.data; + const issue = parsed.error.issues[0]; + throw new AutomationMutationInputError( + issue ? formatMutationIssue(value, issue, operation) : "Invalid automation request" + ); +} + +export function parseCreateAutomationMutation(value: unknown): CreateAutomationMutation { + return parseMutation(createAutomationMutationSchema, value, "create"); +} + +export function parseUpdateAutomationMutation( + value: unknown, + currentTriggerType?: string +): UpdateAutomationMutation { + if ( + currentTriggerType === "schedule" && + typeof value === "object" && + value !== null && + "triggerConfig" in value && + value.triggerConfig !== undefined + ) { + throw new AutomationMutationInputError("Cannot set triggerConfig on schedule automations"); + } + return parseMutation(updateAutomationMutationSchema, value, "update"); +} diff --git a/packages/control-plane/src/db/automation-aggregate-writer.test.ts b/packages/control-plane/src/db/automation-aggregate-writer.test.ts new file mode 100644 index 0000000000..d16c36d169 --- /dev/null +++ b/packages/control-plane/src/db/automation-aggregate-writer.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; +import { + D1AutomationAggregateWriter, + type CreateAutomationCommand, + type UpdateAutomationCommand, +} from "./automation-aggregate-writer"; + +const mocks = vi.hoisted(() => ({ + automation: { + bindAutomationInsert: vi.fn(), + bindAutomationUpdate: vi.fn(), + bindRepositoryInserts: vi.fn(), + bindEnvironmentInserts: vi.fn(), + bindReplaceRepositories: vi.fn(), + bindReplaceEnvironments: vi.fn(), + }, + providerAuth: { + bindInserts: vi.fn(), + bindReplace: vi.fn(), + }, + slack: { + bindChannelStatements: vi.fn(), + }, +})); + +vi.mock("./automation-store", () => ({ + AutomationStore: vi.fn().mockImplementation(function () { + return mocks.automation; + }), +})); + +vi.mock("./automation-model-provider-auth", () => ({ + AutomationModelProviderAuthStore: vi.fn().mockImplementation(function () { + return mocks.providerAuth; + }), +})); + +vi.mock("./slack-channel-store", () => ({ + SlackChannelStore: vi.fn().mockImplementation(function () { + return mocks.slack; + }), +})); + +function statement(name: string): SqlStatement { + return { name } as unknown as SqlStatement; +} + +function resolvedCommand(command: Omit): T { + return command as T; +} + +const createCommand = resolvedCommand({ + id: "auto-1", + name: "Daily review", + instructions: "Review open changes", + triggerType: "slack_event", + scheduleCron: null, + scheduleTz: "UTC", + model: "openai/gpt-5.2-codex", + reasoningEffort: "high", + nextRunAt: null, + createdBy: "user-1", + userId: "canonical-user-1", + eventType: null, + triggerConfig: { + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C123"] }], + }, + triggerAuthData: null, + repositories: [{ repo_owner: "acme", repo_name: "api", repo_id: 42, base_branch: "main" }], + environmentIds: ["env_1"], + providerSelections: { openai: { mode: "api_key" } }, + now: 123, +}); + +describe("D1AutomationAggregateWriter", () => { + const batch = vi.fn(); + const db = { batch } as unknown as SqlDatabase; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.automation.bindAutomationInsert.mockReturnValue(statement("automation-insert")); + mocks.automation.bindAutomationUpdate.mockReturnValue(statement("automation-update")); + mocks.automation.bindRepositoryInserts.mockReturnValue([statement("repository-insert")]); + mocks.automation.bindEnvironmentInserts.mockReturnValue([statement("environment-insert")]); + mocks.automation.bindReplaceRepositories.mockReturnValue([statement("repository-replace")]); + mocks.automation.bindReplaceEnvironments.mockReturnValue([statement("environment-replace")]); + mocks.providerAuth.bindInserts.mockReturnValue([statement("provider-insert")]); + mocks.providerAuth.bindReplace.mockReturnValue([statement("provider-replace")]); + mocks.slack.bindChannelStatements.mockReturnValue([statement("slack-replace")]); + batch.mockResolvedValue([]); + }); + + it("creates the complete aggregate in one ordered atomic batch", async () => { + await new D1AutomationAggregateWriter(db).create(createCommand); + + expect(mocks.automation.bindAutomationInsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: "auto-1", + trigger_type: "slack_event", + trigger_config: JSON.stringify(createCommand.triggerConfig), + created_at: 123, + updated_at: 123, + }) + ); + expect(mocks.automation.bindRepositoryInserts).toHaveBeenCalledWith( + "auto-1", + createCommand.repositories, + 123 + ); + expect(mocks.automation.bindEnvironmentInserts).toHaveBeenCalledWith("auto-1", ["env_1"], 123); + expect(mocks.providerAuth.bindInserts).toHaveBeenCalledWith( + "auto-1", + createCommand.providerSelections, + 123 + ); + expect(mocks.slack.bindChannelStatements).toHaveBeenCalledWith("auto-1", ["C123"]); + expect(batch).toHaveBeenCalledWith([ + expect.objectContaining({ name: "automation-insert" }), + expect.objectContaining({ name: "repository-insert" }), + expect.objectContaining({ name: "environment-insert" }), + expect.objectContaining({ name: "provider-insert" }), + expect.objectContaining({ name: "slack-replace" }), + ]); + }); + + it("replaces only explicitly supplied update selections and Slack indexes", async () => { + await new D1AutomationAggregateWriter(db).update( + resolvedCommand({ + id: "auto-1", + triggerType: "slack_event", + name: "Renamed", + triggerConfig: { + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C999"] }], + }, + repositories: [], + providerSelections: {}, + now: 456, + }) + ); + + expect(mocks.automation.bindAutomationUpdate).toHaveBeenCalledWith( + "auto-1", + { + name: "Renamed", + trigger_config: JSON.stringify({ + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C999"] }], + }), + }, + 456 + ); + expect(mocks.automation.bindReplaceRepositories).toHaveBeenCalledWith("auto-1", [], 456); + expect(mocks.automation.bindReplaceEnvironments).not.toHaveBeenCalled(); + expect(mocks.providerAuth.bindReplace).toHaveBeenCalledWith("auto-1", {}, 456); + expect(mocks.slack.bindChannelStatements).toHaveBeenCalledWith("auto-1", ["C999"]); + expect(batch).toHaveBeenCalledTimes(1); + }); + + it("does not write an update when every field is omitted", async () => { + mocks.automation.bindAutomationUpdate.mockReturnValue(null); + + await new D1AutomationAggregateWriter(db).update( + resolvedCommand({ + id: "auto-1", + triggerType: "schedule", + now: 456, + }) + ); + + expect(mocks.automation.bindReplaceRepositories).not.toHaveBeenCalled(); + expect(mocks.automation.bindReplaceEnvironments).not.toHaveBeenCalled(); + expect(mocks.providerAuth.bindReplace).not.toHaveBeenCalled(); + expect(mocks.slack.bindChannelStatements).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + }); + + it("distinguishes an omitted trigger config from an explicit clear", async () => { + await new D1AutomationAggregateWriter(db).update( + resolvedCommand({ + id: "auto-1", + triggerType: "webhook", + triggerConfig: null, + now: 456, + }) + ); + + expect(mocks.automation.bindAutomationUpdate).toHaveBeenCalledWith( + "auto-1", + { trigger_config: null }, + 456 + ); + expect(mocks.slack.bindChannelStatements).not.toHaveBeenCalled(); + expect(batch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/control-plane/src/db/automation-aggregate-writer.ts b/packages/control-plane/src/db/automation-aggregate-writer.ts new file mode 100644 index 0000000000..aa58591458 --- /dev/null +++ b/packages/control-plane/src/db/automation-aggregate-writer.ts @@ -0,0 +1,127 @@ +import type { TriggerConfig } from "@open-inspect/shared/triggers"; +import { AutomationStore, type AutomationRow } from "./automation-store"; +import { AutomationModelProviderAuthStore } from "./automation-model-provider-auth"; +import { SlackChannelStore } from "./slack-channel-store"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; +import type { + CreateAutomationCommand, + UpdateAutomationCommand, +} from "../automation/automation-command-resolver"; + +export type { + CreateAutomationCommand, + UpdateAutomationCommand, +} from "../automation/automation-command-resolver"; + +function extractSlackChannels(triggerConfig: TriggerConfig | null): string[] { + for (const condition of triggerConfig?.conditions ?? []) { + if (condition.type === "slack_channel") return condition.value; + } + return []; +} + +function toAutomationRow(command: CreateAutomationCommand): AutomationRow { + return { + id: command.id, + name: command.name, + instructions: command.instructions, + trigger_type: command.triggerType, + schedule_cron: command.scheduleCron, + schedule_tz: command.scheduleTz, + model: command.model, + reasoning_effort: command.reasoningEffort, + enabled: 1, + next_run_at: command.nextRunAt, + consecutive_failures: 0, + created_by: command.createdBy, + user_id: command.userId, + created_at: command.now, + updated_at: command.now, + deleted_at: null, + event_type: command.eventType, + trigger_config: command.triggerConfig ? JSON.stringify(command.triggerConfig) : null, + trigger_auth_data: command.triggerAuthData, + }; +} + +function toAutomationUpdate(command: UpdateAutomationCommand): Partial { + return { + ...(command.name !== undefined ? { name: command.name } : {}), + ...(command.instructions !== undefined ? { instructions: command.instructions } : {}), + ...(command.scheduleCron !== undefined ? { schedule_cron: command.scheduleCron } : {}), + ...(command.scheduleTz !== undefined ? { schedule_tz: command.scheduleTz } : {}), + ...(command.model !== undefined ? { model: command.model } : {}), + ...(command.reasoningEffort !== undefined ? { reasoning_effort: command.reasoningEffort } : {}), + ...(command.nextRunAt !== undefined ? { next_run_at: command.nextRunAt } : {}), + ...(command.eventType !== undefined ? { event_type: command.eventType } : {}), + ...(command.triggerConfig !== undefined + ? { + trigger_config: command.triggerConfig ? JSON.stringify(command.triggerConfig) : null, + } + : {}), + }; +} + +export class D1AutomationAggregateWriter { + private readonly automations: AutomationStore; + private readonly providerAuth: AutomationModelProviderAuthStore; + private readonly slackChannels: SlackChannelStore; + + constructor(private readonly db: SqlDatabase) { + this.automations = new AutomationStore(db); + this.providerAuth = new AutomationModelProviderAuthStore(db); + this.slackChannels = new SlackChannelStore(db); + } + + async create(command: CreateAutomationCommand): Promise { + const statements = [ + this.automations.bindAutomationInsert(toAutomationRow(command)), + ...this.automations.bindRepositoryInserts(command.id, command.repositories, command.now), + ...this.automations.bindEnvironmentInserts(command.id, command.environmentIds, command.now), + ...this.providerAuth.bindInserts(command.id, command.providerSelections, command.now), + ]; + if (command.triggerType === "slack_event") { + statements.push( + ...this.slackChannels.bindChannelStatements( + command.id, + extractSlackChannels(command.triggerConfig) + ) + ); + } + await this.db.batch(statements); + } + + async update(command: UpdateAutomationCommand): Promise { + const statements: SqlStatement[] = []; + const updateStatement = this.automations.bindAutomationUpdate( + command.id, + toAutomationUpdate(command), + command.now + ); + if (updateStatement) statements.push(updateStatement); + if (command.repositories !== undefined) { + statements.push( + ...this.automations.bindReplaceRepositories(command.id, command.repositories, command.now) + ); + } + if (command.environmentIds !== undefined) { + statements.push( + ...this.automations.bindReplaceEnvironments(command.id, command.environmentIds, command.now) + ); + } + if (command.providerSelections !== undefined) { + statements.push( + ...this.providerAuth.bindReplace(command.id, command.providerSelections, command.now) + ); + } + if (command.triggerType === "slack_event" && command.triggerConfig !== undefined) { + statements.push( + ...this.slackChannels.bindChannelStatements( + command.id, + extractSlackChannels(command.triggerConfig) + ) + ); + } + if (statements.length > 0) await this.db.batch(statements); + } +} diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 8ecb0df39c..1e48d0d2cb 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -320,8 +320,8 @@ export class AutomationStore { // --- Automation CRUD --- /** - * Prepared INSERT for an automation row. Public so a route can compose it with - * `SlackChannelStore.bindChannelStatements` into one atomic `db.batch`. + * Prepared INSERT for an automation row. The aggregate writer composes it with + * relationship and trigger-index statements in one atomic batch. */ bindAutomationInsert(row: AutomationRow): SqlStatement { return this.db @@ -462,10 +462,14 @@ export class AutomationStore { /** * Build the dynamic UPDATE statement for the allowed automation fields, or - * null when `fields` carries nothing to write. Public so a route can compose it - * with `SlackChannelStore.bindChannelStatements` into one atomic `db.batch`. + * null when `fields` carries nothing to write. The aggregate writer composes + * it with relationship and trigger-index statements in one atomic batch. */ - bindAutomationUpdate(id: string, fields: Partial): SqlStatement | null { + bindAutomationUpdate( + id: string, + fields: Partial, + now: number = Date.now() + ): SqlStatement | null { const setClauses: string[] = []; const params: unknown[] = []; @@ -496,7 +500,7 @@ export class AutomationStore { if (setClauses.length === 0) return null; setClauses.push("updated_at = ?"); - params.push(Date.now()); + params.push(now); params.push(id); return this.db diff --git a/packages/control-plane/src/db/slack-channel-store.ts b/packages/control-plane/src/db/slack-channel-store.ts index c80b0c1d3f..33b634a916 100644 --- a/packages/control-plane/src/db/slack-channel-store.ts +++ b/packages/control-plane/src/db/slack-channel-store.ts @@ -9,9 +9,9 @@ * Kept out of AutomationStore so trigger-source-specific persistence doesn't leak * into the generic automation store — slack is the only source that needs a * dedicated index. The index is a denormalized copy of each automation's - * `slack_channel` condition (held in trigger_config); a route writes the - * automation row and the channel rows in one `db.batch` (via bindChannelStatements) - * so the two can't drift apart on a partial failure. + * `slack_channel` condition (held in trigger_config). The aggregate writer + * persists the automation row and channel rows in one batch so the two can't + * drift apart on a partial failure. */ import type { AutomationRow } from "./automation-store"; @@ -48,8 +48,8 @@ export class SlackChannelStore { /** * Statements that replace an automation's watched-channel set (DELETE + re-INSERT). - * Public so a route can compose them with the automation insert/update into one - * `db.batch`, keeping the canonical trigger_config and this index atomic. + * The aggregate writer composes these with the automation insert/update, + * keeping the canonical trigger_config and this index atomic. */ bindChannelStatements(automationId: string, channelIds: string[]): SqlStatement[] { const statements: SqlStatement[] = [ diff --git a/packages/control-plane/src/routes/automations.test.ts b/packages/control-plane/src/routes/automations.test.ts index 2c378c32ca..3fcc69904c 100644 --- a/packages/control-plane/src/routes/automations.test.ts +++ b/packages/control-plane/src/routes/automations.test.ts @@ -941,7 +941,8 @@ describe("automation route handlers", () => { expect(res.status).toBe(200); expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( "auto-1", - expect.objectContaining({ name: "Updated" }) + expect.objectContaining({ name: "Updated" }), + expect.any(Number) ); expect(mockBatch).toHaveBeenCalledWith( expect.arrayContaining([{ sql: "update-automation" }]) @@ -1033,7 +1034,8 @@ describe("automation route handlers", () => { expect(res.status).toBe(200); expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( "auto-1", - expect.objectContaining({ reasoning_effort: "high" }) + expect.objectContaining({ reasoning_effort: "high" }), + expect.any(Number) ); }); @@ -1047,7 +1049,8 @@ describe("automation route handlers", () => { expect(res.status).toBe(200); expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( "auto-1", - expect.objectContaining({ model: "openai/gpt-5.4", reasoning_effort: null }) + expect.objectContaining({ model: "openai/gpt-5.4", reasoning_effort: null }), + expect.any(Number) ); }); @@ -1287,7 +1290,8 @@ describe("automation route handlers", () => { expect.objectContaining({ schedule_cron: "0 12 * * *", next_run_at: expect.any(Number), - }) + }), + expect.any(Number) ); }); }); diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index a6856ae0d9..ba321719b7 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -2,32 +2,9 @@ * Automation CRUD routes. */ -import { isValidCron, nextCronOccurrence, cronIntervalMinutes } from "@open-inspect/shared/cron"; -import { - triggerConfigSchema, - validateConditions, - conditionRegistry, - TRIGGER_TYPE_TO_SOURCE, -} from "@open-inspect/shared/triggers"; -import type { AutomationTriggerType, TriggerConfig } from "@open-inspect/shared/triggers"; -import type { - CreateAutomationRequest, - UpdateAutomationRequest, -} from "@open-inspect/shared/types/automations"; -import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts"; import { listChannels } from "@open-inspect/shared/slack"; -import { - getValidModelOrDefault, - isValidModel, - isValidReasoningEffort, -} from "@open-inspect/shared/models"; -import { - AutomationStore, - toAutomation, - toAutomationRun, - type AutomationRow, - type AutomationRepositoryInsert, -} from "../db/automation-store"; +import { nextCronOccurrence } from "@open-inspect/shared/cron"; +import { AutomationStore, toAutomation, toAutomationRun } from "../db/automation-store"; import { encodeAutomationListCursor, parseAutomationListCursor, @@ -48,10 +25,15 @@ import { createLogger } from "../logger"; import { Scheduler } from "../scheduler/scheduler"; import { hydrateAutomation } from "../automation/hydrate"; import { - automationRepositoriesInputSchema, - MAX_AUTOMATION_REPOSITORIES, -} from "@open-inspect/shared/types/automations"; -import { isEnvironmentId } from "@open-inspect/shared/types/environments"; + AutomationMutationInputError, + parseCreateAutomationMutation, + parseUpdateAutomationMutation, +} from "../automation/automation-mutation"; +import { D1AutomationAggregateWriter } from "../db/automation-aggregate-writer"; +import { + AutomationCommandResolver, + AutomationMutationResolutionError, +} from "../automation/automation-command-resolver"; import { type Route, type RequestContext, @@ -64,255 +46,70 @@ import { resolveRepoOrError, } from "./shared"; import type { Env } from "../types"; -import type { SqlDatabase, SqlStatement } from "../db/sql-database"; +import type { SqlDatabase } from "../db/sql-database"; import { z } from "zod"; import { ProviderAccountSelectionPolicyError } from "../model-provider-accounts/selection-policy"; const logger = createLogger("router:automations"); -/** Minimum cron interval in minutes. */ -const MIN_CRON_INTERVAL_MINUTES = 15; - -/** Maximum name length. */ const MAX_NAME_LENGTH = 200; - -/** Maximum instructions length. Keep in sync with INSTRUCTIONS_MAX_LENGTH in packages/web/src/components/automations/automation-form.tsx. */ -const MAX_INSTRUCTIONS_LENGTH = 15_000; - const RECENT_EXECUTION_COUNT = 10; -type ParseTriggerConfigResult = - | { ok: true; triggerConfig: TriggerConfig } - | { ok: false; error: string }; - -function parseTriggerConfig(value: unknown): ParseTriggerConfigResult { - const parsed = triggerConfigSchema.safeParse(value); - if (parsed.success) return { ok: true, triggerConfig: parsed.data }; - - const issue = parsed.error.issues[0]; - if (issue?.path.length === 1 && issue.path[0] === "conditions") { - return { ok: false, error: "triggerConfig.conditions must be an array" }; - } - - const path = ["triggerConfig", ...(issue?.path ?? [])].map(String).join("."); - const conditionIndex = issue?.path[0] === "conditions" ? issue.path[1] : undefined; - const rawConditions = - typeof value === "object" && value !== null && "conditions" in value - ? (value as { conditions?: unknown }).conditions - : undefined; - const rawCondition = - typeof conditionIndex === "number" && Array.isArray(rawConditions) - ? rawConditions[conditionIndex] - : undefined; - const conditionType = - typeof rawCondition === "object" && - rawCondition !== null && - "type" in rawCondition && - typeof rawCondition.type === "string" - ? `${rawCondition.type}: ` - : ""; - return { - ok: false, - error: `${path}: ${conditionType}${issue?.message ?? "invalid trigger config"}`, - }; -} - /** Warn if next run is more than 31 days away. */ const FAR_FUTURE_THRESHOLD_MS = 31 * 24 * 60 * 60 * 1000; -function resolveReasoningEffort( - model: string, - reasoningEffort: string | null | undefined -): string | null { - if (reasoningEffort === undefined || reasoningEffort === null) return null; - return isValidReasoningEffort(model, reasoningEffort) ? reasoningEffort : null; -} - -interface NormalizedRepositoryInput { - repoOwner: string; - repoName: string; - baseBranch: string | null; -} - -type RepositorySelectionRequest = - | { kind: "unchanged" } - | { kind: "replace"; repositories: NormalizedRepositoryInput[] }; - -/** - * Thrown by {@link parseRepositorySelection} and {@link parseEnvironmentBinding} - * when the session-target payload is invalid. Route handlers catch it and answer - * 400 — the parsers stay free of HTTP concerns (mirrors - * normalizeOptionalRepositoryPair / RepositoryPairValidationError). - */ -class TargetSelectionError extends Error { - constructor(message: string) { - super(message); - this.name = "TargetSelectionError"; - } -} - -/** - * Parse the repository selection from a create/update body. `unchanged` means - * the body did not touch the selection (create treats that as empty). - * - * @throws TargetSelectionError when the `repositories` payload is invalid. - */ -function parseRepositorySelection(body: { repositories?: unknown }): RepositorySelectionRequest { - if (body.repositories === undefined) return { kind: "unchanged" }; - const parsed = automationRepositoriesInputSchema.safeParse(body.repositories); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - const path = issue?.path.length ? `[${String(issue.path[0])}]` : ""; - throw new TargetSelectionError(`repositories${path}: ${issue?.message ?? "invalid"}`); - } - return { kind: "replace", repositories: parsed.data }; -} - -/** - * Target-count rules across BOTH selections (repositories + environments): - * repo-scoped event triggers need exactly one repository and no environments; - * fan-out over several targets is a schedule/manual-only product scope (event - * fan-out semantics are undefined, not technically prevented). Repositories - * and environments share one combined cap. - */ -function validateTargetCounts( - triggerType: AutomationTriggerType, - repositoryCount: number, - environmentCount: number -): void { - if (triggerType === "github_event" || triggerType === "linear_event") { - if (repositoryCount === 0) { - throw new TargetSelectionError("Repository-scoped triggers require exactly one repository"); - } - if (environmentCount > 0) { - throw new TargetSelectionError("Repository-scoped triggers cannot target environments"); - } - } - if (repositoryCount + environmentCount > 1 && triggerType !== "schedule") { - throw new TargetSelectionError("Multi-target selections require a schedule trigger"); - } - if (repositoryCount + environmentCount > MAX_AUTOMATION_REPOSITORIES) { - throw new TargetSelectionError( - `At most ${MAX_AUTOMATION_REPOSITORIES} repositories and environments combined` - ); - } -} - -type EnvironmentSelectionRequest = - | { kind: "unchanged" } - | { kind: "replace"; environmentIds: string[] }; - -/** - * Parse the environment selection from a create/update body (design §13.3). - * `unchanged` means the body did not touch the selection (create treats that - * as empty); an array replaces it wholesale (empty clears). - * - * @throws TargetSelectionError when the `environmentIds` payload is malformed. - */ -function parseEnvironmentSelection(body: { - environmentIds?: unknown; -}): EnvironmentSelectionRequest { - if (body.environmentIds === undefined) return { kind: "unchanged" }; - if ( - !Array.isArray(body.environmentIds) || - body.environmentIds.some((id) => typeof id !== "string" || !isEnvironmentId(id)) - ) { - throw new TargetSelectionError("environmentIds must be an array of environment ids (env_…)"); - } - const environmentIds = body.environmentIds as string[]; - if (new Set(environmentIds).size !== environmentIds.length) { - throw new TargetSelectionError("environmentIds must not contain duplicates"); - } - return { kind: "replace", environmentIds }; -} - -/** - * Verify every selected environment exists — a selection must not silently - * point at deleted environments. - * - * @throws TargetSelectionError naming every missing environment. - */ -async function resolveEnvironmentSelection( - db: SqlDatabase, - environmentIds: string[] -): Promise { - if (environmentIds.length === 0) return; - const store = new EnvironmentStore(db); - const found = await Promise.all(environmentIds.map((id) => store.getById(id))); - const missing = environmentIds.filter((_, index) => !found[index]); - if (missing.length > 0) { - throw new TargetSelectionError(`Environment not found: ${missing.join(", ")}`); - } -} - -/** - * Resolve every requested repository through the SCM provider concurrently. - * The first failure IN INPUT ORDER wins. A repo change always takes the body - * branch or the freshly resolved default — never a previous row's branch. - */ -async function resolveRepositorySelection( +function createAutomationCommandResolver( env: Env, - repositories: NormalizedRepositoryInput[], - ctx: RequestContext -): Promise { - const settled = await Promise.allSettled( - repositories.map((repository) => - resolveRepoOrError(env, repository.repoOwner, repository.repoName, ctx, logger) - ) - ); - const resolved = settled.map((result) => { - if (result.status === "rejected") throw result.reason; - return result.value; - }); - - return repositories.map((repository, index) => { - const access = resolved[index]; - return { - repo_owner: repository.repoOwner, - repo_name: repository.repoName, - repo_id: access.repoId, - base_branch: repository.baseBranch ?? access.defaultBranch, - }; + ctx: RequestContext, + resolveUserId: (metadata: { + displayName?: string; + email?: string; + avatarUrl?: string; + }) => Promise = async () => { + throw new Error("Canonical user resolution is unavailable for updates"); + } +): AutomationCommandResolver { + const store = new AutomationStore(ctx.db); + const environments = new EnvironmentStore(ctx.db); + return new AutomationCommandResolver({ + now: () => Date.now(), + generateId, + resolveRepository: async (repository) => { + const access = await resolveRepoOrError( + env, + repository.repoOwner, + repository.repoName, + ctx, + logger + ); + return { repoId: access.repoId, defaultBranch: access.defaultBranch }; + }, + environmentExists: async (id) => (await environments.getById(id)) !== null, + getRepositoryCount: async (automationId) => + (await store.getRepositoriesForAutomation(automationId)).length, + getEnvironmentCount: async (automationId) => + (await store.getEnvironmentsForAutomation(automationId)).length, + resolveProviderSelections: async (value) => { + try { + return await parseAndValidateAutomationProviderSelections(ctx.db, value); + } catch (e) { + if (e instanceof AutomationProviderSelectionError) { + throw new AutomationMutationResolutionError(e.message, 400); + } + if (e instanceof ProviderAccountSelectionPolicyError) { + throw new AutomationMutationResolutionError(e.message, e.status); + } + throw e; + } + }, + resolveCanonicalUserId: resolveUserId, + generateWebhookApiKey, + hashWebhookApiKey: hashApiKey, + encryptSentrySecret: (secret) => encryptSentrySecret(secret, env.REPO_SECRETS_ENCRYPTION_KEY!), + hasSentryEncryptionKey: Boolean(env.REPO_SECRETS_ENCRYPTION_KEY), }); } -/** - * Validate an IANA timezone string. - */ -function isValidTimezone(tz: string): boolean { - try { - Intl.DateTimeFormat(undefined, { timeZone: tz }); - return true; - } catch { - return false; - } -} - -/** Extract the watched channel IDs from a slack automation's `slack_channel` condition. */ -function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): string[] { - for (const condition of triggerConfig?.conditions ?? []) { - if (condition.type === "slack_channel") return condition.value; - } - return []; -} - -/** - * Validate a slack_event trigger config before persistence. It must be scoped to - * an explicit channel set (net-new validation; the engine otherwise skips - * condition validation entirely when none are present). A text_match is optional - * — without one the automation fires on every message in the watched channel. - * Returns an error message, or null when valid. - */ -function validateSlackTriggerConfig( - triggerConfig: TriggerConfig | null | undefined -): string | null { - const conditions = triggerConfig?.conditions ?? []; - if (!conditions.some((c) => c.type === "slack_channel")) { - return "slack_event triggers require a slack_channel condition"; - } - return null; -} - // ─── Handlers ──────────────────────────────────────────────────────────────── const DEFAULT_AUTOMATION_LIST_PAGE_SIZE = 25; @@ -444,235 +241,61 @@ async function handleCreateAutomation( _match: RegExpMatchArray, ctx: RequestContext ): Promise { - const body = await parseJsonBody< - CreateAutomationRequest & { - // Bot-asserted actor display fields — cosmetic, never identity. - actorDisplayName?: string; - actorEmail?: string; - actorAvatarUrl?: string; - } - >(request); - if (body instanceof Response) return body; - if (body.triggerConfig !== undefined) { - const parsedTriggerConfig = parseTriggerConfig(body.triggerConfig); - if (!parsedTriggerConfig.ok) return error(parsedTriggerConfig.error, 400); - body.triggerConfig = parsedTriggerConfig.triggerConfig; - } + const rawBody = await parseJsonBody(request); + if (rawBody instanceof Response) return rawBody; // Automation attribution comes from the verified principal. The stored // values are replayed by the scheduler as session identity at fire time, // so this is where they become trustworthy. - const enforcement = applyIdentityEnforcement(ctx, "automation-create", body); + const enforcement = applyIdentityEnforcement(ctx, "automation-create", rawBody); if (enforcement.rejection) return enforcement.rejection; const enforced = enforcement.enforced; - // Validate required fields - if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { - return error("name is required", 400); - } - if (body.name.length > MAX_NAME_LENGTH) { - return error(`name must be at most ${MAX_NAME_LENGTH} characters`, 400); - } - if ( - !body.instructions || - typeof body.instructions !== "string" || - body.instructions.trim().length === 0 - ) { - return error("instructions is required", 400); - } - if (body.instructions.length > MAX_INSTRUCTIONS_LENGTH) { - return error(`instructions must be at most ${MAX_INSTRUCTIONS_LENGTH} characters`, 400); - } - - let selection: RepositorySelectionRequest; - try { - selection = parseRepositorySelection(body); - } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); - throw e; - } - const requestedRepositories = selection.kind === "replace" ? selection.repositories : []; - - // Validate trigger type - const triggerType: AutomationTriggerType = body.triggerType || "schedule"; - const validTriggerTypes: AutomationTriggerType[] = [ - "schedule", - "sentry", - "webhook", - "github_event", - "linear_event", - "slack_event", - ]; - if (!validTriggerTypes.includes(triggerType)) { - return error(`triggerType must be one of: ${validTriggerTypes.join(", ")}`, 400); - } - let requestedEnvironmentIds: string[]; + let body; try { - const environmentSelection = parseEnvironmentSelection(body); - requestedEnvironmentIds = - environmentSelection.kind === "replace" ? environmentSelection.environmentIds : []; - validateTargetCounts(triggerType, requestedRepositories.length, requestedEnvironmentIds.length); - await resolveEnvironmentSelection(ctx.db, requestedEnvironmentIds); + body = parseCreateAutomationMutation(rawBody); } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); + if (e instanceof AutomationMutationInputError) return error(e.message, 400); throw e; } - const isSchedule = triggerType === "schedule"; - - // Schedule-specific validation - if (isSchedule) { - if (!body.scheduleCron || !isValidCron(body.scheduleCron)) { - return error("scheduleCron must be a valid 5-field cron expression", 400); - } - const interval = cronIntervalMinutes(body.scheduleCron); - if (interval !== null && interval < MIN_CRON_INTERVAL_MINUTES) { - return error(`Schedule interval must be at least ${MIN_CRON_INTERVAL_MINUTES} minutes`, 400); - } - if (!body.scheduleTz || !isValidTimezone(body.scheduleTz)) { - return error("scheduleTz must be a valid IANA timezone", 400); - } - } else { - // Reject schedule fields for non-schedule types - if (body.scheduleCron || body.scheduleTz) { - return error("scheduleCron and scheduleTz are only valid for schedule triggers", 400); - } - } - - // Event-type validation for sentry triggers - if (triggerType === "sentry" && !body.eventType) { - return error("eventType is required for sentry triggers", 400); - } - - // Validate conditions - if (body.triggerConfig?.conditions) { - const source = TRIGGER_TYPE_TO_SOURCE[triggerType]; - if (source) { - const conditionErrors = validateConditions( - body.triggerConfig.conditions, - source, - conditionRegistry + let command; + let webhookApiKey: string | undefined; + try { + const resolver = createAutomationCommandResolver(env, ctx, async (metadata) => { + const resolution = await resolveCanonicalUserId( + new UserStore(ctx.db), + ctx, + enforced, + metadata ); - if (conditionErrors.length > 0) { - return error(conditionErrors.join("; "), 400); + if (resolution instanceof Response) { + throw new AutomationMutationResolutionError("Failed to resolve session identity", 500); } - } - } - - // Slack triggers require explicit scoping (at least one watched channel). - if (triggerType === "slack_event") { - const slackError = validateSlackTriggerConfig(body.triggerConfig); - if (slackError) return error(slackError, 400); - } - - // Validate model - const model = getValidModelOrDefault(body.model); - const reasoningEffort = resolveReasoningEffort(model, body.reasoningEffort); - if (body.reasoningEffort !== undefined && body.reasoningEffort !== null && !reasoningEffort) { - return error("Invalid reasoning effort for selected model", 400); - } - - const newRepositories = await resolveRepositorySelection(env, requestedRepositories, ctx); - - let providerSelections: ModelProviderSelections; - try { - providerSelections = await parseAndValidateAutomationProviderSelections( - ctx.db, - body.providerSelections ?? {} - ); + return resolution.userId; + }); + const resolved = await resolver.resolveCreate(body, { + createdBy: enforced.participantUserId, + }); + command = resolved.command; + webhookApiKey = resolved.webhookApiKey; } catch (e) { - if (e instanceof AutomationProviderSelectionError) return error(e.message, 400); - if (e instanceof ProviderAccountSelectionPolicyError) return error(e.message, e.status); + if (e instanceof AutomationMutationResolutionError) return error(e.message, e.status); throw e; } - - // Compute next run (only for schedule triggers) - const nextRunAt = isSchedule - ? nextCronOccurrence(body.scheduleCron!, body.scheduleTz!).getTime() - : null; - - const id = generateId(); - const now = Date.now(); - - // Generate auth data for trigger types that need it - let webhookApiKey: string | undefined; - let triggerAuthData: string | null = null; - if (triggerType === "webhook") { - webhookApiKey = generateWebhookApiKey(); - triggerAuthData = await hashApiKey(webhookApiKey); - } else if (triggerType === "sentry") { - const sentrySecret = body.sentryClientSecret; - if (!sentrySecret || typeof sentrySecret !== "string" || sentrySecret.trim().length === 0) { - return error("sentryClientSecret is required for sentry triggers", 400); - } - if (!env.REPO_SECRETS_ENCRYPTION_KEY) { - return error("Encryption key not configured", 503); - } - triggerAuthData = await encryptSentrySecret(sentrySecret, env.REPO_SECRETS_ENCRYPTION_KEY); - } - - // Resolve the canonical user model ID fail-closed from the verified - // principal — the scheduler replays user_id as session identity at fire - // time, so an automation must never be created with lost attribution. - const resolution = await resolveCanonicalUserId(new UserStore(ctx.db), ctx, enforced, { - displayName: body.actorDisplayName, - email: body.actorEmail, - avatarUrl: body.actorAvatarUrl, - }); - if (resolution instanceof Response) return resolution; - const resolvedUserId = resolution.userId; - const db: SqlDatabase = ctx.db; const store = new AutomationStore(db); - const providerAuthStore = new AutomationModelProviderAuthStore(db); - const row: AutomationRow = { - id, - name: body.name.trim(), - instructions: body.instructions, - trigger_type: triggerType, - schedule_cron: body.scheduleCron ?? null, - schedule_tz: body.scheduleTz ?? "UTC", - model, - reasoning_effort: reasoningEffort, - enabled: 1, - next_run_at: nextRunAt, - consecutive_failures: 0, - created_by: enforced.participantUserId, - user_id: resolvedUserId, - created_at: now, - updated_at: now, - deleted_at: null, - event_type: body.eventType ?? null, - trigger_config: body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, - trigger_auth_data: triggerAuthData, - }; - - // Persist the automation, its repository selection, and (for slack_event) - // its watched-channel index in a single atomic write, so none of the three - // can drift apart on a partial failure. The batch composes the single-table - // stores' prepared statements. - const createStatements = [ - store.bindAutomationInsert(row), - ...store.bindRepositoryInserts(id, newRepositories, now), - ...store.bindEnvironmentInserts(id, requestedEnvironmentIds, now), - ...providerAuthStore.bindInserts(id, providerSelections, now), - ]; - if (triggerType === "slack_event") { - const slackStore = new SlackChannelStore(db); - createStatements.push( - ...slackStore.bindChannelStatements(row.id, extractSlackChannels(body.triggerConfig)) - ); - } - await db.batch(createStatements); + await new D1AutomationAggregateWriter(db).create(command); - const automation = await hydrateAutomation(db, (await store.getById(id))!); + const automation = await hydrateAutomation(db, (await store.getById(command.id))!); logger.info("automation.created", { event: "automation.created", - automation_id: id, - repo: newRepositories.map((repo) => `${repo.repo_owner}/${repo.repo_name}`).join(",") || null, - environments: requestedEnvironmentIds.join(",") || null, - trigger_type: triggerType, + automation_id: command.id, + repo: + command.repositories.map((repo) => `${repo.repo_owner}/${repo.repo_name}`).join(",") || null, + environments: command.environmentIds.join(",") || null, + trigger_type: command.triggerType, request_id: ctx.request_id, trace_id: ctx.trace_id, }); @@ -688,14 +311,14 @@ async function handleCreateAutomation( if (webhookApiKey) { result.webhookApiKey = webhookApiKey; - result.webhookUrl = `${workerUrl}/webhooks/automation/${id}`; + result.webhookUrl = `${workerUrl}/webhooks/automation/${command.id}`; } - if (triggerType === "sentry") { - result.sentryWebhookUrl = `${workerUrl}/webhooks/sentry/${id}`; + if (command.triggerType === "sentry") { + result.sentryWebhookUrl = `${workerUrl}/webhooks/sentry/${command.id}`; } - if (nextRunAt && nextRunAt - now > FAR_FUTURE_THRESHOLD_MS) { + if (command.nextRunAt && command.nextRunAt - command.now > FAR_FUTURE_THRESHOLD_MS) { result.warning = "Next scheduled run is more than 31 days away"; } @@ -729,252 +352,26 @@ async function handleUpdateAutomation( const db: SqlDatabase = ctx.db; const store = new AutomationStore(db); - const providerAuthStore = new AutomationModelProviderAuthStore(db); const existing = await store.getById(id); if (!existing) return error("Automation not found", 404); - const body = await parseJsonBody(request); - if (body instanceof Response) return body; - if (body.triggerConfig !== undefined) { - if (existing.trigger_type === "schedule") { - return error("Cannot set triggerConfig on schedule automations", 400); - } - if (body.triggerConfig !== null) { - const parsedTriggerConfig = parseTriggerConfig(body.triggerConfig); - if (!parsedTriggerConfig.ok) return error(parsedTriggerConfig.error, 400); - body.triggerConfig = parsedTriggerConfig.triggerConfig; - } - } - - let replacementProviderSelections: ModelProviderSelections | null = null; - if (body.providerSelections !== undefined) { - try { - replacementProviderSelections = await parseAndValidateAutomationProviderSelections( - ctx.db, - body.providerSelections - ); - } catch (e) { - if (e instanceof AutomationProviderSelectionError) return error(e.message, 400); - if (e instanceof ProviderAccountSelectionPolicyError) return error(e.message, e.status); - throw e; - } - } - - // Validate fields if provided - if (body.name !== undefined) { - if (typeof body.name !== "string" || body.name.trim().length === 0) { - return error("name cannot be empty", 400); - } - if (body.name.length > MAX_NAME_LENGTH) { - return error(`name must be at most ${MAX_NAME_LENGTH} characters`, 400); - } - } - - if (body.instructions !== undefined) { - if (typeof body.instructions !== "string" || body.instructions.trim().length === 0) { - return error("instructions cannot be empty", 400); - } - if (body.instructions.length > MAX_INSTRUCTIONS_LENGTH) { - return error(`instructions must be at most ${MAX_INSTRUCTIONS_LENGTH} characters`, 400); - } - } - - if (body.scheduleCron !== undefined) { - if (!isValidCron(body.scheduleCron)) { - return error("scheduleCron must be a valid 5-field cron expression", 400); - } - const interval = cronIntervalMinutes(body.scheduleCron); - if (interval !== null && interval < MIN_CRON_INTERVAL_MINUTES) { - return error(`Schedule interval must be at least ${MIN_CRON_INTERVAL_MINUTES} minutes`, 400); - } - } - - if (body.scheduleTz !== undefined && !isValidTimezone(body.scheduleTz)) { - return error("scheduleTz must be a valid IANA timezone", 400); - } - - if (body.model !== undefined && !isValidModel(body.model)) { - return error("Invalid model", 400); - } - - const nextModel = body.model !== undefined ? getValidModelOrDefault(body.model) : existing.model; - const requestedReasoningEffort = body.reasoningEffort; - const resolvedReasoningEffort = - requestedReasoningEffort !== undefined - ? resolveReasoningEffort(nextModel, requestedReasoningEffort) - : body.model !== undefined && existing.reasoning_effort !== null - ? resolveReasoningEffort(nextModel, existing.reasoning_effort) - : existing.reasoning_effort; - - if ( - requestedReasoningEffort !== undefined && - requestedReasoningEffort !== null && - resolvedReasoningEffort === null - ) { - return error("Invalid reasoning effort for selected model", 400); - } - - // Build update fields - const updateFields: Record = {}; - if (body.name !== undefined) updateFields.name = body.name.trim(); - if (body.instructions !== undefined) updateFields.instructions = body.instructions; - if (body.scheduleCron !== undefined) updateFields.schedule_cron = body.scheduleCron; - if (body.scheduleTz !== undefined) updateFields.schedule_tz = body.scheduleTz; - if (body.model !== undefined) updateFields.model = nextModel; - if (body.reasoningEffort !== undefined || body.model !== undefined) { - updateFields.reasoning_effort = resolvedReasoningEffort; - } - - // Repository-set edits are UNCONDITIONAL — no cardinality freeze and no - // active-invocation guard. In-flight invocations already materialized their - // children from their firing-time snapshot, so an edit cannot corrupt them; - // it simply applies from the next invocation. - let selection: RepositorySelectionRequest; + const rawBody = await parseJsonBody(request); + if (rawBody instanceof Response) return rawBody; + let body; try { - selection = parseRepositorySelection(body); + body = parseUpdateAutomationMutation(rawBody, existing.trigger_type); } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); + if (e instanceof AutomationMutationInputError) return error(e.message, 400); throw e; } - - let environmentSelection: EnvironmentSelectionRequest; + let command; try { - environmentSelection = parseEnvironmentSelection(body); + command = await createAutomationCommandResolver(env, ctx).resolveUpdate(body, existing); } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); + if (e instanceof AutomationMutationResolutionError) return error(e.message, e.status); throw e; } - - // The count rules span both selections, so when EITHER is replaced they are - // validated against the automation's FINAL state (the replacement plus the - // other side's existing rows). Edits that touch neither selection skip this - // — count rules stay write-time so a stored selection predating a rule can - // never brick unrelated edits. - let replacementRepositories: AutomationRepositoryInsert[] | null = null; - const replacementEnvironmentIds: string[] | null = - environmentSelection.kind === "replace" ? environmentSelection.environmentIds : null; - if (selection.kind === "replace" || replacementEnvironmentIds !== null) { - try { - const finalRepositoryCount = - selection.kind === "replace" - ? selection.repositories.length - : (await store.getRepositoriesForAutomation(id)).length; - const finalEnvironmentCount = - replacementEnvironmentIds !== null - ? replacementEnvironmentIds.length - : (await store.getEnvironmentsForAutomation(id)).length; - validateTargetCounts( - existing.trigger_type as AutomationTriggerType, - finalRepositoryCount, - finalEnvironmentCount - ); - if (replacementEnvironmentIds !== null) { - await resolveEnvironmentSelection(ctx.db, replacementEnvironmentIds); - } - } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); - throw e; - } - if (selection.kind === "replace") { - replacementRepositories = await resolveRepositorySelection(env, selection.repositories, ctx); - } - } - - // Update event type — only for non-schedule types - if (body.eventType !== undefined) { - if (existing.trigger_type === "schedule") { - return error("Cannot set eventType on schedule automations", 400); - } - updateFields.event_type = body.eventType; - } - - // Validate trigger config (conditions) — only for non-schedule types - if (body.triggerConfig !== undefined) { - if (body.triggerConfig === null) { - // A slack_event's trigger_config holds its required scoping (channel + - // text_match) and the watched-channel index is derived from it. Clearing - // it would leave the automation enabled but untriggerable, so reject null - // — pause or delete instead. (Other sources may clear conditions to a - // match-all, so null stays allowed for them.) - if (existing.trigger_type === "slack_event") { - return error( - "Cannot clear triggerConfig on slack_event automations; pause or delete instead", - 400 - ); - } - } else { - if (existing.trigger_type === "slack_event") { - const slackError = validateSlackTriggerConfig(body.triggerConfig); - if (slackError) return error(slackError, 400); - } - if (body.triggerConfig.conditions) { - const source = TRIGGER_TYPE_TO_SOURCE[existing.trigger_type as AutomationTriggerType]; - if (source) { - const conditionErrors = validateConditions( - body.triggerConfig.conditions, - source, - conditionRegistry - ); - if (conditionErrors.length > 0) { - return error(conditionErrors.join("; "), 400); - } - } - } - } - } - - // trigger_config is a single source-interpreted JSON blob (the conditions), - // so a PUT replaces it wholesale (null clears it). The caller owns the full - // blob; the web form always re-submits the conditions within triggerConfig. - if (body.triggerConfig === null) { - updateFields.trigger_config = null; - } else if (body.triggerConfig !== undefined) { - updateFields.trigger_config = JSON.stringify(body.triggerConfig); - } - - // Recompute next_run_at if schedule changed (only for schedule types) - if ( - existing.trigger_type === "schedule" && - (body.scheduleCron !== undefined || body.scheduleTz !== undefined) - ) { - const cron = body.scheduleCron ?? existing.schedule_cron; - const tz = body.scheduleTz ?? existing.schedule_tz; - if (!cron) { - return error("Cannot compute schedule: no cron expression", 400); - } - updateFields.next_run_at = nextCronOccurrence(cron, tz).getTime(); - } - - // Apply the field update, the repository-selection replacement (which - // carries the transitional scalar-mirror dual-write), and any slack - // watched-channel re-sync in ONE atomic batch so none of them can drift - // apart on a partial failure. Tolerates a null update statement (e.g. a - // repositories-only edit). - const resyncSlackChannels = - existing.trigger_type === "slack_event" && body.triggerConfig !== undefined; - const statements: SqlStatement[] = []; - const updateStatement = store.bindAutomationUpdate(id, updateFields); - if (updateStatement) statements.push(updateStatement); - if (replacementRepositories !== null) { - statements.push(...store.bindReplaceRepositories(id, replacementRepositories, Date.now())); - } - if (replacementEnvironmentIds !== null) { - statements.push(...store.bindReplaceEnvironments(id, replacementEnvironmentIds, Date.now())); - } - if (replacementProviderSelections !== null) { - statements.push( - ...providerAuthStore.bindReplace(id, replacementProviderSelections, Date.now()) - ); - } - if (resyncSlackChannels) { - const slackStore = new SlackChannelStore(db); - statements.push( - ...slackStore.bindChannelStatements(id, extractSlackChannels(body.triggerConfig)) - ); - } - if (statements.length > 0) { - await db.batch(statements); - } + await new D1AutomationAggregateWriter(db).update(command); const updated = await store.getById(id); if (!updated) return error("Automation not found", 404);