Skip to content

Commit 8bc634a

Browse files
committed
fix(workflows): split references without the non-overlapping token pass
Review round 3. An oversized value fell back to plain splitting, which tore a comma-bearing reference into fragments that were then validated as literal ids. The fallback existed to avoid `findWorkflowReferenceTokens`, which is superlinear in candidate count. That pass was never needed here. It returns contractually NON-overlapping tokens, and the O(tokens^2) overlap check is the cost of producing that partition. A splitter only needs to know whether an index sits inside SOME reference, so scanning environment placeholders and `<...>` candidates independently gives the union directly - cheaper and more accurate, since nothing is suppressed. Splitting is now linear and the size fallback is gone, so a comma-bearing reference survives at any length: 240KB of `<a.b>,` 682ms -> 15ms 240KB of `<a.p({{X}},y)>,` 177ms -> 12ms 1MB of literal ids 1ms The length cap now applies to a single ENTRY rather than the whole field, which is all it was ever needed for: classifying one entry tokenizes it, and there is no cheap way to tell a literal from a dynamic binding past that size. Exports `ENV_REFERENCE_PATTERN` from `@sim/utils` rather than duplicating the pattern, so the two stay in step.
1 parent 7d2fe33 commit 8bc634a

4 files changed

Lines changed: 55 additions & 47 deletions

File tree

apps/sim/lib/workflows/editing/validation.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,26 @@ describe('collectUnresolvedReferences', () => {
13221322
expect(refs).toHaveLength(0)
13231323
})
13241324

1325+
it('keeps a comma-bearing reference intact even in an oversized list', async () => {
1326+
// Splitting scans reference regions directly and stays linear, so size does not force a
1327+
// fallback that would tear `<start.pick(a,b)>` into fragments validated as ids.
1328+
mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] })
1329+
const padding = Array.from({ length: 400 }, (_, index) => `kb_${'x'.repeat(30)}${index}`)
1330+
const value = [...padding, '<start.pick(a,b)>', 'kb_missing'].join(',')
1331+
expect(value.length).toBeGreaterThan(10_000)
1332+
const state = {
1333+
blocks: {
1334+
kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value } } },
1335+
},
1336+
}
1337+
await collectUnresolvedReferences(state, CTX)
1338+
1339+
const [, ids] = mockValidateSelectorIds.mock.calls[0]
1340+
expect(ids).toContain('kb_missing')
1341+
expect(ids).not.toContain('<start.pick(a')
1342+
expect(ids).not.toContain('b)>')
1343+
})
1344+
13251345
it('still validates the literal entries of an oversized list', async () => {
13261346
// The cap gives up reference-aware splitting, not validation: the entries are still short,
13271347
// so each is classified and the literals are still checked.

apps/sim/lib/workflows/editing/validation.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,15 +1083,15 @@ interface SelectorFieldToValidate {
10831083
}
10841084

10851085
/**
1086-
* Longest selector string reference detection will tokenize.
1086+
* Longest single selector entry `containsReference` will classify.
10871087
*
1088-
* `findWorkflowReferenceTokens` parses the whole string and is superlinear in candidate count, so
1089-
* an oversized value is expensive on a write path that admits megabytes. Past this a value is
1090-
* split plainly instead: its entries are still short, so each one is classified and validated as
1091-
* usual, and only the comma-inside-a-reference protection is given up. An individual ENTRY past
1092-
* the cap is skipped, since there is no cheap way to tell a literal from a dynamic binding.
1088+
* Classifying one entry tokenizes it, and `findWorkflowReferenceTokens` is superlinear in
1089+
* candidate count, so an entry of unbounded length is expensive on a write path that admits
1090+
* megabytes. Splitting is unaffected - it scans reference regions directly and stays linear - so
1091+
* only an individual oversized ENTRY is skipped, where there is no cheap way to tell a literal
1092+
* from a dynamic binding. A long LIST of ordinary ids still splits and validates normally.
10931093
*/
1094-
const MAX_SELECTOR_VALUE_LENGTH = 10_000
1094+
const MAX_SELECTOR_ENTRY_LENGTH = 10_000
10951095

10961096
/**
10971097
* Walk a workflow state and collect selector/credential fields to validate.
@@ -1148,17 +1148,12 @@ function collectSelectorFields(
11481148
if (!subBlockValue) continue
11491149

11501150
const isOversized = (entry: unknown) =>
1151-
typeof entry === 'string' && entry.length > MAX_SELECTOR_VALUE_LENGTH
1151+
typeof entry === 'string' && entry.length > MAX_SELECTOR_ENTRY_LENGTH
11521152

11531153
// Handle comma-separated values for multi-select
11541154
let values: string | string[] = subBlockValue
11551155
if (typeof subBlockValue === 'string' && subBlockValue.includes(',')) {
1156-
values = isOversized(subBlockValue)
1157-
? subBlockValue
1158-
.split(',')
1159-
.map((entry: string) => entry.trim())
1160-
.filter(Boolean)
1161-
: splitOutsideReferences(subBlockValue)
1156+
values = splitOutsideReferences(subBlockValue)
11621157
}
11631158

11641159
// A dynamically bound value only acquires its id at execution time, so a static

apps/sim/lib/workflows/sanitization/references.ts

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ENV_REFERENCE_PATTERN,
23
findWorkflowReferenceTokens,
34
isLikelyWorkflowReferenceSegment,
45
splitWorkflowReferenceSegment,
@@ -24,14 +25,24 @@ export function containsReference(value: unknown): boolean {
2425
}
2526

2627
/**
27-
* Mark every `<...>` region that reads as a workflow reference, including one the tokenizer
28-
* dropped for overlapping an `{{ENV_VAR}}` token.
28+
* Mark every region of `value` that belongs to a reference, as a union rather than a partition.
2929
*
30-
* `findWorkflowReferenceTokens` is contractually NON-overlapping, so for `<a.pick({{B}},c)>` it
31-
* reports only the inner `{{B}}` and discards the outer candidate. That is right for a tokenizer
32-
* and wrong for a splitter, which needs the UNION of protected regions rather than a disjoint set.
30+
* Deliberately does NOT use `findWorkflowReferenceTokens`. That returns contractually
31+
* non-overlapping tokens, which costs an O(tokens^2) overlap check and drops a `<...>` candidate
32+
* that wraps an `{{ENV_VAR}}` - both wrong here. A splitter only needs to know whether an index is
33+
* inside SOME reference, so scanning each kind independently is both cheaper and more accurate.
3334
*/
34-
function markCandidateReferenceRegions(value: string, insideReference: Uint8Array): void {
35+
function markReferenceRegions(value: string, insideReference: Uint8Array): void {
36+
const mark = (start: number, end: number) => {
37+
for (let index = Math.max(start, 0); index < Math.min(end, value.length); index += 1) {
38+
insideReference[index] = 1
39+
}
40+
}
41+
42+
for (const match of value.matchAll(ENV_REFERENCE_PATTERN)) {
43+
mark(match.index, match.index + match[0].length)
44+
}
45+
3546
let candidateStart = -1
3647
for (let index = 0; index < value.length; index += 1) {
3748
const character = value[index]
@@ -49,10 +60,7 @@ function markCandidateReferenceRegions(value: string, insideReference: Uint8Arra
4960
const split = splitReferenceSegment(candidate)
5061
if (split && isLikelyReferenceSegment(candidate)) {
5162
const start = candidateStart + split.leading.length
52-
const end = Math.min(start + split.reference.length, value.length)
53-
for (let position = start; position < end; position += 1) {
54-
insideReference[position] = 1
55-
}
63+
mark(start, start + split.reference.length)
5664
}
5765
candidateStart = -1
5866
}
@@ -66,34 +74,15 @@ function markCandidateReferenceRegions(value: string, insideReference: Uint8Arra
6674
* value into several bogus ones. Only commas outside every reference region are separators.
6775
*/
6876
export function splitOutsideReferences(value: string): string[] {
69-
const plainSplit = () =>
70-
value
77+
if (!value.includes(REFERENCE.START) && !value.includes(REFERENCE.ENV_VAR_START)) {
78+
return value
7179
.split(',')
7280
.map((part) => part.trim())
7381
.filter(Boolean)
74-
75-
if (!value.includes(REFERENCE.START) && !value.includes(REFERENCE.ENV_VAR_START)) {
76-
return plainSplit()
7782
}
7883

79-
// Marked once up front rather than scanned per comma: a per-comma `tokens.some()` is
80-
// O(commas x tokens), which reached ~2.5s on a 240KB value of repeated `{{A}},`.
81-
const tokens = findWorkflowReferenceTokens(value)
8284
const insideReference = new Uint8Array(value.length)
83-
let hasEnvironmentToken = false
84-
for (const token of tokens) {
85-
if (token.kind === 'environment') hasEnvironmentToken = true
86-
const end = Math.min(token.end, value.length)
87-
for (let index = Math.max(token.start, 0); index < end; index += 1) {
88-
insideReference[index] = 1
89-
}
90-
}
91-
92-
// Overlap with an environment token is the only reason the tokenizer drops a workflow
93-
// candidate, so without one it already reported every region and re-scanning is duplicate work.
94-
if (hasEnvironmentToken) {
95-
markCandidateReferenceRegions(value, insideReference)
96-
}
85+
markReferenceRegions(value, insideReference)
9786

9887
const parts: string[] = []
9988
let partStart = 0

packages/utils/src/workflow-references.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ const REFERENCE_END = '>'
33
const REFERENCE_PATH_DELIMITER = '.'
44
const INVALID_REFERENCE_CHARS = /[+*/=<>!&|]/
55
const LEADING_REFERENCE_PATTERN = /^[<>=!\s]*$/
6-
const ENV_REFERENCE_PATTERN = /\{\{[^{}\r\n]+\}\}/g
6+
/**
7+
* `{{ENV_VAR}}` placeholders. Exported so a consumer that needs the UNION of reference regions
8+
* can find them without going through the non-overlapping token pass.
9+
*/
10+
export const ENV_REFERENCE_PATTERN = /\{\{[^{}\r\n]+\}\}/g
711

812
export type WorkflowReferenceTokenKind = 'environment' | 'workflow'
913

0 commit comments

Comments
 (0)