Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions apps/sim/lib/workflows/search-replace/indexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,113 @@ describe('indexWorkflowSearchMatches', () => {
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
})

describe('block references search under the name the canvas shows', () => {
/**
* The panel's own pipeline: index everything, then keep what the query
* matches. Block references resolve no label of their own, so they reach the
* filter with `displayLabel` fallen back to the raw token, as the hydration
* hook leaves them.
*/
function findReferenceMatches(query: string) {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['agent-1'].subBlocks.systemPrompt.value =
'Summarize <api1.output> and <deletedblock.output>, then loop <loop.index>.'

return indexWorkflowSearchMatches({
workflow,
query,
mode: 'all',
includeResourceMatchesWithoutQuery: true,
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
.filter((match) => match.kind === 'workflow-reference')
.filter((match) =>
workflowSearchMatchMatchesQuery({ ...match, displayLabel: match.rawValue }, query)
)
}

it('matches a reference by the spaced block name', () => {
expect(findReferenceMatches('API 1').map((match) => match.rawValue)).toEqual([
'<api1.output>',
])
})

it('still matches a reference by the token as stored', () => {
expect(findReferenceMatches('api1').map((match) => match.rawValue)).toEqual(['<api1.output>'])
})

it('reads the resolved name back as the block is titled', () => {
const [match] = findReferenceMatches('API 1')

expect(match.searchText).toBe('API 1.output')
expect(match.rawValue).toBe('<api1.output>')
expect(match.range).toEqual({ start: 10, end: 23 })
})

it('leaves a prefix that names no block as written', () => {
const matches = indexWorkflowSearchMatches({
workflow: (() => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['agent-1'].subBlocks.systemPrompt.value =
'Summarize <api1.output> and <deletedblock.output>, then loop <loop.index>.'
return workflow
})(),
mode: 'all',
includeResourceMatchesWithoutQuery: true,
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})

expect(
matches
.filter((match) => match.kind === 'workflow-reference')
.map((match) => match.searchText)
).toEqual(['API 1.output', 'deletedblock.output', 'loop.index'])
})

it('leaves an environment reference keyed by its variable name', () => {
const matches = indexWorkflowSearchMatches({
workflow: createSearchReplaceWorkflowFixture(),
mode: 'all',
includeResourceMatchesWithoutQuery: true,
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})

expect(
matches.filter((match) => match.kind === 'environment').map((match) => match.searchText)
).toEqual(['OLD_SECRET', 'OLD_SECRET'])
})

/**
* Legacy workflows can hold two names that collide only now that
* `normalizeName` strips dots. `BlockResolver` gives the key to the dot-free
* name whichever order the blocks arrive in, so search has to name the same
* block or it would label the reference with a title that block does not own
* at execution time.
*/
it.each([
['dotted first', ['Hunter.io 1', 'Hunterio 1']],
['dot-free first', ['Hunterio 1', 'Hunter.io 1']],
])('names a legacy dot collision after the dot-free block (%s)', (_order, names) => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['knowledge-1'].name = names[0]
workflow.blocks['api-1'].name = names[1]
workflow.blocks['agent-1'].subBlocks.systemPrompt.value = 'Read <hunterio1.email>.'

const matches = indexWorkflowSearchMatches({
workflow,
mode: 'all',
includeResourceMatchesWithoutQuery: true,
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})

expect(
matches
.filter((match) => match.kind === 'workflow-reference')
.map((match) => match.searchText)
).toEqual(['Hunterio 1.email'])
})
})

it('does not index internal row metadata in structured subblock values', () => {
const workflow = createSearchReplaceWorkflowFixture()

Expand Down
22 changes: 18 additions & 4 deletions apps/sim/lib/workflows/search-replace/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
shouldParseSerializedSubBlockValue,
} from '@/lib/workflows/search-replace/json-value-fields'
import {
buildBlockNamesByReferencePrefix,
getResourceKindForSubBlock,
matchesSearchText,
parseInlineReferences,
parseStructuredResourceReferences,
resolveInlineReferenceSearchText,
} from '@/lib/workflows/search-replace/resources'
import { getWorkflowSearchSubflowFields } from '@/lib/workflows/search-replace/subflow-fields'
import type {
Expand Down Expand Up @@ -937,6 +939,7 @@ function addToolInputMatches({
blockConfigs,
customTools,
mcpToolNamesById,
blockNamesByReferencePrefix,
}: {
matches: WorkflowSearchMatch[]
block: WorkflowSearchBlockState
Expand All @@ -958,6 +961,7 @@ function addToolInputMatches({
blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs']
customTools?: WorkflowSearchIndexerOptions['customTools']
mcpToolNamesById?: WorkflowSearchIndexerOptions['mcpToolNamesById']
blockNamesByReferencePrefix: ReadonlyMap<string, string>
}) {
const parentCanonicalModes = getSearchCanonicalModes(block)

Expand Down Expand Up @@ -1058,7 +1062,11 @@ function addToolInputMatches({
for (const leaf of getSearchableStringLeaves(paramValue, subBlockType, 'reference')) {
const inlineReferences = parseInlineReferences(leaf.value)
inlineReferences.forEach((reference, referenceIndex) => {
const searchable = `${reference.rawValue} ${reference.searchText}`
const searchText = resolveInlineReferenceSearchText(
reference,
blockNamesByReferencePrefix
)
const searchable = `${reference.rawValue} ${reference.searchText} ${searchText}`
if (
!includeResourceMatchesWithoutQuery &&
!matchesSearchText(searchable, query, caseSensitive)
Expand Down Expand Up @@ -1088,7 +1096,7 @@ function addToolInputMatches({
target: { kind: 'subblock' },
kind: reference.kind,
rawValue: reference.rawValue,
searchText: reference.searchText,
searchText,
range: reference.range,
dependentValuePaths: nestedDependentValuePaths,
resource: reference.resource,
Expand Down Expand Up @@ -1250,6 +1258,7 @@ export function indexWorkflowSearchMatches(

const matches: WorkflowSearchMatch[] = []
const resourceQueryEnabled = includeResourceMatchesWithoutQuery || Boolean(query)
const blockNamesByReferencePrefix = buildBlockNamesByReferencePrefix(workflow.blocks)

for (const block of Object.values(workflow.blocks)) {
const blockConfig = blockConfigs[block.type] ?? getBlock(block.type)
Expand Down Expand Up @@ -1383,6 +1392,7 @@ export function indexWorkflowSearchMatches(
blockConfigs,
customTools,
mcpToolNamesById,
blockNamesByReferencePrefix,
})
continue
}
Expand Down Expand Up @@ -1471,7 +1481,11 @@ export function indexWorkflowSearchMatches(
for (const leaf of referenceLeaves) {
const inlineReferences = parseInlineReferences(leaf.value)
inlineReferences.forEach((reference, referenceIndex) => {
const searchable = `${reference.rawValue} ${reference.searchText}`
const searchText = resolveInlineReferenceSearchText(
reference,
blockNamesByReferencePrefix
)
const searchable = `${reference.rawValue} ${reference.searchText} ${searchText}`
if (
!includeResourceMatchesWithoutQuery &&
!matchesSearchText(searchable, query, caseSensitive)
Expand Down Expand Up @@ -1499,7 +1513,7 @@ export function indexWorkflowSearchMatches(
target: { kind: 'subblock' },
kind: reference.kind,
rawValue: reference.rawValue,
searchText: reference.searchText,
searchText,
range: reference.range,
resource: reference.resource,
editable,
Expand Down
65 changes: 65 additions & 0 deletions apps/sim/lib/workflows/search-replace/resources/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
WorkflowSearchResourceMeta,
} from '@/lib/workflows/search-replace/types'
import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName, REFERENCE } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import type { SelectorContext } from '@/hooks/selectors/types'

Expand Down Expand Up @@ -67,6 +68,70 @@ export function parseInlineReferences(value: string): ParsedInlineReference[] {
return references.sort((a, b) => a.range.start - b.range.start)
}

/**
* Indexes a workflow's block names by the prefix their references carry, so a
* parsed reference can be read back as the name the canvas shows.
*
* Creating or renaming a block enforces uniqueness at the normalized level, but
* legacy workflows can still hold two names that collide only now that
* `normalizeName` strips dots. `BlockResolver` settles that tie by letting the
* dot-free name keep ownership of the key, so previously working references
* never change targets; this mirrors that rule rather than taking whichever
* block happens to be iterated last, so search names the block a reference
* actually resolves to at execution time.
*
* Blank names are skipped rather than mapped to an empty prefix.
*/
export function buildBlockNamesByReferencePrefix(
blocks: Record<string, { name?: string }>
): Map<string, string> {
const namesByPrefix = new Map<string, string>()

for (const block of Object.values(blocks)) {
if (typeof block.name !== 'string') continue
const prefix = normalizeName(block.name)
if (!prefix) continue

const incumbent = namesByPrefix.get(prefix)
if (incumbent === undefined || incumbent.includes(REFERENCE.PATH_DELIMITER)) {
namesByPrefix.set(prefix, block.name)
}
}

return namesByPrefix
}
Comment thread
icecrasher321 marked this conversation as resolved.

/**
* Rewrites a block reference's search text into the name the block is shown
* under, so searching reads the same as the canvas does.
*
* A reference stores its target as `normalizeName(block.name)` - lowercased with
* whitespace and dots stripped - so a block headed "Send Email" is written
* `<sendemail.content>`. Searching the two words the card shows found the block
* itself but none of its references; only the run-together form found those.
*
* Only the prefix is rewritten. What follows it is the block's output path, not
* a name. A prefix that names no block - a system prefix like `loop`, or a
* reference left behind by a deleted block - is left exactly as written, and so
* is an environment reference, whose search text is its key rather than a name.
*/
export function resolveInlineReferenceSearchText(
reference: ParsedInlineReference,
blockNamesByReferencePrefix: ReadonlyMap<string, string>
): string {
if (reference.kind !== 'workflow-reference') return reference.searchText

const delimiterIndex = reference.searchText.indexOf(REFERENCE.PATH_DELIMITER)
const prefix =
delimiterIndex === -1 ? reference.searchText : reference.searchText.slice(0, delimiterIndex)
const blockName = blockNamesByReferencePrefix.get(normalizeName(prefix))
if (!blockName || blockName === prefix) return reference.searchText

return delimiterIndex === -1
? blockName
: `${blockName}${reference.searchText.slice(delimiterIndex)}`
}

export function parseStructuredResourceReferences(
value: unknown,
subBlockConfig?: Pick<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>,
Expand Down
Loading