Skip to content

Commit 29affda

Browse files
improvement(search-replace): dedupe double indexed segments (#4517)
* improvement(search-replace): dedupe double indexed segments * cleanup error message * address comments
1 parent 9e9ddaa commit 29affda

4 files changed

Lines changed: 204 additions & 5 deletions

File tree

apps/sim/app/api/chat/manage/[id]/route.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,6 @@ export const PATCH = withRouteHandler(
126126
}
127127
}
128128

129-
if (workflowId && workflowId !== existingChat[0].workflowId) {
130-
return createErrorResponse('Changing a chat deployment workflow is not supported', 400)
131-
}
132-
133129
let encryptedPassword
134130

135131
if (password) {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace
99
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
1010
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
1111
import {
12+
dedupeOverlappingWorkflowSearchMatches,
1213
getCompatibleResourceReplacementOptions,
1314
getWorkflowSearchCompatibleResourceMatches,
1415
getWorkflowSearchMatchResourceGroupKey,
@@ -197,7 +198,10 @@ export function WorkflowSearchReplace() {
197198
})
198199

199200
const hydratedMatches = useMemo(
200-
() => allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query)),
201+
() =>
202+
dedupeOverlappingWorkflowSearchMatches(
203+
allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query))
204+
),
201205
[allHydratedMatches, query]
202206
)
203207

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { dedupeOverlappingWorkflowSearchMatches } from '@/lib/workflows/search-replace/resources/resolvers'
6+
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
7+
8+
function createMatch(overrides: Partial<WorkflowSearchMatch>): WorkflowSearchMatch {
9+
return {
10+
id: 'match',
11+
blockId: 'block-1',
12+
blockName: 'Block',
13+
blockType: 'function',
14+
subBlockId: 'code',
15+
canonicalSubBlockId: 'code',
16+
subBlockType: 'code',
17+
valuePath: [],
18+
target: { kind: 'subblock' },
19+
kind: 'text',
20+
rawValue: '',
21+
searchText: '',
22+
editable: true,
23+
navigable: true,
24+
protected: false,
25+
...overrides,
26+
}
27+
}
28+
29+
describe('dedupeOverlappingWorkflowSearchMatches', () => {
30+
it('keeps the narrower text hit when a partial literal query overlaps an inline reference', () => {
31+
const textMatch = createMatch({
32+
id: 'text-partial',
33+
kind: 'text',
34+
rawValue: '<start.h',
35+
searchText: "return '<start.hello>'",
36+
range: { start: 8, end: 16 },
37+
})
38+
const referenceMatch = createMatch({
39+
id: 'workflow-reference',
40+
kind: 'workflow-reference',
41+
rawValue: '<start.hello>',
42+
searchText: 'start.hello',
43+
range: { start: 8, end: 21 },
44+
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
45+
})
46+
47+
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([textMatch])
48+
})
49+
50+
it('keeps the inline reference when it covers the same span as a text hit', () => {
51+
const textMatch = createMatch({
52+
id: 'text-full',
53+
kind: 'text',
54+
rawValue: '<start.hello>',
55+
searchText: "return '<start.hello>'",
56+
range: { start: 8, end: 21 },
57+
})
58+
const referenceMatch = createMatch({
59+
id: 'workflow-reference',
60+
kind: 'workflow-reference',
61+
rawValue: '<start.hello>',
62+
searchText: 'start.hello',
63+
range: { start: 8, end: 21 },
64+
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
65+
})
66+
67+
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([
68+
referenceMatch,
69+
])
70+
})
71+
72+
it('uses kind priority rather than iteration order for equal-span non-text matches', () => {
73+
const workflowReferenceMatch = createMatch({
74+
id: 'workflow-reference',
75+
kind: 'workflow-reference',
76+
rawValue: '{{API_KEY}}',
77+
searchText: 'API_KEY',
78+
range: { start: 0, end: 11 },
79+
resource: { kind: 'workflow-reference', token: '{{API_KEY}}', key: 'API_KEY' },
80+
})
81+
const environmentMatch = createMatch({
82+
id: 'environment',
83+
kind: 'environment',
84+
rawValue: '{{API_KEY}}',
85+
searchText: 'API_KEY',
86+
range: { start: 0, end: 11 },
87+
resource: { kind: 'environment', token: '{{API_KEY}}', key: 'API_KEY' },
88+
})
89+
90+
expect(
91+
dedupeOverlappingWorkflowSearchMatches([workflowReferenceMatch, environmentMatch])
92+
).toEqual([workflowReferenceMatch])
93+
expect(
94+
dedupeOverlappingWorkflowSearchMatches([environmentMatch, workflowReferenceMatch])
95+
).toEqual([workflowReferenceMatch])
96+
})
97+
98+
it('does not collapse matches from different fields', () => {
99+
const firstMatch = createMatch({
100+
id: 'first',
101+
range: { start: 0, end: 4 },
102+
valuePath: ['first'],
103+
})
104+
const secondMatch = createMatch({
105+
id: 'second',
106+
range: { start: 0, end: 4 },
107+
valuePath: ['second'],
108+
})
109+
110+
expect(dedupeOverlappingWorkflowSearchMatches([firstMatch, secondMatch])).toEqual([
111+
firstMatch,
112+
secondMatch,
113+
])
114+
})
115+
})

apps/sim/lib/workflows/search-replace/resources/resolvers.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import type {
22
WorkflowSearchMatch,
3+
WorkflowSearchMatchKind,
34
WorkflowSearchReplacementOption,
45
WorkflowSearchResourceMeta,
6+
WorkflowSearchValuePath,
57
} from '@/lib/workflows/search-replace/types'
68
import type { SelectorContext } from '@/hooks/selectors/types'
79

10+
const OVERLAPPING_MATCH_KIND_PRIORITY: Record<WorkflowSearchMatchKind, number> = {
11+
text: 0,
12+
environment: 1,
13+
'workflow-reference': 2,
14+
'oauth-credential': 3,
15+
'knowledge-base': 3,
16+
'knowledge-document': 3,
17+
workflow: 3,
18+
'mcp-server': 3,
19+
'mcp-tool': 3,
20+
table: 3,
21+
file: 3,
22+
'selector-resource': 3,
23+
}
24+
825
export function stableStringifyWorkflowSearchValue(value: unknown): string {
926
if (!value || typeof value !== 'object') return JSON.stringify(value)
1027
if (Array.isArray(value)) {
@@ -88,6 +105,73 @@ export function getWorkflowSearchCompatibleResourceMatches(
88105
)
89106
}
90107

108+
function searchValuePathKey(path: WorkflowSearchValuePath): string {
109+
return path.map((segment) => `${typeof segment}:${String(segment)}`).join('/')
110+
}
111+
112+
function getRangeMatchScopeKey(match: WorkflowSearchMatch): string | null {
113+
if (!match.range) return null
114+
if (match.target.kind !== 'subblock') return null
115+
return [match.blockId, match.subBlockId, searchValuePathKey(match.valuePath)].join(':')
116+
}
117+
118+
function rangesOverlap(
119+
left: NonNullable<WorkflowSearchMatch['range']>,
120+
right: NonNullable<WorkflowSearchMatch['range']>
121+
): boolean {
122+
return left.start < right.end && right.start < left.end
123+
}
124+
125+
function getRangeLength(match: WorkflowSearchMatch): number {
126+
return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY
127+
}
128+
129+
function shouldPreferOverlappingMatch(
130+
candidate: WorkflowSearchMatch,
131+
current: WorkflowSearchMatch
132+
): boolean {
133+
const candidateLength = getRangeLength(candidate)
134+
const currentLength = getRangeLength(current)
135+
if (candidateLength !== currentLength) return candidateLength < currentLength
136+
137+
const candidatePriority = OVERLAPPING_MATCH_KIND_PRIORITY[candidate.kind]
138+
const currentPriority = OVERLAPPING_MATCH_KIND_PRIORITY[current.kind]
139+
if (candidatePriority !== currentPriority) return candidatePriority > currentPriority
140+
141+
return false
142+
}
143+
144+
export function dedupeOverlappingWorkflowSearchMatches<T extends WorkflowSearchMatch>(
145+
matches: T[]
146+
): T[] {
147+
const deduped: T[] = []
148+
149+
for (const match of matches) {
150+
const scopeKey = getRangeMatchScopeKey(match)
151+
const matchRange = match.range
152+
const existingIndex =
153+
scopeKey && matchRange
154+
? deduped.findIndex(
155+
(candidate) =>
156+
getRangeMatchScopeKey(candidate) === scopeKey &&
157+
candidate.range &&
158+
rangesOverlap(candidate.range, matchRange)
159+
)
160+
: -1
161+
162+
if (existingIndex === -1) {
163+
deduped.push(match)
164+
continue
165+
}
166+
167+
if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) {
168+
deduped[existingIndex] = match
169+
}
170+
}
171+
172+
return deduped
173+
}
174+
91175
export function workflowSearchMatchMatchesQuery(
92176
match: WorkflowSearchMatch & { displayLabel?: string },
93177
query: string,

0 commit comments

Comments
 (0)