d25d482dc2
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
396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
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 }
|
|
}
|
|
}
|
|
}
|