diff --git a/plugins/automations/src/automations.test.ts b/plugins/automations/src/automations.test.ts index c44d3596c1..fa0053cf1e 100644 --- a/plugins/automations/src/automations.test.ts +++ b/plugins/automations/src/automations.test.ts @@ -22,16 +22,31 @@ import { listAutomationsForProject, listAutomationRuns, migrations, + parseAutomationExecution, + parseAutomationTrigger, setAutomationEnabled, setAutomationRunThread, AUTOMATION_RETRY_BASE_MS, type Db, } from "./data.js"; +import { + AUTOMATION_PROMPT_MAX_LENGTH, + automationExecutionSchema, + automationTriggerSchema, + createAutomationInputSchema, + updateAutomationInputSchema, + type AutomationTrigger, +} from "./rpc-types.js"; +import { + SCHEDULE_CRON_MAX_LENGTH, + SCHEDULE_TIMEZONE_MAX_LENGTH, +} from "./limits.js"; import { ingestLegacyImport } from "./legacy-import.js"; import { computeInitialNextRunAt, computeNextScheduledTime, validateOnceDefinition, + validateScheduleDefinition, } from "./schedule-helpers.js"; import { bbBinaryCandidates, @@ -1456,6 +1471,581 @@ describe("automation CLI --script-file", () => { }); }); +// Shared CLI-backed harness for the length-cap regression blocks below. Both +// the execution caps (#2166) and the trigger caps exercise the same path: the +// real CLI registration on top of the real service and an in-memory database. +const cliCtx = { cwd: "/", threadId: undefined } as never; + +async function setupCliHarness() { + const db = createTestDb(); + const pluginDataDir = await mkdtemp(join(tmpdir(), "bb-2166-data-")); + const warnings: string[] = []; + const serviceBb = createAutomationServiceBb(); + const service = createAutomationService({ + bb: { + ...serviceBb, + log: { + ...serviceBb.log, + warn: (message: string) => { + warnings.push(message); + }, + }, + }, + db, + pluginDataDir, + serverUrl: "http://127.0.0.1:1", + }); + let cli: PluginCliRegistration | undefined; + registerAutomationCli({ + bb: { + sdk: { ...serviceBb.sdk, hosts: { list: async () => [] } } as never, + cli: { + register: (registration) => { + cli = registration; + }, + }, + }, + service, + }); + if (!cli) throw new Error("automation CLI was not registered"); + return { + cli, + db, + service, + pluginDataDir, + warnings, + async cleanup() { + await rm(pluginDataDir, { recursive: true, force: true }); + }, + }; +} + +async function createAgentAutomation( + cli: PluginCliRegistration, +): Promise { + const created = await cli.run( + [ + "create", + "--project", + "proj_test", + "--name", + "issue-2166", + "--cron", + "*/5 * * * *", + "--timezone", + "UTC", + "--prompt", + "Reply only with ok.", + "--provider", + "codex", + "--model", + "gpt-5", + ], + cliCtx, + ); + expect(created.exitCode, created.stderr).toBe(0); + const id = /Automation created: (\S+)/.exec(created.stdout ?? "")?.[1]; + if (!id) throw new Error(`no id in: ${created.stdout}`); + return id; +} + +function storedAgentPrompt(db: Db, id: string): string { + const row = getAutomation(db, id); + if (!row) throw new Error(`missing automation ${id}`); + const execution = parseAutomationExecution(row.execution); + if (execution.mode !== "agent") throw new Error("not an agent automation"); + return execution.prompt; +} + +describe("automation CLI length caps (#2166)", () => { + // Regression for get-bb/bb#2166: the CLI skipped the request-side length + // caps, so `update --prompt ` wrote the row and only failed when + // the stored row was re-parsed. Every later list/show/update for the project + // then failed with the same `too_big` issue. Caps must be enforced at the + // argv boundary before anything is persisted, and rows that already exceed a + // cap must stay readable and repairable. + const overCapPrompt = "x".repeat(AUTOMATION_PROMPT_MAX_LENGTH + 39); + + it("rejects an over-cap --prompt on update without persisting it", async () => { + const t = await setupCliHarness(); + try { + const id = await createAgentAutomation(t.cli); + const update = await t.cli.run( + ["update", id, "--project", "proj_test", "--prompt", overCapPrompt], + cliCtx, + ); + expect(update.exitCode).toBe(1); + expect(update.stderr).toContain("too_big"); + expect(storedAgentPrompt(t.db, id)).toBe("Reply only with ok."); + + // The project is still fully usable afterwards. + const list = await t.cli.run(["list", "--project", "proj_test"], cliCtx); + expect(list.exitCode, list.stderr).toBe(0); + expect(list.stdout).toContain(id); + const show = await t.cli.run( + ["show", id, "--project", "proj_test"], + cliCtx, + ); + expect(show.exitCode, show.stderr).toBe(0); + const repair = await t.cli.run( + ["update", id, "--project", "proj_test", "--prompt", "short again"], + cliCtx, + ); + expect(repair.exitCode, repair.stderr).toBe(0); + expect(storedAgentPrompt(t.db, id)).toBe("short again"); + } finally { + await t.cleanup(); + } + }); + + it("rejects an over-cap --prompt on create without persisting it", async () => { + const t = await setupCliHarness(); + try { + const created = await t.cli.run( + [ + "create", + "--project", + "proj_test", + "--name", + "issue-2166", + "--in", + "30m", + "--prompt", + overCapPrompt, + "--provider", + "codex", + "--model", + "gpt-5", + ], + cliCtx, + ); + expect(created.exitCode).toBe(1); + expect(created.stderr).toContain("too_big"); + expect(t.service.list({ projectId: "proj_test" })).toEqual([]); + } finally { + await t.cleanup(); + } + }); + + it("rejects an over-cap inline --script on create and update before writing the snapshot", async () => { + const t = await setupCliHarness(); + try { + const overCapScript = `#!/bin/sh\n# ${"y".repeat(262_144)}\n`; + const created = await t.cli.run( + [ + "create", + "--project", + "proj_test", + "--name", + "issue-2166", + "--in", + "30m", + "--script", + overCapScript, + ], + cliCtx, + ); + expect(created.exitCode).toBe(1); + expect(created.stderr).toContain("too_big"); + expect(t.service.list({ projectId: "proj_test" })).toEqual([]); + await expect(readdir(join(t.pluginDataDir, "scripts"))).rejects.toThrow(); + + const id = await createAgentAutomation(t.cli); + const update = await t.cli.run( + ["update", id, "--project", "proj_test", "--script", overCapScript], + cliCtx, + ); + expect(update.exitCode).toBe(1); + expect(update.stderr).toContain("too_big"); + expect(storedAgentPrompt(t.db, id)).toBe("Reply only with ok."); + await expect(readdir(join(t.pluginDataDir, "scripts"))).rejects.toThrow(); + } finally { + await t.cleanup(); + } + }); + + it("keeps an already-stored over-cap prompt readable and repairable", async () => { + // A user who hit the bug before the fix has such a row on disk. + const t = await setupCliHarness(); + try { + const healthyId = await createAgentAutomation(t.cli); + const brokenId = await createAgentAutomation(t.cli); + t.db + .prepare( + "UPDATE automations SET execution = json_set(execution, '$.prompt', ?) WHERE id = ?", + ) + .run(overCapPrompt, brokenId); + expect(storedAgentPrompt(t.db, brokenId)).toBe(overCapPrompt); + + const list = await t.cli.run(["list", "--project", "proj_test"], cliCtx); + expect(list.exitCode, list.stderr).toBe(0); + expect(list.stdout).toContain(healthyId); + expect(list.stdout).toContain(brokenId); + + const show = await t.cli.run( + ["show", brokenId, "--project", "proj_test"], + cliCtx, + ); + expect(show.exitCode, show.stderr).toBe(0); + + const repair = await t.cli.run( + ["update", brokenId, "--project", "proj_test", "--prompt", "short"], + cliCtx, + ); + expect(repair.exitCode, repair.stderr).toBe(0); + expect(storedAgentPrompt(t.db, brokenId)).toBe("short"); + } finally { + await t.cleanup(); + } + }); + + it("list skips a malformed row instead of failing the whole project", async () => { + const t = await setupCliHarness(); + try { + const healthyId = await createAgentAutomation(t.cli); + const brokenId = await createAgentAutomation(t.cli); + t.db + .prepare("UPDATE automations SET execution = ? WHERE id = ?") + .run(JSON.stringify({ mode: "agent" }), brokenId); + + const listed = t.service.list({ projectId: "proj_test" }); + expect(listed.map((automation) => automation.id)).toEqual([healthyId]); + expect(t.warnings.join("\n")).toContain( + `Skipping malformed automation ${brokenId}`, + ); + } finally { + await t.cleanup(); + } + }); + + it("the RPC request schemas still enforce the caps", () => { + const base = { + projectId: "proj_test", + name: "issue-2166", + trigger: oneShotTrigger(), + origin: "human" as const, + }; + const agentExecution = { + mode: "agent" as const, + providerId: "codex", + model: "gpt-5", + permissionMode: "auto" as const, + environment: { type: "project-default" as const }, + }; + expect( + createAutomationInputSchema.safeParse({ + ...base, + execution: { ...agentExecution, prompt: overCapPrompt }, + }).success, + ).toBe(false); + expect( + createAutomationInputSchema.safeParse({ + ...base, + execution: { mode: "script", script: "z".repeat(262_145) }, + }).success, + ).toBe(false); + expect( + updateAutomationInputSchema.safeParse({ + projectId: "proj_test", + automationId: "auto_1", + agent: { prompt: overCapPrompt }, + }).success, + ).toBe(false); + expect( + updateAutomationInputSchema.safeParse({ + projectId: "proj_test", + automationId: "auto_1", + execution: { ...agentExecution, prompt: overCapPrompt }, + }).success, + ).toBe(false); + // The stored shape does not carry the caps. + expect( + automationExecutionSchema.safeParse({ + ...agentExecution, + prompt: overCapPrompt, + }).success, + ).toBe(true); + }); +}); + +describe("automation CLI trigger caps (#2166 on the trigger column)", () => { + // The #2166 fix above split request policy from the stored shape for the + // execution column only. The trigger column kept the same write-then-fail + // mechanism: `automationScheduleTriggerSchema` carried the cron/timezone caps + // and also parsed the stored `trigger_config`, while the CLI's buildTrigger + // handed an unparsed value straight to service.create/update. A valid but + // over-cap cron was therefore committed and only rejected when the row was + // read back, which poisoned show/update/pause/resume and made list drop the + // row silently. Caps belong on the request schema; the stored shape must stay + // readable and repairable. + + // 60 comma-separated minutes: 110 characters of valid 5-field cron. + const overCapCron = `${Array.from({ length: 60 }, (_, minute) => minute).join(",")} * * * *`; + // No valid IANA timezone is anywhere near the cap, so the timezone half of + // the request policy is only exercised at the schema level below. + const overCapTimezone = `America/${"a".repeat(SCHEDULE_TIMEZONE_MAX_LENGTH)}`; + + function storedTrigger(db: Db, id: string): AutomationTrigger { + const row = getAutomation(db, id); + if (!row) throw new Error(`missing automation ${id}`); + return parseAutomationTrigger(row.triggerConfig); + } + + it("keeps the over-cap cron valid so the cap is the only reason to reject", () => { + expect(overCapCron.length).toBeGreaterThan(SCHEDULE_CRON_MAX_LENGTH); + expect(() => + validateScheduleDefinition({ cron: overCapCron, timezone: "UTC" }), + ).not.toThrow(); + }); + + it("rejects an over-cap --cron on create without persisting the row", async () => { + const t = await setupCliHarness(); + try { + const created = await t.cli.run( + [ + "create", + "--project", + "proj_test", + "--name", + "over-cap-cron", + "--cron", + overCapCron, + "--timezone", + "UTC", + "--prompt", + "Reply only with ok.", + "--provider", + "codex", + "--model", + "gpt-5", + ], + cliCtx, + ); + expect(created.exitCode).toBe(1); + expect(created.stderr).toContain("too_big"); + expect(listAutomationsForProject(t.db, "proj_test")).toEqual([]); + } finally { + await t.cleanup(); + } + }); + + it("rejects an over-cap --cron on update without poisoning a healthy row", async () => { + const t = await setupCliHarness(); + try { + const id = await createAgentAutomation(t.cli); + const before = getAutomation(t.db, id); + const update = await t.cli.run( + [ + "update", + id, + "--project", + "proj_test", + "--cron", + overCapCron, + "--timezone", + "UTC", + ], + cliCtx, + ); + expect(update.exitCode).toBe(1); + expect(update.stderr).toContain("too_big"); + expect(getAutomation(t.db, id)).toEqual(before); + expect(storedTrigger(t.db, id)).toEqual({ + triggerType: "schedule", + cron: "*/5 * * * *", + timezone: "UTC", + }); + + // The automation is still fully usable afterwards. + const show = await t.cli.run( + ["show", id, "--project", "proj_test"], + cliCtx, + ); + expect(show.exitCode, show.stderr).toBe(0); + const pause = await t.cli.run( + ["pause", id, "--project", "proj_test"], + cliCtx, + ); + expect(pause.exitCode, pause.stderr).toBe(0); + const resume = await t.cli.run( + ["resume", id, "--project", "proj_test"], + cliCtx, + ); + expect(resume.exitCode, resume.stderr).toBe(0); + } finally { + await t.cleanup(); + } + }); + + it("keeps an already-stored over-cap cron readable and repairable", async () => { + // A row written by an older build (or any non-request path) has such a + // trigger on disk; every read path must survive it. + const t = await setupCliHarness(); + try { + const healthyId = await createAgentAutomation(t.cli); + const brokenId = await createAgentAutomation(t.cli); + t.db + .prepare( + "UPDATE automations SET trigger_config = json_set(trigger_config, '$.cron', ?) WHERE id = ?", + ) + .run(overCapCron, brokenId); + expect(storedTrigger(t.db, brokenId)).toEqual({ + triggerType: "schedule", + cron: overCapCron, + timezone: "UTC", + }); + + const list = await t.cli.run(["list", "--project", "proj_test"], cliCtx); + expect(list.exitCode, list.stderr).toBe(0); + expect(list.stdout).toContain(healthyId); + expect(list.stdout).toContain(brokenId); + + const show = await t.cli.run( + ["show", brokenId, "--project", "proj_test"], + cliCtx, + ); + expect(show.exitCode, show.stderr).toBe(0); + + // An unrelated patch must not fail on the stored trigger it re-parses. + const rename = await t.cli.run( + ["update", brokenId, "--project", "proj_test", "--name", "renamed"], + cliCtx, + ); + expect(rename.exitCode, rename.stderr).toBe(0); + + const pause = await t.cli.run( + ["pause", brokenId, "--project", "proj_test"], + cliCtx, + ); + expect(pause.exitCode, pause.stderr).toBe(0); + const resume = await t.cli.run( + ["resume", brokenId, "--project", "proj_test"], + cliCtx, + ); + expect(resume.exitCode, resume.stderr).toBe(0); + + const repair = await t.cli.run( + [ + "update", + brokenId, + "--project", + "proj_test", + "--cron", + "0 9 * * *", + "--timezone", + "UTC", + ], + cliCtx, + ); + expect(repair.exitCode, repair.stderr).toBe(0); + expect(storedTrigger(t.db, brokenId)).toEqual({ + triggerType: "schedule", + cron: "0 9 * * *", + timezone: "UTC", + }); + } finally { + await t.cleanup(); + } + }); + + it("the sweep no longer rejects an already-stored over-cap cron row", async () => { + // Before the split the sweep logged "invalid stored configuration" for such + // a row on every tick (every SWEEP_INTERVAL_MS) and never advanced it. + const t = await setupCliHarness(); + try { + const id = await createAgentAutomation(t.cli); + const now = 10_000_000; + t.db + .prepare( + "UPDATE automations SET trigger_config = json_set(trigger_config, '$.cron', ?), next_run_at = ? WHERE id = ?", + ) + .run(overCapCron, now - 1, id); + const errors: string[] = []; + const sweepBb = { + sdk: { + hosts: { list: async () => [] }, + threads: { + get: async () => { + throw new Error("not expected"); + }, + send: async () => { + throw new Error("not expected"); + }, + spawn: async () => { + throw new Error("not expected"); + }, + }, + }, + realtime: { publish: () => undefined }, + log: { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: (message: string) => { + errors.push(message); + }, + }, + }; + await sweepDueAutomations(sweepBb, t.db, { + pluginDataDir: t.pluginDataDir, + serverUrl: "http://127.0.0.1:1", + now, + }); + expect(errors).toEqual([]); + } finally { + await t.cleanup(); + } + }); + + it("the RPC request schemas still enforce the trigger caps", () => { + const base = { + projectId: "proj_test", + name: "over-cap-cron", + origin: "human" as const, + execution: { + mode: "agent" as const, + prompt: "Reply only with ok.", + providerId: "codex", + model: "gpt-5", + permissionMode: "auto" as const, + environment: { type: "project-default" as const }, + }, + }; + const overCapTrigger = { + triggerType: "schedule" as const, + cron: overCapCron, + timezone: "UTC", + }; + expect( + createAutomationInputSchema.safeParse({ + ...base, + trigger: overCapTrigger, + }).success, + ).toBe(false); + expect( + createAutomationInputSchema.safeParse({ + ...base, + trigger: { + triggerType: "schedule" as const, + cron: "*/5 * * * *", + timezone: overCapTimezone, + }, + }).success, + ).toBe(false); + expect( + updateAutomationInputSchema.safeParse({ + projectId: "proj_test", + automationId: "auto_1", + trigger: overCapTrigger, + }).success, + ).toBe(false); + // The stored shape does not carry the caps. + expect(automationTriggerSchema.safeParse(overCapTrigger).success).toBe( + true, + ); + }); +}); + describe("bb CLI injection for script runs", () => { it("prefers the env pointers over PATH and macOS install locations", () => { expect( diff --git a/plugins/automations/src/cli.ts b/plugins/automations/src/cli.ts index 5225be6d23..b4f32c809f 100644 --- a/plugins/automations/src/cli.ts +++ b/plugins/automations/src/cli.ts @@ -25,7 +25,11 @@ import { } from "./provider-permissions.js"; import { AUTOMATION_SCRIPT_TIMEOUT_DEFAULT_MS, + agentExecutionUpdateSchema, + automationAgentExecutionRequestSchema, automationScriptInterpreterSchema, + automationScriptRequestSchema, + automationTriggerRequestSchema, } from "./rpc-types.js"; const DURATION_PATTERN = @@ -136,7 +140,15 @@ function buildTrigger(args: ParsedArgs): CreateAutomationInput["trigger"] { if (cron !== undefined) { const timezone = flag(args, "timezone"); if (!timezone) throw new Error("--cron requires --timezone."); - return { triggerType: "schedule", cron, timezone }; + // argv is a system boundary: apply the same request policy (cron and + // timezone caps) as the RPC route before the service persists anything. + // Without this the CLI writes the row and only fails when the stored + // trigger is re-parsed, which is the #2166 defect on the trigger column. + return automationTriggerRequestSchema.parse({ + triggerType: "schedule", + cron, + timezone, + }); } if (flag(args, "timezone") !== undefined) { throw new Error("--timezone is only used with --cron."); @@ -471,8 +483,10 @@ async function buildExecution( const serviceTier = flag(args, "service-tier"); const parsedServiceTier = serviceTier === undefined ? undefined : parseServiceTier(serviceTier); + // argv is a system boundary: apply the same request policy (prompt cap) + // as the RPC route before the service persists anything (#2166). return { - execution: { + execution: automationAgentExecutionRequestSchema.parse({ mode: "agent", prompt, providerId: provider, @@ -492,7 +506,7 @@ async function buildExecution( ...(flag(args, "target-thread") ? { targetThreadId: flag(args, "target-thread") } : {}), - }, + }), }; } if ( @@ -515,8 +529,11 @@ async function buildExecution( const timeoutMs = parseTimeoutMs(flag(args, "timeout")); const env = parseScriptEnv(flag(args, "env-json")); const scriptSource = await loadScriptFileSource(bb, args, ctx); - const content = scriptSource ? scriptSource.content : script; - if (!content) throw new Error("Missing script content."); + // The CLI shape carries both the content and its source path, so only the + // script content is parsed with the RPC request policy (size cap) here. + const content = automationScriptRequestSchema.parse( + scriptSource ? scriptSource.content : script, + ); const interpreter = explicitInterpreter ?? (scriptSource ? inferInterpreterFromPath(scriptSource.path) : undefined); @@ -591,7 +608,9 @@ async function buildAgentExecutionUpdate( environment: await buildAgentEnvironment(bb, args), }; } - return update; + // argv is a system boundary: apply the same request policy as the RPC route + // so an over-cap prompt is rejected before anything is persisted (#2166). + return agentExecutionUpdateSchema.parse(update); } async function buildUpdateRequest( diff --git a/plugins/automations/src/rpc-types.ts b/plugins/automations/src/rpc-types.ts index 2681cf612e..2c5597cac8 100644 --- a/plugins/automations/src/rpc-types.ts +++ b/plugins/automations/src/rpc-types.ts @@ -124,11 +124,19 @@ export type AutomationScriptInterpreter = z.infer< typeof automationScriptInterpreterSchema >; +/** + * Stored/response trigger shape. Like the execution shape below, the cron and + * timezone length caps are request policy and live only on the request variant: + * this schema also parses the stored `trigger_config` column (list, show, + * update, pause/resume, and the sweep all go through `parseAutomationTrigger`), + * so a cap here would make an already-persisted over-cap row unreadable and + * unrepairable. See #2166. + */ const automationScheduleTriggerSchema = z .object({ triggerType: z.literal("schedule"), - cron: z.string().min(1).max(SCHEDULE_CRON_MAX_LENGTH), - timezone: z.string().min(1).max(SCHEDULE_TIMEZONE_MAX_LENGTH), + cron: z.string().min(1), + timezone: z.string().min(1), }) .strict(); const automationOnceTriggerSchema = z @@ -143,10 +151,34 @@ export const automationTriggerSchema = z.discriminatedUnion("triggerType", [ ]); export type AutomationTrigger = z.infer; +/** + * Request-side trigger schema: the stored shape plus the length caps. Every + * entry point that accepts trigger input (the RPC routes through + * `createAutomationInputSchema`/`updateAutomationInputSchema`, and the CLI argv + * boundary through `buildTrigger`) parses with this before anything is + * persisted. The one-shot variant carries no string, so it is unchanged. + */ +const automationScheduleTriggerRequestSchema = automationScheduleTriggerSchema + .extend({ + cron: z.string().min(1).max(SCHEDULE_CRON_MAX_LENGTH), + timezone: z.string().min(1).max(SCHEDULE_TIMEZONE_MAX_LENGTH), + }) + .strict(); +export const automationTriggerRequestSchema = z.discriminatedUnion( + "triggerType", + [automationScheduleTriggerRequestSchema, automationOnceTriggerSchema], +); + +/** + * Stored/response execution shape. Length caps are request policy and live + * only on the `*RequestSchema` variants below: a cap on the stored shape would + * make an already-persisted over-cap row unreadable (list/show fail) and + * unrepairable (update parses the stored row before it writes). See #2166. + */ const automationAgentExecutionSchema = z .object({ mode: z.literal("agent"), - prompt: z.string().min(1).max(AUTOMATION_PROMPT_MAX_LENGTH), + prompt: z.string().min(1), providerId: z.string().min(1), model: z.string().min(1), reasoningLevel: reasoningLevelSchema.default("medium"), @@ -160,12 +192,8 @@ const automationAgentExecutionSchema = z const automationScriptExecutionSchema = z .object({ mode: z.literal("script"), - script: z.string().min(1).max(AUTOMATION_SCRIPT_MAX_LENGTH).optional(), - scriptFile: z - .string() - .min(1) - .max(AUTOMATION_SCRIPT_FILE_MAX_LENGTH) - .optional(), + script: z.string().min(1).optional(), + scriptFile: z.string().min(1).optional(), interpreter: automationScriptInterpreterSchema.optional(), timeoutMs: z .number() @@ -199,9 +227,41 @@ function requireExactlyOneScriptSource( } } -const automationExecutionRequestSchema = automationExecutionSchema.superRefine( - requireExactlyOneScriptSource, -); +export const automationPromptRequestSchema = z + .string() + .min(1) + .max(AUTOMATION_PROMPT_MAX_LENGTH); +export const automationScriptRequestSchema = z + .string() + .min(1) + .max(AUTOMATION_SCRIPT_MAX_LENGTH); + +/** + * Request-side execution schemas: the stored shape plus the length caps. Every + * entry point that accepts execution input (RPC routes and the CLI argv + * boundary) parses with these before anything is persisted. + */ +export const automationAgentExecutionRequestSchema = + automationAgentExecutionSchema + .extend({ prompt: automationPromptRequestSchema }) + .strict(); +const automationScriptExecutionRequestSchema = automationScriptExecutionSchema + .extend({ + script: automationScriptRequestSchema.optional(), + scriptFile: z + .string() + .min(1) + .max(AUTOMATION_SCRIPT_FILE_MAX_LENGTH) + .optional(), + }) + .strict(); + +const automationExecutionRequestSchema = z + .discriminatedUnion("mode", [ + automationAgentExecutionRequestSchema, + automationScriptExecutionRequestSchema, + ]) + .superRefine(requireExactlyOneScriptSource); /** * Execution as returned to clients. Script automations add `storedScriptPath`: @@ -231,9 +291,9 @@ const agentExecutionTargetSchema = z.discriminatedUnion("type", [ .strict(), ]); -const agentExecutionUpdateSchema = z +export const agentExecutionUpdateSchema = z .object({ - prompt: z.string().min(1).max(AUTOMATION_PROMPT_MAX_LENGTH).optional(), + prompt: automationPromptRequestSchema.optional(), providerId: z.string().min(1).optional(), model: z.string().min(1).optional(), reasoningLevel: reasoningLevelSchema.optional(), @@ -313,7 +373,7 @@ export const createAutomationInputSchema = z projectId: z.string().min(1), name: z.string().min(1).max(AUTOMATION_NAME_MAX_LENGTH), enabled: z.boolean().default(true), - trigger: automationTriggerSchema, + trigger: automationTriggerRequestSchema, execution: automationExecutionRequestSchema, origin: automationOriginSchema, createdByThreadId: z.string().min(1).optional(), @@ -329,7 +389,7 @@ export const updateAutomationInputSchema = z projectId: z.string().min(1), automationId: z.string().min(1), name: z.string().min(1).max(AUTOMATION_NAME_MAX_LENGTH).optional(), - trigger: automationTriggerSchema.optional(), + trigger: automationTriggerRequestSchema.optional(), execution: automationExecutionRequestSchema.optional(), agent: agentExecutionUpdateSchema.optional(), }) diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts index 5955dc8902..1a8562aa54 100644 --- a/plugins/automations/src/service.ts +++ b/plugins/automations/src/service.ts @@ -428,9 +428,19 @@ export function createAutomationService(args: { }, list(input) { - return listAutomationsForProject(db, input.projectId).map((row) => - toStoredAutomationResponse(pluginDataDir, row), - ); + const automations: AutomationResponse[] = []; + for (const row of listAutomationsForProject(db, input.projectId)) { + try { + automations.push(toStoredAutomationResponse(pluginDataDir, row)); + } catch (error) { + bb.log.warn( + `Skipping malformed automation ${row.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + return automations; }, get(input) {