chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { z } from 'zod'
|
||||
import { generateToolInputSchema, generateToolZodSchema } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
extractTriggerMockPayload,
|
||||
selectBestTrigger,
|
||||
} from '@/lib/workflows/triggers/trigger-utils'
|
||||
import {
|
||||
getLegacyStarterMode,
|
||||
resolveStartCandidates,
|
||||
type StartBlockCandidate,
|
||||
StartBlockPath,
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { coerceValue } from '@/executor/utils/start-block'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
/**
|
||||
* How a trigger expects its run-time input, surfaced to the agent so it can tell
|
||||
* the difference between building flat form fields and an event payload.
|
||||
*/
|
||||
export type TriggerInputKind = 'fields' | 'event_payload' | 'chat' | 'none'
|
||||
|
||||
/** Minimal block shape needed to resolve and describe a trigger. */
|
||||
interface TriggerBlockLike {
|
||||
type: string
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
triggerMode?: boolean
|
||||
subBlocks?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TriggerRunOption {
|
||||
/** The block ID to pass to run_workflow's triggerBlockId. */
|
||||
triggerBlockId: string
|
||||
blockName: string
|
||||
triggerType: string
|
||||
path: StartBlockPath
|
||||
isDefault: boolean
|
||||
inputKind: TriggerInputKind
|
||||
/** JSON-Schema-ish description of the input the agent should build. */
|
||||
inputSchema: Record<string, unknown>
|
||||
/** A ready-to-use example the agent may copy only if it can't build its own. */
|
||||
mockPayload: unknown
|
||||
/**
|
||||
* Raw input fields used for strict validation. Internal — callers serializing
|
||||
* to the agent should omit this (see toPublicRunOption).
|
||||
*/
|
||||
inputFormat: InputFormatField[]
|
||||
}
|
||||
|
||||
export interface TriggerInputValidationResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
function readSubBlockValue(block: TriggerBlockLike, key: string): unknown {
|
||||
const raw = (block.subBlocks as Record<string, unknown> | undefined)?.[key]
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return (raw as { value?: unknown }).value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mapOutputType(type: string): string {
|
||||
switch (type) {
|
||||
case 'json':
|
||||
return 'object'
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'array':
|
||||
case 'object':
|
||||
case 'string':
|
||||
return type
|
||||
default:
|
||||
return 'string'
|
||||
}
|
||||
}
|
||||
|
||||
function outputFieldToSchema(field: unknown): Record<string, unknown> {
|
||||
if (
|
||||
field &&
|
||||
typeof field === 'object' &&
|
||||
'type' in field &&
|
||||
typeof (field as { type: unknown }).type === 'string'
|
||||
) {
|
||||
const typed = field as {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
items?: unknown
|
||||
}
|
||||
if ((typed.type === 'object' || typed.type === 'json') && typed.properties) {
|
||||
const properties: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(typed.properties)) {
|
||||
properties[key] = outputFieldToSchema(value)
|
||||
}
|
||||
return { type: 'object', properties }
|
||||
}
|
||||
if (typed.type === 'array' && typed.items) {
|
||||
return { type: 'array', items: outputFieldToSchema(typed.items) }
|
||||
}
|
||||
return { type: mapOutputType(typed.type) }
|
||||
}
|
||||
|
||||
if (field && typeof field === 'object' && !Array.isArray(field)) {
|
||||
const properties: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(field)) {
|
||||
properties[key] = outputFieldToSchema(value)
|
||||
}
|
||||
return { type: 'object', properties }
|
||||
}
|
||||
|
||||
return { type: 'string' }
|
||||
}
|
||||
|
||||
function triggerOutputsToJsonSchema(outputs: Record<string, unknown>): Record<string, unknown> {
|
||||
const properties: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
if (key === 'visualization') continue
|
||||
properties[key] = outputFieldToSchema(value)
|
||||
}
|
||||
return { type: 'object', properties }
|
||||
}
|
||||
|
||||
function mockValueForType(type: string | undefined, name: string): unknown {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return 42
|
||||
case 'boolean':
|
||||
return true
|
||||
case 'array':
|
||||
return []
|
||||
case 'object':
|
||||
return {}
|
||||
case 'files':
|
||||
case 'file[]':
|
||||
return []
|
||||
default:
|
||||
return `mock_${name}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildFieldsSample(inputFormat: InputFormatField[]): Record<string, unknown> {
|
||||
const sample: Record<string, unknown> = {}
|
||||
for (const field of inputFormat) {
|
||||
if (!field.name) continue
|
||||
sample[field.name] =
|
||||
field.value !== undefined && field.value !== null
|
||||
? coerceValue(field.type, field.value)
|
||||
: mockValueForType(field.type, field.name)
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
function resolveEventTriggerId(block: TriggerBlockLike): string {
|
||||
const selected = readSubBlockValue(block, 'selectedTriggerId')
|
||||
if (typeof selected === 'string' && selected) {
|
||||
return selected
|
||||
}
|
||||
const blockConfig = getBlock(block.type)
|
||||
if (blockConfig?.triggers?.available?.length === 1) {
|
||||
return blockConfig.triggers.available[0]
|
||||
}
|
||||
return block.type
|
||||
}
|
||||
|
||||
function safeTriggerOutputs(triggerId: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const trigger = getTrigger(triggerId)
|
||||
return trigger?.outputs as Record<string, unknown> | undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extractEventMockPayload(candidate: StartBlockCandidate<TriggerBlockLike>): unknown {
|
||||
const triggerId = resolveEventTriggerId(candidate.block)
|
||||
const sampleRaw =
|
||||
readSubBlockValue(candidate.block, `samplePayload_${triggerId}`) ??
|
||||
readSubBlockValue(candidate.block, 'samplePayload')
|
||||
|
||||
if (typeof sampleRaw === 'string' && sampleRaw.trim()) {
|
||||
try {
|
||||
return JSON.parse(sampleRaw)
|
||||
} catch {
|
||||
// fall through to generated mock
|
||||
}
|
||||
} else if (sampleRaw && typeof sampleRaw === 'object') {
|
||||
return sampleRaw
|
||||
}
|
||||
|
||||
return extractTriggerMockPayload(candidate)
|
||||
}
|
||||
|
||||
function resolveInputKind(path: StartBlockPath, block: TriggerBlockLike): TriggerInputKind {
|
||||
if (path === StartBlockPath.SPLIT_CHAT) return 'chat'
|
||||
if (path === StartBlockPath.LEGACY_STARTER) {
|
||||
return getLegacyStarterMode(block) === 'chat' ? 'chat' : 'fields'
|
||||
}
|
||||
if (path === StartBlockPath.EXTERNAL_TRIGGER) {
|
||||
return block.type === 'schedule' ? 'none' : 'event_payload'
|
||||
}
|
||||
return 'fields'
|
||||
}
|
||||
|
||||
function buildTriggerRunOption(
|
||||
candidate: StartBlockCandidate<TriggerBlockLike>,
|
||||
isDefault: boolean
|
||||
): TriggerRunOption {
|
||||
const { blockId, block, path } = candidate
|
||||
const blockConfig = getBlock(block.type)
|
||||
const blockName = block.name || blockConfig?.name || block.type
|
||||
const inputKind = resolveInputKind(path, block)
|
||||
const inputFormat = normalizeInputFormatValue(readSubBlockValue(block, 'inputFormat'))
|
||||
|
||||
let inputSchema: Record<string, unknown>
|
||||
let mockPayload: unknown
|
||||
|
||||
switch (inputKind) {
|
||||
case 'fields': {
|
||||
inputSchema = generateToolInputSchema(inputFormat)
|
||||
mockPayload = buildFieldsSample(inputFormat)
|
||||
break
|
||||
}
|
||||
case 'event_payload': {
|
||||
const triggerId = resolveEventTriggerId(block)
|
||||
const outputs = safeTriggerOutputs(triggerId)
|
||||
inputSchema = outputs
|
||||
? triggerOutputsToJsonSchema(outputs)
|
||||
: { type: 'object', properties: {} }
|
||||
mockPayload = extractEventMockPayload(candidate)
|
||||
break
|
||||
}
|
||||
case 'chat': {
|
||||
inputSchema = {
|
||||
type: 'object',
|
||||
required: ['input'],
|
||||
properties: {
|
||||
input: { type: 'string', description: 'User message' },
|
||||
conversationId: { type: 'string', description: 'Optional conversation ID' },
|
||||
},
|
||||
}
|
||||
mockPayload = { input: 'mock_message' }
|
||||
break
|
||||
}
|
||||
default: {
|
||||
inputSchema = { type: 'object', properties: {} }
|
||||
mockPayload = {}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
triggerBlockId: blockId,
|
||||
blockName,
|
||||
triggerType: block.type,
|
||||
path,
|
||||
isDefault,
|
||||
inputKind,
|
||||
inputSchema,
|
||||
mockPayload,
|
||||
inputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerates every runnable trigger in a workflow (across manual + chat entry
|
||||
* kinds), marking the one the executor would pick by default. Used by the
|
||||
* get_workflow_run_options tool (to describe) and run_workflow (to validate),
|
||||
* guaranteeing describe == enforce.
|
||||
*/
|
||||
export function resolveTriggerRunOptions(
|
||||
blocks: Record<string, TriggerBlockLike>,
|
||||
edges?: Array<{ source: string; target: string }>
|
||||
): TriggerRunOption[] {
|
||||
const manual = resolveStartCandidates(blocks, { execution: 'manual' })
|
||||
const chat = resolveStartCandidates(blocks, { execution: 'chat' })
|
||||
|
||||
const byId = new Map<string, StartBlockCandidate<TriggerBlockLike>>()
|
||||
for (const candidate of [...manual, ...chat]) {
|
||||
if (!byId.has(candidate.blockId)) {
|
||||
byId.set(candidate.blockId, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [...byId.values()]
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Single overall default (no edges => one best); ties broken by trigger priority.
|
||||
const defaultBlockId = selectBestTrigger(candidates)[0]?.blockId
|
||||
|
||||
return candidates.map((candidate) =>
|
||||
buildTriggerRunOption(candidate, candidate.blockId === defaultBlockId)
|
||||
)
|
||||
}
|
||||
|
||||
/** Strips internal fields so the option can be returned to the agent. */
|
||||
export function toPublicRunOption(option: TriggerRunOption): {
|
||||
triggerBlockId: string
|
||||
blockName: string
|
||||
triggerType: string
|
||||
isDefault: boolean
|
||||
inputKind: TriggerInputKind
|
||||
inputSchema: Record<string, unknown>
|
||||
mockPayload: unknown
|
||||
} {
|
||||
const { inputFormat: _inputFormat, path: _path, ...rest } = option
|
||||
return rest
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly validates an agent-supplied workflow_input against a trigger. There
|
||||
* are no fallbacks: anything incorrect returns an error so the agent retries.
|
||||
*/
|
||||
export function validateTriggerInput(
|
||||
option: TriggerRunOption,
|
||||
input: unknown
|
||||
): TriggerInputValidationResult {
|
||||
switch (option.inputKind) {
|
||||
case 'none':
|
||||
return { ok: true }
|
||||
|
||||
case 'chat': {
|
||||
if (!isRecordLike(input) || typeof input.input !== 'string' || input.input.trim() === '') {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Chat trigger "${option.blockName}" requires workflow_input shaped like { "input": "<message>" }.`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
case 'event_payload': {
|
||||
if (!isRecordLike(input) || Object.keys(input).length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`Trigger "${option.blockName}" (${option.triggerType}) requires a non-empty event payload. ` +
|
||||
`Build workflow_input matching this shape, or run with useMockPayload: true. ` +
|
||||
`Expected shape: ${JSON.stringify(option.inputSchema)}`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
default: {
|
||||
const baseShape = generateToolZodSchema(option.inputFormat)
|
||||
if (!baseShape) {
|
||||
// Trigger declares no input fields — accept an object (including {}).
|
||||
if (input === undefined || input === null) return { ok: true }
|
||||
if (!isRecordLike(input)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Trigger "${option.blockName}" expects a JSON object for workflow_input.`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// A field with an author-configured default is optional: the executor fills
|
||||
// the default when it's omitted (deriveInputFromFormat), so requiring it
|
||||
// would reject a run the workflow itself accepts.
|
||||
const shape: Record<string, z.ZodTypeAny> = {}
|
||||
for (const [name, baseType] of Object.entries(baseShape)) {
|
||||
const zodType = baseType as z.ZodTypeAny
|
||||
const field = option.inputFormat.find((f) => f.name === name)
|
||||
const hasDefault = field?.value !== undefined && field?.value !== null
|
||||
shape[name] = hasDefault ? zodType.optional() : zodType
|
||||
}
|
||||
|
||||
// UNIFIED start blocks pass arbitrary keys through to their output, so
|
||||
// unknown keys are valid there; other trigger kinds only consume declared
|
||||
// fields, so unknown keys signal a mistake and are rejected.
|
||||
const objectSchema = z.object(shape)
|
||||
const schema =
|
||||
option.path === StartBlockPath.UNIFIED ? objectSchema.passthrough() : objectSchema.strict()
|
||||
const result = schema.safeParse(input ?? {})
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||
.join('; ')
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`workflow_input does not match trigger "${option.blockName}" (${issues}). ` +
|
||||
`Expected: ${JSON.stringify(option.inputSchema)}`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user