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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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 {}
}
}
+632
View File
@@ -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 }
}
}