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,28 @@
|
||||
/**
|
||||
* Trigger types that define workflow input parameters (inputFormat).
|
||||
* These are triggers where users can configure input schema for the workflow.
|
||||
*
|
||||
* This module is kept lightweight with no dependencies to avoid circular imports.
|
||||
*
|
||||
* Note: External triggers like webhook/schedule are NOT included here because
|
||||
* they receive input from external event payloads, not user-defined inputFormat.
|
||||
*/
|
||||
export const INPUT_DEFINITION_TRIGGER_TYPES = [
|
||||
'starter',
|
||||
'start',
|
||||
'start_trigger',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
] as const
|
||||
|
||||
export type InputDefinitionTriggerType = (typeof INPUT_DEFINITION_TRIGGER_TYPES)[number]
|
||||
|
||||
/**
|
||||
* Check if a block type is a trigger that defines workflow input parameters.
|
||||
* Used to find blocks that have inputFormat subblock for workflow input schema.
|
||||
*/
|
||||
export function isInputDefinitionTrigger(
|
||||
blockType: string
|
||||
): blockType is InputDefinitionTriggerType {
|
||||
return INPUT_DEFINITION_TRIGGER_TYPES.includes(blockType as InputDefinitionTriggerType)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type TriggerInputKind,
|
||||
type TriggerRunOption,
|
||||
validateTriggerInput,
|
||||
} from '@/lib/workflows/triggers/run-options'
|
||||
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
|
||||
function makeOption(overrides: Partial<TriggerRunOption>): TriggerRunOption {
|
||||
const inputKind: TriggerInputKind = overrides.inputKind ?? 'fields'
|
||||
return {
|
||||
triggerBlockId: 'blk_1',
|
||||
blockName: 'Test Trigger',
|
||||
triggerType: 'api_trigger',
|
||||
path: StartBlockPath.SPLIT_API,
|
||||
isDefault: true,
|
||||
inputKind,
|
||||
inputSchema: { type: 'object' },
|
||||
mockPayload: {},
|
||||
inputFormat: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const fields = (...f: InputFormatField[]): InputFormatField[] => f
|
||||
|
||||
describe('validateTriggerInput', () => {
|
||||
describe('fields', () => {
|
||||
it('accepts input that provides all declared fields with correct types', () => {
|
||||
const option = makeOption({
|
||||
inputFormat: fields({ name: 'city', type: 'string' }, { name: 'days', type: 'number' }),
|
||||
})
|
||||
expect(validateTriggerInput(option, { city: 'SF', days: 3 }).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a missing required field (no default)', () => {
|
||||
const option = makeOption({ inputFormat: fields({ name: 'city', type: 'string' }) })
|
||||
const result = validateTriggerInput(option, {})
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain('city')
|
||||
})
|
||||
|
||||
it('treats a field with an author default as optional (matches executor)', () => {
|
||||
const option = makeOption({
|
||||
inputFormat: fields(
|
||||
{ name: 'city', type: 'string' },
|
||||
{ name: 'limit', type: 'number', value: 10 }
|
||||
),
|
||||
})
|
||||
// limit omitted -> still valid because the workflow defaults it
|
||||
expect(validateTriggerInput(option, { city: 'SF' }).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a wrong field type', () => {
|
||||
const option = makeOption({ inputFormat: fields({ name: 'days', type: 'number' }) })
|
||||
expect(validateTriggerInput(option, { days: 'three' }).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown keys for non-UNIFIED triggers', () => {
|
||||
const option = makeOption({
|
||||
path: StartBlockPath.SPLIT_API,
|
||||
inputFormat: fields({ name: 'city', type: 'string' }),
|
||||
})
|
||||
expect(validateTriggerInput(option, { city: 'SF', extra: 1 }).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('allows passthrough keys for UNIFIED start blocks', () => {
|
||||
const option = makeOption({
|
||||
path: StartBlockPath.UNIFIED,
|
||||
triggerType: 'start_trigger',
|
||||
inputFormat: fields({ name: 'city', type: 'string' }),
|
||||
})
|
||||
expect(validateTriggerInput(option, { city: 'SF', files: [], conversationId: 'c1' }).ok).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts an empty object when the trigger declares no fields', () => {
|
||||
const option = makeOption({ inputFormat: [] })
|
||||
expect(validateTriggerInput(option, {}).ok).toBe(true)
|
||||
expect(validateTriggerInput(option, undefined).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-object input when fields are declared', () => {
|
||||
const option = makeOption({ inputFormat: fields({ name: 'city', type: 'string' }) })
|
||||
expect(validateTriggerInput(option, 'SF').ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event_payload', () => {
|
||||
const option = makeOption({
|
||||
inputKind: 'event_payload',
|
||||
path: StartBlockPath.EXTERNAL_TRIGGER,
|
||||
triggerType: 'gmail',
|
||||
})
|
||||
|
||||
it('accepts a non-empty object', () => {
|
||||
expect(validateTriggerInput(option, { email: { from: 'a@b.com' } }).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an empty object', () => {
|
||||
expect(validateTriggerInput(option, {}).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing/non-object input', () => {
|
||||
expect(validateTriggerInput(option, undefined).ok).toBe(false)
|
||||
expect(validateTriggerInput(option, []).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat', () => {
|
||||
const option = makeOption({
|
||||
inputKind: 'chat',
|
||||
path: StartBlockPath.SPLIT_CHAT,
|
||||
triggerType: 'chat_trigger',
|
||||
})
|
||||
|
||||
it('accepts a non-empty input string', () => {
|
||||
expect(validateTriggerInput(option, { input: 'hello' }).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty or missing input', () => {
|
||||
expect(validateTriggerInput(option, {}).ok).toBe(false)
|
||||
expect(validateTriggerInput(option, { input: '' }).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('none', () => {
|
||||
const option = makeOption({
|
||||
inputKind: 'none',
|
||||
path: StartBlockPath.EXTERNAL_TRIGGER,
|
||||
triggerType: 'schedule',
|
||||
})
|
||||
|
||||
it('accepts any input (no input required)', () => {
|
||||
expect(validateTriggerInput(option, undefined).ok).toBe(true)
|
||||
expect(validateTriggerInput(option, { anything: 1 }).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
|
||||
const logger = createLogger('TriggerUtils')
|
||||
|
||||
/**
|
||||
* Check if a workflow has a valid start block by loading from database
|
||||
*/
|
||||
export async function hasValidStartBlock(workflowId: string): Promise<boolean> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
return hasValidStartBlockInState(normalizedData)
|
||||
} catch (error) {
|
||||
logger.warn('Error checking for start block:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import {
|
||||
type StartBlockCandidate,
|
||||
StartBlockPath,
|
||||
TRIGGER_TYPES,
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import { getAllBlocks, getBlock } from '@/blocks'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
const logger = createLogger('TriggerUtils')
|
||||
|
||||
/**
|
||||
* Check if a workflow state has a valid start block
|
||||
*/
|
||||
export function hasValidStartBlockInState(state: WorkflowState | null | undefined): boolean {
|
||||
if (!state?.blocks) {
|
||||
return false
|
||||
}
|
||||
|
||||
const startBlock = Object.values(state.blocks).find((block: BlockState) => {
|
||||
const blockType = block?.type
|
||||
return isInputDefinitionTrigger(blockType)
|
||||
})
|
||||
|
||||
return !!startBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates mock data based on the output type definition
|
||||
*/
|
||||
function generateMockValue(type: string, _description?: string, fieldName?: string): unknown {
|
||||
const name = fieldName || 'value'
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return `mock_${name}`
|
||||
|
||||
case 'number':
|
||||
return 42
|
||||
|
||||
case 'boolean':
|
||||
return true
|
||||
|
||||
case 'array':
|
||||
return [
|
||||
{
|
||||
id: 'item_1',
|
||||
name: 'Sample Item',
|
||||
value: 'Sample Value',
|
||||
},
|
||||
]
|
||||
|
||||
case 'json':
|
||||
case 'object':
|
||||
return {
|
||||
id: 'sample_id',
|
||||
name: 'Sample Object',
|
||||
status: 'active',
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively processes nested output structures, expanding JSON-Schema-style
|
||||
* objects/arrays that define `properties` or `items` instead of returning
|
||||
* a generic placeholder.
|
||||
*/
|
||||
function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown {
|
||||
if (depth > maxDepth) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
field &&
|
||||
typeof field === 'object' &&
|
||||
'type' in field &&
|
||||
typeof (field as Record<string, unknown>).type === 'string'
|
||||
) {
|
||||
const typedField = field as {
|
||||
type: string
|
||||
description?: string
|
||||
properties?: Record<string, unknown>
|
||||
items?: unknown
|
||||
}
|
||||
|
||||
if (
|
||||
(typedField.type === 'object' || typedField.type === 'json') &&
|
||||
typedField.properties &&
|
||||
typeof typedField.properties === 'object'
|
||||
) {
|
||||
const nestedObject: Record<string, unknown> = {}
|
||||
for (const [nestedKey, nestedField] of Object.entries(typedField.properties)) {
|
||||
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
|
||||
}
|
||||
return nestedObject
|
||||
}
|
||||
|
||||
if (typedField.type === 'array' && typedField.items && typeof typedField.items === 'object') {
|
||||
const itemValue = processOutputField(`${key}_item`, typedField.items, depth + 1, maxDepth)
|
||||
return [itemValue]
|
||||
}
|
||||
|
||||
return generateMockValue(typedField.type, typedField.description, key)
|
||||
}
|
||||
|
||||
if (field && typeof field === 'object' && !Array.isArray(field)) {
|
||||
const nestedObject: Record<string, unknown> = {}
|
||||
for (const [nestedKey, nestedField] of Object.entries(field)) {
|
||||
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
|
||||
}
|
||||
return nestedObject
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates mock payload from outputs object
|
||||
*/
|
||||
function generateMockPayloadFromOutputs(outputs: Record<string, unknown>): Record<string, unknown> {
|
||||
const mockPayload: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, output] of Object.entries(outputs)) {
|
||||
if (key === 'visualization') {
|
||||
continue
|
||||
}
|
||||
mockPayload[key] = processOutputField(key, output)
|
||||
}
|
||||
|
||||
return mockPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a mock payload based on outputs definition
|
||||
*/
|
||||
export function generateMockPayloadFromOutputsDefinition(
|
||||
outputs: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return generateMockPayloadFromOutputs(outputs)
|
||||
}
|
||||
|
||||
interface TriggerInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
color: string
|
||||
category: 'core' | 'integration'
|
||||
enableTriggerMode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blocks that can act as triggers
|
||||
* This includes both dedicated trigger blocks and tools with trigger capabilities
|
||||
*/
|
||||
export function getAllTriggerBlocks(): TriggerInfo[] {
|
||||
const allBlocks = getAllBlocks()
|
||||
const triggers: TriggerInfo[] = []
|
||||
|
||||
for (const block of allBlocks) {
|
||||
// Skip hidden blocks
|
||||
if (block.hideFromToolbar) continue
|
||||
|
||||
// Check if it's a core trigger block (category: 'triggers')
|
||||
if (block.category === 'triggers') {
|
||||
triggers.push({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description,
|
||||
icon: block.icon,
|
||||
color: block.bgColor,
|
||||
category: 'core',
|
||||
enableTriggerMode: hasTriggerCapability(block),
|
||||
})
|
||||
}
|
||||
// Check if it's a tool with trigger capability (has trigger-config subblock)
|
||||
else if (hasTriggerCapability(block)) {
|
||||
triggers.push({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description.replace(' or trigger workflows from ', ', trigger from '),
|
||||
icon: block.icon,
|
||||
color: block.bgColor,
|
||||
category: 'integration',
|
||||
enableTriggerMode: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: core triggers first, then integration triggers, alphabetically within each category
|
||||
return triggers.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
return a.category === 'core' ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a block has trigger capability (contains trigger mode subblocks)
|
||||
*/
|
||||
export function hasTriggerCapability(block: BlockConfig): boolean {
|
||||
const hasTriggerModeSubBlocks = block.subBlocks.some((subBlock) => subBlock.mode === 'trigger')
|
||||
|
||||
if (block.category === 'triggers') {
|
||||
return hasTriggerModeSubBlocks
|
||||
}
|
||||
|
||||
return (
|
||||
(block.triggers?.enabled === true && block.triggers.available.length > 0) ||
|
||||
hasTriggerModeSubBlocks
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocks that should appear in the triggers tab
|
||||
* This includes all trigger blocks and tools with trigger mode
|
||||
*/
|
||||
export function getTriggersForSidebar(): BlockConfig[] {
|
||||
const allBlocks = getAllBlocks()
|
||||
return allBlocks.filter((block) => {
|
||||
if (block.hideFromToolbar) return false
|
||||
// Include blocks with triggers category or trigger-config subblock
|
||||
return block.category === 'triggers' || hasTriggerCapability(block)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the proper display name for a trigger block in the UI
|
||||
*/
|
||||
export function getTriggerDisplayName(blockType: string): string {
|
||||
const block = getBlock(blockType)
|
||||
if (!block) return blockType
|
||||
|
||||
if (blockType === TRIGGER_TYPES.GENERIC_WEBHOOK) {
|
||||
return 'Webhook'
|
||||
}
|
||||
|
||||
return block.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups triggers by their immediate downstream blocks to identify disjoint paths
|
||||
*/
|
||||
export function groupTriggersByPath<
|
||||
T extends { type: string; subBlocks?: Record<string, unknown> },
|
||||
>(
|
||||
candidates: StartBlockCandidate<T>[],
|
||||
edges: Array<{ source: string; target: string }>
|
||||
): Array<StartBlockCandidate<T>[]> {
|
||||
if (candidates.length <= 1) {
|
||||
return [candidates]
|
||||
}
|
||||
|
||||
const groups: Array<StartBlockCandidate<T>[]> = []
|
||||
const processed = new Set<string>()
|
||||
|
||||
// Build adjacency map (edges should already be filtered to exclude trigger-to-trigger)
|
||||
const adjacency = new Map<string, string[]>()
|
||||
for (const edge of edges) {
|
||||
if (!adjacency.has(edge.source)) {
|
||||
adjacency.set(edge.source, [])
|
||||
}
|
||||
adjacency.get(edge.source)!.push(edge.target)
|
||||
}
|
||||
|
||||
// Group triggers that feed into the same immediate blocks
|
||||
for (const trigger of candidates) {
|
||||
if (processed.has(trigger.blockId)) continue
|
||||
|
||||
const immediateTargets = adjacency.get(trigger.blockId) || []
|
||||
const targetSet = new Set(immediateTargets)
|
||||
|
||||
// Find all triggers with the same immediate targets
|
||||
const group = candidates.filter((t) => {
|
||||
if (processed.has(t.blockId)) return false
|
||||
if (t.blockId === trigger.blockId) return true
|
||||
|
||||
const tTargets = adjacency.get(t.blockId) || []
|
||||
|
||||
// Different number of targets = different paths
|
||||
if (immediateTargets.length !== tTargets.length) return false
|
||||
|
||||
// Check if all targets match
|
||||
return tTargets.every((target) => targetSet.has(target))
|
||||
})
|
||||
|
||||
group.forEach((t) => processed.add(t.blockId))
|
||||
groups.push(group)
|
||||
}
|
||||
|
||||
logger.info('Grouped triggers by path', {
|
||||
groupCount: groups.length,
|
||||
groups: groups.map((g) => ({
|
||||
count: g.length,
|
||||
triggers: g.map((t) => ({ id: t.blockId, type: t.block.type })),
|
||||
})),
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the best trigger from a list of candidates based on priority
|
||||
* Priority: Start Block > Schedules > External Triggers > Legacy
|
||||
* If multiple disjoint paths exist, returns one trigger per path
|
||||
*/
|
||||
export function selectBestTrigger<T extends { type: string; subBlocks?: Record<string, unknown> }>(
|
||||
candidates: StartBlockCandidate<T>[],
|
||||
edges?: Array<{ source: string; target: string }>
|
||||
): StartBlockCandidate<T>[] {
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('No trigger candidates provided')
|
||||
}
|
||||
|
||||
// If edges provided, group by path and select best from each group
|
||||
if (edges) {
|
||||
const groups = groupTriggersByPath(candidates, edges)
|
||||
return groups.map((group) => selectBestFromGroup(group))
|
||||
}
|
||||
|
||||
// Otherwise just select the single best trigger
|
||||
return [selectBestFromGroup(candidates)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the best trigger from a group based on priority
|
||||
*/
|
||||
function selectBestFromGroup<T extends { type: string; subBlocks?: Record<string, unknown> }>(
|
||||
candidates: StartBlockCandidate<T>[]
|
||||
): StartBlockCandidate<T> {
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority)
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const getPriority = (trigger: StartBlockCandidate<T>): number => {
|
||||
// Start block - highest priority
|
||||
if (trigger.path === StartBlockPath.UNIFIED) return 0
|
||||
if (trigger.path === StartBlockPath.LEGACY_STARTER) return 1
|
||||
|
||||
// For external triggers, differentiate schedules from webhooks
|
||||
if (trigger.path === StartBlockPath.EXTERNAL_TRIGGER) {
|
||||
if (trigger.block.type === 'schedule') return 2
|
||||
return 3 // Webhooks and other external triggers
|
||||
}
|
||||
|
||||
// Other trigger types
|
||||
if (trigger.path === StartBlockPath.SPLIT_API) return 4
|
||||
if (trigger.path === StartBlockPath.SPLIT_INPUT) return 5
|
||||
if (trigger.path === StartBlockPath.SPLIT_MANUAL) return 6
|
||||
if (trigger.path === StartBlockPath.SPLIT_CHAT) return 7
|
||||
|
||||
return 99 // Unknown
|
||||
}
|
||||
|
||||
return getPriority(a) - getPriority(b)
|
||||
})
|
||||
|
||||
const selected = sorted[0]
|
||||
logger.info('Selected best trigger from group', {
|
||||
selectedId: selected.blockId,
|
||||
selectedType: selected.block.type,
|
||||
selectedPath: selected.path,
|
||||
groupSize: candidates.length,
|
||||
})
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a trigger needs mock payload (external triggers/webhooks, but not schedules)
|
||||
*/
|
||||
export function triggerNeedsMockPayload<T extends { type: string }>(
|
||||
trigger: StartBlockCandidate<T>
|
||||
): boolean {
|
||||
// Only webhooks and external integrations need mock payloads
|
||||
// Schedules run normally without mock data
|
||||
return trigger.path === StartBlockPath.EXTERNAL_TRIGGER && trigger.block.type !== 'schedule'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts or generates mock payload for external trigger execution
|
||||
*/
|
||||
export function extractTriggerMockPayload<
|
||||
T extends { type: string; subBlocks?: Record<string, unknown> },
|
||||
>(trigger: StartBlockCandidate<T>): unknown {
|
||||
const subBlocks = trigger.block.subBlocks as Record<string, { value?: unknown }> | undefined
|
||||
|
||||
// Determine the trigger ID
|
||||
let triggerId: string
|
||||
|
||||
// Check for selectedTriggerId (multi-trigger blocks like Linear, Jira)
|
||||
if (typeof subBlocks?.selectedTriggerId?.value === 'string') {
|
||||
triggerId = subBlocks.selectedTriggerId.value
|
||||
} else {
|
||||
// For single-trigger blocks, get from block config
|
||||
const blockConfig = getBlock(trigger.block.type)
|
||||
|
||||
if (blockConfig?.triggers?.available?.length === 1) {
|
||||
triggerId = blockConfig.triggers.available[0]
|
||||
} else {
|
||||
// Fallback to block type (for blocks that are themselves triggers like schedule)
|
||||
triggerId = trigger.block.type
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const triggerConfig = getTrigger(triggerId)
|
||||
|
||||
if (!triggerConfig || !triggerConfig.outputs) {
|
||||
logger.warn('No trigger config or outputs found', {
|
||||
triggerId,
|
||||
blockId: trigger.blockId,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const payload = generateMockPayloadFromOutputsDefinition(triggerConfig.outputs)
|
||||
|
||||
logger.info('Generated mock payload from trigger outputs', {
|
||||
triggerId,
|
||||
blockId: trigger.blockId,
|
||||
topLevelKeys: Object.keys(payload ?? {}),
|
||||
})
|
||||
|
||||
return payload
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate mock payload from trigger outputs', {
|
||||
triggerId,
|
||||
blockId: trigger.blockId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Unified trigger type definitions
|
||||
*/
|
||||
export const TRIGGER_TYPES = {
|
||||
INPUT: 'input_trigger',
|
||||
MANUAL: 'manual_trigger',
|
||||
CHAT: 'chat_trigger',
|
||||
API: 'api_trigger',
|
||||
WEBHOOK: 'webhook',
|
||||
GENERIC_WEBHOOK: 'generic_webhook',
|
||||
SCHEDULE: 'schedule',
|
||||
SIM: 'sim_workspace_event',
|
||||
START: 'start_trigger',
|
||||
STARTER: 'starter', // Legacy
|
||||
} as const
|
||||
|
||||
export type TriggerType = (typeof TRIGGER_TYPES)[keyof typeof TRIGGER_TYPES]
|
||||
|
||||
export enum StartBlockPath {
|
||||
UNIFIED = 'unified_start',
|
||||
LEGACY_STARTER = 'legacy_starter',
|
||||
SPLIT_INPUT = 'legacy_input_trigger',
|
||||
SPLIT_API = 'legacy_api_trigger',
|
||||
SPLIT_CHAT = 'legacy_chat_trigger',
|
||||
SPLIT_MANUAL = 'legacy_manual_trigger',
|
||||
EXTERNAL_TRIGGER = 'external_trigger',
|
||||
}
|
||||
|
||||
type StartExecutionKind = 'chat' | 'manual' | 'api' | 'external'
|
||||
|
||||
const EXECUTION_PRIORITIES: Record<StartExecutionKind, StartBlockPath[]> = {
|
||||
chat: [StartBlockPath.UNIFIED, StartBlockPath.SPLIT_CHAT, StartBlockPath.LEGACY_STARTER],
|
||||
manual: [
|
||||
StartBlockPath.UNIFIED,
|
||||
StartBlockPath.SPLIT_API,
|
||||
StartBlockPath.SPLIT_INPUT,
|
||||
StartBlockPath.SPLIT_MANUAL,
|
||||
StartBlockPath.LEGACY_STARTER,
|
||||
StartBlockPath.EXTERNAL_TRIGGER,
|
||||
],
|
||||
api: [
|
||||
StartBlockPath.UNIFIED,
|
||||
StartBlockPath.SPLIT_API,
|
||||
StartBlockPath.SPLIT_INPUT,
|
||||
StartBlockPath.LEGACY_STARTER,
|
||||
],
|
||||
external: [StartBlockPath.EXTERNAL_TRIGGER],
|
||||
}
|
||||
|
||||
const CHILD_PRIORITIES: StartBlockPath[] = [
|
||||
StartBlockPath.UNIFIED,
|
||||
StartBlockPath.SPLIT_INPUT,
|
||||
StartBlockPath.LEGACY_STARTER,
|
||||
]
|
||||
|
||||
const START_CONFLICT_TYPES: TriggerType[] = [
|
||||
TRIGGER_TYPES.START,
|
||||
TRIGGER_TYPES.API,
|
||||
TRIGGER_TYPES.INPUT,
|
||||
TRIGGER_TYPES.MANUAL,
|
||||
TRIGGER_TYPES.CHAT,
|
||||
TRIGGER_TYPES.STARTER, // Legacy starter also conflicts with start_trigger
|
||||
]
|
||||
|
||||
type MinimalBlock = { type: string; subBlocks?: Record<string, unknown> | undefined }
|
||||
|
||||
export interface StartBlockCandidate<T extends MinimalBlock> {
|
||||
blockId: string
|
||||
block: T
|
||||
path: StartBlockPath
|
||||
}
|
||||
|
||||
type ClassifyStartOptions = {
|
||||
category?: string
|
||||
triggerModeEnabled?: boolean
|
||||
}
|
||||
|
||||
export function classifyStartBlockType(
|
||||
type: string,
|
||||
opts?: ClassifyStartOptions
|
||||
): StartBlockPath | null {
|
||||
switch (type) {
|
||||
case TRIGGER_TYPES.START:
|
||||
return StartBlockPath.UNIFIED
|
||||
case TRIGGER_TYPES.STARTER:
|
||||
return StartBlockPath.LEGACY_STARTER
|
||||
case TRIGGER_TYPES.INPUT:
|
||||
return StartBlockPath.SPLIT_INPUT
|
||||
case TRIGGER_TYPES.API:
|
||||
return StartBlockPath.SPLIT_API
|
||||
case TRIGGER_TYPES.CHAT:
|
||||
return StartBlockPath.SPLIT_CHAT
|
||||
case TRIGGER_TYPES.MANUAL:
|
||||
return StartBlockPath.SPLIT_MANUAL
|
||||
case TRIGGER_TYPES.WEBHOOK:
|
||||
case TRIGGER_TYPES.SCHEDULE:
|
||||
case TRIGGER_TYPES.SIM:
|
||||
return StartBlockPath.EXTERNAL_TRIGGER
|
||||
default:
|
||||
if (opts?.category === 'triggers' || opts?.triggerModeEnabled) {
|
||||
return StartBlockPath.EXTERNAL_TRIGGER
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyStartBlock<T extends MinimalBlock>(block: T): StartBlockPath | null {
|
||||
const blockState = block as Partial<BlockState>
|
||||
|
||||
// Try to get metadata from the block itself first
|
||||
let category: string | undefined
|
||||
const triggerModeEnabled = Boolean(blockState.triggerMode)
|
||||
|
||||
// If not available on the block, fetch from registry
|
||||
const blockConfig = getBlock(block.type)
|
||||
|
||||
if (blockConfig) {
|
||||
category = blockConfig.category
|
||||
}
|
||||
|
||||
return classifyStartBlockType(block.type, { category, triggerModeEnabled })
|
||||
}
|
||||
|
||||
export function isLegacyStartPath(path: StartBlockPath): boolean {
|
||||
return path !== StartBlockPath.UNIFIED
|
||||
}
|
||||
|
||||
function toEntries<T extends MinimalBlock>(blocks: Record<string, T> | T[]): Array<[string, T]> {
|
||||
if (Array.isArray(blocks)) {
|
||||
return blocks.map((block, index) => {
|
||||
const potentialId = (block as { id?: unknown }).id
|
||||
const inferredId = typeof potentialId === 'string' ? potentialId : `${index}`
|
||||
return [inferredId, block]
|
||||
})
|
||||
}
|
||||
return Object.entries(blocks)
|
||||
}
|
||||
|
||||
type ResolveStartOptions = {
|
||||
execution: StartExecutionKind
|
||||
isChildWorkflow?: boolean
|
||||
allowLegacyStarter?: boolean
|
||||
}
|
||||
|
||||
function supportsExecution(path: StartBlockPath, execution: StartExecutionKind): boolean {
|
||||
if (execution === 'external') {
|
||||
return path === StartBlockPath.EXTERNAL_TRIGGER
|
||||
}
|
||||
|
||||
if (path === StartBlockPath.UNIFIED || path === StartBlockPath.LEGACY_STARTER) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (execution === 'chat') {
|
||||
return path === StartBlockPath.SPLIT_CHAT
|
||||
}
|
||||
|
||||
if (execution === 'api') {
|
||||
return path === StartBlockPath.SPLIT_API || path === StartBlockPath.SPLIT_INPUT
|
||||
}
|
||||
|
||||
return (
|
||||
path === StartBlockPath.SPLIT_API ||
|
||||
path === StartBlockPath.SPLIT_INPUT ||
|
||||
path === StartBlockPath.SPLIT_MANUAL ||
|
||||
path === StartBlockPath.EXTERNAL_TRIGGER
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveStartCandidates<T extends MinimalBlock>(
|
||||
blocks: Record<string, T> | T[],
|
||||
options: ResolveStartOptions
|
||||
): StartBlockCandidate<T>[] {
|
||||
const entries = toEntries(blocks)
|
||||
if (entries.length === 0) return []
|
||||
|
||||
const priorities = options.isChildWorkflow
|
||||
? CHILD_PRIORITIES
|
||||
: EXECUTION_PRIORITIES[options.execution]
|
||||
|
||||
const candidates: StartBlockCandidate<T>[] = []
|
||||
|
||||
for (const [blockId, block] of entries) {
|
||||
// Skip disabled blocks - they cannot be used as triggers
|
||||
if ('enabled' in block && block.enabled === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
const path = classifyStartBlock(block)
|
||||
if (!path) continue
|
||||
|
||||
if (options.isChildWorkflow) {
|
||||
if (!CHILD_PRIORITIES.includes(path)) {
|
||||
continue
|
||||
}
|
||||
} else if (!supportsExecution(path, options.execution)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (path === StartBlockPath.LEGACY_STARTER && options.allowLegacyStarter === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates.push({ blockId, block, path })
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const order = options.isChildWorkflow ? CHILD_PRIORITIES : priorities
|
||||
const aIdx = order.indexOf(a.path)
|
||||
const bIdx = order.indexOf(b.path)
|
||||
if (aIdx === -1 && bIdx === -1) return 0
|
||||
if (aIdx === -1) return 1
|
||||
if (bIdx === -1) return -1
|
||||
return aIdx - bIdx
|
||||
})
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
type SubBlockWithValue = { value?: unknown }
|
||||
|
||||
function readSubBlockValue(subBlocks: Record<string, unknown> | undefined, key: string): unknown {
|
||||
const raw = subBlocks?.[key]
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return (raw as SubBlockWithValue).value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getLegacyStarterMode(block: {
|
||||
subBlocks?: Record<string, unknown>
|
||||
}): 'manual' | 'api' | 'chat' | null {
|
||||
const modeValue = readSubBlockValue(block.subBlocks, 'startWorkflow')
|
||||
if (modeValue === 'chat') return 'chat'
|
||||
if (modeValue === 'api' || modeValue === 'run') return 'api'
|
||||
if (modeValue === undefined || modeValue === 'manual') return 'manual'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from reference alias (used in inline refs like <api.*>, <chat.*>, etc.)
|
||||
* to concrete trigger block type identifiers used across the system.
|
||||
*/
|
||||
export const TRIGGER_REFERENCE_ALIAS_MAP = {
|
||||
start: TRIGGER_TYPES.START,
|
||||
api: TRIGGER_TYPES.API,
|
||||
chat: TRIGGER_TYPES.CHAT,
|
||||
manual: TRIGGER_TYPES.START,
|
||||
} as const
|
||||
|
||||
export type TriggerReferenceAlias = keyof typeof TRIGGER_REFERENCE_ALIAS_MAP
|
||||
|
||||
/**
|
||||
* Trigger classification and utilities
|
||||
*/
|
||||
export class TriggerUtils {
|
||||
/**
|
||||
* Check if a block is any kind of trigger
|
||||
*/
|
||||
static isTriggerBlock(block: { type: string; triggerMode?: boolean }): boolean {
|
||||
const blockConfig = getBlock(block.type)
|
||||
|
||||
return (
|
||||
// New trigger blocks (explicit category)
|
||||
blockConfig?.category === 'triggers' ||
|
||||
// Blocks with trigger mode enabled
|
||||
block.triggerMode === true ||
|
||||
// Legacy starter block
|
||||
block.type === TRIGGER_TYPES.STARTER
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a block is a specific trigger type
|
||||
*/
|
||||
static isTriggerType(block: { type: string }, triggerType: TriggerType): boolean {
|
||||
return block.type === triggerType
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a type string is any trigger type
|
||||
*/
|
||||
static isAnyTriggerType(type: string): boolean {
|
||||
return Object.values(TRIGGER_TYPES).includes(type as TriggerType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a block is a chat-compatible trigger
|
||||
*/
|
||||
static isChatTrigger(block: { type: string; subBlocks?: any }): boolean {
|
||||
if (block.type === TRIGGER_TYPES.CHAT || block.type === TRIGGER_TYPES.START) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Legacy: starter block in chat mode
|
||||
if (block.type === TRIGGER_TYPES.STARTER) {
|
||||
return block.subBlocks?.startWorkflow?.value === 'chat'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a block is a manual-compatible trigger
|
||||
*/
|
||||
static isManualTrigger(block: { type: string; subBlocks?: any }): boolean {
|
||||
if (
|
||||
block.type === TRIGGER_TYPES.INPUT ||
|
||||
block.type === TRIGGER_TYPES.MANUAL ||
|
||||
block.type === TRIGGER_TYPES.START
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Legacy: starter block in manual mode or without explicit mode (default to manual)
|
||||
if (block.type === TRIGGER_TYPES.STARTER) {
|
||||
// If startWorkflow is not set or is set to 'manual', treat as manual trigger
|
||||
const startWorkflowValue = block.subBlocks?.startWorkflow?.value
|
||||
return startWorkflowValue === 'manual' || startWorkflowValue === undefined
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a block is an API-compatible trigger
|
||||
* @param block - Block to check
|
||||
* @param isChildWorkflow - Whether this is being called from a child workflow context
|
||||
*/
|
||||
static isApiTrigger(block: { type: string; subBlocks?: any }, isChildWorkflow = false): boolean {
|
||||
if (isChildWorkflow) {
|
||||
// Child workflows (workflow-in-workflow) support legacy input trigger and new start block
|
||||
return block.type === TRIGGER_TYPES.INPUT || block.type === TRIGGER_TYPES.START
|
||||
}
|
||||
// Direct API calls work with api_trigger and the new start block
|
||||
if (block.type === TRIGGER_TYPES.API || block.type === TRIGGER_TYPES.START) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Legacy: starter block in API mode
|
||||
if (block.type === TRIGGER_TYPES.STARTER) {
|
||||
const mode = block.subBlocks?.startWorkflow?.value
|
||||
return mode === 'api' || mode === 'run'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default name for a trigger type
|
||||
*/
|
||||
static getDefaultTriggerName(triggerType: string): string | null {
|
||||
// Use the block's actual name from the registry
|
||||
const block = getBlock(triggerType)
|
||||
if (block) {
|
||||
if (triggerType === TRIGGER_TYPES.GENERIC_WEBHOOK) {
|
||||
return 'Webhook'
|
||||
}
|
||||
return block.name
|
||||
}
|
||||
|
||||
// Fallback for legacy or unknown types
|
||||
switch (triggerType) {
|
||||
case TRIGGER_TYPES.CHAT:
|
||||
return 'Chat'
|
||||
case TRIGGER_TYPES.INPUT:
|
||||
return 'Input Trigger'
|
||||
case TRIGGER_TYPES.MANUAL:
|
||||
return 'Manual'
|
||||
case TRIGGER_TYPES.API:
|
||||
return 'API'
|
||||
case TRIGGER_TYPES.START:
|
||||
return 'Start'
|
||||
case TRIGGER_TYPES.WEBHOOK:
|
||||
return 'Webhook'
|
||||
case TRIGGER_TYPES.SCHEDULE:
|
||||
return 'Schedule'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find trigger blocks of a specific type in a workflow
|
||||
*/
|
||||
static findTriggersByType<T extends { type: string; subBlocks?: any }>(
|
||||
blocks: T[] | Record<string, T>,
|
||||
triggerType: 'chat' | 'manual' | 'api',
|
||||
isChildWorkflow = false
|
||||
): T[] {
|
||||
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
|
||||
|
||||
switch (triggerType) {
|
||||
case 'chat':
|
||||
return blockArray.filter((block) => TriggerUtils.isChatTrigger(block))
|
||||
case 'manual':
|
||||
return blockArray.filter((block) => TriggerUtils.isManualTrigger(block))
|
||||
case 'api':
|
||||
return blockArray.filter((block) => TriggerUtils.isApiTrigger(block, isChildWorkflow))
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the appropriate start block for a given execution context
|
||||
*/
|
||||
static findStartBlock<T extends { type: string; subBlocks?: any }>(
|
||||
blocks: Record<string, T>,
|
||||
executionType: 'chat' | 'manual' | 'api' | 'external',
|
||||
isChildWorkflow = false
|
||||
): (StartBlockCandidate<T> & { block: T }) | null {
|
||||
const candidates = resolveStartCandidates(blocks, {
|
||||
execution: executionType,
|
||||
isChildWorkflow,
|
||||
})
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [primary] = candidates
|
||||
return primary
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if multiple triggers of a restricted type exist
|
||||
*/
|
||||
static hasMultipleTriggers<T extends { type: string }>(
|
||||
blocks: T[] | Record<string, T>,
|
||||
triggerType: TriggerType
|
||||
): boolean {
|
||||
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
|
||||
const count = blockArray.filter((block) => block.type === triggerType).length
|
||||
return count > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a trigger type requires single instance constraint
|
||||
*/
|
||||
static requiresSingleInstance(triggerType: string): boolean {
|
||||
// Each trigger type can only have one instance of itself
|
||||
// Manual and Input Form can coexist
|
||||
// API, Chat triggers must be unique
|
||||
// Schedules and webhooks can have multiple instances
|
||||
return (
|
||||
triggerType === TRIGGER_TYPES.API ||
|
||||
triggerType === TRIGGER_TYPES.INPUT ||
|
||||
triggerType === TRIGGER_TYPES.MANUAL ||
|
||||
triggerType === TRIGGER_TYPES.CHAT ||
|
||||
triggerType === TRIGGER_TYPES.START
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workflow has a legacy starter block
|
||||
*/
|
||||
static hasLegacyStarter<T extends { type: string }>(blocks: T[] | Record<string, T>): boolean {
|
||||
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
|
||||
return blockArray.some((block) => block.type === TRIGGER_TYPES.STARTER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if adding a trigger would violate single instance constraint
|
||||
*/
|
||||
static wouldViolateSingleInstance<T extends { type: string }>(
|
||||
blocks: T[] | Record<string, T>,
|
||||
triggerType: string
|
||||
): boolean {
|
||||
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
|
||||
const hasLegacyStarter = TriggerUtils.hasLegacyStarter(blocks)
|
||||
|
||||
// Legacy starter block can't coexist with Chat, Input, Manual, or API triggers
|
||||
if (hasLegacyStarter) {
|
||||
if (
|
||||
triggerType === TRIGGER_TYPES.CHAT ||
|
||||
triggerType === TRIGGER_TYPES.INPUT ||
|
||||
triggerType === TRIGGER_TYPES.MANUAL ||
|
||||
triggerType === TRIGGER_TYPES.API ||
|
||||
triggerType === TRIGGER_TYPES.START
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerType === TRIGGER_TYPES.STARTER) {
|
||||
const hasModernTriggers = blockArray.some(
|
||||
(block) =>
|
||||
block.type === TRIGGER_TYPES.CHAT ||
|
||||
block.type === TRIGGER_TYPES.INPUT ||
|
||||
block.type === TRIGGER_TYPES.MANUAL ||
|
||||
block.type === TRIGGER_TYPES.API ||
|
||||
block.type === TRIGGER_TYPES.START
|
||||
)
|
||||
if (hasModernTriggers) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Start trigger cannot coexist with other single-instance trigger types
|
||||
if (triggerType === TRIGGER_TYPES.START) {
|
||||
return blockArray.some((block) => START_CONFLICT_TYPES.includes(block.type as TriggerType))
|
||||
}
|
||||
|
||||
// Only one Input trigger allowed
|
||||
if (triggerType === TRIGGER_TYPES.INPUT) {
|
||||
return blockArray.some((block) => block.type === TRIGGER_TYPES.INPUT)
|
||||
}
|
||||
|
||||
// Only one Manual trigger allowed
|
||||
if (triggerType === TRIGGER_TYPES.MANUAL) {
|
||||
return blockArray.some((block) => block.type === TRIGGER_TYPES.MANUAL)
|
||||
}
|
||||
|
||||
// Only one API trigger allowed
|
||||
if (triggerType === TRIGGER_TYPES.API) {
|
||||
return blockArray.some((block) => block.type === TRIGGER_TYPES.API)
|
||||
}
|
||||
|
||||
// Chat trigger must be unique
|
||||
if (triggerType === TRIGGER_TYPES.CHAT) {
|
||||
return blockArray.some((block) => block.type === TRIGGER_TYPES.CHAT)
|
||||
}
|
||||
|
||||
// Centralized rule: only API, Input, Chat are single-instance
|
||||
if (!TriggerUtils.requiresSingleInstance(triggerType)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return blockArray.some((block) => block.type === triggerType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate whether adding a trigger of the given type is allowed and, if not, why.
|
||||
* Returns null if allowed; otherwise returns an object describing the violation.
|
||||
* This avoids duplicating UI logic across toolbar/drop handlers.
|
||||
*/
|
||||
static getTriggerAdditionIssue<T extends { type: string }>(
|
||||
blocks: T[] | Record<string, T>,
|
||||
triggerType: string
|
||||
): { issue: 'legacy' | 'duplicate'; triggerName: string } | null {
|
||||
if (!TriggerUtils.wouldViolateSingleInstance(blocks, triggerType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Legacy starter present + adding modern trigger → legacy incompatibility
|
||||
if (TriggerUtils.hasLegacyStarter(blocks) && TriggerUtils.isAnyTriggerType(triggerType)) {
|
||||
return { issue: 'legacy', triggerName: 'new trigger' }
|
||||
}
|
||||
|
||||
// Otherwise treat as duplicate of a single-instance trigger
|
||||
const triggerName = TriggerUtils.getDefaultTriggerName(triggerType) || 'trigger'
|
||||
return { issue: 'duplicate', triggerName }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trigger validation message
|
||||
*/
|
||||
static getTriggerValidationMessage(
|
||||
triggerType: 'chat' | 'manual' | 'api',
|
||||
issue: 'missing' | 'multiple'
|
||||
): string {
|
||||
const triggerName = triggerType.charAt(0).toUpperCase() + triggerType.slice(1)
|
||||
|
||||
if (issue === 'missing') {
|
||||
return `${triggerName} execution requires a ${triggerName} Trigger block`
|
||||
}
|
||||
|
||||
return `Multiple ${triggerName} Trigger blocks found. Keep only one.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a block is inside a loop or parallel subflow
|
||||
* @param blockId - ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns true if the block is inside a loop or parallel, false otherwise
|
||||
*/
|
||||
static isBlockInSubflow<T extends { id: string; data?: { parentId?: string } }>(
|
||||
blockId: string,
|
||||
blocks: T[] | Record<string, T>
|
||||
): boolean {
|
||||
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
|
||||
const block = blockArray.find((b) => b.id === blockId)
|
||||
|
||||
if (!block || !block.data?.parentId) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the parent is a loop or parallel block
|
||||
const parent = blockArray.find((b) => b.id === block.data?.parentId)
|
||||
if (!parent) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Type-safe check: parent must have a 'type' property
|
||||
const parentWithType = parent as T & { type?: string }
|
||||
return parentWithType.type === 'loop' || parentWithType.type === 'parallel'
|
||||
}
|
||||
|
||||
static isSingleInstanceBlockType(blockType: string): boolean {
|
||||
const blockConfig = getBlock(blockType)
|
||||
return blockConfig?.singleInstance === true
|
||||
}
|
||||
|
||||
static wouldViolateSingleInstanceBlock<T extends { type: string }>(
|
||||
blocks: T[] | Record<string, T>,
|
||||
blockType: string
|
||||
): boolean {
|
||||
if (!TriggerUtils.isSingleInstanceBlockType(blockType)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
|
||||
return blockArray.some((block) => block.type === blockType)
|
||||
}
|
||||
|
||||
static getSingleInstanceBlockIssue<T extends { type: string }>(
|
||||
blocks: T[] | Record<string, T>,
|
||||
blockType: string
|
||||
): { issue: 'duplicate'; blockName: string } | null {
|
||||
if (!TriggerUtils.wouldViolateSingleInstanceBlock(blocks, blockType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(blockType)
|
||||
const blockName = blockConfig?.name || blockType
|
||||
return { issue: 'duplicate', blockName }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user