chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,670 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
classifyStartBlockType,
|
||||
StartBlockPath,
|
||||
TRIGGER_TYPES,
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import {
|
||||
type InputFormatField,
|
||||
START_BLOCK_RESERVED_FIELDS,
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES,
|
||||
USER_FILE_PROPERTY_TYPES,
|
||||
} from '@/lib/workflows/types'
|
||||
import { getBlock } from '@/blocks'
|
||||
import {
|
||||
type BlockConfig,
|
||||
isHiddenFromDisplay,
|
||||
type OutputCondition,
|
||||
type OutputFieldDefinition,
|
||||
} from '@/blocks/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
const logger = createLogger('BlockOutputs')
|
||||
|
||||
type OutputDefinition = Record<string, OutputFieldDefinition>
|
||||
|
||||
interface SubBlockWithValue {
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
interface EffectiveOutputOptions {
|
||||
triggerMode?: boolean
|
||||
preferToolOutputs?: boolean
|
||||
includeHidden?: boolean
|
||||
}
|
||||
|
||||
type ConditionValue = string | number | boolean
|
||||
|
||||
/**
|
||||
* Checks if a value is a valid primitive for condition comparison.
|
||||
*/
|
||||
function isConditionPrimitive(value: unknown): value is ConditionValue {
|
||||
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates an output condition against subBlock values.
|
||||
* Returns true if the condition is met and the output should be shown.
|
||||
*/
|
||||
function evaluateOutputCondition(
|
||||
condition: OutputCondition,
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined
|
||||
): boolean {
|
||||
if (!subBlocks) return false
|
||||
|
||||
const fieldValue = subBlocks[condition.field]?.value
|
||||
|
||||
let matches: boolean
|
||||
if (Array.isArray(condition.value)) {
|
||||
// For array conditions, check if fieldValue is a valid primitive and included
|
||||
matches = isConditionPrimitive(fieldValue) && condition.value.includes(fieldValue)
|
||||
} else {
|
||||
matches = fieldValue === condition.value
|
||||
}
|
||||
|
||||
if (condition.not) {
|
||||
matches = !matches
|
||||
}
|
||||
|
||||
if (condition.and) {
|
||||
const andFieldValue = subBlocks[condition.and.field]?.value
|
||||
let andMatches: boolean
|
||||
|
||||
if (Array.isArray(condition.and.value)) {
|
||||
const primitiveMatch =
|
||||
isConditionPrimitive(andFieldValue) && condition.and.value.includes(andFieldValue)
|
||||
const undefinedMatch = andFieldValue === undefined && condition.and.value.includes(undefined)
|
||||
const nullMatch = andFieldValue === null && condition.and.value.includes(null)
|
||||
andMatches = primitiveMatch || undefinedMatch || nullMatch
|
||||
} else {
|
||||
andMatches = andFieldValue === condition.and.value
|
||||
}
|
||||
|
||||
if (condition.and.not) {
|
||||
andMatches = !andMatches
|
||||
}
|
||||
|
||||
matches = matches && andMatches
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters outputs based on their conditions and hiddenFromDisplay flag.
|
||||
* Returns a new OutputDefinition with only the outputs that should be shown.
|
||||
*/
|
||||
function filterOutputsByCondition(
|
||||
outputs: OutputDefinition,
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined,
|
||||
includeHidden = false
|
||||
): OutputDefinition {
|
||||
const filtered: OutputDefinition = {}
|
||||
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
if (!includeHidden && isHiddenFromDisplay(value)) continue
|
||||
|
||||
if (!value || typeof value !== 'object' || !('condition' in value)) {
|
||||
filtered[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
const condition = value.condition as OutputCondition | undefined
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
const CHAT_OUTPUTS: OutputDefinition = {
|
||||
input: { type: 'string', description: 'User message' },
|
||||
conversationId: { type: 'string', description: 'Conversation ID' },
|
||||
files: { type: 'file[]', description: 'Uploaded files' },
|
||||
}
|
||||
|
||||
const UNIFIED_START_OUTPUTS: OutputDefinition = {
|
||||
input: { type: 'string', description: 'Primary user input or message' },
|
||||
conversationId: { type: 'string', description: 'Conversation thread identifier' },
|
||||
files: { type: 'file[]', description: 'User uploaded files' },
|
||||
}
|
||||
|
||||
function applyInputFormatFields(
|
||||
inputFormat: InputFormatField[],
|
||||
outputs: OutputDefinition
|
||||
): OutputDefinition {
|
||||
for (const field of inputFormat) {
|
||||
const fieldName = field?.name?.trim()
|
||||
if (!fieldName) continue
|
||||
|
||||
outputs[fieldName] = {
|
||||
type: (field?.type || 'any') as any,
|
||||
description: `Field from input format`,
|
||||
}
|
||||
}
|
||||
|
||||
return outputs
|
||||
}
|
||||
|
||||
function hasInputFormat(blockConfig: BlockConfig): boolean {
|
||||
return blockConfig.subBlocks?.some((sb) => sb.type === 'input-format') || false
|
||||
}
|
||||
|
||||
function getTriggerId(
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined,
|
||||
blockConfig: BlockConfig
|
||||
): string | undefined {
|
||||
const selectedTriggerIdValue = subBlocks?.selectedTriggerId?.value
|
||||
const triggerIdValue = subBlocks?.triggerId?.value
|
||||
|
||||
return (
|
||||
(typeof selectedTriggerIdValue === 'string' && isTriggerValid(selectedTriggerIdValue)
|
||||
? selectedTriggerIdValue
|
||||
: undefined) ||
|
||||
(typeof triggerIdValue === 'string' && isTriggerValid(triggerIdValue)
|
||||
? triggerIdValue
|
||||
: undefined) ||
|
||||
blockConfig.triggers?.available?.[0]
|
||||
)
|
||||
}
|
||||
|
||||
function getUnifiedStartOutputs(
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined
|
||||
): OutputDefinition {
|
||||
const outputs = { ...UNIFIED_START_OUTPUTS }
|
||||
const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value)
|
||||
return applyInputFormatFields(normalizedInputFormat, outputs)
|
||||
}
|
||||
|
||||
function getLegacyStarterOutputs(
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined
|
||||
): OutputDefinition {
|
||||
const startWorkflowValue = subBlocks?.startWorkflow?.value
|
||||
|
||||
if (startWorkflowValue === 'chat') {
|
||||
return { ...CHAT_OUTPUTS }
|
||||
}
|
||||
|
||||
if (
|
||||
startWorkflowValue === 'api' ||
|
||||
startWorkflowValue === 'run' ||
|
||||
startWorkflowValue === 'manual'
|
||||
) {
|
||||
const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value)
|
||||
return applyInputFormatFields(normalizedInputFormat, {})
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function shouldClearBaseOutputs(
|
||||
blockType: string,
|
||||
normalizedInputFormat: InputFormatField[]
|
||||
): boolean {
|
||||
if (blockType === TRIGGER_TYPES.API || blockType === TRIGGER_TYPES.INPUT) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (blockType === TRIGGER_TYPES.GENERIC_WEBHOOK && normalizedInputFormat.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function applyInputFormatToOutputs(
|
||||
blockType: string,
|
||||
blockConfig: BlockConfig,
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined,
|
||||
baseOutputs: OutputDefinition
|
||||
): OutputDefinition {
|
||||
if (!hasInputFormat(blockConfig) || !subBlocks?.inputFormat?.value) {
|
||||
return baseOutputs
|
||||
}
|
||||
|
||||
const normalizedInputFormat = normalizeInputFormatValue(subBlocks.inputFormat.value)
|
||||
|
||||
if (!Array.isArray(subBlocks.inputFormat.value)) {
|
||||
if (blockType === TRIGGER_TYPES.API || blockType === TRIGGER_TYPES.INPUT) {
|
||||
return {}
|
||||
}
|
||||
return baseOutputs
|
||||
}
|
||||
|
||||
const shouldClear = shouldClearBaseOutputs(blockType, normalizedInputFormat)
|
||||
const outputs = shouldClear ? {} : { ...baseOutputs }
|
||||
|
||||
return applyInputFormatFields(normalizedInputFormat, outputs)
|
||||
}
|
||||
|
||||
export function getBlockOutputs(
|
||||
blockType: string,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
triggerMode?: boolean,
|
||||
options?: { includeHidden?: boolean }
|
||||
): OutputDefinition {
|
||||
const includeHidden = options?.includeHidden ?? false
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (!blockConfig) return {}
|
||||
|
||||
if (triggerMode && blockConfig.triggers?.enabled) {
|
||||
const triggerId = getTriggerId(subBlocks, blockConfig)
|
||||
if (triggerId && isTriggerValid(triggerId)) {
|
||||
const trigger = getTrigger(triggerId)
|
||||
if (trigger.outputs) {
|
||||
// TriggerOutput is compatible with OutputFieldDefinition at runtime.
|
||||
// Conditions narrow outputs to the selected trigger configuration
|
||||
// (e.g. the Sim trigger only surfaces execution fields for
|
||||
// execution-backed event types).
|
||||
return filterOutputsByCondition(
|
||||
trigger.outputs as OutputDefinition,
|
||||
subBlocks,
|
||||
includeHidden
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startPath = classifyStartBlockType(blockType)
|
||||
|
||||
if (startPath === StartBlockPath.UNIFIED) {
|
||||
return getUnifiedStartOutputs(subBlocks)
|
||||
}
|
||||
|
||||
if (blockType === 'human_in_the_loop') {
|
||||
// Start with block config outputs (respects hiddenFromDisplay via filterOutputsByCondition)
|
||||
const baseOutputs = filterOutputsByCondition(
|
||||
{ ...(blockConfig.outputs || {}) } as OutputDefinition,
|
||||
subBlocks,
|
||||
includeHidden
|
||||
)
|
||||
|
||||
// Add inputFormat fields (resume form fields)
|
||||
const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value)
|
||||
|
||||
for (const field of normalizedInputFormat) {
|
||||
const fieldName = field?.name?.trim()
|
||||
if (!fieldName) continue
|
||||
|
||||
baseOutputs[fieldName] = {
|
||||
type: (field?.type || 'any') as any,
|
||||
description: field?.description || `Field from resume form`,
|
||||
}
|
||||
}
|
||||
|
||||
return baseOutputs
|
||||
}
|
||||
|
||||
if (startPath === StartBlockPath.LEGACY_STARTER) {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function shouldFilterReservedField(
|
||||
blockType: string,
|
||||
key: string,
|
||||
prefix: string,
|
||||
subBlocks: Record<string, SubBlockWithValue> | undefined
|
||||
): boolean {
|
||||
if (blockType !== TRIGGER_TYPES.START || prefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!START_BLOCK_RESERVED_FIELDS.includes(key as any)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value)
|
||||
const isExplicitlyDefined = normalizedInputFormat.some((field) => field?.name?.trim() === key)
|
||||
|
||||
return !isExplicitlyDefined
|
||||
}
|
||||
|
||||
function expandFileTypeProperties(path: string): string[] {
|
||||
return USER_FILE_ACCESSIBLE_PROPERTIES.map((prop) => `${path}.${prop}`)
|
||||
}
|
||||
|
||||
type FileOutputType = 'file' | 'file[]'
|
||||
|
||||
function isFileOutputDefinition(value: unknown): value is { type: FileOutputType } {
|
||||
if (!value || typeof value !== 'object' || !('type' in value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { type } = value as { type?: unknown }
|
||||
return type === 'file' || type === 'file[]'
|
||||
}
|
||||
|
||||
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]) {
|
||||
return null
|
||||
}
|
||||
|
||||
let current: unknown = outputs
|
||||
for (const part of pathParts.slice(0, -1)) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
return null
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
if (isFileOutputDefinition(current)) {
|
||||
return USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unknown {
|
||||
let current: unknown = outputs
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentObj = current as Record<string, unknown>
|
||||
|
||||
if (part in currentObj) {
|
||||
current = currentObj[part]
|
||||
} else if (
|
||||
'type' in currentObj &&
|
||||
currentObj.type === 'object' &&
|
||||
'properties' in currentObj &&
|
||||
currentObj.properties &&
|
||||
typeof currentObj.properties === 'object'
|
||||
) {
|
||||
const props = currentObj.properties as Record<string, unknown>
|
||||
if (part in props) {
|
||||
current = props[part]
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else if (
|
||||
'type' in currentObj &&
|
||||
currentObj.type === 'array' &&
|
||||
'items' in currentObj &&
|
||||
currentObj.items &&
|
||||
typeof currentObj.items === 'object'
|
||||
) {
|
||||
const items = currentObj.items as Record<string, unknown>
|
||||
if ('properties' in items && items.properties && typeof items.properties === 'object') {
|
||||
const itemProps = items.properties as Record<string, unknown>
|
||||
if (part in itemProps) {
|
||||
current = itemProps[part]
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
function extractType(value: unknown): string {
|
||||
if (!value) return 'any'
|
||||
|
||||
if (typeof value === 'object' && 'type' in value) {
|
||||
const typeValue = (value as { type: unknown }).type
|
||||
return typeof typeValue === 'string' ? typeValue : 'any'
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : 'any'
|
||||
}
|
||||
|
||||
export function getEffectiveBlockOutputType(
|
||||
blockType: string,
|
||||
outputPath: string,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
options?: EffectiveOutputOptions
|
||||
): string {
|
||||
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, options)
|
||||
|
||||
const cleanPath = outputPath.replace(/\[(\d+)\]/g, '')
|
||||
const pathParts = cleanPath.split('.').filter(Boolean)
|
||||
|
||||
const filePropertyType = getFilePropertyType(outputs, pathParts)
|
||||
if (filePropertyType) {
|
||||
return filePropertyType
|
||||
}
|
||||
|
||||
const value = traverseOutputPath(outputs, pathParts)
|
||||
return extractType(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively generates all output paths from an outputs schema.
|
||||
*
|
||||
* @param outputs - The outputs schema object
|
||||
* @param prefix - Current path prefix for recursion
|
||||
* @returns Array of dot-separated paths to all output fields
|
||||
*/
|
||||
function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[] {
|
||||
const paths: string[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
const currentPath = prefix ? `${prefix}.${key}` : key
|
||||
|
||||
if (typeof value === 'string') {
|
||||
paths.push(currentPath)
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
if ('type' in value && typeof value.type === 'string') {
|
||||
if (isFileOutputDefinition(value)) {
|
||||
paths.push(...expandFileTypeProperties(currentPath))
|
||||
continue
|
||||
}
|
||||
|
||||
const hasNestedProperties =
|
||||
((value.type === 'object' || value.type === 'json') && value.properties) ||
|
||||
(value.type === 'array' && value.items?.properties) ||
|
||||
(value.type === 'array' &&
|
||||
value.items &&
|
||||
typeof value.items === 'object' &&
|
||||
!('type' in value.items))
|
||||
|
||||
if (!hasNestedProperties) {
|
||||
paths.push(currentPath)
|
||||
}
|
||||
|
||||
if ((value.type === 'object' || value.type === 'json') && value.properties) {
|
||||
paths.push(...generateOutputPaths(value.properties, currentPath))
|
||||
} else if (value.type === 'array' && value.items?.properties) {
|
||||
paths.push(...generateOutputPaths(value.items.properties, currentPath))
|
||||
} else if (
|
||||
value.type === 'array' &&
|
||||
value.items &&
|
||||
typeof value.items === 'object' &&
|
||||
!('type' in value.items)
|
||||
) {
|
||||
paths.push(...generateOutputPaths(value.items, currentPath))
|
||||
}
|
||||
} else {
|
||||
const subPaths = generateOutputPaths(value, currentPath)
|
||||
paths.push(...subPaths)
|
||||
}
|
||||
} else {
|
||||
paths.push(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tool outputs for a block operation.
|
||||
*
|
||||
* @param blockConfig - The block configuration containing tools config
|
||||
* @param subBlocks - SubBlock values to pass to the tool selector
|
||||
* @returns Outputs schema for the tool, or empty object on error
|
||||
*/
|
||||
export function getToolOutputs(
|
||||
blockConfig: BlockConfig,
|
||||
subBlocks?: Record<string, SubBlockWithValue>,
|
||||
options?: { includeHidden?: boolean }
|
||||
): Record<string, any> {
|
||||
const includeHidden = options?.includeHidden ?? false
|
||||
if (!blockConfig?.tools?.config?.tool) return {}
|
||||
|
||||
try {
|
||||
// Build params object from subBlock values for tool selector
|
||||
// This allows tool selectors to use any field (operation, provider, etc.)
|
||||
const params: Record<string, any> = {}
|
||||
if (subBlocks) {
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
params[key] = subBlock.value
|
||||
}
|
||||
}
|
||||
|
||||
const toolId = blockConfig.tools.config.tool(params)
|
||||
if (!toolId) return {}
|
||||
|
||||
const toolConfig = getTool(toolId)
|
||||
if (!toolConfig?.outputs) return {}
|
||||
if (includeHidden) {
|
||||
return toolConfig.outputs
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(toolConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def))
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get tool outputs', { error })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates output paths from a schema definition.
|
||||
*
|
||||
* @param outputs - The outputs schema object
|
||||
* @returns Array of dot-separated paths to all output fields
|
||||
*/
|
||||
export function getOutputPathsFromSchema(outputs: Record<string, any>): string[] {
|
||||
return generateOutputPaths(outputs)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Shared utility for calculating block paths and accessible connections.
|
||||
* Used by both frontend (useBlockConnections) and backend (InputResolver) to ensure consistency.
|
||||
*/
|
||||
export class BlockPathCalculator {
|
||||
/**
|
||||
* Finds all blocks along paths leading to the target block.
|
||||
* This is a reverse traversal from the target node to find all ancestors
|
||||
* along connected paths using BFS.
|
||||
*
|
||||
* @param edges - List of all edges in the graph
|
||||
* @param targetNodeId - ID of the target block we're finding connections for
|
||||
* @returns Array of unique ancestor node IDs
|
||||
*/
|
||||
static findAllPathNodes(
|
||||
edges: Array<{ source: string; target: string }>,
|
||||
targetNodeId: string
|
||||
): string[] {
|
||||
// We'll use a reverse topological sort approach by tracking "distance" from target
|
||||
const nodeDistances = new Map<string, number>()
|
||||
const visited = new Set<string>()
|
||||
const queue: [string, number][] = [[targetNodeId, 0]] // [nodeId, distance]
|
||||
const pathNodes = new Set<string>()
|
||||
|
||||
// Build a reverse adjacency list for faster traversal
|
||||
const reverseAdjList: Record<string, string[]> = {}
|
||||
for (const edge of edges) {
|
||||
if (!reverseAdjList[edge.target]) {
|
||||
reverseAdjList[edge.target] = []
|
||||
}
|
||||
reverseAdjList[edge.target].push(edge.source)
|
||||
}
|
||||
|
||||
// BFS to find all ancestors and their shortest distance from target
|
||||
while (queue.length > 0) {
|
||||
const [currentNodeId, distance] = queue.shift()!
|
||||
|
||||
if (visited.has(currentNodeId)) {
|
||||
// If we've seen this node before, update its distance if this path is shorter
|
||||
const currentDistance = nodeDistances.get(currentNodeId) || Number.POSITIVE_INFINITY
|
||||
if (distance < currentDistance) {
|
||||
nodeDistances.set(currentNodeId, distance)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
visited.add(currentNodeId)
|
||||
nodeDistances.set(currentNodeId, distance)
|
||||
|
||||
// Don't add the target node itself to the results
|
||||
if (currentNodeId !== targetNodeId) {
|
||||
pathNodes.add(currentNodeId)
|
||||
}
|
||||
|
||||
// Get all incoming edges from the reverse adjacency list
|
||||
const incomingNodeIds = reverseAdjList[currentNodeId] || []
|
||||
|
||||
// Add all source nodes to the queue with incremented distance
|
||||
for (const sourceId of incomingNodeIds) {
|
||||
queue.push([sourceId, distance + 1])
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(pathNodes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetBlock } = vi.hoisted(() => ({
|
||||
mockGetBlock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: mockGetBlock,
|
||||
getAllBlocks: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags'
|
||||
|
||||
describe('getBlockReferenceTags', () => {
|
||||
beforeEach(() => {
|
||||
mockGetBlock.mockReset()
|
||||
mockGetBlock.mockReturnValue({
|
||||
outputs: {
|
||||
content: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
},
|
||||
subBlocks: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns agent responseFormat fields instead of default outputs', () => {
|
||||
const tags = getBlockReferenceTags({
|
||||
block: {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Classify Email',
|
||||
subBlocks: {
|
||||
responseFormat: {
|
||||
value: {
|
||||
name: 'email_classification',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
isImportant: { type: 'boolean' },
|
||||
draftReply: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['isImportant', 'draftReply', 'reason'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(tags).toEqual([
|
||||
'classifyemail.isImportant',
|
||||
'classifyemail.draftReply',
|
||||
'classifyemail.reason',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns variables block assignments as block tags', () => {
|
||||
const tags = getBlockReferenceTags({
|
||||
block: {
|
||||
id: 'variables-1',
|
||||
type: 'variables',
|
||||
name: 'Workflow Vars',
|
||||
subBlocks: {
|
||||
variables: {
|
||||
value: [{ variableName: 'currentDraft' }, { variableName: 'needsRevision' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(tags).toEqual(['workflowvars.currentDraft', 'workflowvars.needsRevision'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
|
||||
interface ReferenceableBlock {
|
||||
id: string
|
||||
type: string
|
||||
name?: string
|
||||
triggerMode?: boolean
|
||||
subBlocks?: Record<string, { value?: unknown }>
|
||||
}
|
||||
|
||||
interface GetBlockReferenceTagsOptions {
|
||||
block: ReferenceableBlock
|
||||
currentBlockId?: string
|
||||
subBlocks?: Record<string, { value?: unknown }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the exact reference tags shown in the workflow tag dropdown for a block.
|
||||
*/
|
||||
export function getBlockReferenceTags({
|
||||
block,
|
||||
currentBlockId,
|
||||
subBlocks,
|
||||
}: GetBlockReferenceTagsOptions): string[] {
|
||||
const blockName = block.name || block.type
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
const mergedSubBlocks = subBlocks ?? block.subBlocks
|
||||
|
||||
if (block.type === 'variables') {
|
||||
const variablesValue = mergedSubBlocks?.variables?.value
|
||||
if (Array.isArray(variablesValue) && variablesValue.length > 0) {
|
||||
const validAssignments = variablesValue.filter((assignment: { variableName?: string }) =>
|
||||
assignment?.variableName?.trim()
|
||||
)
|
||||
if (validAssignments.length > 0) {
|
||||
return validAssignments.map(
|
||||
(assignment: { variableName: string }) =>
|
||||
`${normalizedBlockName}.${assignment.variableName.trim()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [normalizedBlockName]
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
if (!blockConfig) {
|
||||
return []
|
||||
}
|
||||
|
||||
const isTriggerCapable = hasTriggerCapability(blockConfig)
|
||||
const effectiveTriggerMode = Boolean(block.triggerMode && isTriggerCapable)
|
||||
const outputPaths = getEffectiveBlockOutputPaths(block.type, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
|
||||
let blockTags: string[]
|
||||
if (block.type === 'human_in_the_loop' && block.id === currentBlockId) {
|
||||
blockTags = allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
} else if (allTags.length === 0) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
blockTags = allTags
|
||||
}
|
||||
|
||||
if (!blockTags.includes(normalizedBlockName)) {
|
||||
blockTags = [normalizedBlockName, ...blockTags]
|
||||
}
|
||||
|
||||
const shouldShowRootTag =
|
||||
block.type === TRIGGER_TYPES.GENERIC_WEBHOOK || block.type === 'start_trigger'
|
||||
if (!shouldShowRootTag) {
|
||||
blockTags = blockTags.filter((tag) => tag !== normalizedBlockName)
|
||||
}
|
||||
|
||||
return blockTags
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
interface WorkflowBlockDimensionsInput {
|
||||
blockType: string
|
||||
category: BlockConfig['category']
|
||||
displayTriggerMode: boolean
|
||||
visibleSubBlockCount: number
|
||||
conditionRowCount?: number
|
||||
routerRowCount?: number
|
||||
}
|
||||
|
||||
export function calculateWorkflowBlockDimensions({
|
||||
blockType,
|
||||
category,
|
||||
displayTriggerMode,
|
||||
visibleSubBlockCount,
|
||||
conditionRowCount = 0,
|
||||
routerRowCount = 0,
|
||||
}: WorkflowBlockDimensionsInput): { width: number; height: number } {
|
||||
const shouldShowDefaultHandles =
|
||||
category !== 'triggers' && blockType !== 'starter' && !displayTriggerMode
|
||||
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
|
||||
|
||||
let rowsCount = 0
|
||||
if (blockType === 'condition') {
|
||||
rowsCount = conditionRowCount + defaultHandlesRow
|
||||
} else if (blockType === 'router_v2') {
|
||||
rowsCount = 1 + routerRowCount + defaultHandlesRow
|
||||
} else {
|
||||
rowsCount = visibleSubBlockCount + defaultHandlesRow
|
||||
}
|
||||
|
||||
const hasContentBelowHeader = rowsCount > 0
|
||||
const contentHeight = hasContentBelowHeader
|
||||
? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
|
||||
: 0
|
||||
const height = Math.max(
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
)
|
||||
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Flatten workflow block outputs into pickable paths.
|
||||
*
|
||||
* Shared helper used by any UI that needs to let a user pick one (or many) of a
|
||||
* workflow's block outputs — deploy modal's OutputSelect, the table workflow-column
|
||||
* config sidebar, etc. Keeping this in one place means the "what counts as an
|
||||
* output" rules (excluded types, trigger-mode handling, recursion into nested
|
||||
* output shapes, BFS sort order) don't drift between consumers.
|
||||
*/
|
||||
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
|
||||
/**
|
||||
* Block types whose "outputs" are really workflow inputs (Start/starter) or flow
|
||||
* control and should never appear in an output picker.
|
||||
*/
|
||||
export const EXCLUDED_OUTPUT_TYPES = new Set(['starter', 'start_trigger', 'human_in_the_loop'])
|
||||
|
||||
export interface FlattenedBlockOutput {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
/** Dot-path into the block's output (e.g. `content`, `content.text`). */
|
||||
path: string
|
||||
/** Type from the block's output schema (e.g. `string`, `number`, `json`).
|
||||
* Used by the table column-sidebar to pick the right column type. */
|
||||
leafType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal shape we need off each block — compatible with both the normalized
|
||||
* WorkflowState.blocks entries and the editor's live block state.
|
||||
*/
|
||||
export interface FlattenOutputsBlockInput {
|
||||
id: string
|
||||
type: string
|
||||
name?: string
|
||||
triggerMode?: boolean
|
||||
subBlocks?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface FlattenOutputsEdgeInput {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a flat list of pickable output paths across every eligible block in
|
||||
* a workflow.
|
||||
*
|
||||
* @param blocks Iterable of blocks from the workflow state.
|
||||
* @param edges Optional edge list — when provided, results are sorted by BFS
|
||||
* distance from a start/trigger block (descending), so terminal blocks
|
||||
* appear first. This matches the deploy modal's grouping order.
|
||||
*/
|
||||
export function flattenWorkflowOutputs(
|
||||
blocks: Iterable<FlattenOutputsBlockInput>,
|
||||
edges: Iterable<FlattenOutputsEdgeInput> = []
|
||||
): FlattenedBlockOutput[] {
|
||||
const blockList = Array.from(blocks)
|
||||
const results: FlattenedBlockOutput[] = []
|
||||
|
||||
for (const block of blockList) {
|
||||
if (!block?.id || !block?.type) continue
|
||||
if (EXCLUDED_OUTPUT_TYPES.has(block.type)) continue
|
||||
|
||||
const normalizedSubBlocks: Record<string, { value: unknown }> = {}
|
||||
if (block.subBlocks && typeof block.subBlocks === 'object') {
|
||||
for (const [k, v] of Object.entries(block.subBlocks)) {
|
||||
normalizedSubBlocks[k] =
|
||||
v && typeof v === 'object' && 'value' in (v as object)
|
||||
? (v as { value: unknown })
|
||||
: { value: v }
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveTriggerMode = Boolean(block.triggerMode)
|
||||
let outs: Record<string, unknown> = {}
|
||||
try {
|
||||
outs = getEffectiveBlockOutputs(block.type, normalizedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
}) as Record<string, unknown>
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!outs || Object.keys(outs).length === 0) continue
|
||||
|
||||
const blockName = block.name || `Block ${block.id}`
|
||||
const add = (path: string, outputObj: unknown, prefix = ''): void => {
|
||||
const fullPath = prefix ? `${prefix}.${path}` : path
|
||||
const declaredType =
|
||||
outputObj &&
|
||||
typeof outputObj === 'object' &&
|
||||
!Array.isArray(outputObj) &&
|
||||
'type' in (outputObj as object) &&
|
||||
typeof (outputObj as { type: unknown }).type === 'string'
|
||||
? (outputObj as { type: string }).type
|
||||
: undefined
|
||||
const isLeaf =
|
||||
typeof outputObj !== 'object' ||
|
||||
outputObj === null ||
|
||||
declaredType !== undefined ||
|
||||
Array.isArray(outputObj)
|
||||
if (isLeaf) {
|
||||
results.push({
|
||||
blockId: block.id,
|
||||
blockName,
|
||||
blockType: block.type,
|
||||
path: fullPath,
|
||||
leafType: declaredType,
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const [key, value] of Object.entries(outputObj as Record<string, unknown>)) {
|
||||
add(key, value, fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(outs)) add(key, value)
|
||||
}
|
||||
|
||||
const edgeList = Array.from(edges)
|
||||
if (edgeList.length === 0 || results.length === 0) return results
|
||||
|
||||
// Sort by BFS distance from the first start/trigger block, descending — so terminal
|
||||
// blocks group to the top. Matches the deploy modal's OutputSelect ordering.
|
||||
const startBlock = blockList.find(
|
||||
(b) => b.type === 'starter' || b.type === 'start_trigger' || !!b.triggerMode
|
||||
)
|
||||
if (!startBlock) return results
|
||||
|
||||
const adj: Record<string, string[]> = {}
|
||||
for (const e of edgeList) {
|
||||
if (!adj[e.source]) adj[e.source] = []
|
||||
adj[e.source].push(e.target)
|
||||
}
|
||||
const distances: Record<string, number> = {}
|
||||
const visited = new Set<string>()
|
||||
const queue: Array<[string, number]> = [[startBlock.id, 0]]
|
||||
while (queue.length > 0) {
|
||||
const [id, d] = queue.shift()!
|
||||
if (visited.has(id)) continue
|
||||
visited.add(id)
|
||||
distances[id] = d
|
||||
for (const t of adj[id] ?? []) queue.push([t, d + 1])
|
||||
}
|
||||
|
||||
return results
|
||||
.map((r, i) => ({ r, d: distances[r.blockId] ?? -1, i }))
|
||||
.sort((a, b) => {
|
||||
if (b.d !== a.d) return b.d - a.d
|
||||
return a.i - b.i // preserve discovery order within the same distance
|
||||
})
|
||||
.map(({ r }) => r)
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS distance from the workflow's start/trigger block to every reachable block,
|
||||
* keyed by blockId. Blocks unreachable from start get `-1`. Pure function over
|
||||
* the same `blocks`/`edges` shape `flattenWorkflowOutputs` accepts; callers that
|
||||
* want a *start-first* (execution-order ASC) sort use this map directly instead
|
||||
* of `flattenWorkflowOutputs` (which sorts terminal-blocks-first for picker UX).
|
||||
*/
|
||||
export function getBlockExecutionOrder(
|
||||
blocks: Iterable<FlattenOutputsBlockInput>,
|
||||
edges: Iterable<FlattenOutputsEdgeInput>
|
||||
): Record<string, number> {
|
||||
const blockList = Array.from(blocks)
|
||||
const startBlock = blockList.find(
|
||||
(b) => b.type === 'starter' || b.type === 'start_trigger' || !!b.triggerMode
|
||||
)
|
||||
const distances: Record<string, number> = {}
|
||||
for (const b of blockList) {
|
||||
if (b?.id) distances[b.id] = -1
|
||||
}
|
||||
if (!startBlock) return distances
|
||||
|
||||
const adj: Record<string, string[]> = {}
|
||||
for (const e of edges) {
|
||||
if (!adj[e.source]) adj[e.source] = []
|
||||
adj[e.source].push(e.target)
|
||||
}
|
||||
const visited = new Set<string>()
|
||||
const queue: Array<[string, number]> = [[startBlock.id, 0]]
|
||||
while (queue.length > 0) {
|
||||
const [id, d] = queue.shift()!
|
||||
if (visited.has(id)) continue
|
||||
visited.add(id)
|
||||
distances[id] = d
|
||||
for (const t of adj[id] ?? []) queue.push([t, d + 1])
|
||||
}
|
||||
return distances
|
||||
}
|
||||
Reference in New Issue
Block a user