mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-10 14:45:16 -05:00
Compare commits
3 Commits
improvemen
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff7e57f99 | ||
|
|
8902752c17 | ||
|
|
e321f883b0 |
@@ -76,7 +76,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.PROCESSING || job.status === JOB_STATUS.PENDING) {
|
||||
response.estimatedDuration = 180000
|
||||
response.estimatedDuration = 300000
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
@@ -59,7 +59,6 @@ export async function POST(
|
||||
checkDeployment: false, // Resuming existing execution, deployment already checked
|
||||
skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits
|
||||
workspaceId: workflow.workspaceId || undefined,
|
||||
isResumeContext: true, // Enable billing fallback for paused workflow resumes
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
|
||||
@@ -4,8 +4,11 @@ import type React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import { getToolOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -121,26 +124,41 @@ export function OutputSelect({
|
||||
: `block-${block.id}`
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const effectiveTriggerMode = Boolean(block.triggerMode && isTriggerCapable)
|
||||
const responseFormatValue =
|
||||
shouldUseBaseline && baselineWorkflow
|
||||
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value
|
||||
: subBlockValues?.[block.id]?.responseFormat
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
|
||||
|
||||
let outputsToProcess: Record<string, unknown> = {}
|
||||
const rawSubBlockValues =
|
||||
shouldUseBaseline && baselineWorkflow
|
||||
? baselineWorkflow.blocks?.[block.id]?.subBlocks
|
||||
: subBlockValues?.[block.id]
|
||||
const subBlocks: Record<string, { value: unknown }> = {}
|
||||
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
|
||||
for (const [key, val] of Object.entries(rawSubBlockValues)) {
|
||||
// Handle both { value: ... } and raw value formats
|
||||
subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val }
|
||||
}
|
||||
}
|
||||
|
||||
outputsToProcess = getEffectiveBlockOutputs(block.type, subBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
}) as Record<string, unknown>
|
||||
if (responseFormat) {
|
||||
const schemaFields = extractFieldsFromSchema(responseFormat)
|
||||
if (schemaFields.length > 0) {
|
||||
schemaFields.forEach((field) => {
|
||||
outputsToProcess[field.name] = { type: field.type }
|
||||
})
|
||||
} else {
|
||||
outputsToProcess = blockConfig?.outputs || {}
|
||||
}
|
||||
} else {
|
||||
// Build subBlocks object for tool selector
|
||||
const rawSubBlockValues =
|
||||
shouldUseBaseline && baselineWorkflow
|
||||
? baselineWorkflow.blocks?.[block.id]?.subBlocks
|
||||
: subBlockValues?.[block.id]
|
||||
const subBlocks: Record<string, { value: unknown }> = {}
|
||||
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
|
||||
for (const [key, val] of Object.entries(rawSubBlockValues)) {
|
||||
// Handle both { value: ... } and raw value formats
|
||||
subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val }
|
||||
}
|
||||
}
|
||||
|
||||
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, subBlocks) : {}
|
||||
outputsToProcess =
|
||||
Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {}
|
||||
}
|
||||
|
||||
if (Object.keys(outputsToProcess).length === 0) return
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ function ConnectionItem({
|
||||
blockId: connection.id,
|
||||
blockType: connection.type,
|
||||
mergedSubBlocks,
|
||||
responseFormat: connection.responseFormat,
|
||||
operation: connection.operation,
|
||||
triggerMode: sourceBlock?.triggerMode,
|
||||
})
|
||||
const hasFields = fields.length > 0
|
||||
|
||||
@@ -324,7 +324,10 @@ export function DocumentTagEntry({
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[cellKey] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
|
||||
!isReadOnly && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className='w-full whitespace-pre' style={{ minWidth: 'fit-content' }}>
|
||||
{formatDisplayText(
|
||||
|
||||
@@ -226,7 +226,10 @@ export function EvalInput({
|
||||
ref={(el) => {
|
||||
if (el) descriptionOverlayRefs.current[metric.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 overflow-auto bg-transparent px-[8px] py-[8px] font-medium font-sans text-[#eeeeee] text-sm'
|
||||
className={cn(
|
||||
'absolute inset-0 overflow-auto bg-transparent px-[8px] py-[8px] font-medium font-sans text-[#eeeeee] text-sm',
|
||||
!(isPreview || disabled) && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className='whitespace-pre-wrap'>
|
||||
{formatDisplayText(metric.description || '', {
|
||||
|
||||
@@ -273,7 +273,10 @@ function InputMappingField({
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current.set(fieldId, el)
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
|
||||
!disabled && 'pointer-events-none'
|
||||
)}
|
||||
style={{ overflowX: 'auto' }}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -312,7 +312,10 @@ export function KnowledgeTagFilters({
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[cellKey] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
|
||||
!isReadOnly && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className='w-full whitespace-pre' style={{ minWidth: 'fit-content' }}>
|
||||
{formatDisplayText(
|
||||
|
||||
@@ -353,8 +353,9 @@ export function LongInput({
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm',
|
||||
(isPreview || disabled) && 'opacity-50'
|
||||
'absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm',
|
||||
(isPreview || disabled) && 'opacity-50',
|
||||
!(isPreview || disabled) && 'pointer-events-none'
|
||||
)}
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
|
||||
@@ -719,7 +719,10 @@ export function MessagesInput({
|
||||
ref={(el) => {
|
||||
overlayRefs.current[fieldId] = el
|
||||
}}
|
||||
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
className={cn(
|
||||
'absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
!(isPreview || disabled) && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
{formatDisplayText(message.content, {
|
||||
accessiblePrefixes,
|
||||
|
||||
@@ -372,8 +372,9 @@ export const ShortInput = memo(function ShortInput({
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] pr-3 font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
(isPreview || disabled) && 'opacity-50'
|
||||
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] pr-3 font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
(isPreview || disabled) && 'opacity-50',
|
||||
!(isPreview || disabled) && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className='min-w-fit whitespace-pre'>{formattedText}</div>
|
||||
|
||||
@@ -269,7 +269,10 @@ export function FieldFormat({
|
||||
ref={(el) => {
|
||||
if (el) nameOverlayRefs.current[field.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
|
||||
!isReadOnly && 'pointer-events-none'
|
||||
)}
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
<div
|
||||
@@ -514,7 +517,10 @@ export function FieldFormat({
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[field.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
|
||||
!isReadOnly && 'pointer-events-none'
|
||||
)}
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -14,11 +14,16 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
getEffectiveBlockOutputPaths,
|
||||
getEffectiveBlockOutputType,
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import {
|
||||
getBlockOutputPaths,
|
||||
getBlockOutputType,
|
||||
getOutputPathsFromSchema,
|
||||
getToolOutputPaths,
|
||||
getToolOutputType,
|
||||
} from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
|
||||
import { KeyboardNavigationHandler } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler'
|
||||
import type {
|
||||
@@ -209,19 +214,43 @@ const getOutputTypeForPath = (
|
||||
outputPath: string,
|
||||
mergedSubBlocksOverride?: Record<string, any>
|
||||
): string => {
|
||||
if (block?.type === 'variables') {
|
||||
return 'any'
|
||||
if (block?.triggerMode && blockConfig?.triggers?.enabled) {
|
||||
return getBlockOutputType(block.type, outputPath, mergedSubBlocksOverride, true)
|
||||
}
|
||||
if (block?.type === 'starter') {
|
||||
const startWorkflowValue =
|
||||
mergedSubBlocksOverride?.startWorkflow?.value ?? getSubBlockValue(blockId, 'startWorkflow')
|
||||
|
||||
if (startWorkflowValue === 'chat') {
|
||||
const chatModeTypes: Record<string, string> = {
|
||||
input: 'string',
|
||||
conversationId: 'string',
|
||||
files: 'file[]',
|
||||
}
|
||||
return chatModeTypes[outputPath] || 'any'
|
||||
}
|
||||
const inputFormatValue =
|
||||
mergedSubBlocksOverride?.inputFormat?.value ?? getSubBlockValue(blockId, 'inputFormat')
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue)) {
|
||||
const field = inputFormatValue.find(
|
||||
(f: { name?: string; type?: string }) => f.name === outputPath
|
||||
)
|
||||
if (field?.type) return field.type
|
||||
}
|
||||
} else if (blockConfig?.category === 'triggers') {
|
||||
const blockState = useWorkflowStore.getState().blocks[blockId]
|
||||
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
|
||||
return getBlockOutputType(block.type, outputPath, subBlocks)
|
||||
} else if (blockConfig?.tools?.config?.tool) {
|
||||
const blockState = useWorkflowStore.getState().blocks[blockId]
|
||||
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
|
||||
return getToolOutputType(blockConfig, subBlocks, outputPath)
|
||||
}
|
||||
|
||||
const subBlocks =
|
||||
mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(block?.triggerMode && isTriggerCapable)
|
||||
|
||||
return getEffectiveBlockOutputType(block?.type ?? '', outputPath, subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
})
|
||||
const triggerMode = block?.triggerMode && blockConfig?.triggers?.enabled
|
||||
return getBlockOutputType(block?.type ?? '', outputPath, subBlocks, triggerMode)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1059,9 +1088,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const mergedSubBlocks = getMergedSubBlocks(activeSourceBlockId)
|
||||
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, activeSourceBlockId)
|
||||
|
||||
let blockTags: string[]
|
||||
|
||||
if (sourceBlock.type === 'variables') {
|
||||
if (sourceBlock.type === 'evaluator') {
|
||||
const metricsValue = getSubBlockValue(activeSourceBlockId, 'metrics')
|
||||
|
||||
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
|
||||
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
|
||||
blockTags = validMetrics.map(
|
||||
(metric: { name: string }) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
|
||||
)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (sourceBlock.type === 'variables') {
|
||||
const variablesValue = getSubBlockValue(activeSourceBlockId, 'variables')
|
||||
|
||||
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
|
||||
@@ -1075,24 +1119,106 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else {
|
||||
const sourceBlockConfig = getBlock(sourceBlock.type)
|
||||
const isTriggerCapable = sourceBlockConfig ? hasTriggerCapability(sourceBlockConfig) : false
|
||||
const effectiveTriggerMode = Boolean(sourceBlock.triggerMode && isTriggerCapable)
|
||||
const outputPaths = getEffectiveBlockOutputPaths(sourceBlock.type, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
|
||||
if (sourceBlock.type === 'human_in_the_loop' && activeSourceBlockId === blockId) {
|
||||
blockTags = allTags.filter(
|
||||
(tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')
|
||||
)
|
||||
} else if (allTags.length === 0) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else if (responseFormat) {
|
||||
const schemaFields = extractFieldsFromSchema(responseFormat)
|
||||
if (schemaFields.length > 0) {
|
||||
blockTags = schemaFields.map((field) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
blockTags = allTags
|
||||
const outputPaths = getBlockOutputPaths(
|
||||
sourceBlock.type,
|
||||
mergedSubBlocks,
|
||||
sourceBlock.triggerMode
|
||||
)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) {
|
||||
if (sourceBlock.type === 'starter') {
|
||||
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
|
||||
|
||||
if (startWorkflowValue === 'chat') {
|
||||
blockTags = [
|
||||
`${normalizedBlockName}.input`,
|
||||
`${normalizedBlockName}.conversationId`,
|
||||
`${normalizedBlockName}.files`,
|
||||
]
|
||||
} else {
|
||||
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
|
||||
|
||||
if (
|
||||
inputFormatValue &&
|
||||
Array.isArray(inputFormatValue) &&
|
||||
inputFormatValue.length > 0
|
||||
) {
|
||||
blockTags = inputFormatValue
|
||||
.filter((field: { name?: string }) => field.name && field.name.trim() !== '')
|
||||
.map((field: { name: string }) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
}
|
||||
} else if (sourceBlock.type === 'api_trigger' || sourceBlock.type === 'input_trigger') {
|
||||
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
|
||||
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
blockTags = inputFormatValue
|
||||
.filter((field: { name?: string }) => field.name && field.name.trim() !== '')
|
||||
.map((field: { name: string }) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
blockTags = []
|
||||
}
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else {
|
||||
if (blockConfig.category === 'triggers' || sourceBlock.type === 'starter') {
|
||||
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
if (dynamicOutputs.length > 0) {
|
||||
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else if (sourceBlock.type === 'starter') {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else if (sourceBlock.type === TRIGGER_TYPES.GENERIC_WEBHOOK) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
blockTags = []
|
||||
}
|
||||
} else if (sourceBlock?.triggerMode && blockConfig.triggers?.enabled) {
|
||||
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true)
|
||||
if (dynamicOutputs.length > 0) {
|
||||
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (sourceBlock.type === 'human_in_the_loop') {
|
||||
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
|
||||
const isSelfReference = activeSourceBlockId === blockId
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
}
|
||||
} else {
|
||||
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
|
||||
|
||||
if (toolOutputPaths.length > 0) {
|
||||
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(
|
||||
sourceBlock.type,
|
||||
mergedSubBlocks,
|
||||
sourceBlock.triggerMode
|
||||
)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1306,10 +1432,45 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const mergedSubBlocks = getMergedSubBlocks(accessibleBlockId)
|
||||
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, accessibleBlockId)
|
||||
|
||||
let blockTags: string[]
|
||||
|
||||
if (accessibleBlock.type === 'variables') {
|
||||
if (blockConfig.category === 'triggers' || accessibleBlock.type === 'starter') {
|
||||
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else if (accessibleBlock.type === 'starter') {
|
||||
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
|
||||
if (startWorkflowValue === 'chat') {
|
||||
blockTags = [
|
||||
`${normalizedBlockName}.input`,
|
||||
`${normalizedBlockName}.conversationId`,
|
||||
`${normalizedBlockName}.files`,
|
||||
]
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else if (accessibleBlock.type === TRIGGER_TYPES.GENERIC_WEBHOOK) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
blockTags = []
|
||||
}
|
||||
} else if (accessibleBlock.type === 'evaluator') {
|
||||
const metricsValue = getSubBlockValue(accessibleBlockId, 'metrics')
|
||||
|
||||
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
|
||||
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
|
||||
blockTags = validMetrics.map(
|
||||
(metric: { name: string }) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
|
||||
)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (accessibleBlock.type === 'variables') {
|
||||
const variablesValue = getSubBlockValue(accessibleBlockId, 'variables')
|
||||
|
||||
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
|
||||
@@ -1323,26 +1484,57 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else {
|
||||
const accessibleBlockConfig = getBlock(accessibleBlock.type)
|
||||
const isTriggerCapable = accessibleBlockConfig
|
||||
? hasTriggerCapability(accessibleBlockConfig)
|
||||
: false
|
||||
const effectiveTriggerMode = Boolean(accessibleBlock.triggerMode && isTriggerCapable)
|
||||
const outputPaths = getEffectiveBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
|
||||
if (accessibleBlock.type === 'human_in_the_loop' && accessibleBlockId === blockId) {
|
||||
blockTags = allTags.filter(
|
||||
(tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')
|
||||
)
|
||||
} else if (allTags.length === 0) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else if (responseFormat) {
|
||||
const schemaFields = extractFieldsFromSchema(responseFormat)
|
||||
if (schemaFields.length > 0) {
|
||||
blockTags = schemaFields.map((field) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
blockTags = allTags
|
||||
const outputPaths = getBlockOutputPaths(
|
||||
accessibleBlock.type,
|
||||
mergedSubBlocks,
|
||||
accessibleBlock.triggerMode
|
||||
)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
const blockState = blocks[accessibleBlockId]
|
||||
if (blockState?.triggerMode && blockConfig.triggers?.enabled) {
|
||||
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true)
|
||||
if (dynamicOutputs.length > 0) {
|
||||
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (accessibleBlock.type === 'human_in_the_loop') {
|
||||
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
|
||||
|
||||
const isSelfReference = accessibleBlockId === blockId
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
} else {
|
||||
blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`]
|
||||
}
|
||||
} else {
|
||||
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
|
||||
|
||||
if (toolOutputPaths.length > 0) {
|
||||
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(
|
||||
accessibleBlock.type,
|
||||
mergedSubBlocks,
|
||||
accessibleBlock.triggerMode
|
||||
)
|
||||
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -452,7 +452,10 @@ export function VariablesInput({
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[assignment.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm'
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm',
|
||||
!isReadOnly && 'pointer-events-none'
|
||||
)}
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
<div className='w-full whitespace-pre-wrap break-words'>
|
||||
@@ -514,7 +517,10 @@ export function VariablesInput({
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[assignment.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
|
||||
!isReadOnly && 'pointer-events-none'
|
||||
)}
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -18,7 +20,18 @@ export interface ConnectedBlock {
|
||||
type: string
|
||||
outputType: string | string[]
|
||||
name: string
|
||||
responseFormat?: {
|
||||
// Support both formats
|
||||
fields?: Field[]
|
||||
name?: string
|
||||
schema?: {
|
||||
type: string
|
||||
properties: Record<string, any>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
outputs?: Record<string, any>
|
||||
operation?: string
|
||||
}
|
||||
|
||||
export function useBlockConnections(blockId: string) {
|
||||
@@ -89,32 +102,47 @@ export function useBlockConnections(blockId: string) {
|
||||
|
||||
// Get merged subblocks for this source block
|
||||
const mergedSubBlocks = getMergedSubBlocks(sourceId)
|
||||
const blockConfig = getBlock(sourceBlock.type)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const effectiveTriggerMode = Boolean(sourceBlock.triggerMode && isTriggerCapable)
|
||||
|
||||
const blockOutputs = getEffectiveBlockOutputs(sourceBlock.type, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
// Get the response format from the subblock store
|
||||
const responseFormatValue = useSubBlockStore.getState().getValue(sourceId, 'responseFormat')
|
||||
|
||||
const outputFields: Field[] = Object.entries(blockOutputs).map(
|
||||
([key, value]: [string, any]) => ({
|
||||
// Safely parse response format with proper error handling
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, sourceId)
|
||||
|
||||
// Get operation value for tool-based blocks
|
||||
const operationValue = useSubBlockStore.getState().getValue(sourceId, 'operation')
|
||||
|
||||
// Use getBlockOutputs to properly handle dynamic outputs from inputFormat
|
||||
const blockOutputs = getBlockOutputs(
|
||||
sourceBlock.type,
|
||||
mergedSubBlocks,
|
||||
sourceBlock.triggerMode
|
||||
)
|
||||
|
||||
// Extract fields from the response format if available, otherwise use block outputs
|
||||
let outputFields: Field[]
|
||||
if (responseFormat) {
|
||||
outputFields = extractFieldsFromSchema(responseFormat)
|
||||
} else {
|
||||
// Convert block outputs to field format
|
||||
outputFields = Object.entries(blockOutputs).map(([key, value]: [string, any]) => ({
|
||||
name: key,
|
||||
type: value && typeof value === 'object' && 'type' in value ? value.type : 'string',
|
||||
description:
|
||||
value && typeof value === 'object' && 'description' in value
|
||||
? value.description
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
id: sourceBlock.id,
|
||||
type: sourceBlock.type,
|
||||
outputType: outputFields.map((field: Field) => field.name),
|
||||
name: sourceBlock.name,
|
||||
responseFormat,
|
||||
outputs: blockOutputs,
|
||||
operation: operationValue,
|
||||
distance: nodeDistances.get(sourceId) || Number.POSITIVE_INFINITY,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { extractFieldsFromSchema } from '@/lib/core/utils/response-format'
|
||||
import {
|
||||
getBlockOutputPaths,
|
||||
getBlockOutputs,
|
||||
getToolOutputs,
|
||||
} from '@/lib/workflows/blocks/block-outputs'
|
||||
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
|
||||
import type { SchemaField } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -71,7 +76,11 @@ const extractNestedFields = (properties: Record<string, any>): SchemaField[] =>
|
||||
/**
|
||||
* Creates a schema field from an output definition
|
||||
*/
|
||||
const createFieldFromOutput = (name: string, output: any): SchemaField => {
|
||||
const createFieldFromOutput = (
|
||||
name: string,
|
||||
output: any,
|
||||
responseFormatFields?: SchemaField[]
|
||||
): SchemaField => {
|
||||
const hasExplicitType = isObject(output) && typeof output.type === 'string'
|
||||
const type = hasExplicitType ? output.type : isObject(output) ? 'object' : 'string'
|
||||
|
||||
@@ -81,7 +90,11 @@ const createFieldFromOutput = (name: string, output: any): SchemaField => {
|
||||
description: isObject(output) && 'description' in output ? output.description : undefined,
|
||||
}
|
||||
|
||||
field.children = extractChildFields(output)
|
||||
if (name === 'data' && responseFormatFields && responseFormatFields.length > 0) {
|
||||
field.children = responseFormatFields
|
||||
} else {
|
||||
field.children = extractChildFields(output)
|
||||
}
|
||||
|
||||
return field
|
||||
}
|
||||
@@ -90,6 +103,8 @@ interface UseBlockOutputFieldsParams {
|
||||
blockId: string
|
||||
blockType: string
|
||||
mergedSubBlocks?: Record<string, any>
|
||||
responseFormat?: any
|
||||
operation?: string
|
||||
triggerMode?: boolean
|
||||
}
|
||||
|
||||
@@ -101,6 +116,8 @@ export function useBlockOutputFields({
|
||||
blockId,
|
||||
blockType,
|
||||
mergedSubBlocks,
|
||||
responseFormat,
|
||||
operation,
|
||||
triggerMode,
|
||||
}: UseBlockOutputFieldsParams): SchemaField[] {
|
||||
return useMemo(() => {
|
||||
@@ -121,6 +138,21 @@ export function useBlockOutputFields({
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle evaluator blocks - use metrics if available
|
||||
if (blockType === 'evaluator') {
|
||||
const metricsValue = mergedSubBlocks?.metrics?.value ?? getSubBlockValue(blockId, 'metrics')
|
||||
|
||||
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
|
||||
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
|
||||
return validMetrics.map((metric: { name: string }) => ({
|
||||
name: metric.name.toLowerCase(),
|
||||
type: 'number',
|
||||
description: `Metric: ${metric.name}`,
|
||||
}))
|
||||
}
|
||||
// Fall through to use blockConfig.outputs
|
||||
}
|
||||
|
||||
// Handle variables blocks - use variable assignments if available
|
||||
if (blockType === 'variables') {
|
||||
const variablesValue =
|
||||
@@ -140,16 +172,123 @@ export function useBlockOutputFields({
|
||||
return []
|
||||
}
|
||||
|
||||
const isTriggerCapable = hasTriggerCapability(blockConfig)
|
||||
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
|
||||
const baseOutputs = getEffectiveBlockOutputs(blockType, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
}) as Record<string, any>
|
||||
// Get base outputs using getBlockOutputs (handles triggers, starter, approval, etc.)
|
||||
let baseOutputs: Record<string, any> = {}
|
||||
|
||||
if (blockConfig.category === 'triggers' || blockType === 'starter') {
|
||||
// Use getBlockOutputPaths to get dynamic outputs, then reconstruct the structure
|
||||
const outputPaths = getBlockOutputPaths(blockType, mergedSubBlocks, triggerMode)
|
||||
if (outputPaths.length > 0) {
|
||||
// Reconstruct outputs structure from paths
|
||||
// This is a simplified approach - we'll use the paths to build the structure
|
||||
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, triggerMode)
|
||||
} else if (blockType === 'starter') {
|
||||
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
|
||||
if (startWorkflowValue === 'chat') {
|
||||
baseOutputs = {
|
||||
input: { type: 'string', description: 'User message' },
|
||||
conversationId: { type: 'string', description: 'Conversation ID' },
|
||||
files: { type: 'file[]', description: 'Uploaded files' },
|
||||
}
|
||||
} else {
|
||||
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
baseOutputs = {}
|
||||
inputFormatValue.forEach((field: { name?: string; type?: string }) => {
|
||||
if (field.name && field.name.trim() !== '') {
|
||||
baseOutputs[field.name] = {
|
||||
type: field.type || 'string',
|
||||
description: `Field from input format`,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (blockType === TRIGGER_TYPES.GENERIC_WEBHOOK) {
|
||||
// Generic webhook returns the whole payload
|
||||
baseOutputs = {}
|
||||
} else {
|
||||
baseOutputs = {}
|
||||
}
|
||||
} else if (triggerMode && blockConfig.triggers?.enabled) {
|
||||
// Trigger mode enabled
|
||||
const dynamicOutputs = getBlockOutputPaths(blockType, mergedSubBlocks, true)
|
||||
if (dynamicOutputs.length > 0) {
|
||||
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, true)
|
||||
} else {
|
||||
baseOutputs = blockConfig.outputs || {}
|
||||
}
|
||||
} else if (blockType === 'approval') {
|
||||
// Approval block uses dynamic outputs from inputFormat
|
||||
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks)
|
||||
} else {
|
||||
// For tool-based blocks, try to get tool outputs first
|
||||
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, mergedSubBlocks) : {}
|
||||
|
||||
if (Object.keys(toolOutputs).length > 0) {
|
||||
baseOutputs = toolOutputs
|
||||
} else {
|
||||
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, triggerMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle responseFormat
|
||||
const responseFormatFields = responseFormat ? extractFieldsFromSchema(responseFormat) : []
|
||||
|
||||
// If responseFormat exists and has fields, merge with base outputs
|
||||
if (responseFormatFields.length > 0) {
|
||||
// If base outputs is empty, use responseFormat fields directly
|
||||
if (Object.keys(baseOutputs).length === 0) {
|
||||
return responseFormatFields.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
description: field.description,
|
||||
children: undefined, // ResponseFormat fields are flat
|
||||
}))
|
||||
}
|
||||
|
||||
// Otherwise, merge: responseFormat takes precedence for 'data' field
|
||||
const fields: SchemaField[] = []
|
||||
const responseFormatFieldNames = new Set(responseFormatFields.map((f) => f.name))
|
||||
|
||||
// Add base outputs, replacing 'data' with responseFormat fields if present
|
||||
for (const [name, output] of Object.entries(baseOutputs)) {
|
||||
if (name === 'data' && responseFormatFields.length > 0) {
|
||||
fields.push(
|
||||
createFieldFromOutput(
|
||||
name,
|
||||
output,
|
||||
responseFormatFields.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
description: f.description,
|
||||
}))
|
||||
)
|
||||
)
|
||||
} else if (!responseFormatFieldNames.has(name)) {
|
||||
fields.push(createFieldFromOutput(name, output))
|
||||
}
|
||||
}
|
||||
|
||||
// Add responseFormat fields that aren't in base outputs
|
||||
for (const field of responseFormatFields) {
|
||||
if (!baseOutputs[field.name]) {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
description: field.description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// No responseFormat, just use base outputs
|
||||
if (Object.keys(baseOutputs).length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(baseOutputs).map(([name, output]) => createFieldFromOutput(name, output))
|
||||
}, [blockId, blockType, mergedSubBlocks, triggerMode])
|
||||
}, [blockId, blockType, mergedSubBlocks, responseFormat, operation, triggerMode])
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/p
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
|
||||
import type { BlockConfig, BlockIcon, SubBlockConfig, SubBlockType } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||
@@ -1140,9 +1140,17 @@ function PreviewEditorContent({
|
||||
(block.advancedMode ?? false) ||
|
||||
hasAdvancedValues(blockConfig.subBlocks, rawValues, canonicalIndex)
|
||||
|
||||
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
|
||||
const effectiveTrigger = block.triggerMode === true
|
||||
|
||||
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden || subBlock.hideFromPreview) return false
|
||||
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
|
||||
|
||||
if (subBlock.type === ('trigger-config' as SubBlockType)) {
|
||||
return effectiveTrigger || isPureTriggerBlock
|
||||
}
|
||||
if (subBlock.mode === 'trigger' && !effectiveTrigger) return false
|
||||
if (effectiveTrigger && subBlock.mode !== 'trigger') return false
|
||||
if (!isSubBlockFeatureEnabled(subBlock)) return false
|
||||
if (
|
||||
!isSubBlockVisibleForMode(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { OutputSchema } from '@/executor/utils/block-reference'
|
||||
@@ -10,6 +12,8 @@ import {
|
||||
isBranchNodeId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
export interface BlockDataCollection {
|
||||
blockData: Record<string, unknown>
|
||||
@@ -17,44 +21,118 @@ export interface BlockDataCollection {
|
||||
blockOutputSchemas: Record<string, OutputSchema>
|
||||
}
|
||||
|
||||
interface SubBlockWithValue {
|
||||
value?: unknown
|
||||
}
|
||||
/**
|
||||
* Block types where inputFormat fields should be merged into outputs schema.
|
||||
* These are blocks where users define custom fields via inputFormat that become
|
||||
* valid output paths (e.g., <start.myField>, <webhook1.customField>, <hitl1.resumeField>).
|
||||
*
|
||||
* Note: This includes non-trigger blocks like 'starter' and 'human_in_the_loop' which
|
||||
* have category 'blocks' but still need their inputFormat exposed as outputs.
|
||||
*/
|
||||
const BLOCKS_WITH_INPUT_FORMAT_OUTPUTS = [
|
||||
'start_trigger',
|
||||
'starter',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
'generic_webhook',
|
||||
'human_in_the_loop',
|
||||
] as const
|
||||
|
||||
function paramsToSubBlocks(
|
||||
params: Record<string, unknown> | undefined
|
||||
): Record<string, SubBlockWithValue> {
|
||||
if (!params) return {}
|
||||
|
||||
const subBlocks: Record<string, SubBlockWithValue> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
subBlocks[key] = { value }
|
||||
function getInputFormatFields(block: SerializedBlock): OutputSchema {
|
||||
const inputFormat = normalizeInputFormatValue(block.config?.params?.inputFormat)
|
||||
if (inputFormat.length === 0) {
|
||||
return {}
|
||||
}
|
||||
return subBlocks
|
||||
|
||||
const schema: OutputSchema = {}
|
||||
for (const field of inputFormat) {
|
||||
if (!field.name) continue
|
||||
schema[field.name] = { type: field.type || 'any' }
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
function getRegistrySchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
function getEvaluatorMetricsSchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
if (block.metadata?.id !== 'evaluator') return undefined
|
||||
|
||||
const metrics = block.config?.params?.metrics
|
||||
if (!Array.isArray(metrics) || metrics.length === 0) return undefined
|
||||
|
||||
const validMetrics = metrics.filter(
|
||||
(m: { name?: string }) => m?.name && typeof m.name === 'string'
|
||||
)
|
||||
if (validMetrics.length === 0) return undefined
|
||||
|
||||
const schema: OutputSchema = { ...(block.outputs as OutputSchema) }
|
||||
for (const metric of validMetrics) {
|
||||
schema[metric.name.toLowerCase()] = { type: 'number' }
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
function getResponseFormatSchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
const responseFormatValue = block.config?.params?.responseFormat
|
||||
if (!responseFormatValue) return undefined
|
||||
|
||||
const parsed = parseResponseFormatSafely(responseFormatValue, block.id)
|
||||
if (!parsed) return undefined
|
||||
|
||||
const fields = extractFieldsFromSchema(parsed)
|
||||
if (fields.length === 0) return undefined
|
||||
|
||||
const schema: OutputSchema = {}
|
||||
for (const field of fields) {
|
||||
schema[field.name] = { type: field.type || 'any' }
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
export function getBlockSchema(
|
||||
block: SerializedBlock,
|
||||
toolConfig?: ToolConfig
|
||||
): OutputSchema | undefined {
|
||||
const blockType = block.metadata?.id
|
||||
if (!blockType) return undefined
|
||||
|
||||
const subBlocks = paramsToSubBlocks(block.config?.params)
|
||||
const blockConfig = getBlock(blockType)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(isTriggerBehavior(block) && isTriggerCapable)
|
||||
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
includeHidden: true,
|
||||
}) as OutputSchema
|
||||
|
||||
if (!outputs || Object.keys(outputs).length === 0) {
|
||||
return undefined
|
||||
if (
|
||||
blockType &&
|
||||
BLOCKS_WITH_INPUT_FORMAT_OUTPUTS.includes(
|
||||
blockType as (typeof BLOCKS_WITH_INPUT_FORMAT_OUTPUTS)[number]
|
||||
)
|
||||
) {
|
||||
const baseOutputs = (block.outputs as OutputSchema) || {}
|
||||
const inputFormatFields = getInputFormatFields(block)
|
||||
const merged = { ...baseOutputs, ...inputFormatFields }
|
||||
if (Object.keys(merged).length > 0) {
|
||||
return merged
|
||||
}
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
export function getBlockSchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
return getRegistrySchema(block)
|
||||
const evaluatorSchema = getEvaluatorMetricsSchema(block)
|
||||
if (evaluatorSchema) {
|
||||
return evaluatorSchema
|
||||
}
|
||||
|
||||
const responseFormatSchema = getResponseFormatSchema(block)
|
||||
if (responseFormatSchema) {
|
||||
return responseFormatSchema
|
||||
}
|
||||
|
||||
const isTrigger = isTriggerBehavior(block)
|
||||
|
||||
if (isTrigger && block.outputs && Object.keys(block.outputs).length > 0) {
|
||||
return block.outputs as OutputSchema
|
||||
}
|
||||
|
||||
if (toolConfig?.outputs && Object.keys(toolConfig.outputs).length > 0) {
|
||||
return toolConfig.outputs as OutputSchema
|
||||
}
|
||||
|
||||
if (block.outputs && Object.keys(block.outputs).length > 0) {
|
||||
return block.outputs as OutputSchema
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function collectBlockData(
|
||||
@@ -92,7 +170,9 @@ export function collectBlockData(
|
||||
blockNameMapping[normalizeName(block.metadata.name)] = id
|
||||
}
|
||||
|
||||
const schema = getBlockSchema(block)
|
||||
const toolId = block.config?.tool
|
||||
const toolConfig = toolId ? getTool(toolId) : undefined
|
||||
const schema = getBlockSchema(block, toolConfig)
|
||||
if (schema && Object.keys(schema).length > 0) {
|
||||
blockOutputSchemas[id] = schema
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { BlockResolver } from './block'
|
||||
import type { ResolutionContext } from './reference'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
vi.mock('@/blocks/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/blocks/registry')>('@/blocks/registry')
|
||||
return actual
|
||||
})
|
||||
|
||||
vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
|
||||
getBlockOutputs: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
function createTestWorkflow(
|
||||
blocks: Array<{
|
||||
@@ -135,7 +135,7 @@ describe('BlockResolver', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for non-existent path when no schema defined', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { existing: 'value' },
|
||||
@@ -144,93 +144,55 @@ describe('BlockResolver', () => {
|
||||
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should throw error for path not in output schema', () => {
|
||||
it.concurrent('should throw error for path not in output schema', async () => {
|
||||
const { getBlockOutputs } = await import('@/lib/workflows/blocks/block-outputs')
|
||||
const mockGetBlockOutputs = vi.mocked(getBlockOutputs)
|
||||
const customOutputs = {
|
||||
validField: { type: 'string', description: 'A valid field' },
|
||||
nested: {
|
||||
child: { type: 'number', description: 'Nested child' },
|
||||
},
|
||||
}
|
||||
mockGetBlockOutputs.mockReturnValue(customOutputs as any)
|
||||
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'source',
|
||||
type: 'start_trigger',
|
||||
outputs: customOutputs,
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { input: 'value' },
|
||||
source: { validField: 'value', nested: { child: 42 } },
|
||||
})
|
||||
|
||||
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(
|
||||
/"invalidField" doesn't exist on block "source"/
|
||||
)
|
||||
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
|
||||
|
||||
mockGetBlockOutputs.mockReturnValue({})
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for path in schema but missing in data', () => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'source',
|
||||
type: 'function',
|
||||
outputs: {
|
||||
requiredField: { type: 'string', description: 'Always present' },
|
||||
optionalField: { type: 'string', description: 'Sometimes missing' },
|
||||
},
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { stdout: 'log output' },
|
||||
source: { requiredField: 'value' },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<source.stdout>', ctx)).toBe('log output')
|
||||
expect(resolver.resolve('<source.result>', ctx)).toBeUndefined()
|
||||
expect(resolver.resolve('<source.requiredField>', ctx)).toBe('value')
|
||||
expect(resolver.resolve('<source.optionalField>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should allow hiddenFromDisplay fields for pre-execution schema validation',
|
||||
() => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'workflow-block',
|
||||
name: 'Workflow',
|
||||
type: 'workflow',
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {})
|
||||
|
||||
expect(resolver.resolve('<workflow.childTraceSpans>', ctx)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'should allow hiddenFromDisplay fields for workflow_input pre-execution schema validation',
|
||||
() => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'workflow-input-block',
|
||||
name: 'Workflow Input',
|
||||
type: 'workflow_input',
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {})
|
||||
|
||||
expect(resolver.resolve('<workflowinput.childTraceSpans>', ctx)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'should allow hiddenFromDisplay fields for HITL pre-execution schema validation',
|
||||
() => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'hitl-block',
|
||||
name: 'HITL',
|
||||
type: 'human_in_the_loop',
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {})
|
||||
|
||||
expect(resolver.resolve('<hitl.response>', ctx)).toBeUndefined()
|
||||
expect(resolver.resolve('<hitl.submission>', ctx)).toBeUndefined()
|
||||
expect(resolver.resolve('<hitl.resumeInput>', ctx)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should return undefined for non-existent block', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'existing' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
@@ -1013,7 +975,7 @@ describe('BlockResolver', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should handle output with undefined values', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { value: undefined, other: 'exists' },
|
||||
@@ -1023,7 +985,7 @@ describe('BlockResolver', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for deeply nested non-existent path', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { level1: { level2: {} } },
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type Resolver,
|
||||
} from '@/executor/variables/resolvers/reference'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
export class BlockResolver implements Resolver {
|
||||
private nameToBlockId: Map<string, string>
|
||||
@@ -67,7 +68,9 @@ export class BlockResolver implements Resolver {
|
||||
blockData[blockId] = output
|
||||
}
|
||||
|
||||
const outputSchema = getBlockSchema(block)
|
||||
const toolId = block.config?.tool
|
||||
const toolConfig = toolId ? getTool(toolId) : undefined
|
||||
const outputSchema = getBlockSchema(block, toolConfig)
|
||||
|
||||
if (outputSchema && Object.keys(outputSchema).length > 0) {
|
||||
blockOutputSchemas[blockId] = outputSchema
|
||||
|
||||
@@ -8,15 +8,13 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/workflow-utils'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
|
||||
import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import {
|
||||
loadDeployedWorkflowState,
|
||||
loadWorkflowFromNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
import {
|
||||
@@ -345,13 +343,7 @@ export async function executeGetBlockOutputs(
|
||||
continue
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(block.triggerMode && isTriggerCapable)
|
||||
const outputs = getEffectiveBlockOutputPaths(block.type, block.subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
})
|
||||
const outputs = getBlockOutputPaths(block.type, block.subBlocks, block.triggerMode)
|
||||
results.push({
|
||||
blockId,
|
||||
blockName,
|
||||
@@ -493,13 +485,7 @@ export async function executeGetBlockUpstreamReferences(
|
||||
? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels)
|
||||
: ['results']
|
||||
} else {
|
||||
const blockConfig = getBlock(block.type)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(block.triggerMode && isTriggerCapable)
|
||||
outputPaths = getEffectiveBlockOutputPaths(block.type, block.subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
})
|
||||
outputPaths = getBlockOutputPaths(block.type, block.subBlocks, block.triggerMode)
|
||||
}
|
||||
|
||||
const formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName)
|
||||
|
||||
@@ -232,11 +232,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
||||
const resolvedToolId = resolveToolIdForOperation(blockConfig, opId)
|
||||
const toolCfg = resolvedToolId ? toolsRegistry[resolvedToolId] : undefined
|
||||
const toolParams: Record<string, any> = toolCfg?.params || {}
|
||||
const toolOutputs: Record<string, any> = toolCfg?.outputs
|
||||
? Object.fromEntries(
|
||||
Object.entries(toolCfg.outputs).filter(([_, def]) => !isHiddenFromDisplay(def))
|
||||
)
|
||||
: {}
|
||||
const toolOutputs: Record<string, any> = toolCfg?.outputs || {}
|
||||
const filteredToolParams: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(toolParams)) {
|
||||
if (!(k in blockInputs)) filteredToolParams[k] = v
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createBlockFromParams } from './builders'
|
||||
|
||||
const agentBlockConfig = {
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Default content output' },
|
||||
},
|
||||
subBlocks: [{ id: 'responseFormat', type: 'response-format' }],
|
||||
}
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getAllBlocks: () => [agentBlockConfig],
|
||||
getBlock: (type: string) => (type === 'agent' ? agentBlockConfig : undefined),
|
||||
}))
|
||||
|
||||
describe('createBlockFromParams', () => {
|
||||
it('derives agent outputs from responseFormat when outputs are not provided', () => {
|
||||
const block = createBlockFromParams('b-agent', {
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
inputs: {
|
||||
responseFormat: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
type: 'string',
|
||||
description: 'Structured answer text',
|
||||
},
|
||||
},
|
||||
required: ['answer'],
|
||||
},
|
||||
},
|
||||
triggerMode: false,
|
||||
})
|
||||
|
||||
expect(block.outputs.answer).toBeDefined()
|
||||
expect(block.outputs.answer.type).toBe('string')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
import crypto from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
@@ -40,8 +39,6 @@ export function createBlockFromParams(
|
||||
|
||||
// Determine outputs based on trigger mode
|
||||
const triggerMode = params.triggerMode || false
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
|
||||
let outputs: Record<string, any>
|
||||
|
||||
if (params.outputs) {
|
||||
@@ -57,10 +54,7 @@ export function createBlockFromParams(
|
||||
subBlocks[key] = { id: key, type: 'short-input', value: value }
|
||||
})
|
||||
}
|
||||
outputs = getEffectiveBlockOutputs(params.type, subBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
outputs = getBlockOutputs(params.type, subBlocks, triggerMode)
|
||||
} else {
|
||||
outputs = {}
|
||||
}
|
||||
|
||||
@@ -19,94 +19,6 @@ const BILLING_ERROR_MESSAGES = {
|
||||
BILLING_ERROR_GENERIC: 'Error resolving billing account',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Attempts to resolve billing actor with fallback for resume contexts.
|
||||
* Returns the resolved actor user ID or null if resolution fails and should block execution.
|
||||
*
|
||||
* For resume contexts, this function allows fallback to the workflow owner if workspace
|
||||
* billing cannot be resolved, ensuring users can complete their paused workflows even
|
||||
* if billing configuration changes mid-execution.
|
||||
*
|
||||
* @returns Object containing actorUserId (null if should block) and shouldBlock flag
|
||||
*/
|
||||
async function resolveBillingActorWithFallback(params: {
|
||||
requestId: string
|
||||
workflowId: string
|
||||
workspaceId: string
|
||||
executionId: string
|
||||
triggerType: string
|
||||
workflowRecord: WorkflowRecord
|
||||
userId: string
|
||||
isResumeContext: boolean
|
||||
baseActorUserId: string | null
|
||||
failureReason: 'null' | 'error'
|
||||
error?: unknown
|
||||
loggingSession?: LoggingSession
|
||||
}): Promise<
|
||||
{ actorUserId: string; shouldBlock: false } | { actorUserId: null; shouldBlock: true }
|
||||
> {
|
||||
const {
|
||||
requestId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
executionId,
|
||||
triggerType,
|
||||
workflowRecord,
|
||||
userId,
|
||||
isResumeContext,
|
||||
baseActorUserId,
|
||||
failureReason,
|
||||
error,
|
||||
loggingSession,
|
||||
} = params
|
||||
|
||||
if (baseActorUserId) {
|
||||
return { actorUserId: baseActorUserId, shouldBlock: false }
|
||||
}
|
||||
|
||||
const workflowOwner = workflowRecord.userId?.trim()
|
||||
if (isResumeContext && workflowOwner) {
|
||||
const logMessage =
|
||||
failureReason === 'null'
|
||||
? '[BILLING_FALLBACK] Workspace billing account is null. Using workflow owner for billing.'
|
||||
: '[BILLING_FALLBACK] Exception during workspace billing resolution. Using workflow owner for billing.'
|
||||
|
||||
logger.warn(`[${requestId}] ${logMessage}`, {
|
||||
workflowId,
|
||||
workspaceId,
|
||||
fallbackUserId: workflowOwner,
|
||||
...(error ? { error } : {}),
|
||||
})
|
||||
|
||||
return { actorUserId: workflowOwner, shouldBlock: false }
|
||||
}
|
||||
|
||||
const fallbackUserId = workflowRecord.userId || userId || 'unknown'
|
||||
const errorMessage =
|
||||
failureReason === 'null'
|
||||
? BILLING_ERROR_MESSAGES.BILLING_REQUIRED
|
||||
: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC
|
||||
|
||||
logger.warn(`[${requestId}] ${errorMessage}`, {
|
||||
workflowId,
|
||||
workspaceId,
|
||||
...(error ? { error } : {}),
|
||||
})
|
||||
|
||||
await logPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: fallbackUserId,
|
||||
workspaceId,
|
||||
errorMessage,
|
||||
loggingSession,
|
||||
})
|
||||
|
||||
return { actorUserId: null, shouldBlock: true }
|
||||
}
|
||||
|
||||
export interface PreprocessExecutionOptions {
|
||||
// Required fields
|
||||
workflowId: string
|
||||
@@ -123,7 +35,7 @@ export interface PreprocessExecutionOptions {
|
||||
// Context information
|
||||
workspaceId?: string // If known, used for billing resolution
|
||||
loggingSession?: LoggingSession // If provided, will be used for error logging
|
||||
isResumeContext?: boolean // If true, allows fallback billing on resolution failure (for paused workflow resumes)
|
||||
isResumeContext?: boolean // Deprecated: no billing fallback is allowed
|
||||
useAuthenticatedUserAsActor?: boolean // If true, use the authenticated userId as actorUserId (for client-side executions and personal API keys)
|
||||
/** @deprecated No longer used - background/async executions always use deployed state */
|
||||
useDraftState?: boolean
|
||||
@@ -170,7 +82,7 @@ export async function preprocessExecution(
|
||||
skipUsageLimits = false,
|
||||
workspaceId: providedWorkspaceId,
|
||||
loggingSession: providedLoggingSession,
|
||||
isResumeContext = false,
|
||||
isResumeContext: _isResumeContext = false,
|
||||
useAuthenticatedUserAsActor = false,
|
||||
} = options
|
||||
|
||||
@@ -274,68 +186,54 @@ export async function preprocessExecution(
|
||||
}
|
||||
|
||||
if (!actorUserId) {
|
||||
actorUserId = workflowRecord.userId || userId
|
||||
logger.info(`[${requestId}] Using workflow owner as actor: ${actorUserId}`)
|
||||
}
|
||||
|
||||
if (!actorUserId) {
|
||||
const result = await resolveBillingActorWithFallback({
|
||||
requestId,
|
||||
const fallbackUserId = userId || workflowRecord.userId || 'unknown'
|
||||
logger.warn(`[${requestId}] ${BILLING_ERROR_MESSAGES.BILLING_REQUIRED}`, {
|
||||
workflowId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
await logPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
workflowRecord,
|
||||
userId,
|
||||
isResumeContext,
|
||||
baseActorUserId: actorUserId,
|
||||
failureReason: 'null',
|
||||
requestId,
|
||||
userId: fallbackUserId,
|
||||
workspaceId,
|
||||
errorMessage: BILLING_ERROR_MESSAGES.BILLING_REQUIRED,
|
||||
loggingSession: providedLoggingSession,
|
||||
})
|
||||
|
||||
if (result.shouldBlock) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Unable to resolve billing account',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
actorUserId = result.actorUserId
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error resolving billing actor`, { error, workflowId })
|
||||
|
||||
const result = await resolveBillingActorWithFallback({
|
||||
requestId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
executionId,
|
||||
triggerType,
|
||||
workflowRecord,
|
||||
userId,
|
||||
isResumeContext,
|
||||
baseActorUserId: null,
|
||||
failureReason: 'error',
|
||||
error,
|
||||
loggingSession: providedLoggingSession,
|
||||
})
|
||||
|
||||
if (result.shouldBlock) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Error resolving billing account',
|
||||
message: 'Unable to resolve billing account',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error resolving billing actor`, { error, workflowId })
|
||||
const fallbackUserId = userId || workflowRecord.userId || 'unknown'
|
||||
await logPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: fallbackUserId,
|
||||
workspaceId,
|
||||
errorMessage: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC,
|
||||
loggingSession: providedLoggingSession,
|
||||
})
|
||||
|
||||
actorUserId = result.actorUserId
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Error resolving billing account',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 4: Get User Subscription ==========
|
||||
|
||||
@@ -14,7 +14,7 @@ import { mistralParserTool } from '@/tools/mistral/parser'
|
||||
const logger = createLogger('DocumentProcessor')
|
||||
|
||||
const TIMEOUTS = {
|
||||
FILE_DOWNLOAD: 180000,
|
||||
FILE_DOWNLOAD: 600000,
|
||||
MISTRAL_OCR_API: 120000,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import type {
|
||||
WorkflowExecutionSnapshot,
|
||||
WorkflowState,
|
||||
} from '@/lib/logs/types'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
|
||||
export interface ToolCall {
|
||||
@@ -210,16 +209,15 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
|
||||
logger.debug(`Completing workflow execution ${executionId}`, { isResume })
|
||||
|
||||
// If this is a resume, fetch the existing log to merge data
|
||||
let existingLog: any = null
|
||||
if (isResume) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(workflowExecutionLogs)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
existingLog = existing
|
||||
}
|
||||
const [existingLog] = await db
|
||||
.select()
|
||||
.from(workflowExecutionLogs)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
const billingUserId = this.extractBillingUserId(existingLog?.executionData)
|
||||
const existingExecutionData = existingLog?.executionData as
|
||||
| { traceSpans?: TraceSpan[] }
|
||||
| undefined
|
||||
|
||||
// Determine if workflow failed by checking trace spans for errors
|
||||
// Use the override if provided (for cost-only fallback scenarios)
|
||||
@@ -244,7 +242,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
const mergedTraceSpans = isResume
|
||||
? traceSpans && traceSpans.length > 0
|
||||
? traceSpans
|
||||
: existingLog?.executionData?.traceSpans || []
|
||||
: existingExecutionData?.traceSpans || []
|
||||
: traceSpans
|
||||
|
||||
const filteredTraceSpans = filterForDisplay(mergedTraceSpans)
|
||||
@@ -329,7 +327,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
|
||||
const limit = before.usageData.limit
|
||||
@@ -367,7 +366,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
|
||||
const percentBefore =
|
||||
@@ -393,7 +393,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -401,7 +402,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -410,7 +412,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
} catch {}
|
||||
logger.warn('Usage threshold notification check failed (non-fatal)', { error: e })
|
||||
@@ -472,6 +475,22 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
* Updates user stats with cost and token information
|
||||
* Maintains same logic as original execution logger for billing consistency
|
||||
*/
|
||||
private extractBillingUserId(executionData: unknown): string | null {
|
||||
if (!executionData || typeof executionData !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const environment = (executionData as { environment?: { userId?: unknown } }).environment
|
||||
const userId = environment?.userId
|
||||
|
||||
if (typeof userId !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmedUserId = userId.trim()
|
||||
return trimmedUserId.length > 0 ? trimmedUserId : null
|
||||
}
|
||||
|
||||
private async updateUserStats(
|
||||
workflowId: string | null,
|
||||
costSummary: {
|
||||
@@ -494,7 +513,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
>
|
||||
},
|
||||
trigger: ExecutionTrigger['type'],
|
||||
executionId?: string
|
||||
executionId?: string,
|
||||
billingUserId?: string | null
|
||||
): Promise<void> {
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug('Billing is disabled, skipping user stats cost update')
|
||||
@@ -512,7 +532,6 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the workflow record to get workspace and fallback userId
|
||||
const [workflowRecord] = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
@@ -524,12 +543,16 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
return
|
||||
}
|
||||
|
||||
let billingUserId: string | null = null
|
||||
if (workflowRecord.workspaceId) {
|
||||
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
|
||||
const userId = billingUserId?.trim() || null
|
||||
if (!userId) {
|
||||
logger.error('Missing billing actor in execution context; skipping stats update', {
|
||||
workflowId,
|
||||
trigger,
|
||||
executionId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const userId = billingUserId || workflowRecord.userId
|
||||
const costToStore = costSummary.totalCost
|
||||
|
||||
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
@@ -135,7 +136,10 @@ export async function resolveFileInputToUrl(
|
||||
* For internal URLs, uses direct storage access (server-side only)
|
||||
* For external URLs, validates DNS/SSRF and uses secure fetch with IP pinning
|
||||
*/
|
||||
export async function downloadFileFromUrl(fileUrl: string, timeoutMs = 180000): Promise<Buffer> {
|
||||
export async function downloadFileFromUrl(
|
||||
fileUrl: string,
|
||||
timeoutMs = getMaxExecutionTimeout()
|
||||
): Promise<Buffer> {
|
||||
const { parseInternalFileUrl } = await import('./file-utils')
|
||||
|
||||
if (isInternalFileUrl(fileUrl)) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
|
||||
import { bufferToBase64 } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage, downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
|
||||
const DEFAULT_MAX_BASE64_BYTES = 10 * 1024 * 1024
|
||||
const DEFAULT_TIMEOUT_MS = 180000
|
||||
const DEFAULT_TIMEOUT_MS = getMaxExecutionTimeout()
|
||||
const DEFAULT_CACHE_TTL_SECONDS = 300
|
||||
const REDIS_KEY_PREFIX = 'user-file:base64:'
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getEffectiveBlockOutputPaths,
|
||||
getEffectiveBlockOutputs,
|
||||
getEffectiveBlockOutputType,
|
||||
} from '@/lib/workflows/blocks/block-outputs'
|
||||
|
||||
type SubBlocks = Record<string, { value: unknown }>
|
||||
|
||||
function rootPaths(paths: string[]): string[] {
|
||||
return [...new Set(paths.map((path) => path.split('.')[0]).filter(Boolean))].sort()
|
||||
}
|
||||
|
||||
describe('block outputs parity', () => {
|
||||
it.concurrent('keeps evaluator tag paths and types aligned', () => {
|
||||
const subBlocks: SubBlocks = {
|
||||
metrics: {
|
||||
value: [
|
||||
{
|
||||
name: 'Accuracy',
|
||||
description: 'How accurate the answer is',
|
||||
range: { min: 0, max: 1 },
|
||||
},
|
||||
{
|
||||
name: 'Relevance',
|
||||
description: 'How relevant the answer is',
|
||||
range: { min: 0, max: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const options = { triggerMode: false, preferToolOutputs: true }
|
||||
const outputs = getEffectiveBlockOutputs('evaluator', subBlocks, options)
|
||||
const paths = getEffectiveBlockOutputPaths('evaluator', subBlocks, options)
|
||||
|
||||
expect(rootPaths(paths)).toEqual(Object.keys(outputs).sort())
|
||||
expect(paths).toContain('accuracy')
|
||||
expect(paths).toContain('relevance')
|
||||
expect(getEffectiveBlockOutputType('evaluator', 'accuracy', subBlocks, options)).toBe('number')
|
||||
expect(getEffectiveBlockOutputType('evaluator', 'relevance', subBlocks, options)).toBe('number')
|
||||
})
|
||||
|
||||
it.concurrent('keeps agent responseFormat tag paths and types aligned', () => {
|
||||
const subBlocks: SubBlocks = {
|
||||
responseFormat: {
|
||||
value: {
|
||||
name: 'calculator_output',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
min: { type: 'number' },
|
||||
max: { type: 'number' },
|
||||
},
|
||||
required: ['min', 'max'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const options = { triggerMode: false, preferToolOutputs: true }
|
||||
const outputs = getEffectiveBlockOutputs('agent', subBlocks, options)
|
||||
const paths = getEffectiveBlockOutputPaths('agent', subBlocks, options)
|
||||
|
||||
expect(rootPaths(paths)).toEqual(Object.keys(outputs).sort())
|
||||
expect(paths).toContain('min')
|
||||
expect(paths).toContain('max')
|
||||
expect(getEffectiveBlockOutputType('agent', 'min', subBlocks, options)).toBe('number')
|
||||
expect(getEffectiveBlockOutputType('agent', 'max', subBlocks, options)).toBe('number')
|
||||
})
|
||||
})
|
||||
@@ -33,12 +33,6 @@ interface SubBlockWithValue {
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
interface EffectiveOutputOptions {
|
||||
triggerMode?: boolean
|
||||
preferToolOutputs?: boolean
|
||||
includeHidden?: boolean
|
||||
}
|
||||
|
||||
type ConditionValue = string | number | boolean
|
||||
|
||||
/**
|
||||
@@ -102,13 +96,12 @@ function evaluateOutputCondition(
|
||||
*/
|
||||
function filterOutputsByCondition(
|
||||
outputs: OutputDefinition,
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined,
|
||||
includeHidden = false
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined
|
||||
): OutputDefinition {
|
||||
const filtered: OutputDefinition = {}
|
||||
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
if (!includeHidden && isHiddenFromDisplay(value)) continue
|
||||
if (isHiddenFromDisplay(value)) continue
|
||||
|
||||
if (!value || typeof value !== 'object' || !('condition' in value)) {
|
||||
filtered[key] = value
|
||||
@@ -119,13 +112,8 @@ function filterOutputsByCondition(
|
||||
const passes = !condition || evaluateOutputCondition(condition, subBlocks)
|
||||
|
||||
if (passes) {
|
||||
if (includeHidden) {
|
||||
const { condition: _, ...rest } = value
|
||||
filtered[key] = rest
|
||||
} else {
|
||||
const { condition: _, hiddenFromDisplay: __, ...rest } = value
|
||||
filtered[key] = rest
|
||||
}
|
||||
const { condition: _, hiddenFromDisplay: __, ...rest } = value
|
||||
filtered[key] = rest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,10 +243,8 @@ function applyInputFormatToOutputs(
|
||||
export function getBlockOutputs(
|
||||
blockType: string,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
triggerMode?: boolean,
|
||||
options?: { includeHidden?: boolean }
|
||||
triggerMode?: boolean
|
||||
): OutputDefinition {
|
||||
const includeHidden = options?.includeHidden ?? false
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (!blockConfig) return {}
|
||||
|
||||
@@ -283,8 +269,7 @@ export function getBlockOutputs(
|
||||
// Start with block config outputs (respects hiddenFromDisplay via filterOutputsByCondition)
|
||||
const baseOutputs = filterOutputsByCondition(
|
||||
{ ...(blockConfig.outputs || {}) } as OutputDefinition,
|
||||
subBlocks,
|
||||
includeHidden
|
||||
subBlocks
|
||||
)
|
||||
|
||||
// Add inputFormat fields (resume form fields)
|
||||
@@ -307,111 +292,29 @@ export function getBlockOutputs(
|
||||
return getLegacyStarterOutputs(subBlocks)
|
||||
}
|
||||
|
||||
const baseOutputs = { ...(blockConfig.outputs || {}) }
|
||||
const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks, includeHidden)
|
||||
return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs)
|
||||
}
|
||||
|
||||
export function getResponseFormatOutputs(
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
blockId = 'block'
|
||||
): OutputDefinition | undefined {
|
||||
const responseFormatValue = subBlocks?.responseFormat?.value
|
||||
if (!responseFormatValue) return undefined
|
||||
|
||||
const parsed = parseResponseFormatSafely(responseFormatValue, blockId)
|
||||
if (!parsed) return undefined
|
||||
|
||||
const fields = extractFieldsFromSchema(parsed)
|
||||
if (fields.length === 0) return undefined
|
||||
|
||||
const outputs: OutputDefinition = {}
|
||||
for (const field of fields) {
|
||||
outputs[field.name] = {
|
||||
type: (field.type || 'any') as any,
|
||||
description: field.description || `Field from Agent: ${field.name}`,
|
||||
}
|
||||
}
|
||||
|
||||
return outputs
|
||||
}
|
||||
|
||||
export function getEvaluatorMetricOutputs(
|
||||
subBlocks?: Record<string, SubBlockWithValue>
|
||||
): OutputDefinition | undefined {
|
||||
const metricsValue = subBlocks?.metrics?.value
|
||||
if (!metricsValue || !Array.isArray(metricsValue) || metricsValue.length === 0) return undefined
|
||||
|
||||
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
|
||||
if (validMetrics.length === 0) return undefined
|
||||
|
||||
const outputs: OutputDefinition = {}
|
||||
for (const metric of validMetrics as Array<{ name: string }>) {
|
||||
outputs[metric.name.toLowerCase()] = {
|
||||
type: 'number',
|
||||
description: `Metric score: ${metric.name}`,
|
||||
}
|
||||
}
|
||||
|
||||
return outputs
|
||||
}
|
||||
|
||||
export function getEffectiveBlockOutputs(
|
||||
blockType: string,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
options?: EffectiveOutputOptions
|
||||
): OutputDefinition {
|
||||
const triggerMode = options?.triggerMode ?? false
|
||||
const preferToolOutputs = options?.preferToolOutputs ?? !triggerMode
|
||||
const includeHidden = options?.includeHidden ?? false
|
||||
|
||||
if (blockType === 'agent') {
|
||||
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
|
||||
if (responseFormatOutputs) return responseFormatOutputs
|
||||
}
|
||||
|
||||
let baseOutputs: OutputDefinition
|
||||
if (triggerMode) {
|
||||
baseOutputs = getBlockOutputs(blockType, subBlocks, true, { includeHidden })
|
||||
} else if (preferToolOutputs) {
|
||||
const blockConfig = getBlock(blockType)
|
||||
const toolOutputs = blockConfig
|
||||
? (getToolOutputs(blockConfig, subBlocks, { includeHidden }) as OutputDefinition)
|
||||
: {}
|
||||
baseOutputs =
|
||||
toolOutputs && Object.keys(toolOutputs).length > 0
|
||||
? toolOutputs
|
||||
: getBlockOutputs(blockType, subBlocks, false, { includeHidden })
|
||||
} else {
|
||||
baseOutputs = getBlockOutputs(blockType, subBlocks, false, { includeHidden })
|
||||
}
|
||||
|
||||
if (blockType === 'evaluator') {
|
||||
const metricOutputs = getEvaluatorMetricOutputs(subBlocks)
|
||||
if (metricOutputs) {
|
||||
return { ...baseOutputs, ...metricOutputs }
|
||||
const responseFormatValue = subBlocks?.responseFormat?.value
|
||||
if (responseFormatValue) {
|
||||
const parsed = parseResponseFormatSafely(responseFormatValue, 'agent')
|
||||
if (parsed) {
|
||||
const fields = extractFieldsFromSchema(parsed)
|
||||
if (fields.length > 0) {
|
||||
const outputs: OutputDefinition = {}
|
||||
for (const field of fields) {
|
||||
outputs[field.name] = {
|
||||
type: (field.type || 'any') as any,
|
||||
description: field.description || `Field from Agent: ${field.name}`,
|
||||
}
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return baseOutputs
|
||||
}
|
||||
|
||||
export function getEffectiveBlockOutputPaths(
|
||||
blockType: string,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
options?: EffectiveOutputOptions
|
||||
): string[] {
|
||||
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, options)
|
||||
const paths = generateOutputPaths(outputs)
|
||||
|
||||
if (blockType === TRIGGER_TYPES.START) {
|
||||
return paths.filter((path) => {
|
||||
const key = path.split('.')[0]
|
||||
return !shouldFilterReservedField(blockType, key, '', subBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
return paths
|
||||
const baseOutputs = { ...(blockConfig.outputs || {}) }
|
||||
const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks)
|
||||
return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs)
|
||||
}
|
||||
|
||||
function shouldFilterReservedField(
|
||||
@@ -449,6 +352,24 @@ function isFileOutputDefinition(value: unknown): value is { type: FileOutputType
|
||||
return type === 'file' || type === 'file[]'
|
||||
}
|
||||
|
||||
export function getBlockOutputPaths(
|
||||
blockType: string,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
triggerMode?: boolean
|
||||
): string[] {
|
||||
const outputs = getBlockOutputs(blockType, subBlocks, triggerMode)
|
||||
const paths = generateOutputPaths(outputs)
|
||||
|
||||
if (blockType === TRIGGER_TYPES.START) {
|
||||
return paths.filter((path) => {
|
||||
const key = path.split('.')[0]
|
||||
return !shouldFilterReservedField(blockType, key, '', subBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function getFilePropertyType(outputs: OutputDefinition, pathParts: string[]): string | null {
|
||||
const lastPart = pathParts[pathParts.length - 1]
|
||||
if (!lastPart || !USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES]) {
|
||||
@@ -532,13 +453,13 @@ function extractType(value: unknown): string {
|
||||
return typeof value === 'string' ? value : 'any'
|
||||
}
|
||||
|
||||
export function getEffectiveBlockOutputType(
|
||||
export function getBlockOutputType(
|
||||
blockType: string,
|
||||
outputPath: string,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
options?: EffectiveOutputOptions
|
||||
triggerMode?: boolean
|
||||
): string {
|
||||
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, options)
|
||||
const outputs = getBlockOutputs(blockType, subBlocks, triggerMode)
|
||||
|
||||
const cleanPath = outputPath.replace(/\[(\d+)\]/g, '')
|
||||
const pathParts = cleanPath.split('.').filter(Boolean)
|
||||
@@ -610,6 +531,60 @@ function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively generates all output paths with their types from an outputs schema.
|
||||
*
|
||||
* @param outputs - The outputs schema object
|
||||
* @param prefix - Current path prefix for recursion
|
||||
* @returns Array of objects containing path and type for each output field
|
||||
*/
|
||||
function generateOutputPathsWithTypes(
|
||||
outputs: Record<string, any>,
|
||||
prefix = ''
|
||||
): Array<{ path: string; type: string }> {
|
||||
const paths: Array<{ path: string; type: string }> = []
|
||||
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
const currentPath = prefix ? `${prefix}.${key}` : key
|
||||
|
||||
if (typeof value === 'string') {
|
||||
paths.push({ path: currentPath, type: value })
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
if ('type' in value && typeof value.type === 'string') {
|
||||
if (isFileOutputDefinition(value)) {
|
||||
paths.push({ path: currentPath, type: value.type })
|
||||
for (const prop of USER_FILE_ACCESSIBLE_PROPERTIES) {
|
||||
paths.push({
|
||||
path: `${currentPath}.${prop}`,
|
||||
type: USER_FILE_PROPERTY_TYPES[prop as keyof typeof USER_FILE_PROPERTY_TYPES],
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (value.type === 'array' && value.items?.properties) {
|
||||
paths.push({ path: currentPath, type: 'array' })
|
||||
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
|
||||
paths.push(...subPaths)
|
||||
} else if ((value.type === 'object' || value.type === 'json') && value.properties) {
|
||||
paths.push({ path: currentPath, type: value.type })
|
||||
const subPaths = generateOutputPathsWithTypes(value.properties, currentPath)
|
||||
paths.push(...subPaths)
|
||||
} else {
|
||||
paths.push({ path: currentPath, type: value.type })
|
||||
}
|
||||
} else {
|
||||
const subPaths = generateOutputPathsWithTypes(value, currentPath)
|
||||
paths.push(...subPaths)
|
||||
}
|
||||
} else {
|
||||
paths.push({ path: currentPath, type: 'any' })
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tool outputs for a block operation.
|
||||
*
|
||||
@@ -619,10 +594,8 @@ function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[
|
||||
*/
|
||||
export function getToolOutputs(
|
||||
blockConfig: BlockConfig,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
options?: { includeHidden?: boolean }
|
||||
subBlocks?: Record<string, SubBlockWithValue>
|
||||
): Record<string, any> {
|
||||
const includeHidden = options?.includeHidden ?? false
|
||||
if (!blockConfig?.tools?.config?.tool) return {}
|
||||
|
||||
try {
|
||||
@@ -640,18 +613,49 @@ export function getToolOutputs(
|
||||
|
||||
const toolConfig = getTool(toolId)
|
||||
if (!toolConfig?.outputs) return {}
|
||||
if (includeHidden) {
|
||||
return toolConfig.outputs
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(toolConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def))
|
||||
)
|
||||
|
||||
return toolConfig.outputs
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get tool outputs', { error })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getToolOutputPaths(
|
||||
blockConfig: BlockConfig,
|
||||
subBlocks?: Record<string, SubBlockWithValue>
|
||||
): string[] {
|
||||
const outputs = getToolOutputs(blockConfig, subBlocks)
|
||||
|
||||
if (!outputs || Object.keys(outputs).length === 0) return []
|
||||
|
||||
if (subBlocks && blockConfig.outputs) {
|
||||
const filteredOutputs: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
const blockOutput = blockConfig.outputs[key]
|
||||
|
||||
if (!blockOutput || typeof blockOutput !== 'object') {
|
||||
filteredOutputs[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
const condition = 'condition' in blockOutput ? blockOutput.condition : undefined
|
||||
if (condition) {
|
||||
if (evaluateOutputCondition(condition, subBlocks)) {
|
||||
filteredOutputs[key] = value
|
||||
}
|
||||
} else {
|
||||
filteredOutputs[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return generateOutputPaths(filteredOutputs)
|
||||
}
|
||||
|
||||
return generateOutputPaths(outputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates output paths from a schema definition.
|
||||
*
|
||||
@@ -661,3 +665,24 @@ export function getToolOutputs(
|
||||
export function getOutputPathsFromSchema(outputs: Record<string, any>): string[] {
|
||||
return generateOutputPaths(outputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the output type for a specific path in a tool's outputs.
|
||||
*
|
||||
* @param blockConfig - The block configuration containing tools config
|
||||
* @param subBlocks - SubBlock values for tool selection
|
||||
* @param path - The dot-separated path to the output field
|
||||
* @returns The type of the output field, or 'any' if not found
|
||||
*/
|
||||
export function getToolOutputType(
|
||||
blockConfig: BlockConfig,
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined,
|
||||
path: string
|
||||
): string {
|
||||
const outputs = getToolOutputs(blockConfig, subBlocks)
|
||||
if (!outputs || Object.keys(outputs).length === 0) return 'any'
|
||||
|
||||
const pathsWithTypes = generateOutputPathsWithTypes(outputs)
|
||||
const matchingPath = pathsWithTypes.find((p) => p.path === path)
|
||||
return matchingPath?.type || 'any'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -85,10 +85,7 @@ function buildStartBlockState(
|
||||
subBlockValues[config.id] = initialValue ?? null
|
||||
})
|
||||
|
||||
const outputs = getEffectiveBlockOutputs(blockConfig.type, subBlocks, {
|
||||
triggerMode: false,
|
||||
preferToolOutputs: true,
|
||||
})
|
||||
const outputs = getBlockOutputs(blockConfig.type, subBlocks)
|
||||
|
||||
const blockState: BlockState = {
|
||||
id: blockId,
|
||||
|
||||
@@ -739,7 +739,6 @@ export class PauseResumeManager {
|
||||
skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits
|
||||
workspaceId: baseSnapshot.metadata.workspaceId,
|
||||
loggingSession,
|
||||
isResumeContext: true, // Enable billing fallback for paused workflow resumes
|
||||
})
|
||||
|
||||
if (!preprocessingResult.success) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
|
||||
@@ -189,12 +188,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
|
||||
})
|
||||
}
|
||||
|
||||
const isTriggerCapable = hasTriggerCapability(blockConfig)
|
||||
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
|
||||
const outputs = getEffectiveBlockOutputs(type, subBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
const outputs = getBlockOutputs(type, subBlocks, triggerMode)
|
||||
|
||||
return {
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user