import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks' import type { SubBlockType } from '@sim/workflow-types/blocks' import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { COMPARISON_OPERATORS, LOGICAL_OPERATORS } from '@/lib/table/query-builder/constants' import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies' import { getSearchableJsonStringLeaves, isSearchableJsonValueSubBlock, shouldParseSerializedSubBlockValue, } from '@/lib/workflows/search-replace/json-value-fields' import { getResourceKindForSubBlock, matchesSearchText, parseInlineReferences, parseStructuredResourceReferences, } from '@/lib/workflows/search-replace/resources' import { getWorkflowSearchSubflowFields } from '@/lib/workflows/search-replace/subflow-fields' import type { WorkflowSearchBlockState, WorkflowSearchIndexerOptions, WorkflowSearchMatch, WorkflowSearchValuePath, } from '@/lib/workflows/search-replace/types' import { pathToKey, walkStringValues } from '@/lib/workflows/search-replace/value-walker' import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' import { resolveStoredToolName } from '@/lib/workflows/subblocks/display' import { buildCanonicalIndex, buildSubBlockValues, type CanonicalModeOverrides, evaluateSubBlockCondition, isSubBlockFeatureEnabled, isSubBlockHidden, isSubBlockVisibleForMode, isSubBlockVisibleForTriggerMode, normalizeDependencyValue, parseDependsOn, resolveDependencyValue, scopeCanonicalModesForTool, shouldUseSubBlockForTriggerModeCanonicalIndex, } from '@/lib/workflows/subblocks/visibility' import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' import { type ParsedStoredTool, parseStoredToolInputValue } from '@/lib/workflows/tool-input/types' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { isReference } from '@/executor/constants' import type { SelectorContext } from '@/hooks/selectors/types' import { formatParameterLabel, getSubBlocksForToolInput, getToolIdForOperation, getToolParametersConfig, type ToolParameterConfig, } from '@/tools/params' function normalizeForSearch(value: string, caseSensitive: boolean): string { return caseSensitive ? value : value.toLowerCase() } function findTextRanges(value: string, query: string, caseSensitive: boolean) { if (!query) return [] const source = normalizeForSearch(value, caseSensitive) const target = normalizeForSearch(query, caseSensitive) const ranges: Array<{ start: number; end: number }> = [] let index = source.indexOf(target) while (index !== -1) { ranges.push({ start: index, end: index + target.length }) index = source.indexOf(target, index + Math.max(target.length, 1)) } return ranges } function createMatchId(parts: Array): string { return parts .filter((part) => part !== undefined && part !== '') .map((part) => String(part).replaceAll(':', '_')) .join(':') } const STRUCTURED_METADATA_LEAF_KEYS = new Set(['id', 'collapsed']) const INPUT_FORMAT_FIELD_TITLES: Record = { name: 'Name', description: 'Description', value: 'Value', } const EVAL_INPUT_FIELD_TITLES: Record = { name: 'Name', description: 'Description', min: 'Min Value', max: 'Max Value', } const PLAIN_TEXT_EXCLUDED_SUBBLOCK_TYPES = new Set([ 'dropdown', 'checkbox-list', 'grouped-checkbox-list', 'skill-input', 'sort-builder', 'time-input', 'file-upload', 'modal', 'schedule-info', 'slider', 'switch', 'text', 'webhook-config', ]) const DISPLAY_ONLY_SUBBLOCK_TYPES = new Set([ 'modal', 'schedule-info', 'text', 'webhook-config', ]) const TEXT_VALUE_ONLY_SUBBLOCK_TYPES = new Set(['filter-builder', 'variables-input']) const DISPLAY_LABEL_READONLY_REASON = 'Display labels cannot be replaced' const TOOL_INPUT_TEXT_EXCLUDED_LEAF_KEYS = new Set([ 'type', 'toolId', 'customToolId', 'operation', 'usageControl', 'serverId', 'toolName', 'credentialId', 'oauthCredential', 'workflowId', ]) const TOOL_INPUT_TEXT_EXCLUDED_PATH_KEYS = new Set(['schema']) type WorkflowSearchSubBlockConfig = Pick & Partial type DisplayLabelLeaf = { value: string; path: WorkflowSearchValuePath; fieldTitle?: string } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } function looksLikeStoredSkillList(value: unknown): boolean { return ( Array.isArray(value) && value.length > 0 && value.every( (item) => item && typeof item === 'object' && !Array.isArray(item) && typeof (item as Record).skillId === 'string' ) ) } function looksLikeStructuredString(value: string): boolean { const trimmed = value.trim() return ( (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')) ) } function getFallbackToolParamType(value: unknown, paramType?: string): SubBlockType { if (paramType === 'object') return 'workflow-input-mapper' if (value && typeof value === 'object' && !Array.isArray(value)) return 'workflow-input-mapper' if (typeof value !== 'string') return DEFAULT_SUBBLOCK_TYPE as SubBlockType const trimmed = value.trim() if (!(trimmed.startsWith('{') && trimmed.endsWith('}'))) { return DEFAULT_SUBBLOCK_TYPE as SubBlockType } try { const parsed: unknown = JSON.parse(trimmed) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return 'workflow-input-mapper' } } catch {} return DEFAULT_SUBBLOCK_TYPE as SubBlockType } function isSearchableLeafPath( path: Array, subBlockType: SubBlockType | undefined, mode: 'text' | 'reference' ): boolean { if (mode === 'text' && subBlockType && PLAIN_TEXT_EXCLUDED_SUBBLOCK_TYPES.has(subBlockType)) { return false } const lastSegment = path.at(-1) if (typeof lastSegment !== 'string') return true if (mode === 'text' && subBlockType === 'messages-input' && lastSegment === 'role') { return false } if (mode === 'text' && subBlockType === 'tool-input') { if (TOOL_INPUT_TEXT_EXCLUDED_LEAF_KEYS.has(lastSegment)) return false if (lastSegment.endsWith('Id')) return false if ( path.some( (segment) => typeof segment === 'string' && TOOL_INPUT_TEXT_EXCLUDED_PATH_KEYS.has(segment) ) ) { return false } } if (mode === 'text' && subBlockType && TEXT_VALUE_ONLY_SUBBLOCK_TYPES.has(subBlockType)) { return lastSegment === 'value' } if ( mode === 'text' && (subBlockType === 'input-format' || subBlockType === 'response-format' || subBlockType === 'eval-input') && lastSegment === 'type' ) { return false } return !STRUCTURED_METADATA_LEAF_KEYS.has(lastSegment) } function getSearchableStringLeaves( value: unknown, subBlockType: SubBlockType | undefined, mode: 'text' | 'reference' ) { return walkStringValues(value).filter((leaf) => isSearchableLeafPath(leaf.path, subBlockType, mode) ) } function getStructuredFieldTitle( subBlockType: SubBlockType | undefined, path: WorkflowSearchValuePath ) { const lastSegment = path.at(-1) if (typeof lastSegment !== 'string') return undefined if (subBlockType === 'input-format' || subBlockType === 'response-format') { return INPUT_FORMAT_FIELD_TITLES[lastSegment] } if (subBlockType === 'eval-input') { return EVAL_INPUT_FIELD_TITLES[lastSegment] } return undefined } function getTextLeaves(value: unknown, subBlockType: SubBlockType | undefined) { if (isSearchableJsonValueSubBlock(subBlockType)) { return getSearchableJsonStringLeaves(value, subBlockType) } if (looksLikeStoredSkillList(value)) return [] return getSearchableStringLeaves(value, subBlockType, 'text') .filter( (leaf) => subBlockType !== 'tool-input' || typeof leaf.value !== 'string' || !looksLikeStructuredString(leaf.value) ) .map((leaf) => ({ ...leaf, fieldTitle: getStructuredFieldTitle(subBlockType, leaf.path), })) } function getSelectedOptionValues(value: unknown, multiSelect?: boolean): string[] { if (multiSelect) { if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string') return typeof value === 'string' && value.length > 0 ? [value] : [] } return typeof value === 'string' && value.length > 0 ? [value] : [] } function getStaticOptionLabels(subBlockConfig?: WorkflowSearchSubBlockConfig): Map { const options = subBlockConfig?.options if (!Array.isArray(options)) return new Map() return new Map( options.flatMap((option) => { if (typeof option === 'string') return [[option, option] as const] return [[option.id, option.label] as const] }) ) } function formatTimeInputDisplayLabel(value: string): string { const [hours, minutes] = value.split(':') const hour = Number.parseInt(hours ?? '', 10) if (!Number.isFinite(hour) || !minutes) return value const ampm = hour >= 12 ? 'PM' : 'AM' const displayHour = hour % 12 || 12 return `${displayHour}:${minutes} ${ampm}` } function getOptionLabelLeaves( subBlockConfig: WorkflowSearchSubBlockConfig | undefined, fieldTitle: string | undefined, basePath: string ): DisplayLabelLeaf[] { const options = subBlockConfig?.options if (!Array.isArray(options)) return [] return options.flatMap((option, index) => { if (typeof option === 'string') { return [{ value: option, path: [basePath, index], fieldTitle }] } return option.label ? [{ value: option.label, path: [basePath, index], fieldTitle }] : [] }) } function getMcpDynamicArgEnumLabelLeaves(value: unknown, schema: unknown): DisplayLabelLeaf[] { const parsedValue = typeof value === 'string' ? safeParseJson(value) : value if (!isRecord(parsedValue) || !isRecord(schema) || !isRecord(schema.properties)) return [] return Object.entries(schema.properties).flatMap(([paramName, paramSchema]) => { if (!isRecord(paramSchema) || !Array.isArray(paramSchema.enum)) return [] const selectedValue = parsedValue[paramName] if (selectedValue === undefined || selectedValue === null || selectedValue === '') return [] return [ { value: String(selectedValue), path: [paramName], fieldTitle: formatParameterLabel(paramName), }, ] }) } function getDisplayLabelLeaves({ value, subBlockConfig, subBlockType, }: { value: unknown subBlockConfig?: WorkflowSearchSubBlockConfig subBlockType: SubBlockType }): DisplayLabelLeaf[] { if (subBlockType === 'table') { return (subBlockConfig?.columns ?? []).map((column, index) => ({ value: column, path: ['columns', index], fieldTitle: 'Column', })) } if (subBlockType === 'checkbox-list' || subBlockType === 'grouped-checkbox-list') { return getOptionLabelLeaves(subBlockConfig, subBlockConfig?.title, 'options') } if (subBlockType === 'time-input') { return typeof value === 'string' && value.length > 0 ? [{ value: formatTimeInputDisplayLabel(value), path: [], fieldTitle: subBlockConfig?.title }] : [] } if (subBlockType === 'variables-input') { const parsed = typeof value === 'string' ? safeParseJson(value) : value if (!Array.isArray(parsed)) return [] return parsed.flatMap((assignment, index) => { if (!assignment || typeof assignment !== 'object' || Array.isArray(assignment)) return [] const variableName = (assignment as Record).variableName return typeof variableName === 'string' && variableName.length > 0 ? [{ value: variableName, path: [index, 'variableName'], fieldTitle: 'Variable' }] : [] }) } if (subBlockType === 'skill-input') { const parsed = typeof value === 'string' ? safeParseJson(value) : value if (!Array.isArray(parsed)) return [] return parsed.flatMap((skill, index) => { if (!skill || typeof skill !== 'object' || Array.isArray(skill)) return [] const name = (skill as Record).name return typeof name === 'string' && name.length > 0 ? [{ value: name, path: [index, 'name'], fieldTitle: subBlockConfig?.title ?? 'Skill' }] : [] }) } if (subBlockType === 'sort-builder') { const directionLabels: Record = { asc: 'ascending', desc: 'descending' } const parsed = typeof value === 'string' ? safeParseJson(value) : value if (!Array.isArray(parsed)) return [] return parsed.flatMap((rule, index) => { if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return [] const record = rule as Record const leaves: Array<{ value: string; path: WorkflowSearchValuePath; fieldTitle?: string }> = [] if (typeof record.column === 'string' && record.column.length > 0) { leaves.push({ value: record.column, path: [index, 'column'], fieldTitle: 'Column' }) } if (typeof record.direction === 'string' && record.direction.length > 0) { leaves.push({ value: directionLabels[record.direction] ?? record.direction, path: [index, 'direction'], fieldTitle: 'Direction', }) } return leaves }) } if (subBlockType === 'filter-builder') { const operatorLabels = new Map( COMPARISON_OPERATORS.map((option) => [option.value, option.label]) ) const logicalLabels = new Map( LOGICAL_OPERATORS.map((option) => [option.value, option.label]) ) const parsed = typeof value === 'string' ? safeParseJson(value) : value if (!Array.isArray(parsed)) return [] return parsed.flatMap((rule, index) => { if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return [] const record = rule as Record const leaves: DisplayLabelLeaf[] = [] if (typeof record.column === 'string' && record.column.length > 0) { leaves.push({ value: record.column, path: [index, 'column'], fieldTitle: 'Column' }) } if (typeof record.operator === 'string' && record.operator.length > 0) { leaves.push({ value: operatorLabels.get(record.operator) ?? record.operator, path: [index, 'operator'], fieldTitle: 'Operator', }) } if ( index > 0 && typeof record.logicalOperator === 'string' && record.logicalOperator.length > 0 ) { leaves.push({ value: logicalLabels.get(record.logicalOperator) ?? record.logicalOperator, path: [index, 'logicalOperator'], fieldTitle: 'Logic', }) } return leaves }) } if (subBlockType !== 'dropdown' && subBlockType !== 'combobox') return [] const optionLabels = getStaticOptionLabels(subBlockConfig) if (optionLabels.size === 0) return [] return getSelectedOptionValues(value, subBlockConfig?.multiSelect).flatMap( (selectedValue, index) => { const optionLabel = optionLabels.get(selectedValue) const label = subBlockType === 'dropdown' ? optionLabel?.toLowerCase() : optionLabel if (!label) return [] return [ { value: label, path: subBlockConfig?.multiSelect ? [index] : [], fieldTitle: subBlockConfig?.title, }, ] } ) } function addDisplayLabelMatches({ matches, block, subBlockId, canonicalSubBlockId, subBlockType, subBlockConfig, value, query, caseSensitive, protectedByLock, isSnapshotView, basePath = [], }: { matches: WorkflowSearchMatch[] block: WorkflowSearchBlockState subBlockId: string canonicalSubBlockId: string subBlockType: SubBlockType subBlockConfig?: WorkflowSearchSubBlockConfig value: unknown query?: string caseSensitive: boolean protectedByLock: boolean isSnapshotView: boolean basePath?: WorkflowSearchValuePath }) { for (const leaf of getDisplayLabelLeaves({ value, subBlockConfig, subBlockType })) { addTextMatches({ matches, idPrefix: 'display-label', block, subBlockId, canonicalSubBlockId, subBlockType, fieldTitle: leaf.fieldTitle ?? subBlockConfig?.title, value: leaf.value, valuePath: [...basePath, ...leaf.path], target: { kind: 'subblock' }, query, caseSensitive, editable: false, protectedByLock, isSnapshotView, readonlyReason: DISPLAY_LABEL_READONLY_REASON, }) } } function safeParseJson(value: string): unknown { try { return JSON.parse(value) } catch { return value } } function parseToolParamValue(value: unknown, subBlockType: SubBlockType): unknown { if (value === undefined || value === null) return '' if (typeof value !== 'string') return value if (!shouldParseSerializedSubBlockValue(subBlockType)) { return value } try { const parsed: unknown = JSON.parse(value) return parsed && typeof parsed === 'object' ? parsed : value } catch { return value } } function isToolParamVisibleForReactiveCondition({ subBlockConfig, values, canonicalIndex, canonicalModes, credentialTypeById, }: { subBlockConfig: WorkflowSearchSubBlockConfig values: Record canonicalIndex: ReturnType canonicalModes?: CanonicalModeOverrides credentialTypeById?: Record }) { if (!subBlockConfig.reactiveCondition) return true return isReactiveSearchSubBlockVisible({ subBlockConfig, subBlockValues: values, canonicalIndex, canonicalModes, credentialTypeById, }) } interface AddTextMatchesOptions { matches: WorkflowSearchMatch[] idPrefix: string block: WorkflowSearchBlockState subBlockId: string canonicalSubBlockId: string subBlockType: SubBlockType fieldTitle?: string value: string valuePath: WorkflowSearchValuePath target: WorkflowSearchMatch['target'] query?: string caseSensitive: boolean editable: boolean protectedByLock: boolean isSnapshotView: boolean readonlyReason?: string } function getReadonlyReason({ editable, isSnapshotView, readonlyReason, }: { editable: boolean isSnapshotView: boolean readonlyReason?: string }) { if (editable) return undefined return readonlyReason ?? (isSnapshotView ? 'Snapshot view is readonly' : 'Block is locked') } function addTextMatches({ matches, idPrefix, block, subBlockId, canonicalSubBlockId, subBlockType, fieldTitle, value, valuePath, target, query, caseSensitive, editable, protectedByLock, isSnapshotView, readonlyReason, }: AddTextMatchesOptions) { const ranges = query ? findTextRanges(value, query, caseSensitive) : [] ranges.forEach((range, occurrenceIndex) => { matches.push({ id: createMatchId([ idPrefix, block.id, subBlockId, pathToKey(valuePath), range.start, occurrenceIndex, ]), blockId: block.id, blockName: block.name, blockType: block.type, subBlockId, canonicalSubBlockId, subBlockType, fieldTitle, valuePath, target, kind: 'text', rawValue: value.slice(range.start, range.end), searchText: value, range, editable, navigable: true, protected: protectedByLock, reason: getReadonlyReason({ editable, isSnapshotView, readonlyReason }), }) }) } function buildToolInputSearchConfig(param: ToolParameterConfig): WorkflowSearchSubBlockConfig { const uiComponent = param.uiComponent return { id: param.id, title: uiComponent?.title ?? param.id, type: (uiComponent?.type ?? getFallbackToolParamType(undefined, param.type)) as SubBlockType, placeholder: uiComponent?.placeholder, condition: uiComponent?.condition as SubBlockConfig['condition'], serviceId: uiComponent?.serviceId, selectorKey: uiComponent?.selectorKey, requiredScopes: uiComponent?.requiredScopes, mimeType: uiComponent?.mimeType, canonicalParamId: uiComponent?.canonicalParamId, mode: uiComponent?.mode, password: uiComponent?.password, dependsOn: uiComponent?.dependsOn, } } function isVisibleToolParameter(param: ToolParameterConfig, values: Record) { if (param.visibility === 'hidden' || param.visibility === 'llm-only') return false const condition = param.uiComponent?.condition return ( !condition || evaluateSubBlockCondition(condition as Parameters[0], values) ) } /** * Resolve a stored tool's params to their subBlock configs (the same resolution * the search index + UI use). Exported so cross-workspace remapping (fork/promote) * can detect and rewrite the workspace-scoped resource ids nested inside block * tools, not just the top-level selectors. */ export function getToolInputParamConfigs({ tool, toolIndex, parentCanonicalModes, credentialTypeById, blockConfigs, }: { tool: ParsedStoredTool /** Position of `tool` within its parent's `tool-input` array - canonical-mode overrides are keyed by this, not `tool.type`, so same-type tools don't collide. Omit only when `parentCanonicalModes` is also absent (e.g. fork/promote remaps that don't resolve canonical modes). */ toolIndex?: number parentCanonicalModes?: CanonicalModeOverrides credentialTypeById?: Record blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs'] }): Array<{ paramId: string config: WorkflowSearchSubBlockConfig value: unknown selectorContext?: SelectorContext dependentValuePaths?: WorkflowSearchValuePath[] }> { const toolId = tool.type !== 'custom-tool' && tool.type !== 'mcp' ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId : tool.toolId const toolParamValues = tool.params ?? {} const values = { operation: tool.operation, ...toolParamValues } const genericFallback = () => Object.entries(toolParamValues) .filter(([paramId, value]) => { if (TOOL_INPUT_TEXT_EXCLUDED_LEAF_KEYS.has(paramId)) return false if (paramId.endsWith('Id')) return false return ( typeof value !== 'string' || !looksLikeStructuredString(value) || value.trim().startsWith('{') ) }) .map(([paramId, value]) => { const type = getFallbackToolParamType(value) return { paramId, config: { id: paramId, title: paramId, type, condition: undefined, }, value: parseToolParamValue(value, type), } }) if (!toolId) return genericFallback() const scopedCanonicalModes = scopeCanonicalModesForTool( parentCanonicalModes, toolIndex, tool.type ) const blockConfig = tool.type !== 'custom-tool' && tool.type !== 'mcp' ? (blockConfigs?.[tool.type] ?? getBlock(tool.type)) : null const subBlocksResult = tool.type !== 'custom-tool' && tool.type !== 'mcp' ? getSubBlocksForToolInput( toolId, tool.type, values, scopedCanonicalModes, blockConfig?.subBlocks ? { subBlocks: blockConfig.subBlocks } : undefined ) : null const toolParams = getToolParametersConfig(toolId, tool.type, values) const displayParams = toolParams?.userInputParameters ?? [] if (!toolParams && !subBlocksResult) return genericFallback() if (!subBlocksResult?.subBlocks.length) { const fallbackCanonicalIndex = buildCanonicalIndex([]) return displayParams .filter((param) => isVisibleToolParameter(param, values)) .map((param) => { const config = buildToolInputSearchConfig(param) return { paramId: param.id, config, value: parseToolParamValue(toolParamValues[param.id], config.type), selectorContext: config.selectorKey || config.dependsOn ? buildSelectorContext({ subBlockConfig: config, subBlockValues: values, canonicalIndex: fallbackCanonicalIndex, canonicalModes: scopedCanonicalModes, }) : undefined, } }) } const toolCanonicalIndex = buildCanonicalIndex( blockConfig?.subBlocks ?? subBlocksResult.subBlocks ) const visibleSubBlocks = subBlocksResult.subBlocks.filter((subBlock) => isToolParamVisibleForReactiveCondition({ subBlockConfig: subBlock, values, canonicalIndex: toolCanonicalIndex, canonicalModes: scopedCanonicalModes, credentialTypeById, }) ) const allToolSubBlocks = blockConfig?.subBlocks ?? subBlocksResult.subBlocks const getDependentValuePaths = (changedSubBlockId: string): WorkflowSearchValuePath[] => getWorkflowSearchDependentClears(allToolSubBlocks, changedSubBlockId).map((clear) => [ 'params', clear.subBlockId, ]) const coveredParamIds = new Set( visibleSubBlocks.flatMap((subBlock) => { const ids = [subBlock.id] if (subBlock.canonicalParamId) ids.push(subBlock.canonicalParamId) const canonicalId = toolCanonicalIndex.canonicalIdBySubBlockId[subBlock.id] if (canonicalId) { const group = toolCanonicalIndex.groupsById[canonicalId] if (group) { if (group.basicId) ids.push(group.basicId) ids.push(...group.advancedIds) } } return ids }) ) const subBlockParams = visibleSubBlocks.map((config) => ({ paramId: config.id, config, value: parseToolParamValue(toolParamValues[config.id], config.type), dependentValuePaths: getDependentValuePaths(config.id), selectorContext: config.selectorKey || config.dependsOn ? buildSelectorContext({ subBlockConfig: config, subBlockValues: values, canonicalIndex: toolCanonicalIndex, canonicalModes: scopedCanonicalModes, }) : undefined, })) const uncoveredParams = displayParams .filter((param) => !coveredParamIds.has(param.id) && isVisibleToolParameter(param, values)) .map((param) => { const config = buildToolInputSearchConfig(param) return { paramId: param.id, config, value: parseToolParamValue(toolParamValues[param.id], config.type), selectorContext: config.selectorKey || config.dependsOn ? buildSelectorContext({ subBlockConfig: config, subBlockValues: values, canonicalIndex: toolCanonicalIndex, canonicalModes: scopedCanonicalModes, }) : undefined, } }) return [...subBlockParams, ...uncoveredParams] } function buildSelectorContext({ subBlockConfig, subBlockValues, canonicalIndex, canonicalModes, workspaceId, workflowId, }: { subBlockConfig?: WorkflowSearchSubBlockConfig subBlockValues: Record canonicalIndex: ReturnType canonicalModes?: CanonicalModeOverrides workspaceId?: string workflowId?: string }): SelectorContext { const context: SelectorContext = {} if (workspaceId) context.workspaceId = workspaceId if (workflowId) { context.workflowId = workflowId context.excludeWorkflowId = workflowId } if (subBlockConfig?.mimeType) context.mimeType = subBlockConfig.mimeType const { allDependsOnFields } = parseDependsOn(subBlockConfig?.dependsOn) for (const subBlockId of allDependsOnFields) { const value = normalizeDependencyValue( resolveDependencyValue(subBlockId, subBlockValues, canonicalIndex, canonicalModes) ) if (value === null || value === undefined) continue const stringValue = typeof value === 'string' ? value : String(value) if (!stringValue) continue if (isReference(stringValue)) continue const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[subBlockId] ?? subBlockId if (subBlockConfig?.type === 'mcp-tool-selector' && canonicalKey === 'server') { context.mcpServerId = stringValue continue } if (SELECTOR_CONTEXT_FIELDS.has(canonicalKey as keyof SelectorContext)) { context[canonicalKey as keyof SelectorContext] = stringValue } } return context } function buildSearchSelectorContext({ block, subBlockConfig, subBlockValues, canonicalIndex, workspaceId, workflowId, }: { block: WorkflowSearchBlockState subBlockConfig?: WorkflowSearchSubBlockConfig subBlockValues: Record canonicalIndex: ReturnType workspaceId?: string workflowId?: string }): SelectorContext { return buildSelectorContext({ subBlockConfig, subBlockValues, canonicalIndex, canonicalModes: getSearchCanonicalModes(block), workspaceId, workflowId, }) } function addToolInputMatches({ matches, block, subBlockId, canonicalSubBlockId, value, mode, query, caseSensitive, includeResourceMatchesWithoutQuery, resourceQueryEnabled, editable, protectedByLock, isSnapshotView, readonlyReason, workspaceId, workflowId, credentialTypeById, blockConfigs, customTools, mcpToolNamesById, }: { matches: WorkflowSearchMatch[] block: WorkflowSearchBlockState subBlockId: string canonicalSubBlockId: string value: unknown mode: WorkflowSearchIndexerOptions['mode'] query?: string caseSensitive: boolean includeResourceMatchesWithoutQuery: boolean resourceQueryEnabled: boolean editable: boolean protectedByLock: boolean isSnapshotView: boolean readonlyReason?: string workspaceId?: string workflowId?: string credentialTypeById?: Record blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs'] customTools?: WorkflowSearchIndexerOptions['customTools'] mcpToolNamesById?: WorkflowSearchIndexerOptions['mcpToolNamesById'] }) { const parentCanonicalModes = getSearchCanonicalModes(block) parseStoredToolInputValue(value).forEach((tool, toolIndex) => { // Index the resolved display name (not the stored mutable title) so // search text and highlights match what the tool chip actually renders. const toolDisplayName = resolveStoredToolName(tool, { customTools, mcpToolNamesById, getBlockConfig: (type) => blockConfigs?.[type] ?? getBlock(type), }) if (mode !== 'resource' && toolDisplayName) { addTextMatches({ matches, idPrefix: 'tool-input-title', block, subBlockId, canonicalSubBlockId, subBlockType: 'tool-input', fieldTitle: 'Tool', value: toolDisplayName, valuePath: [toolIndex, 'title'], target: { kind: 'subblock' }, query, caseSensitive, editable: false, protectedByLock, isSnapshotView, readonlyReason: DISPLAY_LABEL_READONLY_REASON, }) } const params = getToolInputParamConfigs({ tool, toolIndex, parentCanonicalModes, credentialTypeById, blockConfigs, }) for (const { paramId, config, value: paramValue, selectorContext, dependentValuePaths, } of params) { const subBlockType = config.type const structuredResourceKind = getResourceKindForSubBlock(config) const basePath: WorkflowSearchValuePath = [toolIndex, 'params', paramId] const nestedDependentValuePaths = dependentValuePaths?.map((path) => [toolIndex, ...path]) if (mode !== 'resource' && !structuredResourceKind) { addDisplayLabelMatches({ matches, block, subBlockId, canonicalSubBlockId, subBlockType, subBlockConfig: config, value: paramValue, query, caseSensitive, protectedByLock, isSnapshotView, basePath, }) for (const leaf of getTextLeaves(paramValue, subBlockType)) { const leafEditable = editable && typeof leaf.originalValue === 'string' addTextMatches({ matches, idPrefix: 'tool-input-text', block, subBlockId, canonicalSubBlockId, subBlockType, fieldTitle: config.title, value: leaf.value, valuePath: [...basePath, ...leaf.path], target: { kind: 'subblock' }, query, caseSensitive, editable: leafEditable, protectedByLock, isSnapshotView, readonlyReason: leafEditable ? undefined : typeof leaf.originalValue === 'string' ? readonlyReason : 'Only text values can be replaced', }) } } if (mode === 'text' || !resourceQueryEnabled) continue for (const leaf of getSearchableStringLeaves(paramValue, subBlockType, 'reference')) { const inlineReferences = parseInlineReferences(leaf.value) inlineReferences.forEach((reference, referenceIndex) => { const searchable = `${reference.rawValue} ${reference.searchText}` if ( !includeResourceMatchesWithoutQuery && !matchesSearchText(searchable, query, caseSensitive) ) { return } matches.push({ id: createMatchId([ reference.kind, block.id, subBlockId, toolIndex, paramId, pathToKey(leaf.path), reference.range.start, referenceIndex, ]), blockId: block.id, blockName: block.name, blockType: block.type, subBlockId, canonicalSubBlockId, subBlockType, fieldTitle: config.title, valuePath: [...basePath, ...leaf.path], target: { kind: 'subblock' }, kind: reference.kind, rawValue: reference.rawValue, searchText: reference.searchText, range: reference.range, dependentValuePaths: nestedDependentValuePaths, resource: reference.resource, editable, navigable: true, protected: protectedByLock, reason: getReadonlyReason({ editable, isSnapshotView, readonlyReason }), }) }) } const structuredReferences = parseStructuredResourceReferences( paramValue, config, selectorContext ? { ...selectorContext, ...(workspaceId && { workspaceId }), ...(workflowId && { workflowId, excludeWorkflowId: workflowId }), } : undefined ) structuredReferences.forEach((reference, referenceIndex) => { const searchable = `${reference.rawValue} ${reference.searchText} ${reference.kind}` if ( !includeResourceMatchesWithoutQuery && !matchesSearchText(searchable, query, caseSensitive) ) { return } matches.push({ id: createMatchId([ reference.kind, block.id, subBlockId, toolIndex, paramId, reference.rawValue, referenceIndex, ]), blockId: block.id, blockName: block.name, blockType: block.type, subBlockId, canonicalSubBlockId, subBlockType, fieldTitle: config.title, valuePath: [...basePath, ...(reference.valuePath ?? [])], target: { kind: 'subblock' }, kind: reference.kind, rawValue: reference.rawValue, searchText: reference.searchText, structuredOccurrenceIndex: referenceIndex, dependentValuePaths: nestedDependentValuePaths, resource: reference.resource, editable, navigable: true, protected: protectedByLock, reason: getReadonlyReason({ editable, isSnapshotView, readonlyReason }), }) }) } }) } function getSearchCanonicalModes( block: WorkflowSearchBlockState ): CanonicalModeOverrides | undefined { const data = block.data if (!data || typeof data !== 'object') return undefined return (data as { canonicalModes?: CanonicalModeOverrides }).canonicalModes } function isReactiveSearchSubBlockVisible({ subBlockConfig, subBlockValues, canonicalIndex, canonicalModes, credentialTypeById, }: { subBlockConfig?: WorkflowSearchSubBlockConfig subBlockValues: Record canonicalIndex: ReturnType canonicalModes?: CanonicalModeOverrides credentialTypeById?: Record }): boolean { const reactiveCondition = subBlockConfig?.reactiveCondition if (!reactiveCondition) return true const watchedCredentialId = reactiveCondition.watchFields .map((field) => normalizeDependencyValue( resolveDependencyValue(field, subBlockValues, canonicalIndex, canonicalModes) ) ) .find((value): value is string => typeof value === 'string' && value.length > 0) if (!watchedCredentialId || isReference(watchedCredentialId)) return false return credentialTypeById?.[watchedCredentialId] === reactiveCondition.requiredType } function isSearchSubBlockVisibleForMode({ block, blockConfig, subBlockConfig, subBlockValues, canonicalIndex, canonicalModes, }: { block: WorkflowSearchBlockState blockConfig?: NonNullable[string] subBlockConfig?: WorkflowSearchSubBlockConfig subBlockValues: Record canonicalIndex: ReturnType canonicalModes?: CanonicalModeOverrides }): boolean { if (!subBlockConfig) return true const displayTriggerMode = Boolean(block.triggerMode) if ( !isSubBlockVisibleForTriggerMode( subBlockConfig as SubBlockConfig, displayTriggerMode, blockConfig ) ) { return false } return isSubBlockVisibleForMode( subBlockConfig as SubBlockConfig, Boolean(block.advancedMode), canonicalIndex, subBlockValues, canonicalModes ) } export function indexWorkflowSearchMatches( options: WorkflowSearchIndexerOptions ): WorkflowSearchMatch[] { const { workflow, query, mode = 'all', caseSensitive = false, includeResourceMatchesWithoutQuery = false, isSnapshotView = false, isReadOnly = isSnapshotView, readonlyReason, workspaceId, workflowId, blockConfigs = {}, credentialTypeById, customTools, mcpToolNamesById, } = options const matches: WorkflowSearchMatch[] = [] const resourceQueryEnabled = includeResourceMatchesWithoutQuery || Boolean(query) for (const block of Object.values(workflow.blocks)) { const blockConfig = blockConfigs[block.type] ?? getBlock(block.type) const subBlockConfigs = blockConfig?.subBlocks ?? [] const canonicalSubBlockConfigs = block.triggerMode ? subBlockConfigs.filter(shouldUseSubBlockForTriggerModeCanonicalIndex) : subBlockConfigs const configsById = new Map(subBlockConfigs.map((subBlock) => [subBlock.id, subBlock])) const canonicalIndex = buildCanonicalIndex(canonicalSubBlockConfigs) const subBlockValues = buildSubBlockValues(block.subBlocks ?? {}) const canonicalModes = getSearchCanonicalModes(block) const protectedByLock = isWorkflowBlockProtected(block.id, workflow.blocks) const editable = !protectedByLock && !isReadOnly if (mode !== 'resource' && query && typeof block.name === 'string' && block.name.length > 0) { const blockNameRanges = findTextRanges(block.name, query, caseSensitive) blockNameRanges.forEach((range, occurrenceIndex) => { matches.push({ id: createMatchId(['block-name', block.id, range.start, occurrenceIndex]), blockId: block.id, blockName: block.name, blockType: block.type, subBlockId: '', canonicalSubBlockId: '', subBlockType: 'short-input', fieldTitle: 'Block name', valuePath: [], target: { kind: 'block-name' }, kind: 'text', rawValue: block.name.slice(range.start, range.end), searchText: block.name, range, editable: false, navigable: true, protected: protectedByLock, reason: 'Block names cannot be edited via replace', }) }) } if (mode !== 'resource') { for (const field of getWorkflowSearchSubflowFields(block)) { const fieldEditable = editable && field.editable addTextMatches({ matches, idPrefix: 'subflow-text', block, subBlockId: field.id, canonicalSubBlockId: field.id, subBlockType: field.type, fieldTitle: field.title, value: field.value, valuePath: [], target: { kind: 'subflow', fieldId: field.id }, query, caseSensitive, editable: fieldEditable, protectedByLock, isSnapshotView, readonlyReason: fieldEditable ? undefined : !editable ? readonlyReason : field.reason, }) } } for (const [subBlockId, subBlockState] of Object.entries(block.subBlocks ?? {})) { if (isSyntheticToolSubBlockId(subBlockId)) continue if (subBlockId.startsWith('_')) continue const subBlockConfig = configsById.get(subBlockId) if (subBlockConfig?.hidden) continue if (subBlockConfig && !isSubBlockFeatureEnabled(subBlockConfig)) continue if (subBlockConfig && isSubBlockHidden(subBlockConfig)) continue if ( !isSearchSubBlockVisibleForMode({ block, blockConfig, subBlockConfig, subBlockValues, canonicalIndex, canonicalModes, }) ) { continue } if ( !isReactiveSearchSubBlockVisible({ subBlockConfig, subBlockValues, canonicalIndex, canonicalModes, credentialTypeById, }) ) { continue } if ( subBlockConfig?.condition && !evaluateSubBlockCondition(subBlockConfig.condition, subBlockValues) ) { continue } const canonicalSubBlockId = canonicalIndex.canonicalIdBySubBlockId[subBlockId] ?? subBlockConfig?.canonicalParamId ?? subBlockId const value = subBlockState?.value const subBlockType = subBlockConfig?.type ?? subBlockState.type if (DISPLAY_ONLY_SUBBLOCK_TYPES.has(subBlockType)) continue const resourceSubBlockConfig = subBlockConfig ?? { type: subBlockType } const structuredResourceKind = getResourceKindForSubBlock(resourceSubBlockConfig) if (subBlockType === 'tool-input') { addToolInputMatches({ matches, block, subBlockId, canonicalSubBlockId, value, mode, query, caseSensitive, includeResourceMatchesWithoutQuery, resourceQueryEnabled, editable, protectedByLock, isSnapshotView, readonlyReason, workspaceId, workflowId, credentialTypeById, blockConfigs, customTools, mcpToolNamesById, }) continue } if (mode !== 'resource') { addDisplayLabelMatches({ matches, block, subBlockId, canonicalSubBlockId, subBlockType, subBlockConfig, value, query, caseSensitive, protectedByLock, isSnapshotView, }) if (subBlockType === 'mcp-dynamic-args') { for (const leaf of getMcpDynamicArgEnumLabelLeaves(value, subBlockValues._toolSchema)) { addTextMatches({ matches, idPrefix: 'mcp-dynamic-args-display-label', block, subBlockId, canonicalSubBlockId, subBlockType, fieldTitle: leaf.fieldTitle ?? subBlockConfig?.title, value: leaf.value, valuePath: leaf.path, target: { kind: 'subblock' }, query, caseSensitive, editable: false, protectedByLock, isSnapshotView, readonlyReason: DISPLAY_LABEL_READONLY_REASON, }) } } } if (mode !== 'resource' && !structuredResourceKind) { const mcpDynamicEnumPathKeys = subBlockType === 'mcp-dynamic-args' ? new Set( getMcpDynamicArgEnumLabelLeaves(value, subBlockValues._toolSchema).map((leaf) => pathToKey(leaf.path) ) ) : undefined const textLeaves = getTextLeaves(value, subBlockType).filter( (leaf) => !mcpDynamicEnumPathKeys?.has(pathToKey(leaf.path)) ) for (const leaf of textLeaves) { const leafEditable = editable && typeof leaf.originalValue === 'string' addTextMatches({ matches, idPrefix: 'text', block, subBlockId, canonicalSubBlockId, subBlockType, fieldTitle: leaf.fieldTitle ?? subBlockConfig?.title, value: leaf.value, valuePath: leaf.path, target: { kind: 'subblock' }, query, caseSensitive, editable: leafEditable, protectedByLock, isSnapshotView, readonlyReason: leafEditable ? undefined : typeof leaf.originalValue === 'string' ? readonlyReason : 'Only text values can be replaced', }) } } if (mode === 'text' || !resourceQueryEnabled) continue const referenceLeaves = getSearchableStringLeaves(value, subBlockType, 'reference') for (const leaf of referenceLeaves) { const inlineReferences = parseInlineReferences(leaf.value) inlineReferences.forEach((reference, referenceIndex) => { const searchable = `${reference.rawValue} ${reference.searchText}` if ( !includeResourceMatchesWithoutQuery && !matchesSearchText(searchable, query, caseSensitive) ) { return } matches.push({ id: createMatchId([ reference.kind, block.id, subBlockId, pathToKey(leaf.path), reference.range.start, referenceIndex, ]), blockId: block.id, blockName: block.name, blockType: block.type, subBlockId, canonicalSubBlockId, subBlockType: subBlockConfig?.type ?? subBlockState.type, fieldTitle: subBlockConfig?.title, valuePath: leaf.path, target: { kind: 'subblock' }, kind: reference.kind, rawValue: reference.rawValue, searchText: reference.searchText, range: reference.range, resource: reference.resource, editable, navigable: true, protected: protectedByLock, reason: getReadonlyReason({ editable, isSnapshotView, readonlyReason }), }) }) } const selectorContext = subBlockConfig?.selectorKey || subBlockConfig?.dependsOn ? buildSearchSelectorContext({ block, subBlockConfig, subBlockValues, canonicalIndex, workspaceId, workflowId, }) : undefined const structuredReferences = parseStructuredResourceReferences( value, resourceSubBlockConfig, selectorContext ) structuredReferences.forEach((reference, referenceIndex) => { const searchable = `${reference.rawValue} ${reference.searchText} ${reference.kind}` if ( !includeResourceMatchesWithoutQuery && !matchesSearchText(searchable, query, caseSensitive) ) { return } matches.push({ id: createMatchId([ reference.kind, block.id, subBlockId, reference.rawValue, referenceIndex, ]), blockId: block.id, blockName: block.name, blockType: block.type, subBlockId, canonicalSubBlockId, subBlockType: subBlockConfig?.type ?? subBlockState.type, fieldTitle: subBlockConfig?.title, valuePath: reference.valuePath ?? [], target: { kind: 'subblock' }, kind: reference.kind, rawValue: reference.rawValue, searchText: reference.searchText, structuredOccurrenceIndex: referenceIndex, resource: reference.resource, editable, navigable: true, protected: protectedByLock, reason: getReadonlyReason({ editable, isSnapshotView, readonlyReason }), }) }) } } return matches }