d25d482dc2
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
1575 lines
49 KiB
TypeScript
1575 lines
49 KiB
TypeScript
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 | number | undefined>): 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<string, string> = {
|
|
name: 'Name',
|
|
description: 'Description',
|
|
value: 'Value',
|
|
}
|
|
|
|
const EVAL_INPUT_FIELD_TITLES: Record<string, string> = {
|
|
name: 'Name',
|
|
description: 'Description',
|
|
min: 'Min Value',
|
|
max: 'Max Value',
|
|
}
|
|
|
|
const PLAIN_TEXT_EXCLUDED_SUBBLOCK_TYPES = new Set<SubBlockType>([
|
|
'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<SubBlockType>([
|
|
'modal',
|
|
'schedule-info',
|
|
'text',
|
|
'webhook-config',
|
|
])
|
|
|
|
const TEXT_VALUE_ONLY_SUBBLOCK_TYPES = new Set<SubBlockType>(['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<SubBlockConfig, 'id' | 'type'> & Partial<SubBlockConfig>
|
|
type DisplayLabelLeaf = { value: string; path: WorkflowSearchValuePath; fieldTitle?: string }
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
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<string, unknown>).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<string | number>,
|
|
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<string, string> {
|
|
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<string, unknown>).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<string, unknown>).name
|
|
return typeof name === 'string' && name.length > 0
|
|
? [{ value: name, path: [index, 'name'], fieldTitle: subBlockConfig?.title ?? 'Skill' }]
|
|
: []
|
|
})
|
|
}
|
|
|
|
if (subBlockType === 'sort-builder') {
|
|
const directionLabels: Record<string, string> = { 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<string, unknown>
|
|
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<string, string>(
|
|
COMPARISON_OPERATORS.map((option) => [option.value, option.label])
|
|
)
|
|
const logicalLabels = new Map<string, string>(
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
canonicalIndex: ReturnType<typeof buildCanonicalIndex>
|
|
canonicalModes?: CanonicalModeOverrides
|
|
credentialTypeById?: Record<string, string | undefined>
|
|
}) {
|
|
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<string, unknown>) {
|
|
if (param.visibility === 'hidden' || param.visibility === 'llm-only') return false
|
|
const condition = param.uiComponent?.condition
|
|
return (
|
|
!condition ||
|
|
evaluateSubBlockCondition(condition as Parameters<typeof evaluateSubBlockCondition>[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<string, string | undefined>
|
|
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<string, unknown>
|
|
canonicalIndex: ReturnType<typeof buildCanonicalIndex>
|
|
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<string, unknown>
|
|
canonicalIndex: ReturnType<typeof buildCanonicalIndex>
|
|
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<string, string | undefined>
|
|
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<string, unknown>
|
|
canonicalIndex: ReturnType<typeof buildCanonicalIndex>
|
|
canonicalModes?: CanonicalModeOverrides
|
|
credentialTypeById?: Record<string, string | undefined>
|
|
}): 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<WorkflowSearchIndexerOptions['blockConfigs']>[string]
|
|
subBlockConfig?: WorkflowSearchSubBlockConfig
|
|
subBlockValues: Record<string, unknown>
|
|
canonicalIndex: ReturnType<typeof buildCanonicalIndex>
|
|
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
|
|
}
|