Skip to content

Commit fcd833f

Browse files
committed
Loosen run options validation
1 parent 9e5f989 commit fcd833f

2 files changed

Lines changed: 159 additions & 3 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
type TriggerInputKind,
4+
type TriggerRunOption,
5+
validateTriggerInput,
6+
} from '@/lib/workflows/triggers/run-options'
7+
import type { InputFormatField } from '@/lib/workflows/types'
8+
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
9+
10+
function makeOption(overrides: Partial<TriggerRunOption>): TriggerRunOption {
11+
const inputKind: TriggerInputKind = overrides.inputKind ?? 'fields'
12+
return {
13+
triggerBlockId: 'blk_1',
14+
blockName: 'Test Trigger',
15+
triggerType: 'api_trigger',
16+
path: StartBlockPath.SPLIT_API,
17+
isDefault: true,
18+
inputKind,
19+
inputSchema: { type: 'object' },
20+
mockPayload: {},
21+
inputFormat: [],
22+
...overrides,
23+
}
24+
}
25+
26+
const fields = (...f: InputFormatField[]): InputFormatField[] => f
27+
28+
describe('validateTriggerInput', () => {
29+
describe('fields', () => {
30+
it('accepts input that provides all declared fields with correct types', () => {
31+
const option = makeOption({
32+
inputFormat: fields({ name: 'city', type: 'string' }, { name: 'days', type: 'number' }),
33+
})
34+
expect(validateTriggerInput(option, { city: 'SF', days: 3 }).ok).toBe(true)
35+
})
36+
37+
it('rejects a missing required field (no default)', () => {
38+
const option = makeOption({ inputFormat: fields({ name: 'city', type: 'string' }) })
39+
const result = validateTriggerInput(option, {})
40+
expect(result.ok).toBe(false)
41+
expect(result.error).toContain('city')
42+
})
43+
44+
it('treats a field with an author default as optional (matches executor)', () => {
45+
const option = makeOption({
46+
inputFormat: fields(
47+
{ name: 'city', type: 'string' },
48+
{ name: 'limit', type: 'number', value: 10 }
49+
),
50+
})
51+
// limit omitted -> still valid because the workflow defaults it
52+
expect(validateTriggerInput(option, { city: 'SF' }).ok).toBe(true)
53+
})
54+
55+
it('rejects a wrong field type', () => {
56+
const option = makeOption({ inputFormat: fields({ name: 'days', type: 'number' }) })
57+
expect(validateTriggerInput(option, { days: 'three' }).ok).toBe(false)
58+
})
59+
60+
it('rejects unknown keys for non-UNIFIED triggers', () => {
61+
const option = makeOption({
62+
path: StartBlockPath.SPLIT_API,
63+
inputFormat: fields({ name: 'city', type: 'string' }),
64+
})
65+
expect(validateTriggerInput(option, { city: 'SF', extra: 1 }).ok).toBe(false)
66+
})
67+
68+
it('allows passthrough keys for UNIFIED start blocks', () => {
69+
const option = makeOption({
70+
path: StartBlockPath.UNIFIED,
71+
triggerType: 'start_trigger',
72+
inputFormat: fields({ name: 'city', type: 'string' }),
73+
})
74+
expect(validateTriggerInput(option, { city: 'SF', files: [], conversationId: 'c1' }).ok).toBe(
75+
true
76+
)
77+
})
78+
79+
it('accepts an empty object when the trigger declares no fields', () => {
80+
const option = makeOption({ inputFormat: [] })
81+
expect(validateTriggerInput(option, {}).ok).toBe(true)
82+
expect(validateTriggerInput(option, undefined).ok).toBe(true)
83+
})
84+
85+
it('rejects non-object input when fields are declared', () => {
86+
const option = makeOption({ inputFormat: fields({ name: 'city', type: 'string' }) })
87+
expect(validateTriggerInput(option, 'SF').ok).toBe(false)
88+
})
89+
})
90+
91+
describe('event_payload', () => {
92+
const option = makeOption({
93+
inputKind: 'event_payload',
94+
path: StartBlockPath.EXTERNAL_TRIGGER,
95+
triggerType: 'gmail',
96+
})
97+
98+
it('accepts a non-empty object', () => {
99+
expect(validateTriggerInput(option, { email: { from: 'a@b.com' } }).ok).toBe(true)
100+
})
101+
102+
it('rejects an empty object', () => {
103+
expect(validateTriggerInput(option, {}).ok).toBe(false)
104+
})
105+
106+
it('rejects missing/non-object input', () => {
107+
expect(validateTriggerInput(option, undefined).ok).toBe(false)
108+
expect(validateTriggerInput(option, []).ok).toBe(false)
109+
})
110+
})
111+
112+
describe('chat', () => {
113+
const option = makeOption({
114+
inputKind: 'chat',
115+
path: StartBlockPath.SPLIT_CHAT,
116+
triggerType: 'chat_trigger',
117+
})
118+
119+
it('accepts a non-empty input string', () => {
120+
expect(validateTriggerInput(option, { input: 'hello' }).ok).toBe(true)
121+
})
122+
123+
it('rejects empty or missing input', () => {
124+
expect(validateTriggerInput(option, {}).ok).toBe(false)
125+
expect(validateTriggerInput(option, { input: '' }).ok).toBe(false)
126+
})
127+
})
128+
129+
describe('none', () => {
130+
const option = makeOption({
131+
inputKind: 'none',
132+
path: StartBlockPath.EXTERNAL_TRIGGER,
133+
triggerType: 'schedule',
134+
})
135+
136+
it('accepts any input (no input required)', () => {
137+
expect(validateTriggerInput(option, undefined).ok).toBe(true)
138+
expect(validateTriggerInput(option, { anything: 1 }).ok).toBe(true)
139+
})
140+
})
141+
})

apps/sim/lib/workflows/triggers/run-options.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,8 @@ export function validateTriggerInput(
350350
return { ok: true }
351351
}
352352
default: {
353-
const shape = generateToolZodSchema(option.inputFormat)
354-
if (!shape) {
353+
const baseShape = generateToolZodSchema(option.inputFormat)
354+
if (!baseShape) {
355355
// Trigger declares no input fields — accept an object (including {}).
356356
if (input === undefined || input === null) return { ok: true }
357357
if (!isPlainObject(input)) {
@@ -363,7 +363,22 @@ export function validateTriggerInput(
363363
return { ok: true }
364364
}
365365

366-
const schema = z.object(shape).strict()
366+
// A field with an author-configured default is optional: the executor fills
367+
// the default when it's omitted (deriveInputFromFormat), so requiring it
368+
// would reject a run the workflow itself accepts.
369+
const shape: z.ZodRawShape = {}
370+
for (const [name, zodType] of Object.entries(baseShape)) {
371+
const field = option.inputFormat.find((f) => f.name === name)
372+
const hasDefault = field?.value !== undefined && field?.value !== null
373+
shape[name] = hasDefault ? zodType.optional() : zodType
374+
}
375+
376+
// UNIFIED start blocks pass arbitrary keys through to their output, so
377+
// unknown keys are valid there; other trigger kinds only consume declared
378+
// fields, so unknown keys signal a mistake and are rejected.
379+
const objectSchema = z.object(shape)
380+
const schema =
381+
option.path === StartBlockPath.UNIFIED ? objectSchema.passthrough() : objectSchema.strict()
367382
const result = schema.safeParse(input ?? {})
368383
if (!result.success) {
369384
const issues = result.error.issues

0 commit comments

Comments
 (0)