onContextMenu(e, row)}>
-
+
{
if (e.button !== 0) return
onRowToggle(rowIndex, e.shiftKey)
@@ -3326,7 +3310,7 @@ const DataRow = React.memo(function DataRow({
>
@@ -3409,7 +3393,8 @@ const DataRow = React.memo(function DataRow({
{isHighlighted && (isMultiCell || isRowChecked) && (
)}
- {isAnchor &&
}
+ {isAnchor && (
+
+ )}
-
+
{titleByMode[config.mode]}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants.ts
new file mode 100644
index 00000000000..74f504d75a1
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants.ts
@@ -0,0 +1,2 @@
+export const WORKFLOW_SEARCH_HIGHLIGHT_CLASS =
+ 'rounded-sm bg-orange-400 shadow-[3px_0_0_#fb923c,-3px_0_0_#fb923c]'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx
index 97e22638656..4a93dee957f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx
@@ -5,11 +5,13 @@ import { Button, Input, Textarea, Tooltip } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
+import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
+import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
interface EvalMetric {
id: string
@@ -27,6 +29,7 @@ interface EvalInputProps {
isPreview?: boolean
previewValue?: EvalMetric[] | null
disabled?: boolean
+ activeSearchTarget?: ActiveSearchTarget | null
}
// Default values
@@ -43,6 +46,7 @@ export function EvalInput({
isPreview = false,
previewValue,
disabled = false,
+ activeSearchTarget,
}: EvalInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue
(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -67,6 +71,17 @@ export function EvalInput({
const defaultMetric = useMemo(() => createDefaultMetric(), [])
const metrics: EvalMetric[] = value || [defaultMetric]
+ const isNestedSearchHighlighted = (metricIndex: number, metricPath: Array) =>
+ activeSearchTarget?.subBlockId === subBlockId &&
+ activeSearchTarget.valuePath[0] === metricIndex &&
+ metricPath.every((segment, index) => activeSearchTarget.valuePath[index + 1] === segment)
+
+ const renderFieldLabel = (label: string, highlighted: boolean) => (
+
+ {highlighted ? {label} : label}
+
+ )
+
const addMetric = () => {
if (isPreview || disabled) return
@@ -176,7 +191,7 @@ export function EvalInput({
-
Name
+ {renderFieldLabel('Name', isNestedSearchHighlighted(index, ['name']))}
-
Description
+ {renderFieldLabel('Description', isNestedSearchHighlighted(index, ['description']))}
{(() => {
const fieldState = inputController.fieldHelpers.getFieldState(metric.id)
@@ -259,7 +274,7 @@ export function EvalInput({
- Min Value
+ {renderFieldLabel('Min Value', isNestedSearchHighlighted(index, ['range', 'min']))}
-
Max Value
+ {renderFieldLabel('Max Value', isNestedSearchHighlighted(index, ['range', 'max']))}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx
index ba5ff8461b1..76476408b28 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx
@@ -46,6 +46,8 @@ interface ShortInputProps {
wandControlRef?: React.MutableRefObject
/** Whether to hide the internal wand button (controlled by parent) */
hideInternalWand?: boolean
+ /** Whether workflow search is actively highlighting this input */
+ isSearchHighlighted?: boolean
}
/**
@@ -74,6 +76,7 @@ export const ShortInput = memo(function ShortInput({
useWebhookUrl = false,
wandControlRef,
hideInternalWand = false,
+ isSearchHighlighted = false,
}: ShortInputProps) {
const [localContent, setLocalContent] = useState('')
const [isFocused, setIsFocused] = useState(false)
@@ -332,16 +335,15 @@ export const ShortInput = memo(function ShortInput({
? webhookManagement.webhookUrl
: ctrlValue
- const displayValue =
- password && !isFocused ? '•'.repeat(actualValue?.length ?? 0) : actualValue
+ const shouldMask = password && !isFocused && !isSearchHighlighted
+ const displayValue = shouldMask ? '•'.repeat(actualValue?.length ?? 0) : actualValue
- const formattedText =
- password && !isFocused
- ? '•'.repeat(actualValue?.length ?? 0)
- : formatDisplayText(actualValue, {
- accessiblePrefixes,
- highlightAll: !accessiblePrefixes,
- })
+ const formattedText = shouldMask
+ ? '•'.repeat(actualValue?.length ?? 0)
+ : formatDisplayText(actualValue, {
+ accessiblePrefixes,
+ highlightAll: !accessiblePrefixes,
+ })
return (
<>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx
index 4e33f85ed8f..a759cc9d7fb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx
@@ -20,11 +20,13 @@ import {
} from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
+import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
+import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
interface Field {
id: string
@@ -49,6 +51,7 @@ interface FieldFormatProps {
valuePlaceholder?: string
descriptionPlaceholder?: string
config?: any
+ activeSearchTarget?: ActiveSearchTarget | null
}
/**
@@ -103,6 +106,7 @@ export function FieldFormat({
showDescription = false,
valuePlaceholder = 'Enter default value',
descriptionPlaceholder = 'Describe this field',
+ activeSearchTarget,
}: FieldFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const valueInputRefs = useRef>({})
@@ -127,6 +131,17 @@ export function FieldFormat({
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [createDefaultField()]
const isReadOnly = isPreview || disabled
+ const isNestedSearchHighlighted = (fieldIndex: number, fieldKey: keyof Field) =>
+ activeSearchTarget?.subBlockId === subBlockId &&
+ activeSearchTarget.valuePath[0] === fieldIndex &&
+ activeSearchTarget.valuePath.at(-1) === fieldKey
+
+ const renderFieldLabel = (label: string, highlighted: boolean) => (
+
+ {highlighted ? {label} : label}
+
+ )
+
/**
* Adds a new field to the list
*/
@@ -555,13 +570,13 @@ export function FieldFormat({
-
Name
+ {renderFieldLabel('Name', isNestedSearchHighlighted(index, 'name'))}
{renderNameInput(field)}
{showType && (
-
Type
+ {renderFieldLabel('Type', isNestedSearchHighlighted(index, 'type'))}
- Description
+ {renderFieldLabel(
+ 'Description',
+ isNestedSearchHighlighted(index, 'description')
+ )}
updateField(field.id, 'description', e.target.value)}
@@ -585,7 +603,7 @@ export function FieldFormat({
{showValue && (
-
Value
+ {renderFieldLabel('Value', isNestedSearchHighlighted(index, 'value'))}
{renderValueInput(field)}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
index a07564db3d2..00538358f10 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
@@ -77,6 +77,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import {
formatParameterLabel,
getSubBlocksForToolInput,
+ getToolIdForOperation,
getToolParametersConfig,
isPasswordParameter,
type SubBlocksForToolInput,
@@ -383,36 +384,6 @@ function getOperationOptions(blockType: string): { label: string; id: string }[]
})
}
-/**
- * Gets the correct tool ID for a given operation.
- *
- * @param blockType - The block type
- * @param operation - The selected operation (for multi-operation tools)
- * @returns The tool ID to use for execution, or `undefined` if not found
- */
-function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
- const block = getAllBlocks().find((b) => b.type === blockType)
- if (!block || !block.tools?.access) return undefined
-
- if (block.tools.access.length === 1) {
- return block.tools.access[0]
- }
-
- if (operation && block.tools?.config?.tool) {
- try {
- return block.tools.config.tool({ operation })
- } catch (error) {
- logger.error('Error selecting tool for operation:', error)
- }
- }
-
- if (operation && block.tools.access.includes(operation)) {
- return operation
- }
-
- return block.tools.access[0]
-}
-
/**
* Creates a styled icon element for tool items in the selection dropdown.
*
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts
index c46bb97c186..5ea7263e392 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts
@@ -1,36 +1 @@
-/**
- * Represents a tool selected and configured in the workflow
- *
- * @remarks
- * Valid types include:
- * - Standard block types (e.g., 'api', 'search', 'function')
- * - 'custom-tool': User-defined tools with custom code
- * - 'mcp': Individual MCP tool from a connected server
- *
- * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
- * Everything else (title, schema, code) is loaded dynamically from the database.
- * Legacy custom tools with inline schema/code are still supported for backwards compatibility.
- */
-export interface StoredTool {
- /** Block type identifier */
- type: string
- /** Display title for the tool (optional for new custom tool format) */
- title?: string
- /** Direct tool ID for execution (optional for new custom tool format) */
- toolId?: string
- /** Parameter values configured by the user */
- params?: Record
- /** Whether the tool details are expanded in UI */
- isExpanded?: boolean
- /** Database ID for custom tools (new format - reference only) */
- customToolId?: string
- /** Tool schema for custom tools (legacy format - inline JSON schema) */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- schema?: Record
- /** Implementation code for custom tools (legacy format - inline) */
- code?: string
- /** Selected operation for multi-operation tools */
- operation?: string
- /** Tool usage control mode for LLM */
- usageControl?: 'auto' | 'force' | 'none'
-}
+export type { StoredTool } from '@/lib/workflows/tool-input/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts
index 91976d7ac91..338e3b9a11f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts
@@ -6,6 +6,8 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'
import {
buildCanonicalIndex,
isNonEmptyValue,
+ normalizeDependencyValue,
+ parseDependsOn,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
@@ -14,35 +16,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
-type DependsOnConfig = string[] | { all?: string[]; any?: string[] }
-
-/**
- * Parses dependsOn config and returns normalized all/any arrays
- */
-function parseDependsOn(dependsOn: DependsOnConfig | undefined): {
- allFields: string[]
- anyFields: string[]
- allDependsOnFields: string[]
-} {
- if (!dependsOn) {
- return { allFields: [], anyFields: [], allDependsOnFields: [] }
- }
-
- if (Array.isArray(dependsOn)) {
- // Simple array format: all fields required (AND logic)
- return { allFields: dependsOn, anyFields: [], allDependsOnFields: dependsOn }
- }
-
- // Object format with all/any
- const allFields = dependsOn.all || []
- const anyFields = dependsOn.any || []
- return {
- allFields,
- anyFields,
- allDependsOnFields: [...allFields, ...anyFields],
- }
-}
-
/**
* Centralized dependsOn gating for sub-block components.
* - Computes dependency values from the active workflow/block
@@ -76,29 +49,6 @@ export function useDependsOnGate(
// For backward compatibility, expose flat list of all dependency fields
const dependsOn = allDependsOnFields
- const normalizeDependencyValue = (rawValue: unknown): unknown => {
- if (rawValue === null || rawValue === undefined) return null
-
- if (typeof rawValue === 'object') {
- if (Array.isArray(rawValue)) {
- if (rawValue.length === 0) return null
- return rawValue.map((item) => normalizeDependencyValue(item))
- }
-
- const record = rawValue as Record
- if ('value' in record) {
- return normalizeDependencyValue(record.value)
- }
- if ('id' in record) {
- return record.id
- }
-
- return record
- }
-
- return rawValue
- }
-
const dependencySelector = useCallback(
(state: ReturnType) => {
if (allDependsOnFields.length === 0) return {} as Record
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
index db50e5b3200..b69a2238c1f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
@@ -54,6 +54,8 @@ import { MODAL_REGISTRY } from '@/app/workspace/[workspaceId]/w/[workflowId]/com
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import type { SubBlockConfig } from '@/blocks/types'
import { useWebhookManagement } from '@/hooks/use-webhook-management'
+import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
+import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '../constants'
const SLACK_OVERRIDES: SelectorOverrides = {
transformContext: (context, deps) => {
@@ -72,7 +74,17 @@ const FOLDER_OVERRIDES: SelectorOverrides = {
},
}
-const WORKFLOW_SEARCH_CURRENT_MATCH_CLASS = 'rounded-md bg-orange-400 px-1 py-0.5'
+function hasNestedWorkflowSearchHighlight(
+ config: SubBlockConfig,
+ activeSearchTarget?: ActiveSearchTarget | null
+) {
+ if (!activeSearchTarget || activeSearchTarget.valuePath.length === 0) return false
+ return (
+ config.type === 'input-format' ||
+ config.type === 'response-format' ||
+ config.type === 'eval-input'
+ )
+}
/**
* Interface for wand control handlers exposed by sub-block inputs
@@ -106,6 +118,7 @@ interface SubBlockProps {
/** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */
dependencyContext?: Record
isSearchHighlighted?: boolean
+ activeSearchTarget?: ActiveSearchTarget | null
}
/**
@@ -253,7 +266,7 @@ const renderLabel = (
{isSearchHighlighted ? (
- {config.title}
+ {config.title}
) : (
config.title
)}
@@ -445,6 +458,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
canonicalToggleEqual &&
prevProps.labelSuffix === nextProps.labelSuffix &&
prevProps.isSearchHighlighted === nextProps.isSearchHighlighted &&
+ prevProps.activeSearchTarget === nextProps.activeSearchTarget &&
prevProps.dependencyContext === nextProps.dependencyContext
)
}
@@ -462,6 +476,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
* @param labelSuffix - Additional content rendered after the label text
* @param dependencyContext - Sibling values for dependency resolution in non-preview contexts (e.g. tool-input)
* @param isSearchHighlighted - Whether workflow search should highlight this field
+ * @param activeSearchTarget - Active workflow search target for nested field highlighting
*/
function SubBlockComponent({
blockId,
@@ -474,6 +489,7 @@ function SubBlockComponent({
labelSuffix,
dependencyContext,
isSearchHighlighted,
+ activeSearchTarget,
}: SubBlockProps): JSX.Element {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -655,6 +671,7 @@ function SubBlockComponent({
disabled={isDisabled}
wandControlRef={wandControlRef}
hideInternalWand={true}
+ isSearchHighlighted={isSearchHighlighted}
/>
)
@@ -886,6 +903,7 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
+ activeSearchTarget={activeSearchTarget}
/>
)
@@ -1014,6 +1032,7 @@ function SubBlockComponent({
disabled={isDisabled}
config={config}
showValue={true}
+ activeSearchTarget={activeSearchTarget}
/>
)
@@ -1049,6 +1068,7 @@ function SubBlockComponent({
previewValue={previewValue}
config={config}
disabled={isDisabled}
+ activeSearchTarget={activeSearchTarget}
/>
)
@@ -1175,6 +1195,9 @@ function SubBlockComponent({
}
}
+ const highlightParentLabel =
+ isSearchHighlighted && !hasNestedWorkflowSearchHighlight(config, activeSearchTarget)
+
return (
{isTypeHighlighted ? (
-
+
{currentBlock.type === 'loop' ? 'Loop Type' : 'Parallel Type'}
) : currentBlock.type === 'loop' ? (
@@ -128,7 +127,7 @@ export function SubflowEditor({
>
{isConfigHighlighted ? (
-
+
{isCountMode
? `${currentBlock.type === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: isConditionMode
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
index cb9b63761c0..d573578a4ec 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
@@ -24,6 +24,7 @@ import {
hasAdvancedValues,
isCanonicalPair,
resolveCanonicalMode,
+ shouldUseSubBlockForTriggerModeCanonicalIndex,
} from '@/lib/workflows/subblocks/visibility'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -47,7 +48,6 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
-import type { SubBlockType } from '@/blocks/types'
import { useFolderMap } from '@/hooks/queries/folders'
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
import { useWorkflowMap, useWorkflowState } from '@/hooks/queries/workflows'
@@ -154,12 +154,7 @@ export function Editor() {
const subBlocksForCanonical = useMemo(() => {
const subBlocks = blockConfig?.subBlocks || []
if (!triggerMode) return subBlocks
- return subBlocks.filter(
- (subBlock) =>
- subBlock.mode === 'trigger' ||
- subBlock.mode === 'trigger-advanced' ||
- subBlock.type === ('trigger-config' as SubBlockType)
- )
+ return subBlocks.filter(shouldUseSubBlockForTriggerModeCanonicalIndex)
}, [blockConfig?.subBlocks, triggerMode])
const canonicalIndex = useMemo(
@@ -625,6 +620,7 @@ export function Editor() {
activeSearchTarget.canonicalSubBlockId ===
(subBlock.canonicalParamId ?? subBlock.id))
}
+ activeSearchTarget={activeSearchTarget}
canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId
? {
@@ -699,6 +695,7 @@ export function Editor() {
activeSearchTarget.canonicalSubBlockId ===
(subBlock.canonicalParamId ?? subBlock.id))
}
+ activeSearchTarget={activeSearchTarget}
/>
{index < advancedOnlySubBlocks.length - 1 && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
index ac2554bd577..9c4a0a09213 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
@@ -5,8 +5,10 @@ import {
isSubBlockFeatureEnabled,
isSubBlockHidden,
isSubBlockVisibleForMode,
+ isSubBlockVisibleForTriggerMode,
+ shouldUseSubBlockForTriggerModeCanonicalIndex,
} from '@/lib/workflows/subblocks/visibility'
-import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
+import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useReactiveConditions } from '@/hooks/use-reactive-conditions'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
@@ -100,12 +102,7 @@ export function useEditorSubblockLayout(
)
const subBlocksForCanonical = displayTriggerMode
- ? (config.subBlocks || []).filter(
- (subBlock) =>
- subBlock.mode === 'trigger' ||
- subBlock.mode === 'trigger-advanced' ||
- subBlock.type === ('trigger-config' as SubBlockType)
- )
+ ? (config.subBlocks || []).filter(shouldUseSubBlockForTriggerModeCanonicalIndex)
: config.subBlocks || []
const canonicalIndex = buildCanonicalIndex(subBlocksForCanonical)
const effectiveAdvanced = displayAdvancedMode
@@ -132,19 +129,7 @@ export function useEditorSubblockLayout(
// Hide tool API key fields when hosted or when env var is set
if (isSubBlockHidden(block)) return false
- // Special handling for trigger-config type (legacy trigger configuration UI)
- if (block.type === ('trigger-config' as SubBlockType)) {
- const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
- return displayTriggerMode || isPureTriggerBlock
- }
-
- // Filter by mode if specified
- if (block.mode === 'trigger' || block.mode === 'trigger-advanced') {
- if (!displayTriggerMode) return false
- }
-
- // When in trigger mode, hide blocks that don't have mode: 'trigger' or 'trigger-advanced'
- if (displayTriggerMode && block.mode !== 'trigger' && block.mode !== 'trigger-advanced') {
+ if (!isSubBlockVisibleForTriggerMode(block, displayTriggerMode, config)) {
return false
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts
index fb4880e94d6..96b3375bfee 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts
@@ -6,9 +6,13 @@ import type {
import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment'
import {
flattenWorkflowSearchReplacementOptions,
+ useWorkflowSearchFileReplacementOptions,
useWorkflowSearchKnowledgeReplacementOptions,
+ useWorkflowSearchMcpServerReplacementOptions,
+ useWorkflowSearchMcpToolReplacementOptions,
useWorkflowSearchOAuthReplacementOptions,
useWorkflowSearchSelectorReplacementOptions,
+ useWorkflowSearchTableReplacementOptions,
} from '@/hooks/queries/workflow-search-replace'
interface UseWorkflowResourceReplacementOptionsParams {
@@ -23,8 +27,12 @@ export function useWorkflowResourceReplacementOptions({
workflowId,
}: UseWorkflowResourceReplacementOptionsParams): WorkflowSearchReplacementOption[] {
const oauthOptions = useWorkflowSearchOAuthReplacementOptions(matches, workspaceId, workflowId)
- const knowledgeOptions = useWorkflowSearchKnowledgeReplacementOptions(workspaceId)
+ const knowledgeOptions = useWorkflowSearchKnowledgeReplacementOptions(matches, workspaceId)
const selectorOptions = useWorkflowSearchSelectorReplacementOptions(matches)
+ const tableOptions = useWorkflowSearchTableReplacementOptions(matches, workspaceId)
+ const fileOptions = useWorkflowSearchFileReplacementOptions(matches, workspaceId)
+ const mcpServerOptions = useWorkflowSearchMcpServerReplacementOptions(matches, workspaceId)
+ const mcpToolOptions = useWorkflowSearchMcpToolReplacementOptions(matches, workspaceId)
const { data: personalEnvironment } = usePersonalEnvironment()
const { data: workspaceEnvironment } = useWorkspaceEnvironment(workspaceId ?? '')
@@ -46,6 +54,20 @@ export function useWorkflowResourceReplacementOptions({
...flattenWorkflowSearchReplacementOptions(oauthOptions),
...flattenWorkflowSearchReplacementOptions(knowledgeOptions),
...flattenWorkflowSearchReplacementOptions(selectorOptions),
+ ...flattenWorkflowSearchReplacementOptions(tableOptions),
+ ...flattenWorkflowSearchReplacementOptions(fileOptions),
+ ...flattenWorkflowSearchReplacementOptions(mcpServerOptions),
+ ...flattenWorkflowSearchReplacementOptions(mcpToolOptions),
]
- }, [knowledgeOptions, oauthOptions, personalEnvironment, selectorOptions, workspaceEnvironment])
+ }, [
+ fileOptions,
+ knowledgeOptions,
+ mcpServerOptions,
+ mcpToolOptions,
+ oauthOptions,
+ personalEnvironment,
+ selectorOptions,
+ tableOptions,
+ workspaceEnvironment,
+ ])
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration.ts
index 00e23ba3f38..96ef0a1d985 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration.ts
@@ -1,11 +1,16 @@
import { useMemo } from 'react'
-import { getWorkflowSearchMatchResourceGroupKey } from '@/lib/workflows/search-replace/resource-resolvers'
+import { getWorkflowSearchMatchResourceGroupKey } from '@/lib/workflows/search-replace/resources'
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment'
import {
+ useWorkflowSearchFileDetails,
useWorkflowSearchKnowledgeBaseDetails,
+ useWorkflowSearchMcpServerDetails,
+ useWorkflowSearchMcpToolDetails,
useWorkflowSearchOAuthCredentialDetails,
useWorkflowSearchSelectorDetails,
+ useWorkflowSearchTableDetails,
+ type WorkflowSearchResolvedResource,
} from '@/hooks/queries/workflow-search-replace'
export interface HydratedWorkflowSearchMatch extends WorkflowSearchMatch {
@@ -28,6 +33,10 @@ export function useWorkflowSearchReferenceHydration({
const oauthDetails = useWorkflowSearchOAuthCredentialDetails(matches, workflowId)
const knowledgeDetails = useWorkflowSearchKnowledgeBaseDetails(matches)
const selectorDetails = useWorkflowSearchSelectorDetails(matches)
+ const tableDetails = useWorkflowSearchTableDetails(matches, workspaceId)
+ const fileDetails = useWorkflowSearchFileDetails(matches, workspaceId)
+ const mcpServerDetails = useWorkflowSearchMcpServerDetails(matches, workspaceId)
+ const mcpToolDetails = useWorkflowSearchMcpToolDetails(matches, workspaceId)
const { data: personalEnvironment } = usePersonalEnvironment()
const { data: workspaceEnvironment } = useWorkspaceEnvironment(workspaceId ?? '')
@@ -41,7 +50,7 @@ export function useWorkflowSearchReferenceHydration({
{ label: string; resolved: boolean; inaccessible: boolean }
>()
- const setResolvedLabel = (query: (typeof oauthDetails)[number]) => {
+ const setResolvedLabel = (query: { data?: WorkflowSearchResolvedResource }) => {
if (!query.data) return
const value = {
label: query.data.label,
@@ -60,6 +69,10 @@ export function useWorkflowSearchReferenceHydration({
oauthDetails.forEach(setResolvedLabel)
knowledgeDetails.forEach(setResolvedLabel)
selectorDetails.forEach(setResolvedLabel)
+ tableDetails.forEach(setResolvedLabel)
+ fileDetails.forEach(setResolvedLabel)
+ mcpServerDetails.forEach(setResolvedLabel)
+ mcpToolDetails.forEach(setResolvedLabel)
const personalKeys = new Set(Object.keys(personalEnvironment ?? {}))
const workspaceKeys = new Set(Object.keys(workspaceEnvironment?.workspace ?? {}))
@@ -96,11 +109,15 @@ export function useWorkflowSearchReferenceHydration({
}
})
}, [
+ fileDetails,
knowledgeDetails,
matches,
+ mcpServerDetails,
+ mcpToolDetails,
oauthDetails,
personalEnvironment,
selectorDetails,
+ tableDetails,
workspaceEnvironment,
])
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx
index de1869f1d20..4841e05fce9 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx
@@ -32,30 +32,28 @@ export function ReplacementControls({
}: ReplacementControlsProps) {
return (
-
- {usesResourceReplacement ? (
- ({
- label: option.label,
- value: option.value,
- }))}
- value={replacement}
- onChange={onReplacementChange}
- placeholder='Choose replacement...'
- searchable
- searchPlaceholder='Search resources...'
- emptyMessage='No valid replacements available'
- disabled={disabled || compatibleResourceOptions.length === 0}
- />
- ) : (
- onReplacementChange(event.target.value)}
- />
- )}
-
+ {usesResourceReplacement ? (
+
({
+ label: option.label,
+ value: option.value,
+ }))}
+ value={replacement}
+ onChange={onReplacementChange}
+ placeholder='Choose replacement...'
+ searchable
+ searchPlaceholder='Search resources...'
+ emptyMessage='No valid replacements available'
+ disabled={disabled || compatibleResourceOptions.length === 0}
+ />
+ ) : (
+ onReplacementChange(event.target.value)}
+ />
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx
index 95cbd4c91de..143596c8ddc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx
@@ -7,17 +7,15 @@ import { Button, Input } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
-import {
- getCompatibleResourceReplacementOptions,
- getWorkflowSearchReplacementIssue,
- isConstrainedResourceMatch,
-} from '@/lib/workflows/search-replace/replacement-validation'
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
import {
+ getCompatibleResourceReplacementOptions,
getWorkflowSearchCompatibleResourceMatches,
getWorkflowSearchMatchResourceGroupKey,
+ getWorkflowSearchReplacementIssue,
+ isConstrainedResourceMatch,
workflowSearchMatchMatchesQuery,
-} from '@/lib/workflows/search-replace/resource-resolvers'
+} from '@/lib/workflows/search-replace/resources'
import { getWorkflowSearchBlocks } from '@/lib/workflows/search-replace/state'
import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields'
import type { WorkflowSearchReplaceSubflowUpdate } from '@/lib/workflows/search-replace/types'
@@ -33,6 +31,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { getBlock } from '@/blocks'
+import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useFolderMap } from '@/hooks/queries/folders'
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
import { useWorkflowMap } from '@/hooks/queries/workflows'
@@ -127,6 +126,7 @@ export function WorkflowSearchReplace() {
setReplacement,
setActiveMatchId,
} = useWorkflowSearchReplaceStore()
+ const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId, enabled: isOpen })
useRegisterGlobalCommands([
createCommand({
@@ -151,6 +151,14 @@ export function WorkflowSearchReplace() {
[currentWorkflow.blocks, currentWorkflow.isSnapshotView, workflowSubblockValues]
)
+ const credentialTypeById = useMemo(
+ () =>
+ Object.fromEntries(
+ (workspaceCredentials ?? []).map((credential) => [credential.id, credential.type])
+ ),
+ [workspaceCredentials]
+ )
+
const matches = useMemo(
() =>
indexWorkflowSearchMatches({
@@ -163,9 +171,11 @@ export function WorkflowSearchReplace() {
readonlyReason,
workspaceId,
workflowId,
+ credentialTypeById,
}),
[
currentWorkflow.isSnapshotView,
+ credentialTypeById,
query,
readonlyReason,
searchBlocks,
@@ -481,7 +491,7 @@ export function WorkflowSearchReplace() {
onMouseDown={handleMouseDown}
>
-
+
Search and replace
@@ -490,8 +500,8 @@ export function WorkflowSearchReplace() {
onMouseDown={(event) => event.stopPropagation()}
>
{matchCountLabel}
-
-
+
+
@@ -504,7 +514,10 @@ export function WorkflowSearchReplace() {
onClick={() => setIsReplaceExpanded((expanded) => !expanded)}
>
handleMoveActiveMatch(-1)}
>
-
+
handleMoveActiveMatch(1)}
>
-
+
{isReplaceExpanded && (
diff --git a/apps/sim/blocks/blocks/workflow.ts b/apps/sim/blocks/blocks/workflow.ts
index 37d0826aa85..4cc67230a3a 100644
--- a/apps/sim/blocks/blocks/workflow.ts
+++ b/apps/sim/blocks/blocks/workflow.ts
@@ -14,6 +14,7 @@ export const WorkflowBlock: BlockConfig = {
id: 'workflowId',
title: 'Select Workflow',
type: 'workflow-selector',
+ selectorKey: 'sim.workflows',
placeholder: 'Search workflows...',
required: true,
},
diff --git a/apps/sim/blocks/blocks/workflow_input.ts b/apps/sim/blocks/blocks/workflow_input.ts
index febed399c82..62db4197f3d 100644
--- a/apps/sim/blocks/blocks/workflow_input.ts
+++ b/apps/sim/blocks/blocks/workflow_input.ts
@@ -19,6 +19,7 @@ export const WorkflowInputBlock: BlockConfig = {
id: 'workflowId',
title: 'Select Workflow',
type: 'workflow-selector',
+ selectorKey: 'sim.workflows',
placeholder: 'Search workflows...',
required: true,
},
diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts
index ee852e19e2c..8f255269661 100644
--- a/apps/sim/executor/variables/resolver.test.ts
+++ b/apps/sim/executor/variables/resolver.test.ts
@@ -99,6 +99,163 @@ describe('VariableResolver function block inputs', () => {
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
+ it('breaks JavaScript string literals around quoted block references', () => {
+ const { block, ctx, resolver } = createResolver('javascript')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: "const rawEmail = '';\nreturn rawEmail" },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ "const rawEmail = '' + JSON.stringify(globalThis[\"__blockRef_0\"]) + '';\nreturn rawEmail"
+ )
+ expect(result.displayInputs.code).toBe('const rawEmail = \'"hello world"\';\nreturn rawEmail')
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('uses template interpolation for JavaScript template literal block references', () => {
+ const { block, ctx, resolver } = createResolver('javascript')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: 'return `value: `' },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ 'return `value: ${JSON.stringify(globalThis["__blockRef_0"])}`'
+ )
+ expect(result.displayInputs.code).toBe('return `value: "hello world"`')
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('keeps JavaScript block references inside template expressions executable', () => {
+ const { block, ctx, resolver } = createResolver('javascript')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: 'return `${String()}`' },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe('return `${String(globalThis["__blockRef_0"])}`')
+ expect(result.displayInputs.code).toBe('return `${String("hello world")}`')
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('ignores JavaScript comment quotes before later block references', () => {
+ const { block, ctx, resolver } = createResolver('javascript')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: "// don't confuse quote tracking\nreturn " },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ '// don\'t confuse quote tracking\nreturn globalThis["__blockRef_0"]'
+ )
+ expect(result.displayInputs.code).toBe('// don\'t confuse quote tracking\nreturn "hello world"')
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('breaks Python string literals around quoted block references', () => {
+ const { block, ctx, resolver } = createResolver('python')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: "raw_email = ''\nreturn raw_email" },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ "raw_email = '' + json.dumps(globals()[\"__blockRef_0\"]) + ''\nreturn raw_email"
+ )
+ expect(result.displayInputs.code).toBe('raw_email = \'"hello world"\'\nreturn raw_email')
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('breaks Python triple-double-quoted strings around block references', () => {
+ const { block, ctx, resolver } = createResolver('python')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: 'prompt = """\nSummary: \n"""\nreturn prompt' },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ 'prompt = """\nSummary: """ + json.dumps(globals()["__blockRef_0"]) + """\n"""\nreturn prompt'
+ )
+ expect(result.displayInputs.code).toBe(
+ 'prompt = """\nSummary: "hello world"\n"""\nreturn prompt'
+ )
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('ignores escaped triple-double quotes before later Python block references', () => {
+ const { block, ctx, resolver } = createResolver('python')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: 'prompt = """Escaped delimiter: \\"\\"\\"\nSummary: \n"""' },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ 'prompt = """Escaped delimiter: \\"\\"\\"\nSummary: """ + json.dumps(globals()["__blockRef_0"]) + """\n"""'
+ )
+ expect(result.displayInputs.code).toBe(
+ 'prompt = """Escaped delimiter: \\"\\"\\"\nSummary: "hello world"\n"""'
+ )
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('breaks Python triple-single-quoted strings around block references', () => {
+ const { block, ctx, resolver } = createResolver('python')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: "prompt = '''\nSummary: \n'''\nreturn prompt" },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ "prompt = '''\nSummary: ''' + json.dumps(globals()[\"__blockRef_0\"]) + '''\n'''\nreturn prompt"
+ )
+ expect(result.displayInputs.code).toBe(
+ "prompt = '''\nSummary: \"hello world\"\n'''\nreturn prompt"
+ )
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('ignores Python comment quotes before later block references', () => {
+ const { block, ctx, resolver } = createResolver('python')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: "# don't confuse quote tracking\nreturn " },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ '# don\'t confuse quote tracking\nreturn globals()["__blockRef_0"]'
+ )
+ expect(result.displayInputs.code).toBe('# don\'t confuse quote tracking\nreturn "hello world"')
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
it('uses separate Python context variables for repeated mutable references', () => {
const { block, ctx, resolver } = createResolver('python')
diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts
index 6041234a1bb..c0ab54d23d9 100644
--- a/apps/sim/executor/variables/resolver.ts
+++ b/apps/sim/executor/variables/resolver.ts
@@ -24,6 +24,17 @@ export const FUNCTION_BLOCK_DISPLAY_CODE_KEY = '_runtimeDisplayCode'
const logger = createLogger('VariableResolver')
type ShellQuoteContext = 'single' | 'double' | null
+type CodeStringQuoteContext = ShellQuoteContext | 'triple-single' | 'triple-double' | 'template'
+type CodeScanMode =
+ | { type: 'normal' }
+ | { type: 'single' }
+ | { type: 'double' }
+ | { type: 'triple-single' }
+ | { type: 'triple-double' }
+ | { type: 'template' }
+ | { type: 'template-expression'; depth: number }
+ | { type: 'line-comment' }
+ | { type: 'block-comment' }
export class VariableResolver {
private resolvers: Resolver[]
@@ -351,14 +362,49 @@ export class VariableResolver {
value: unknown
): string {
if (language === 'python') {
- return `globals()[${JSON.stringify(varName)}]`
+ const expression = `globals()[${JSON.stringify(varName)}]`
+ const quoteContext = this.getCodeStringQuoteContext(template, matchIndex, language)
+ if (this.isPythonStringQuoteContext(quoteContext)) {
+ const quote = this.getCodeStringQuoteToken(quoteContext)
+ return `${quote} + json.dumps(${expression}) + ${quote}`
+ }
+ return expression
}
if (language === 'shell') {
return this.formatShellContextVariableReference(varName, template, matchIndex, value)
}
- return `globalThis[${JSON.stringify(varName)}]`
+ const expression = `globalThis[${JSON.stringify(varName)}]`
+ const quoteContext = this.getCodeStringQuoteContext(template, matchIndex, language)
+ if (quoteContext === 'template') {
+ return `\${JSON.stringify(${expression})}`
+ }
+ if (quoteContext === 'single' || quoteContext === 'double') {
+ const quote = this.getCodeStringQuoteToken(quoteContext)
+ return `${quote} + JSON.stringify(${expression}) + ${quote}`
+ }
+ return expression
+ }
+
+ private isPythonStringQuoteContext(
+ quoteContext: CodeStringQuoteContext
+ ): quoteContext is 'single' | 'double' | 'triple-single' | 'triple-double' {
+ return (
+ quoteContext === 'single' ||
+ quoteContext === 'double' ||
+ quoteContext === 'triple-single' ||
+ quoteContext === 'triple-double'
+ )
+ }
+
+ private getCodeStringQuoteToken(
+ quoteContext: 'single' | 'double' | 'triple-single' | 'triple-double'
+ ): string {
+ if (quoteContext === 'single') return "'"
+ if (quoteContext === 'double') return '"'
+ if (quoteContext === 'triple-single') return "'''"
+ return '"""'
}
private formatDisplayValueForCodeContext(
@@ -397,6 +443,163 @@ export class VariableResolver {
return JSON.stringify(value)
}
+ private getCodeStringQuoteContext(
+ template: string,
+ index: number,
+ language: string | undefined
+ ): CodeStringQuoteContext {
+ const isPython = language === 'python'
+ const modes: CodeScanMode[] = [{ type: 'normal' }]
+
+ for (let i = 0; i < index; i++) {
+ const char = template[i]
+ const next = template[i + 1]
+ const mode = modes[modes.length - 1]
+
+ if (mode.type === 'line-comment') {
+ if (char === '\n') {
+ modes.pop()
+ }
+ continue
+ }
+
+ if (mode.type === 'block-comment') {
+ if (char === '*' && next === '/') {
+ modes.pop()
+ i++
+ }
+ continue
+ }
+
+ if (mode.type === 'single' || mode.type === 'double') {
+ const quote = mode.type === 'single' ? "'" : '"'
+ if (char === '\\') {
+ i++
+ continue
+ }
+ if (char === quote || char === '\n') {
+ modes.pop()
+ }
+ continue
+ }
+
+ if (mode.type === 'triple-single' || mode.type === 'triple-double') {
+ const quote = mode.type === 'triple-single' ? "'" : '"'
+ if (char === '\\') {
+ i++
+ continue
+ }
+ if (char === quote && next === quote && template[i + 2] === quote) {
+ modes.pop()
+ i += 2
+ }
+ continue
+ }
+
+ if (mode.type === 'template') {
+ if (char === '\\') {
+ i++
+ continue
+ }
+ if (char === '`') {
+ modes.pop()
+ continue
+ }
+ if (char === '$' && next === '{') {
+ modes.push({ type: 'template-expression', depth: 1 })
+ i++
+ }
+ continue
+ }
+
+ if (mode.type === 'template-expression') {
+ if (!isPython && char === '/' && next === '/') {
+ modes.push({ type: 'line-comment' })
+ i++
+ continue
+ }
+ if (!isPython && char === '/' && next === '*') {
+ modes.push({ type: 'block-comment' })
+ i++
+ continue
+ }
+ if (isPython && char === "'" && next === "'" && template[i + 2] === "'") {
+ modes.push({ type: 'triple-single' })
+ i += 2
+ continue
+ }
+ if (isPython && char === '"' && next === '"' && template[i + 2] === '"') {
+ modes.push({ type: 'triple-double' })
+ i += 2
+ continue
+ }
+ if (char === "'") {
+ modes.push({ type: 'single' })
+ continue
+ }
+ if (char === '"') {
+ modes.push({ type: 'double' })
+ continue
+ }
+ if (!isPython && char === '`') {
+ modes.push({ type: 'template' })
+ continue
+ }
+ if (char === '{') {
+ mode.depth += 1
+ continue
+ }
+ if (char === '}') {
+ mode.depth -= 1
+ if (mode.depth === 0) {
+ modes.pop()
+ }
+ }
+ continue
+ }
+
+ if (isPython && char === '#') {
+ modes.push({ type: 'line-comment' })
+ continue
+ }
+ if (!isPython && char === '/' && next === '/') {
+ modes.push({ type: 'line-comment' })
+ i++
+ continue
+ }
+ if (!isPython && char === '/' && next === '*') {
+ modes.push({ type: 'block-comment' })
+ i++
+ continue
+ }
+ if (isPython && char === "'" && next === "'" && template[i + 2] === "'") {
+ modes.push({ type: 'triple-single' })
+ i += 2
+ } else if (isPython && char === '"' && next === '"' && template[i + 2] === '"') {
+ modes.push({ type: 'triple-double' })
+ i += 2
+ } else if (char === "'") {
+ modes.push({ type: 'single' })
+ } else if (char === '"') {
+ modes.push({ type: 'double' })
+ } else if (!isPython && char === '`') {
+ modes.push({ type: 'template' })
+ }
+ }
+
+ const mode = modes[modes.length - 1]
+ if (
+ mode.type === 'single' ||
+ mode.type === 'double' ||
+ mode.type === 'triple-single' ||
+ mode.type === 'triple-double' ||
+ mode.type === 'template'
+ ) {
+ return mode.type
+ }
+ return null
+ }
+
private formatShellContextVariableReference(
varName: string,
template: string,
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index 770e0f82ee9..1956e1688f2 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -466,6 +466,10 @@ export function useCreateTable(workspaceId: string) {
body: { ...params, workspaceId },
})
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
},
@@ -485,6 +489,10 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext)
body: { workspaceId, column },
})
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
},
@@ -528,6 +536,10 @@ export function useDeleteTable(workspaceId: string) {
query: { workspaceId },
})
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: (_data, _error, tableId) => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
queryClient.removeQueries({ queryKey: tableKeys.detail(tableId) })
@@ -814,6 +826,10 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext)
body: { workspaceId },
})
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: () => {
invalidateRowCount(queryClient, tableId)
},
@@ -851,6 +867,10 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext)
return { deletedRowIds }
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: () => {
invalidateRowCount(queryClient, tableId)
},
@@ -1028,6 +1048,10 @@ export function useRestoreTable() {
params: { tableId },
})
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
},
@@ -1064,12 +1088,12 @@ export function useUploadCsvToTable() {
return response.json()
},
- onSettled: () => {
- queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
- },
onError: (error) => {
logger.error('Failed to upload CSV:', error)
},
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
+ },
})
}
@@ -1140,13 +1164,13 @@ export function useImportCsvIntoTable() {
return response.json()
},
+ onError: (error) => {
+ logger.error('Failed to import CSV into table:', error)
+ },
onSettled: (_data, _error, variables) => {
if (!variables) return
invalidateRowCount(queryClient, variables.tableId)
},
- onError: (error) => {
- logger.error('Failed to import CSV into table:', error)
- },
})
}
@@ -1438,6 +1462,10 @@ export function useAddWorkflowGroup({ workspaceId, tableId }: RowMutationContext
body: { workspaceId, group, outputColumns },
})
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
},
@@ -1464,6 +1492,10 @@ export function useUpdateWorkflowGroup({ workspaceId, tableId }: RowMutationCont
body: { workspaceId, ...vars },
})
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
@@ -1484,6 +1516,10 @@ export function useDeleteWorkflowGroup({ workspaceId, tableId }: RowMutationCont
body: { workspaceId, groupId },
})
},
+ onError: (error) => {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: 5000 })
+ },
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
diff --git a/apps/sim/hooks/queries/workflow-search-replace.test.ts b/apps/sim/hooks/queries/workflow-search-replace.test.ts
index 2ab1993ad61..3ff95f8616a 100644
--- a/apps/sim/hooks/queries/workflow-search-replace.test.ts
+++ b/apps/sim/hooks/queries/workflow-search-replace.test.ts
@@ -2,11 +2,83 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
+import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
import {
+ buildWorkflowSearchMcpToolReplacementOptions,
flattenWorkflowSearchReplacementOptions,
workflowSearchReplaceKeys,
} from '@/hooks/queries/workflow-search-replace'
+function createMcpToolMatch(serverId?: string): WorkflowSearchMatch {
+ return {
+ id: serverId ? `match-${serverId}` : 'match-all',
+ blockId: 'mcp-1',
+ blockName: 'MCP',
+ blockType: 'mcp',
+ subBlockId: 'tool',
+ canonicalSubBlockId: 'tool',
+ subBlockType: 'mcp-tool-selector',
+ valuePath: [],
+ target: { kind: 'subblock' },
+ kind: 'mcp-tool',
+ rawValue: serverId ? `${serverId}-search` : 'search',
+ searchText: 'Search',
+ editable: true,
+ navigable: true,
+ protected: false,
+ resource: {
+ kind: 'mcp-tool',
+ key: serverId ? `${serverId}-search` : 'search',
+ selectorContext: serverId ? { mcpServerId: serverId } : undefined,
+ resourceGroupKey: serverId ? `mcp-tool:${serverId}` : 'mcp-tool:any',
+ },
+ }
+}
+
+describe('buildWorkflowSearchMcpToolReplacementOptions', () => {
+ const tools = [
+ {
+ id: 'a-search',
+ name: 'search',
+ serverId: 'server-a',
+ serverName: 'Server A',
+ inputSchema: {},
+ },
+ {
+ id: 'b-search',
+ name: 'search',
+ serverId: 'server-b',
+ serverName: 'Server B',
+ inputSchema: {},
+ },
+ ]
+
+ it('filters MCP tool replacement options to the matched server context', () => {
+ const options = buildWorkflowSearchMcpToolReplacementOptions(
+ [createMcpToolMatch('server-a')],
+ tools
+ )
+
+ expect(options).toEqual([
+ {
+ kind: 'mcp-tool',
+ value: 'mcp-server-a-search',
+ label: 'Server A: search',
+ resourceGroupKey: 'mcp-tool:server-a',
+ },
+ ])
+ })
+
+ it('keeps all MCP tool replacement options when no server context exists', () => {
+ const options = buildWorkflowSearchMcpToolReplacementOptions([createMcpToolMatch()], tools)
+
+ expect(options.map((option) => option.value)).toEqual([
+ 'mcp-server-a-search',
+ 'mcp-server-b-search',
+ ])
+ })
+})
+
describe('workflowSearchReplaceKeys', () => {
it('builds stable hierarchical keys for credential candidates', () => {
expect(
diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts
index 99404644cd0..389b1f53bd2 100644
--- a/apps/sim/hooks/queries/workflow-search-replace.ts
+++ b/apps/sim/hooks/queries/workflow-search-replace.ts
@@ -1,8 +1,29 @@
import { useMemo } from 'react'
-import { useQueries } from '@tanstack/react-query'
+import { useQueries, useQuery } from '@tanstack/react-query'
+import { requestJson } from '@/lib/api/client/request'
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge'
+import {
+ type DiscoverMcpToolsResponse,
+ discoverMcpToolsContract,
+ type ListMcpServersResponse,
+ listMcpServersContract,
+} from '@/lib/api/contracts/mcp'
+import {
+ type GetTableResponse,
+ getTableContract,
+ type ListTablesResponse,
+ listTablesContract,
+} from '@/lib/api/contracts/tables'
+import {
+ type ListWorkspaceFilesResponse,
+ listWorkspaceFilesContract,
+} from '@/lib/api/contracts/workspace-files'
+import { createMcpToolId } from '@/lib/mcp/shared'
import type { Credential } from '@/lib/oauth'
-import { stableStringifyWorkflowSearchValue } from '@/lib/workflows/search-replace/resource-resolvers'
+import {
+ getWorkflowSearchMatchResourceGroupKey,
+ stableStringifyWorkflowSearchValue,
+} from '@/lib/workflows/search-replace/resources'
import type {
WorkflowSearchMatch,
WorkflowSearchReplacementOption,
@@ -46,6 +67,26 @@ export const workflowSearchReplaceKeys = {
knowledgeDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'knowledge'] as const,
knowledgeDetail: (knowledgeBaseId?: string) =>
[...workflowSearchReplaceKeys.knowledgeDetails(), knowledgeBaseId ?? ''] as const,
+ tableDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'table'] as const,
+ tableDetail: (workspaceId?: string, tableId?: string) =>
+ [...workflowSearchReplaceKeys.tableDetails(), workspaceId ?? '', tableId ?? ''] as const,
+ tableReplacementOptions: (workspaceId?: string) =>
+ [...workflowSearchReplaceKeys.replacementOptions(), 'table', workspaceId ?? ''] as const,
+ fileDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'file'] as const,
+ fileListDetails: (workspaceId?: string) =>
+ [...workflowSearchReplaceKeys.fileDetails(), 'list', workspaceId ?? ''] as const,
+ fileReplacementOptions: (workspaceId?: string) =>
+ [...workflowSearchReplaceKeys.replacementOptions(), 'file', workspaceId ?? ''] as const,
+ mcpServerDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'mcp-server'] as const,
+ mcpServerListDetails: (workspaceId?: string) =>
+ [...workflowSearchReplaceKeys.mcpServerDetails(), 'list', workspaceId ?? ''] as const,
+ mcpServerReplacementOptions: (workspaceId?: string) =>
+ [...workflowSearchReplaceKeys.replacementOptions(), 'mcp-server', workspaceId ?? ''] as const,
+ mcpToolDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'mcp-tool'] as const,
+ mcpToolListDetails: (workspaceId?: string) =>
+ [...workflowSearchReplaceKeys.mcpToolDetails(), 'list', workspaceId ?? ''] as const,
+ mcpToolReplacementOptions: (workspaceId?: string) =>
+ [...workflowSearchReplaceKeys.replacementOptions(), 'mcp-tool', workspaceId ?? ''] as const,
knowledgeReplacementOptions: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.replacementOptions(), 'knowledge', workspaceId ?? ''] as const,
selectorDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'selector'] as const,
@@ -107,6 +148,22 @@ function uniqueSelectorOptionGroups(matches: WorkflowSearchMatch[]): WorkflowSea
})
}
+function uniqueResourceOptionGroups(
+ matches: WorkflowSearchMatch[],
+ kind: WorkflowSearchMatch['kind'],
+ predicate?: (match: WorkflowSearchMatch) => boolean
+): WorkflowSearchMatch[] {
+ const seen = new Set()
+ return matches.filter((match) => {
+ if (match.kind !== kind || predicate?.(match) === false) return false
+
+ const key = getWorkflowSearchMatchResourceGroupKey(match)
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
export function useWorkflowSearchOAuthCredentialDetails(
matches: WorkflowSearchMatch[],
workflowId?: string
@@ -154,6 +211,154 @@ export function useWorkflowSearchKnowledgeBaseDetails(matches: WorkflowSearchMat
})
}
+export function useWorkflowSearchTableDetails(
+ matches: WorkflowSearchMatch[],
+ workspaceId?: string
+) {
+ const tableMatches = useMemo(() => uniqueMatches(matches, 'table'), [matches])
+
+ return useQueries({
+ queries: tableMatches.map((match) => ({
+ queryKey: workflowSearchReplaceKeys.tableDetail(workspaceId, match.rawValue),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(getTableContract, {
+ params: { tableId: match.rawValue },
+ query: { workspaceId: workspaceId as string },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && match.rawValue),
+ staleTime: 60 * 1000,
+ select: (response: GetTableResponse): WorkflowSearchResolvedResource => ({
+ matchRawValue: match.rawValue,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ label: response.data.table.name,
+ resolved: true,
+ inaccessible: false,
+ }),
+ })),
+ })
+}
+
+export function useWorkflowSearchFileDetails(matches: WorkflowSearchMatch[], workspaceId?: string) {
+ const fileMatches = useMemo(
+ () =>
+ uniqueMatches(
+ matches.filter((match) => !match.resource?.selectorKey),
+ 'file'
+ ),
+ [matches]
+ )
+
+ const filesQuery = useQuery({
+ queryKey: workflowSearchReplaceKeys.fileListDetails(workspaceId),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(listWorkspaceFilesContract, {
+ params: { id: workspaceId as string },
+ query: { scope: 'active' },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && fileMatches.length > 0),
+ staleTime: 60 * 1000,
+ })
+
+ return useMemo(
+ () =>
+ fileMatches.map((match) => {
+ const file = filesQuery.data?.files.find((item) =>
+ [item.id, item.key, item.path, item.name].includes(match.rawValue)
+ )
+ return {
+ data: filesQuery.data
+ ? {
+ matchRawValue: match.rawValue,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ label: file?.name ?? match.rawValue,
+ resolved: Boolean(file),
+ inaccessible: false,
+ }
+ : undefined,
+ }
+ }),
+ [fileMatches, filesQuery.data]
+ )
+}
+
+export function useWorkflowSearchMcpServerDetails(
+ matches: WorkflowSearchMatch[],
+ workspaceId?: string
+) {
+ const serverMatches = useMemo(() => uniqueMatches(matches, 'mcp-server'), [matches])
+
+ const serversQuery = useQuery({
+ queryKey: workflowSearchReplaceKeys.mcpServerListDetails(workspaceId),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(listMcpServersContract, {
+ query: { workspaceId: workspaceId as string },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && serverMatches.length > 0),
+ staleTime: 60 * 1000,
+ })
+
+ return useMemo(
+ () =>
+ serverMatches.map((match) => {
+ const server = serversQuery.data?.data.servers.find((item) => item.id === match.rawValue)
+ return {
+ data: serversQuery.data
+ ? {
+ matchRawValue: match.rawValue,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ label: server?.name ?? match.rawValue,
+ resolved: Boolean(server),
+ inaccessible: false,
+ }
+ : undefined,
+ }
+ }),
+ [serverMatches, serversQuery.data]
+ )
+}
+
+export function useWorkflowSearchMcpToolDetails(
+ matches: WorkflowSearchMatch[],
+ workspaceId?: string
+) {
+ const toolMatches = useMemo(() => uniqueMatches(matches, 'mcp-tool'), [matches])
+
+ const toolsQuery = useQuery({
+ queryKey: workflowSearchReplaceKeys.mcpToolListDetails(workspaceId),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(discoverMcpToolsContract, {
+ query: { workspaceId: workspaceId as string },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && toolMatches.length > 0),
+ staleTime: 60 * 1000,
+ })
+
+ return useMemo(
+ () =>
+ toolMatches.map((match) => {
+ const tool = toolsQuery.data?.data.tools.find(
+ (item) => createMcpToolId(item.serverId, item.name) === match.rawValue
+ )
+ return {
+ data: toolsQuery.data
+ ? {
+ matchRawValue: match.rawValue,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ label: tool ? `${tool.serverName}: ${tool.name}` : match.rawValue,
+ resolved: Boolean(tool),
+ inaccessible: false,
+ }
+ : undefined,
+ }
+ }),
+ [toolMatches, toolsQuery.data]
+ )
+}
+
export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) {
const selectorMatches = useMemo(() => uniqueSelectorDetailMatches(matches), [matches])
@@ -228,22 +433,177 @@ export function useWorkflowSearchOAuthReplacementOptions(
})
}
-export function useWorkflowSearchKnowledgeReplacementOptions(workspaceId?: string) {
+export function useWorkflowSearchKnowledgeReplacementOptions(
+ matches: WorkflowSearchMatch[],
+ workspaceId?: string
+) {
+ const knowledgeGroups = useMemo(
+ () => uniqueResourceOptionGroups(matches, 'knowledge-base'),
+ [matches]
+ )
+
return useQueries({
queries: [
{
queryKey: workflowSearchReplaceKeys.knowledgeReplacementOptions(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
fetchKnowledgeBases(workspaceId, 'active', signal),
- enabled: Boolean(workspaceId),
+ enabled: Boolean(workspaceId && knowledgeGroups.length > 0),
staleTime: 60 * 1000,
placeholderData: (previous: KnowledgeBaseData[] | undefined) => previous,
select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] =>
- knowledgeBases.map((knowledgeBase) => ({
- kind: 'knowledge-base',
- value: knowledgeBase.id,
- label: knowledgeBase.name,
- })),
+ knowledgeGroups.flatMap((match) =>
+ knowledgeBases.map((knowledgeBase) => ({
+ kind: 'knowledge-base',
+ value: knowledgeBase.id,
+ label: knowledgeBase.name,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ }))
+ ),
+ },
+ ],
+ })
+}
+
+export function useWorkflowSearchTableReplacementOptions(
+ matches: WorkflowSearchMatch[],
+ workspaceId?: string
+) {
+ const tableGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'table'), [matches])
+
+ return useQueries({
+ queries: [
+ {
+ queryKey: workflowSearchReplaceKeys.tableReplacementOptions(workspaceId),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(listTablesContract, {
+ query: { workspaceId: workspaceId as string, scope: 'active' },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && tableGroups.length > 0),
+ staleTime: 60 * 1000,
+ select: (response: ListTablesResponse): WorkflowSearchReplacementOption[] =>
+ tableGroups.flatMap((match) =>
+ response.data.tables.map((table) => ({
+ kind: 'table',
+ value: table.id,
+ label: table.name,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ }))
+ ),
+ },
+ ],
+ })
+}
+
+export function useWorkflowSearchFileReplacementOptions(
+ matches: WorkflowSearchMatch[],
+ workspaceId?: string
+) {
+ const fileGroups = useMemo(
+ () => uniqueResourceOptionGroups(matches, 'file', (match) => !match.resource?.selectorKey),
+ [matches]
+ )
+
+ return useQueries({
+ queries: [
+ {
+ queryKey: workflowSearchReplaceKeys.fileReplacementOptions(workspaceId),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(listWorkspaceFilesContract, {
+ params: { id: workspaceId as string },
+ query: { scope: 'active' },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && fileGroups.length > 0),
+ staleTime: 60 * 1000,
+ select: (response: ListWorkspaceFilesResponse): WorkflowSearchReplacementOption[] =>
+ fileGroups.flatMap((match) =>
+ response.files.map((file) => ({
+ kind: 'file',
+ value: JSON.stringify({
+ name: file.name,
+ path: file.path,
+ key: file.key,
+ size: file.size,
+ type: file.type,
+ }),
+ label: file.name,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ }))
+ ),
+ },
+ ],
+ })
+}
+
+export function useWorkflowSearchMcpServerReplacementOptions(
+ matches: WorkflowSearchMatch[],
+ workspaceId?: string
+) {
+ const serverGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'mcp-server'), [matches])
+
+ return useQueries({
+ queries: [
+ {
+ queryKey: workflowSearchReplaceKeys.mcpServerReplacementOptions(workspaceId),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(listMcpServersContract, {
+ query: { workspaceId: workspaceId as string },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && serverGroups.length > 0),
+ staleTime: 60 * 1000,
+ select: (response: ListMcpServersResponse): WorkflowSearchReplacementOption[] =>
+ serverGroups.flatMap((match) =>
+ response.data.servers.map((server) => ({
+ kind: 'mcp-server',
+ value: server.id,
+ label: server.name,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ }))
+ ),
+ },
+ ],
+ })
+}
+
+export function buildWorkflowSearchMcpToolReplacementOptions(
+ toolGroups: WorkflowSearchMatch[],
+ tools: DiscoverMcpToolsResponse['data']['tools']
+): WorkflowSearchReplacementOption[] {
+ return toolGroups.flatMap((match) => {
+ const serverId = match.resource?.selectorContext?.mcpServerId
+ return tools
+ .filter((tool) => !serverId || tool.serverId === serverId)
+ .map((tool) => ({
+ kind: 'mcp-tool',
+ value: createMcpToolId(tool.serverId, tool.name),
+ label: `${tool.serverName}: ${tool.name}`,
+ resourceGroupKey: match.resource?.resourceGroupKey,
+ }))
+ })
+}
+
+export function useWorkflowSearchMcpToolReplacementOptions(
+ matches: WorkflowSearchMatch[],
+ workspaceId?: string
+) {
+ const toolGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'mcp-tool'), [matches])
+
+ return useQueries({
+ queries: [
+ {
+ queryKey: workflowSearchReplaceKeys.mcpToolReplacementOptions(workspaceId),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(discoverMcpToolsContract, {
+ query: { workspaceId: workspaceId as string },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && toolGroups.length > 0),
+ staleTime: 60 * 1000,
+ select: (response: DiscoverMcpToolsResponse): WorkflowSearchReplacementOption[] =>
+ buildWorkflowSearchMcpToolReplacementOptions(toolGroups, response.data.tools),
},
],
})
diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts
index be96287a791..45549ea394e 100644
--- a/apps/sim/hooks/selectors/types.ts
+++ b/apps/sim/hooks/selectors/types.ts
@@ -90,6 +90,7 @@ export interface SelectorContext {
awsSecretAccessKey?: string
awsRegion?: string
logGroupName?: string
+ mcpServerId?: string
}
export interface SelectorQueryArgs {
diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts
index fbf9061277e..10585e1f8a9 100644
--- a/apps/sim/hooks/use-collaborative-workflow.ts
+++ b/apps/sim/hooks/use-collaborative-workflow.ts
@@ -12,11 +12,16 @@ import {
} from '@sim/realtime-protocol/constants'
import { generateId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
+import { isEqual } from 'es-toolkit'
import type { Edge } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
+import {
+ type WorkflowSearchSubflowFieldId,
+ workflowSearchSubflowFieldMatchesExpected,
+} from '@/lib/workflows/search-replace/subflow-fields'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { getSubBlocksDependingOnChange } from '@/blocks/utils'
@@ -1542,7 +1547,7 @@ export function useCollaborativeWorkflow() {
subflowUpdates?: Array<{
blockId: string
blockType: 'loop' | 'parallel'
- fieldId: string
+ fieldId: WorkflowSearchSubflowFieldId
before: unknown
after: unknown
}>
@@ -1566,6 +1571,36 @@ export function useCollaborativeWorkflow() {
return false
}
+ const staleUpdate = updates.find((update) => {
+ if (!Object.hasOwn(update, 'expectedValue')) return false
+ const currentValue = useSubBlockStore.getState().getValue(update.blockId, update.subblockId)
+ return !isEqual(currentValue, update.expectedValue)
+ })
+ if (staleUpdate) {
+ logger.warn('Skipping batch subblock update because expected value changed', {
+ blockId: staleUpdate.blockId,
+ subblockId: staleUpdate.subblockId,
+ })
+ return false
+ }
+
+ const staleSubflowUpdate = undoSubflowUpdates.find((update) => {
+ const currentBlock = useWorkflowStore.getState().blocks[update.blockId]
+ if (!currentBlock || currentBlock.type !== update.blockType) return true
+ return !workflowSearchSubflowFieldMatchesExpected(
+ currentBlock,
+ update.fieldId,
+ update.before
+ )
+ })
+ if (staleSubflowUpdate) {
+ logger.warn('Skipping batch subflow update because expected value changed', {
+ blockId: staleSubflowUpdate.blockId,
+ fieldId: staleSubflowUpdate.fieldId,
+ })
+ return false
+ }
+
if (updates.length > 0) {
updates.forEach((update) => {
useSubBlockStore.getState().setValue(update.blockId, update.subblockId, update.value)
diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts
index 1e405d870df..3a8c8e9d051 100644
--- a/apps/sim/lib/api/contracts/mcp.ts
+++ b/apps/sim/lib/api/contracts/mcp.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { defineRouteContract } from '@/lib/api/contracts/types'
+import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types'
const dateStringSchema = z.preprocess(
@@ -271,6 +271,7 @@ export const listMcpServersContract = defineRouteContract({
),
},
})
+export type ListMcpServersResponse = ContractJsonResponse
export const createMcpServerContract = defineRouteContract({
method: 'POST',
@@ -343,6 +344,7 @@ export const discoverMcpToolsContract = defineRouteContract({
),
},
})
+export type DiscoverMcpToolsResponse = ContractJsonResponse
export const refreshMcpToolsContract = defineRouteContract({
method: 'POST',
diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts
index 5c7b467e8f6..6e4a99149b0 100644
--- a/apps/sim/lib/api/contracts/tables.ts
+++ b/apps/sim/lib/api/contracts/tables.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { defineRouteContract } from '@/lib/api/contracts/types'
+import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import type {
CsvHeaderMapping,
Filter,
@@ -330,6 +330,7 @@ export const listTablesContract = defineRouteContract({
),
},
})
+export type ListTablesResponse = ContractJsonResponse
export const createTableContract = defineRouteContract({
method: 'POST',
@@ -356,6 +357,7 @@ export const getTableContract = defineRouteContract({
schema: successResponseSchema(z.object({ table: tableDefinitionSchema })),
},
})
+export type GetTableResponse = ContractJsonResponse
export const renameTableContract = defineRouteContract({
method: 'PATCH',
diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts
index 6aae8b2d2df..b999b6ac2a5 100644
--- a/apps/sim/lib/api/contracts/workspace-files.ts
+++ b/apps/sim/lib/api/contracts/workspace-files.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { defineRouteContract } from '@/lib/api/contracts/types'
+import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
export const workspaceFileScopeSchema = z.enum(['active', 'archived', 'all'])
@@ -58,6 +58,7 @@ export const listWorkspaceFilesContract = defineRouteContract({
}),
},
})
+export type ListWorkspaceFilesResponse = ContractJsonResponse
export const renameWorkspaceFileContract = defineRouteContract({
method: 'PATCH',
diff --git a/apps/sim/lib/workflows/comparison/normalize.ts b/apps/sim/lib/workflows/comparison/normalize.ts
index effb4770a89..40dd2421356 100644
--- a/apps/sim/lib/workflows/comparison/normalize.ts
+++ b/apps/sim/lib/workflows/comparison/normalize.ts
@@ -5,6 +5,7 @@
import type { Edge } from 'reactflow'
import { isNonEmptyValue } from '@/lib/workflows/subblocks/visibility'
+import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks'
import type {
BlockState,
Loop,
@@ -410,13 +411,6 @@ export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlo
}
}
-/**
- * Pattern matching synthetic subBlock IDs created by ToolSubBlockRenderer.
- * These IDs follow the format `{subBlockId}-tool-{index}-{paramId}` and are
- * mirrors of values already stored in toolConfig.value.tools[N].params.
- */
-const SYNTHETIC_TOOL_SUBBLOCK_RE = /-tool-\d+-/
-
/**
* Filters subBlock IDs to exclude system, trigger runtime, and synthetic tool subBlocks.
*
@@ -429,7 +423,7 @@ export function filterSubBlockIds(subBlockIds: string[]): string[] {
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false
if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`)))
return false
- if (SYNTHETIC_TOOL_SUBBLOCK_RE.test(id)) return false
+ if (isSyntheticToolSubBlockId(id)) return false
return true
})
.sort()
diff --git a/apps/sim/lib/workflows/search-replace/dependencies.test.ts b/apps/sim/lib/workflows/search-replace/dependencies.test.ts
new file mode 100644
index 00000000000..f1e2505fa75
--- /dev/null
+++ b/apps/sim/lib/workflows/search-replace/dependencies.test.ts
@@ -0,0 +1,24 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
+import type { SubBlockConfig } from '@/blocks/types'
+
+describe('getWorkflowSearchDependentClears', () => {
+ it('returns transitive dependents without cycling', () => {
+ const subBlocks: SubBlockConfig[] = [
+ { id: 'credential', title: 'Credential', type: 'oauth-input' },
+ { id: 'project', title: 'Project', type: 'project-selector', dependsOn: ['credential'] },
+ { id: 'issue', title: 'Issue', type: 'file-selector', dependsOn: ['project'] },
+ { id: 'assignee', title: 'Assignee', type: 'user-selector', dependsOn: ['issue'] },
+ { id: 'unrelated', title: 'Unrelated', type: 'short-input' },
+ ]
+
+ expect(getWorkflowSearchDependentClears(subBlocks, 'credential')).toEqual([
+ { subBlockId: 'project', reason: 'project depends on credential' },
+ { subBlockId: 'issue', reason: 'issue depends on project' },
+ { subBlockId: 'assignee', reason: 'assignee depends on issue' },
+ ])
+ })
+})
diff --git a/apps/sim/lib/workflows/search-replace/dependencies.ts b/apps/sim/lib/workflows/search-replace/dependencies.ts
index db28ba47717..5059ebc29f2 100644
--- a/apps/sim/lib/workflows/search-replace/dependencies.ts
+++ b/apps/sim/lib/workflows/search-replace/dependencies.ts
@@ -10,8 +10,24 @@ export function getWorkflowSearchDependentClears(
allSubBlocks: SubBlockConfig[],
changedSubBlockId: string
): DependentClear[] {
- return getSubBlocksDependingOnChange(allSubBlocks, changedSubBlockId).map((subBlock) => ({
- subBlockId: subBlock.id,
- reason: `${subBlock.id} depends on ${changedSubBlockId}`,
- }))
+ const clears: DependentClear[] = []
+ const visited = new Set([changedSubBlockId])
+ const queue = [changedSubBlockId]
+
+ while (queue.length > 0) {
+ const currentSubBlockId = queue.shift()
+ if (!currentSubBlockId) continue
+
+ for (const subBlock of getSubBlocksDependingOnChange(allSubBlocks, currentSubBlockId)) {
+ if (!subBlock.id || visited.has(subBlock.id)) continue
+ visited.add(subBlock.id)
+ clears.push({
+ subBlockId: subBlock.id,
+ reason: `${subBlock.id} depends on ${currentSubBlockId}`,
+ })
+ queue.push(subBlock.id)
+ }
+ }
+
+ return clears
}
diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts
index 1a36acd8cdc..53975b65ad5 100644
--- a/apps/sim/lib/workflows/search-replace/indexer.test.ts
+++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts
@@ -3,7 +3,7 @@
*/
import { describe, expect, it } from 'vitest'
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
-import { workflowSearchMatchMatchesQuery } from '@/lib/workflows/search-replace/resource-resolvers'
+import { workflowSearchMatchMatchesQuery } from '@/lib/workflows/search-replace/resources'
import {
createSearchReplaceWorkflowFixture,
SEARCH_REPLACE_BLOCK_CONFIGS,
@@ -60,6 +60,1881 @@ describe('indexWorkflowSearchMatches', () => {
expect(objectMatches).toEqual([])
})
+ it('indexes input format fields by visible nested field labels', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['start-1'] = {
+ id: 'start-1',
+ type: 'start_trigger',
+ name: 'Start',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ inputFormat: {
+ id: 'inputFormat',
+ type: 'input-format',
+ value: [
+ {
+ id: 'internal-field-id',
+ name: 'customerInput',
+ type: 'string',
+ description: 'Incoming payload',
+ value: 'sample',
+ collapsed: false,
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'in',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ start_trigger: {
+ subBlocks: [{ id: 'inputFormat', title: 'Inputs', type: 'input-format' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'start-1')
+
+ expect(matches).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ valuePath: [0, 'name'],
+ fieldTitle: 'Name',
+ searchText: 'customerInput',
+ }),
+ expect.objectContaining({
+ valuePath: [0, 'description'],
+ fieldTitle: 'Description',
+ searchText: 'Incoming payload',
+ }),
+ ])
+ )
+ expect(matches.some((match) => match.rawValue === 'internal-field-id')).toBe(false)
+ expect(matches.some((match) => match.valuePath.join('.') === '0.type')).toBe(false)
+ })
+
+ it('does not index evaluator type metadata', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['evaluator-1'] = {
+ id: 'evaluator-1',
+ type: 'custom',
+ name: 'Evaluator',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ metrics: {
+ id: 'metrics',
+ type: 'eval-input',
+ value: [
+ {
+ id: 'metric-1',
+ name: 'Accuracy',
+ type: 'score-kind',
+ description: 'Factual correctness',
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'score-kind',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'metrics', title: 'Metrics', type: 'eval-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'evaluator-1')
+
+ expect(matches).toEqual([])
+ })
+
+ it('uses the same mode visibility as the editor', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['mode-1'] = {
+ id: 'mode-1',
+ type: 'custom',
+ name: 'Mode Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ advancedMode: false,
+ triggerMode: false,
+ outputs: {},
+ subBlocks: {
+ basicOnly: { id: 'basicOnly', type: 'short-input', value: 'visible-basic' },
+ advancedOnly: { id: 'advancedOnly', type: 'short-input', value: 'hidden-advanced' },
+ triggerOnly: { id: 'triggerOnly', type: 'short-input', value: 'hidden-trigger' },
+ triggerManual: { id: 'triggerManual', type: 'short-input', value: 'hidden-trigger-manual' },
+ triggerConfig: {
+ id: 'triggerConfig',
+ type: 'trigger-config',
+ value: 'visible-trigger-config',
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ { id: 'basicOnly', title: 'Basic', type: 'short-input', mode: 'basic' },
+ { id: 'advancedOnly', title: 'Advanced', type: 'short-input', mode: 'advanced' },
+ {
+ id: 'triggerOnly',
+ title: 'Trigger',
+ type: 'short-input',
+ mode: 'trigger',
+ canonicalParamId: 'triggerValue',
+ },
+ {
+ id: 'triggerManual',
+ title: 'Trigger Manual',
+ type: 'short-input',
+ mode: 'trigger-advanced',
+ canonicalParamId: 'triggerValue',
+ },
+ { id: 'triggerConfig', title: 'Trigger Config', type: 'trigger-config' },
+ ],
+ },
+ }
+
+ expect(
+ indexWorkflowSearchMatches({
+ workflow,
+ query: 'hidden',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'mode-1')
+ ).toEqual([])
+
+ workflow.blocks['mode-1'].advancedMode = true
+ const advancedMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'advanced',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'mode-1')
+ const basicMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'basic',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'mode-1')
+
+ expect(advancedMatches).toHaveLength(1)
+ expect(advancedMatches[0].subBlockId).toBe('advancedOnly')
+ expect(basicMatches).toEqual([])
+
+ workflow.blocks['mode-1'].triggerMode = true
+ const triggerMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'hidden-trigger',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'mode-1')
+ const nonTriggerMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'advanced',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'mode-1')
+
+ expect(triggerMatches).toHaveLength(1)
+ expect(triggerMatches[0].subBlockId).toBe('triggerOnly')
+ expect(nonTriggerMatches).toEqual([])
+
+ const triggerConfigMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'trigger-config',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'mode-1')
+ const triggerManualMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'trigger-manual',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'mode-1')
+
+ expect(triggerConfigMatches).toHaveLength(1)
+ expect(triggerConfigMatches[0].subBlockId).toBe('triggerConfig')
+ expect(triggerManualMatches).toEqual([])
+ })
+
+ it('does not index fixed-choice dropdown values as text replacements', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['dropdown-1'] = {
+ id: 'dropdown-1',
+ type: 'custom',
+ name: 'Dropdown Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ operation: {
+ id: 'operation',
+ type: 'dropdown',
+ value: 'send_email',
+ },
+ flags: {
+ id: 'flags',
+ type: 'dropdown',
+ value: ['read', 'unread'],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'send',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ {
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ options: [{ label: 'Send Email', id: 'send_email' }],
+ },
+ {
+ id: 'flags',
+ title: 'Flags',
+ type: 'dropdown',
+ multiSelect: true,
+ options: [
+ { label: 'Read', id: 'read' },
+ { label: 'Unread', id: 'unread' },
+ ],
+ },
+ ],
+ },
+ },
+ })
+
+ expect(matches.filter((match) => match.blockId === 'dropdown-1')).toEqual([])
+ })
+
+ it('does not index display-only subblocks that render from config', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['display-1'] = {
+ id: 'display-1',
+ type: 'custom',
+ name: 'Display',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ help: { id: 'help', type: 'text', value: 'stored shadow text' },
+ scheduleInfo: { id: 'scheduleInfo', type: 'schedule-info', value: 'stored schedule text' },
+ modal: { id: 'modal', type: 'modal', value: 'stored modal text' },
+ webhook: { id: 'webhook', type: 'webhook-config', value: 'stored webhook text' },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'stored',
+ mode: 'all',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ { id: 'help', title: 'Help', type: 'text', defaultValue: 'Rendered help' },
+ { id: 'scheduleInfo', title: 'Schedule', type: 'schedule-info' },
+ { id: 'modal', title: 'Modal', type: 'modal' },
+ { id: 'webhook', title: 'Webhook', type: 'webhook-config' },
+ ],
+ },
+ },
+ }).filter((match) => match.blockId === 'display-1')
+
+ expect(matches).toEqual([])
+ })
+
+ it('indexes only value fields for JSON-backed knowledge tag subblocks', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tag-block-1'] = {
+ id: 'tag-block-1',
+ type: 'custom',
+ name: 'Tag Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tagFilters: {
+ id: 'tagFilters',
+ type: 'knowledge-tag-filters',
+ value: JSON.stringify([
+ {
+ id: 'filter-open',
+ tagName: 'Status',
+ fieldType: 'text',
+ operator: 'eq',
+ tagValue: 'open ticket',
+ valueTo: 'closed ticket',
+ collapsed: false,
+ },
+ ]),
+ },
+ documentTags: {
+ id: 'documentTags',
+ type: 'document-tag-entry',
+ value: JSON.stringify([
+ {
+ id: 'tag-open',
+ tagName: 'Priority',
+ fieldType: 'text',
+ value: 'open escalation',
+ collapsed: false,
+ },
+ ]),
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ { id: 'tagFilters', title: 'Tag Filters', type: 'knowledge-tag-filters' },
+ { id: 'documentTags', title: 'Document Tags', type: 'document-tag-entry' },
+ ],
+ },
+ }
+
+ const valueMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'open',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'tag-block-1')
+ const tagNameMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'Status',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'tag-block-1')
+ const typeMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'text',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'tag-block-1')
+
+ expect(valueMatches).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ subBlockId: 'tagFilters',
+ valuePath: [0, 'tagValue'],
+ fieldTitle: 'Value',
+ searchText: 'open ticket',
+ }),
+ expect.objectContaining({
+ subBlockId: 'documentTags',
+ valuePath: [0, 'value'],
+ fieldTitle: 'Value',
+ searchText: 'open escalation',
+ }),
+ ])
+ )
+ expect(tagNameMatches).toEqual([])
+ expect(typeMatches).toEqual([])
+ })
+
+ it('indexes only assignment values for stringified variables input', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['variables-1'] = {
+ id: 'variables-1',
+ type: 'custom',
+ name: 'Variables',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ assignments: {
+ id: 'assignments',
+ type: 'variables-input',
+ value: JSON.stringify([
+ {
+ id: 'assignment-needle-id',
+ variableId: 'variable-needle-id',
+ variableName: 'needleVariable',
+ type: 'string',
+ value: 'safe needle value',
+ isExisting: true,
+ },
+ ]),
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'assignments', title: 'Variables', type: 'variables-input' }],
+ },
+ }
+
+ const valueMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'needle value',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'variables-1')
+ const metadataMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'variable-needle-id',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'variables-1')
+ const variableNameMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'needleVariable',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'variables-1')
+
+ expect(valueMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'assignments',
+ valuePath: [0, 'value'],
+ fieldTitle: 'Value',
+ searchText: 'safe needle value',
+ }),
+ ])
+ expect(metadataMatches).toEqual([])
+ expect(variableNameMatches).toEqual([])
+ })
+
+ it('indexes table cells from stringified table values without exposing row metadata', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['table-1'] = {
+ id: 'table-1',
+ type: 'custom',
+ name: 'Table',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ rows: {
+ id: 'rows',
+ type: 'table',
+ value: JSON.stringify([{ id: 'row-needle-id', cells: { Name: 'Acme needle' } }]),
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'rows', title: 'Rows', type: 'table', columns: ['Name'] }],
+ },
+ }
+
+ const cellMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'needle',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'table-1')
+ const metadataMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'row-needle-id',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'table-1')
+
+ expect(cellMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'rows',
+ valuePath: [0, 'cells', 'Name'],
+ fieldTitle: 'Name',
+ searchText: 'Acme needle',
+ }),
+ ])
+ expect(metadataMatches).toEqual([])
+ })
+
+ it('indexes only editable branch values for JSON-backed condition and router subblocks', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['branch-1'] = {
+ id: 'branch-1',
+ type: 'custom',
+ name: 'Branch Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ conditions: {
+ id: 'conditions',
+ type: 'condition-input',
+ value: JSON.stringify([
+ {
+ id: 'branch-hidden-id',
+ title: 'branch hidden title',
+ value: 'branch visible value',
+ showTags: false,
+ },
+ ]),
+ },
+ routes: {
+ id: 'routes',
+ type: 'router-input',
+ value: JSON.stringify([
+ {
+ id: 'route-hidden-id',
+ title: 'route hidden title',
+ value: 'route visible value',
+ showTags: false,
+ },
+ ]),
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ { id: 'conditions', title: 'Conditions', type: 'condition-input' },
+ { id: 'routes', title: 'Routes', type: 'router-input' },
+ ],
+ },
+ }
+
+ const visibleMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'visible',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'branch-1')
+ const hiddenMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'hidden',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'branch-1')
+
+ expect(visibleMatches).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ subBlockId: 'conditions',
+ valuePath: [0, 'value'],
+ fieldTitle: 'Condition',
+ }),
+ expect.objectContaining({
+ subBlockId: 'routes',
+ valuePath: [0, 'value'],
+ fieldTitle: 'Route',
+ }),
+ ])
+ )
+ expect(hiddenMatches).toEqual([])
+ })
+
+ it('does not index non-editable builder enums or message metadata', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['structured-1'] = {
+ id: 'structured-1',
+ type: 'custom',
+ name: 'Structured Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ messages: {
+ id: 'messages',
+ type: 'messages-input',
+ value: [{ role: 'user', content: 'user visible content' }],
+ },
+ filters: {
+ id: 'filters',
+ type: 'filter-builder',
+ value: [
+ {
+ id: 'filter-1',
+ column: 'status',
+ operator: 'contains',
+ value: 'contains visible value',
+ logicalOperator: 'and',
+ },
+ ],
+ },
+ sorts: {
+ id: 'sorts',
+ type: 'sort-builder',
+ value: [{ id: 'sort-1', column: 'status', direction: 'asc' }],
+ },
+ skills: {
+ id: 'skills',
+ type: 'skill-input',
+ value: [{ skillId: 'skill-hidden-id', name: 'Skill Hidden Name' }],
+ },
+ runAt: {
+ id: 'runAt',
+ type: 'time-input',
+ value: '12:30',
+ },
+ mapping: {
+ id: 'mapping',
+ type: 'input-mapping',
+ value: { childInput: 'mapped visible value' },
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ { id: 'messages', title: 'Messages', type: 'messages-input' },
+ { id: 'filters', title: 'Filters', type: 'filter-builder' },
+ { id: 'sorts', title: 'Sorts', type: 'sort-builder' },
+ { id: 'skills', title: 'Skills', type: 'skill-input' },
+ { id: 'runAt', title: 'Run At', type: 'time-input' },
+ { id: 'mapping', title: 'Input Mapping', type: 'input-mapping' },
+ ],
+ },
+ }
+
+ const containsMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'contains',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'structured-1')
+ const userMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'user',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'structured-1')
+ const excludedMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'hidden',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'structured-1')
+ const timeMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: '12',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'structured-1')
+ const mappingMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'mapped',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'structured-1')
+
+ expect(containsMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'filters',
+ valuePath: [0, 'value'],
+ searchText: 'contains visible value',
+ }),
+ ])
+ expect(userMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'messages',
+ valuePath: [0, 'content'],
+ searchText: 'user visible content',
+ }),
+ ])
+ expect(excludedMatches).toEqual([])
+ expect(timeMatches).toEqual([])
+ expect(mappingMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'mapping',
+ valuePath: ['childInput'],
+ searchText: 'mapped visible value',
+ }),
+ ])
+ })
+
+ it('does not index skill-shaped values even when persisted under a legacy id', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['legacy-agent-1'] = {
+ id: 'legacy-agent-1',
+ type: 'custom',
+ name: 'Legacy Agent',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ legacySkills: {
+ id: 'legacySkills',
+ type: 'short-input',
+ value: [{ skillId: 'skill-vik-id', name: 'vik-skill' }],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'vik',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: { subBlocks: [] },
+ },
+ }).filter((match) => match.blockId === 'legacy-agent-1')
+
+ expect(matches).toEqual([])
+ })
+
+ it('indexes only editable variable assignment values as text', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['variables-1'] = {
+ id: 'variables-1',
+ type: 'variables',
+ name: 'Variables',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ variables: {
+ id: 'variables',
+ type: 'variables-input',
+ value: [
+ {
+ id: 'assignment-needle-id',
+ variableId: 'variable-needle-id',
+ variableName: 'needleVariable',
+ type: 'string',
+ value: 'needle assignment value',
+ isExisting: true,
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'needle',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ variables: {
+ subBlocks: [{ id: 'variables', title: 'Variable Assignments', type: 'variables-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'variables-1')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'variables',
+ valuePath: [0, 'value'],
+ searchText: 'needle assignment value',
+ editable: true,
+ }),
+ ])
+ })
+
+ it('does not index upload or dynamic control internals as text', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['dynamic-1'] = {
+ id: 'dynamic-1',
+ type: 'custom',
+ name: 'Dynamic Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ upload: {
+ id: 'upload',
+ type: 'file-upload',
+ value: {
+ name: 'customer.csv',
+ path: '/workspace/customer.csv',
+ key: 'storage-customer-key',
+ },
+ },
+ mcpArgs: {
+ id: 'mcpArgs',
+ type: 'mcp-dynamic-args',
+ value: { prompt: 'customer prompt' },
+ },
+ slider: {
+ id: 'slider',
+ type: 'slider',
+ value: 42,
+ },
+ enabled: {
+ id: 'enabled',
+ type: 'switch',
+ value: true,
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'customer',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ { id: 'upload', title: 'Upload', type: 'file-upload' },
+ { id: 'mcpArgs', title: 'MCP Args', type: 'mcp-dynamic-args' },
+ { id: 'slider', title: 'Slider', type: 'slider' },
+ { id: 'enabled', title: 'Enabled', type: 'switch' },
+ ],
+ },
+ },
+ }).filter((match) => match.blockId === 'dynamic-1')
+
+ expect(matches).toEqual([])
+ })
+
+ it('indexes only safe user-facing paths inside tool input values', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'native',
+ toolId: 'gmail_customer_tool',
+ operation: 'send_customer_message',
+ title: 'Customer notifier',
+ params: {
+ body: 'hello customer',
+ credentialId: 'credential-customer-id',
+ inputMapping: JSON.stringify({ query: 'customer json value' }),
+ },
+ schema: {
+ description: 'customer schema text',
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'customer',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(matches).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ valuePath: [0, 'title'],
+ searchText: 'Customer notifier',
+ }),
+ expect.objectContaining({
+ subBlockId: 'tools',
+ valuePath: [0, 'params', 'body'],
+ searchText: 'hello customer',
+ }),
+ ])
+ )
+ expect(matches.some((match) => match.valuePath.includes('toolId'))).toBe(false)
+ expect(matches.some((match) => match.valuePath.includes('operation'))).toBe(false)
+ expect(matches.some((match) => match.valuePath.includes('credentialId'))).toBe(false)
+ expect(matches).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ valuePath: [0, 'params', 'inputMapping', 'query'],
+ searchText: 'customer json value',
+ }),
+ ])
+ )
+ expect(matches.some((match) => match.valuePath.includes('schema'))).toBe(false)
+ })
+
+ it('indexes explicit secret tool params for intentional replacement', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ botToken: 'xoxb-hidden-token',
+ text: 'visible slack body',
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const hiddenMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'xoxb-hidden-token',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+ const visibleMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'visible slack',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(hiddenMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ valuePath: [0, 'params', 'botToken'],
+ searchText: 'xoxb-hidden-token',
+ }),
+ ])
+ expect(visibleMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ valuePath: [0, 'params', 'text'],
+ searchText: 'visible slack body',
+ }),
+ ])
+ })
+
+ it('indexes structured resources inside tool input params using nested subblock config', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ credential: 'slack-credential',
+ text: 'message with file',
+ attachmentFiles: JSON.stringify({
+ name: 'contract.pdf',
+ key: 'file-key-old',
+ path: '/contract.pdf',
+ size: 12,
+ type: 'application/pdf',
+ }),
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'contract',
+ mode: 'all',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(matches).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ kind: 'file',
+ subBlockId: 'tools',
+ subBlockType: 'file-upload',
+ rawValue: 'file-key-old',
+ searchText: 'contract.pdf',
+ valuePath: [0, 'params', 'attachmentFiles'],
+ }),
+ ])
+ )
+ })
+
+ it('does not double index synthetic tool-input mirror subblocks', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'api',
+ toolId: 'http_request',
+ title: 'API',
+ params: {
+ url: 'Lmfap',
+ },
+ },
+ ],
+ },
+ 'tools-tool-0-url': {
+ id: 'tools-tool-0-url',
+ type: 'short-input',
+ value: 'Lmfap',
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'lmf',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ valuePath: [0, 'params', 'url'],
+ searchText: 'Lmfap',
+ }),
+ ])
+ })
+
+ it('attaches selector context to selector-backed tool input params', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ credential: 'slack-credential',
+ channel: 'COLD',
+ text: 'message',
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'COLD',
+ mode: 'resource',
+ workspaceId: 'workspace-1',
+ workflowId: 'workflow-1',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.kind === 'selector-resource')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ rawValue: 'COLD',
+ resource: expect.objectContaining({
+ selectorKey: 'slack.channels',
+ selectorContext: expect.objectContaining({
+ oauthCredential: 'slack-credential',
+ workspaceId: 'workspace-1',
+ workflowId: 'workflow-1',
+ excludeWorkflowId: 'workflow-1',
+ }),
+ }),
+ }),
+ ])
+ })
+
+ it('indexes workflow-input tool mappings by values without exposing JSON keys', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'workflow_input',
+ toolId: 'workflow_executor',
+ title: 'Workflow',
+ params: {
+ workflowId: 'workflow-old',
+ inputMapping: JSON.stringify({ customerEmail: 'old email value' }),
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const keyMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'customerEmail',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+ const valueMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old email',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(keyMatches).toEqual([])
+ expect(valueMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ subBlockType: 'workflow-input-mapper',
+ valuePath: [0, 'params', 'inputMapping', 'customerEmail'],
+ searchText: 'old email value',
+ }),
+ ])
+ })
+
+ it('indexes object-backed workflow-input tool mappings by values', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'workflow_input',
+ toolId: 'workflow_executor',
+ title: 'Workflow',
+ params: {
+ workflowId: 'workflow-old',
+ inputMapping: { customerEmail: 'object email value' },
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'object email',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ subBlockType: 'workflow-input-mapper',
+ valuePath: [0, 'params', 'inputMapping', 'customerEmail'],
+ searchText: 'object email value',
+ }),
+ ])
+ })
+
+ it('indexes object-valued fallback tool params by leaf values', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'mcp',
+ title: 'MCP tool',
+ params: {
+ payload: {
+ type: 'metadata-type',
+ filter: { status: 'open customer' },
+ },
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ }
+ const valueMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'open customer',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'tool-input-1')
+ const typeMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'metadata-type',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(valueMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ subBlockType: 'workflow-input-mapper',
+ valuePath: [0, 'params', 'payload', 'filter', 'status'],
+ searchText: 'open customer',
+ }),
+ ])
+ expect(typeMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ subBlockType: 'workflow-input-mapper',
+ valuePath: [0, 'params', 'payload', 'type'],
+ searchText: 'metadata-type',
+ }),
+ ])
+ })
+
+ it('indexes visible tool params ending in key', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'custom-tool',
+ title: 'Custom issue tool',
+ params: {
+ issueKey: 'PROJ-123',
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'PROJ',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ valuePath: [0, 'params', 'issueKey'],
+ searchText: 'PROJ-123',
+ }),
+ ])
+ })
+
+ it('indexes nested JSON object fallback tool params by values without exposing keys', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'mcp',
+ toolId: 'server-tool',
+ title: 'MCP tool',
+ params: {
+ payload: JSON.stringify({ customer: { name: 'Acme Corp' } }),
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const valueMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'Acme',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+ const keyMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'customer',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+
+ expect(valueMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ subBlockType: 'workflow-input-mapper',
+ valuePath: [0, 'params', 'payload', 'customer', 'name'],
+ searchText: 'Acme Corp',
+ }),
+ ])
+ expect(keyMatches).toEqual([])
+ })
+
+ it('scopes MCP tool resources to the selected server', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['mcp-1'] = {
+ id: 'mcp-1',
+ type: 'mcp',
+ name: 'MCP',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ server: {
+ id: 'server',
+ type: 'mcp-server-selector',
+ value: 'server-a',
+ },
+ tool: {
+ id: 'tool',
+ type: 'mcp-tool-selector',
+ value: 'server-a-search',
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'search',
+ mode: 'resource',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ mcp: {
+ subBlocks: [
+ { id: 'server', title: 'Server', type: 'mcp-server-selector' },
+ { id: 'tool', title: 'Tool', type: 'mcp-tool-selector', dependsOn: ['server'] },
+ ],
+ },
+ },
+ }).filter((match) => match.kind === 'mcp-tool')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ rawValue: 'server-a-search',
+ resource: expect.objectContaining({
+ selectorContext: expect.objectContaining({ mcpServerId: 'server-a' }),
+ }),
+ }),
+ ])
+ })
+
+ it('does not index condition-hidden subblocks', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['condition-1'] = {
+ id: 'condition-1',
+ type: 'custom',
+ name: 'Conditional Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ operation: {
+ id: 'operation',
+ type: 'dropdown',
+ value: 'send',
+ },
+ hiddenBody: {
+ id: 'hiddenBody',
+ type: 'long-input',
+ value: 'invisible content',
+ },
+ visibleBody: {
+ id: 'visibleBody',
+ type: 'long-input',
+ value: 'visible content',
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'content',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ { id: 'operation', title: 'Operation', type: 'dropdown' },
+ {
+ id: 'hiddenBody',
+ title: 'Hidden Body',
+ type: 'long-input',
+ condition: { field: 'operation', value: 'receive' },
+ },
+ {
+ id: 'visibleBody',
+ title: 'Visible Body',
+ type: 'long-input',
+ condition: { field: 'operation', value: 'send' },
+ },
+ ],
+ },
+ },
+ }).filter((match) => match.blockId === 'condition-1')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'visibleBody',
+ fieldTitle: 'Visible Body',
+ }),
+ ])
+ })
+
+ it('does not index hidden generated subblocks', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['evaluator-1'] = {
+ id: 'evaluator-1',
+ type: 'evaluator',
+ name: 'Evaluator',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ systemPrompt: {
+ id: 'systemPrompt',
+ type: 'code',
+ value: 'Generated content should not be searchable',
+ },
+ content: {
+ id: 'content',
+ type: 'long-input',
+ value: 'Visible content should be searchable',
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'content',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ evaluator: {
+ subBlocks: [
+ { id: 'systemPrompt', title: 'System Prompt', type: 'code', hidden: true },
+ { id: 'content', title: 'Content', type: 'long-input' },
+ ],
+ },
+ },
+ })
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ blockId: 'evaluator-1',
+ subBlockId: 'content',
+ fieldTitle: 'Content',
+ }),
+ ])
+ })
+
+ it('indexes only the active member of a canonical basic/advanced pair', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['canonical-1'] = {
+ id: 'canonical-1',
+ type: 'custom',
+ name: 'Canonical Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ data: {
+ canonicalModes: { file: 'advanced' },
+ },
+ subBlocks: {
+ fileSelector: {
+ id: 'fileSelector',
+ type: 'file-selector',
+ value: 'basic-file-id',
+ },
+ fileReference: {
+ id: 'fileReference',
+ type: 'short-input',
+ value: 'advanced-file-reference',
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ {
+ id: 'fileSelector',
+ title: 'File',
+ type: 'file-selector',
+ canonicalParamId: 'file',
+ mode: 'basic',
+ },
+ {
+ id: 'fileReference',
+ title: 'File',
+ type: 'short-input',
+ canonicalParamId: 'file',
+ mode: 'advanced',
+ },
+ ],
+ },
+ }
+
+ const basicMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'basic-file-id',
+ mode: 'all',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'canonical-1')
+ const advancedMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'advanced-file',
+ mode: 'all',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'canonical-1')
+
+ expect(basicMatches).toEqual([])
+ expect(advancedMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'fileReference',
+ canonicalSubBlockId: 'file',
+ kind: 'text',
+ }),
+ ])
+ })
+
+ it('indexes reactive credential-type fields only when their credential type matches', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['reactive-1'] = {
+ id: 'reactive-1',
+ type: 'custom',
+ name: 'Reactive Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ credential: {
+ id: 'credential',
+ type: 'oauth-input',
+ value: 'credential-1',
+ },
+ impersonateUserEmail: {
+ id: 'impersonateUserEmail',
+ type: 'short-input',
+ value: 'service-account-user@example.com',
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ {
+ id: 'credential',
+ title: 'Google Account',
+ type: 'oauth-input',
+ canonicalParamId: 'oauthCredential',
+ },
+ {
+ id: 'impersonateUserEmail',
+ title: 'Impersonated Account',
+ type: 'short-input',
+ reactiveCondition: {
+ watchFields: ['oauthCredential'],
+ requiredType: 'service_account',
+ },
+ },
+ ],
+ },
+ }
+
+ const hiddenMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'service-account-user',
+ mode: 'text',
+ blockConfigs,
+ credentialTypeById: { 'credential-1': 'oauth' },
+ }).filter((match) => match.blockId === 'reactive-1')
+ const visibleMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'service-account-user',
+ mode: 'text',
+ blockConfigs,
+ credentialTypeById: { 'credential-1': 'service_account' },
+ }).filter((match) => match.blockId === 'reactive-1')
+
+ expect(hiddenMatches).toEqual([])
+ expect(visibleMatches).toEqual([
+ expect.objectContaining({
+ subBlockId: 'impersonateUserEmail',
+ fieldTitle: 'Impersonated Account',
+ }),
+ ])
+ })
+
+ it('indexes editable combobox text and still finds inline references', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['combobox-1'] = {
+ id: 'combobox-1',
+ type: 'custom',
+ name: 'Combobox Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ model: {
+ id: 'model',
+ type: 'combobox',
+ value: 'claude-sonnet-4-6',
+ },
+ dynamicModel: {
+ id: 'dynamicModel',
+ type: 'combobox',
+ value: '',
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ {
+ id: 'model',
+ title: 'Model',
+ type: 'combobox',
+ options: [{ label: 'Claude Sonnet', id: 'claude-sonnet-4-6' }],
+ },
+ {
+ id: 'dynamicModel',
+ title: 'Dynamic Model',
+ type: 'combobox',
+ options: [{ label: 'Claude Sonnet', id: 'claude-sonnet-4-6' }],
+ },
+ ],
+ },
+ }
+
+ const textMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'claude',
+ mode: 'text',
+ blockConfigs,
+ })
+ const referenceMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'start.model',
+ mode: 'resource',
+ blockConfigs,
+ })
+
+ expect(textMatches).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ blockId: 'combobox-1',
+ subBlockId: 'model',
+ kind: 'text',
+ rawValue: 'claude',
+ editable: true,
+ }),
+ ])
+ )
+ expect(referenceMatches).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ blockId: 'combobox-1',
+ subBlockId: 'dynamicModel',
+ kind: 'workflow-reference',
+ rawValue: '',
+ }),
+ ])
+ )
+ })
+
+ it('indexes evaluator metrics by visible nested field labels', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['evaluator-1'] = {
+ id: 'evaluator-1',
+ type: 'evaluator',
+ name: 'Evaluator',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ metrics: {
+ id: 'metrics',
+ type: 'eval-input',
+ value: [
+ {
+ id: 'metric-internal-id',
+ name: 'Accuracy',
+ description: 'Score factual correctness',
+ range: { min: 0, max: 10 },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: '10',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ evaluator: {
+ subBlocks: [{ id: 'metrics', title: 'Evaluation Metrics', type: 'eval-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'evaluator-1')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ valuePath: [0, 'range', 'max'],
+ fieldTitle: 'Max Value',
+ searchText: '10',
+ editable: false,
+ reason: 'Only text values can be replaced',
+ }),
+ ])
+
+ const idMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'metric-internal-id',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ evaluator: {
+ subBlocks: [{ id: 'metrics', title: 'Evaluation Metrics', type: 'eval-input' }],
+ },
+ },
+ })
+
+ expect(idMatches).toEqual([])
+ })
+
it('indexes non-string scalar values as searchable but not editable', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['api-1'].subBlocks.body.value = { count: 2, enabled: true }
@@ -283,6 +2158,206 @@ describe('indexWorkflowSearchMatches', () => {
).toBe(true)
})
+ it('keeps selector-like legacy state out of plain text matches when config is missing', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['legacy-resource-1'] = {
+ id: 'legacy-resource-1',
+ type: 'unknown_block',
+ name: 'Legacy Resource',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ knowledgeBaseIds: {
+ id: 'knowledgeBaseIds',
+ type: 'knowledge-base-selector',
+ value: 'kb-legacy',
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'kb',
+ mode: 'all',
+ blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
+ }).filter((match) => match.blockId === 'legacy-resource-1')
+
+ expect(matches.some((match) => match.kind === 'text')).toBe(false)
+ expect(matches).toEqual([
+ expect.objectContaining({
+ kind: 'knowledge-base',
+ rawValue: 'kb-legacy',
+ }),
+ ])
+ })
+
+ it('indexes workspace file uploads as resource matches by visible file name', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['file-upload-1'] = {
+ id: 'file-upload-1',
+ type: 'custom',
+ name: 'File Upload Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ file: {
+ id: 'file',
+ type: 'file-upload',
+ value: {
+ name: 'violet_polaris.csv',
+ path: '/workspace/ws-1/violet-key',
+ key: 'violet-key',
+ size: 42,
+ type: 'text/csv',
+ },
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'violet',
+ mode: 'all',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'file', title: 'File', type: 'file-upload' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'file-upload-1')
+
+ expect(matches.some((match) => match.kind === 'text')).toBe(false)
+ expect(matches).toEqual([
+ expect.objectContaining({
+ kind: 'file',
+ rawValue: 'violet-key',
+ searchText: 'violet_polaris.csv',
+ }),
+ ])
+ })
+
+ it('attaches selector context for workflow selectors', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['workflow-1'] = {
+ id: 'workflow-1',
+ type: 'workflow',
+ name: 'Workflow Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ workflowId: {
+ id: 'workflowId',
+ type: 'workflow-selector',
+ value: 'child-workflow-1',
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'child',
+ mode: 'resource',
+ workspaceId: 'workspace-1',
+ workflowId: 'current-workflow-1',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ workflow: {
+ subBlocks: [
+ {
+ id: 'workflowId',
+ title: 'Select Workflow',
+ type: 'workflow-selector',
+ selectorKey: 'sim.workflows',
+ },
+ ],
+ },
+ },
+ }).filter((match) => match.blockId === 'workflow-1')
+
+ expect(matches).toEqual([
+ expect.objectContaining({
+ kind: 'workflow',
+ rawValue: 'child-workflow-1',
+ resource: expect.objectContaining({
+ selectorKey: 'sim.workflows',
+ selectorContext: expect.objectContaining({
+ workspaceId: 'workspace-1',
+ workflowId: 'current-workflow-1',
+ excludeWorkflowId: 'current-workflow-1',
+ }),
+ }),
+ }),
+ ])
+ })
+
+ it('builds selector context from declared dependencies instead of sibling selectors', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['spreadsheet-1'] = {
+ id: 'spreadsheet-1',
+ type: 'custom',
+ name: 'Spreadsheet Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ spreadsheetSelector: {
+ id: 'spreadsheetSelector',
+ type: 'file-selector',
+ value: 'spreadsheet-1',
+ },
+ sheetSelector: {
+ id: 'sheetSelector',
+ type: 'sheet-selector',
+ value: 'sheet-1',
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ mode: 'resource',
+ includeResourceMatchesWithoutQuery: true,
+ workspaceId: 'workspace-1',
+ workflowId: 'workflow-1',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ {
+ id: 'spreadsheetSelector',
+ title: 'Spreadsheet',
+ type: 'file-selector',
+ canonicalParamId: 'spreadsheetId',
+ selectorKey: 'google.drive',
+ },
+ {
+ id: 'sheetSelector',
+ title: 'Sheet',
+ type: 'sheet-selector',
+ selectorKey: 'google.sheets',
+ dependsOn: ['spreadsheetSelector'],
+ },
+ ],
+ },
+ },
+ }).filter((match) => match.blockId === 'spreadsheet-1')
+
+ const spreadsheetMatch = matches.find((match) => match.subBlockId === 'spreadsheetSelector')
+ const sheetMatch = matches.find((match) => match.subBlockId === 'sheetSelector')
+
+ expect(spreadsheetMatch?.resource?.selectorContext).not.toHaveProperty('spreadsheetId')
+ expect(sheetMatch?.resource?.selectorContext).toEqual(
+ expect.objectContaining({
+ spreadsheetId: 'spreadsheet-1',
+ workspaceId: 'workspace-1',
+ workflowId: 'workflow-1',
+ })
+ )
+ })
+
it('captures selector context for selector-backed resources', () => {
const workflow = createSearchReplaceWorkflowFixture()
diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts
index b2395117369..198b5c6567f 100644
--- a/apps/sim/lib/workflows/search-replace/indexer.ts
+++ b/apps/sim/lib/workflows/search-replace/indexer.ts
@@ -1,11 +1,18 @@
+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 { 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/reference-registry'
+} from '@/lib/workflows/search-replace/resources'
import { getWorkflowSearchSubflowFields } from '@/lib/workflows/search-replace/subflow-fields'
import type {
WorkflowSearchBlockState,
@@ -15,10 +22,32 @@ import type {
} 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 { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
+import {
+ buildCanonicalIndex,
+ buildSubBlockValues,
+ type CanonicalModeOverrides,
+ evaluateSubBlockCondition,
+ isSubBlockFeatureEnabled,
+ isSubBlockHidden,
+ isSubBlockVisibleForMode,
+ isSubBlockVisibleForTriggerMode,
+ normalizeDependencyValue,
+ parseDependsOn,
+ resolveDependencyValue,
+ 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 {
+ getSubBlocksForToolInput,
+ getToolIdForOperation,
+ getToolParametersConfig,
+ type ToolParameterConfig,
+} from '@/tools/params'
function normalizeForSearch(value: string, caseSensitive: boolean): string {
return caseSensitive ? value : value.toLowerCase()
@@ -48,14 +77,241 @@ function createMatchId(parts: Array): string {
const STRUCTURED_METADATA_LEAF_KEYS = new Set(['id', 'collapsed'])
-function isSearchableLeafPath(path: Array): boolean {
+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',
+ 'mcp-dynamic-args',
+ '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 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
+
+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) {
- return walkStringValues(value).filter((leaf) => isSearchableLeafPath(leaf.path))
+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 scopeToolCanonicalModes(
+ canonicalModes: CanonicalModeOverrides | undefined,
+ blockType: string | undefined
+): CanonicalModeOverrides | undefined {
+ if (!canonicalModes || !blockType) return undefined
+
+ const prefix = `${blockType}:`
+ let scoped: CanonicalModeOverrides | undefined
+ for (const [key, value] of Object.entries(canonicalModes)) {
+ if (!key.startsWith(prefix) || !value) continue
+ scoped = scoped ?? {}
+ scoped[key.slice(prefix.length)] = value
+ }
+ return scoped
+}
+
+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 {
@@ -140,29 +396,240 @@ function addTextMatches({
})
}
-function buildSearchSelectorContext({
- block,
- subBlockConfigs,
+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)
+ )
+}
+
+function getToolInputParamConfigs({
+ tool,
+ parentCanonicalModes,
+ credentialTypeById,
+ blockConfigs,
+}: {
+ tool: ParsedStoredTool
+ 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 = scopeToolCanonicalModes(parentCanonicalModes, 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,
}: {
- block: WorkflowSearchBlockState
- subBlockConfigs: SubBlockConfig[]
+ 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
+ if (workflowId) {
+ context.workflowId = workflowId
+ context.excludeWorkflowId = workflowId
+ }
- const canonicalIndex = buildCanonicalIndex(subBlockConfigs)
- for (const [subBlockId, subBlock] of Object.entries(block.subBlocks ?? {})) {
- const value = subBlock?.value
+ 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
}
@@ -171,6 +638,316 @@ function buildSearchSelectorContext({
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,
+}: {
+ 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']
+}) {
+ const parentCanonicalModes = getSearchCanonicalModes(block)
+
+ parseStoredToolInputValue(value).forEach((tool, toolIndex) => {
+ if (mode !== 'resource' && tool.title) {
+ addTextMatches({
+ matches,
+ idPrefix: 'tool-input-title',
+ block,
+ subBlockId,
+ canonicalSubBlockId,
+ subBlockType: 'tool-input',
+ fieldTitle: 'Tool',
+ value: tool.title,
+ valuePath: [toolIndex, 'title'],
+ target: { kind: 'subblock' },
+ query,
+ caseSensitive,
+ editable,
+ protectedByLock,
+ isSnapshotView,
+ readonlyReason,
+ })
+ }
+
+ const params = getToolInputParamConfigs({
+ tool,
+ 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) {
+ 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,
+ 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[] {
@@ -186,6 +963,7 @@ export function indexWorkflowSearchMatches(
workspaceId,
workflowId,
blockConfigs = {},
+ credentialTypeById,
} = options
const matches: WorkflowSearchMatch[] = []
@@ -194,14 +972,13 @@ export function indexWorkflowSearchMatches(
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(subBlockConfigs)
- const selectorContext = buildSearchSelectorContext({
- block,
- subBlockConfigs,
- workspaceId,
- workflowId,
- })
+ const canonicalIndex = buildCanonicalIndex(canonicalSubBlockConfigs)
+ const subBlockValues = buildSubBlockValues(block.subBlocks ?? {})
+ const canonicalModes = getSearchCanonicalModes(block)
const protectedByLock = isWorkflowBlockProtected(block.id, workflow.blocks)
const editable = !protectedByLock && !isReadOnly
@@ -230,17 +1007,78 @@ export function indexWorkflowSearchMatches(
}
for (const [subBlockId, subBlockState] of Object.entries(block.subBlocks ?? {})) {
+ if (isSyntheticToolSubBlockId(subBlockId)) 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 stringLeaves = getSearchableStringLeaves(value)
- const structuredResourceKind = getResourceKindForSubBlock(subBlockConfig)
+ 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,
+ })
+ continue
+ }
if (mode !== 'resource' && !structuredResourceKind) {
- for (const leaf of stringLeaves) {
+ const textLeaves = getTextLeaves(value, subBlockType)
+ for (const leaf of textLeaves) {
const leafEditable = editable && typeof leaf.originalValue === 'string'
addTextMatches({
matches,
@@ -248,8 +1086,8 @@ export function indexWorkflowSearchMatches(
block,
subBlockId,
canonicalSubBlockId,
- subBlockType: subBlockConfig?.type ?? subBlockState.type,
- fieldTitle: subBlockConfig?.title,
+ subBlockType,
+ fieldTitle: leaf.fieldTitle ?? subBlockConfig?.title,
value: leaf.value,
valuePath: leaf.path,
target: { kind: 'subblock' },
@@ -269,7 +1107,8 @@ export function indexWorkflowSearchMatches(
if (mode === 'text' || !resourceQueryEnabled) continue
- for (const leaf of stringLeaves) {
+ 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}`
@@ -311,9 +1150,20 @@ export function indexWorkflowSearchMatches(
})
}
+ const selectorContext =
+ subBlockConfig?.selectorKey || subBlockConfig?.dependsOn
+ ? buildSearchSelectorContext({
+ block,
+ subBlockConfig,
+ subBlockValues,
+ canonicalIndex,
+ workspaceId,
+ workflowId,
+ })
+ : undefined
const structuredReferences = parseStructuredResourceReferences(
value,
- subBlockConfig,
+ resourceSubBlockConfig,
selectorContext
)
structuredReferences.forEach((reference, referenceIndex) => {
diff --git a/apps/sim/lib/workflows/search-replace/json-value-fields.ts b/apps/sim/lib/workflows/search-replace/json-value-fields.ts
new file mode 100644
index 00000000000..2b4c8a0dbbf
--- /dev/null
+++ b/apps/sim/lib/workflows/search-replace/json-value-fields.ts
@@ -0,0 +1,254 @@
+import type { SubBlockType } from '@sim/workflow-types/blocks'
+import type {
+ WorkflowSearchRange,
+ WorkflowSearchValuePath,
+} from '@/lib/workflows/search-replace/types'
+import { getValueAtPath, setValueAtPath } from '@/lib/workflows/search-replace/value-walker'
+
+const SEARCHABLE_JSON_ARRAY_VALUE_FIELDS: Partial>> = {
+ 'condition-input': {
+ value: 'Condition',
+ },
+ 'router-input': {
+ value: 'Route',
+ },
+ 'knowledge-tag-filters': {
+ tagValue: 'Value',
+ valueTo: 'Value To',
+ },
+ 'document-tag-entry': {
+ value: 'Value',
+ },
+ 'variables-input': {
+ value: 'Value',
+ },
+}
+
+const SEARCHABLE_JSON_OBJECT_VALUE_FIELDS: Partial> = {
+ 'input-mapping': 'Value',
+ 'workflow-input-mapper': 'Value',
+}
+
+const SERIALIZED_SUBBLOCK_VALUE_TYPES = new Set([
+ 'file-upload',
+ 'grouped-checkbox-list',
+ 'table',
+])
+
+export interface SearchableJsonStringLeaf {
+ path: WorkflowSearchValuePath
+ value: string
+ originalValue: string
+ fieldTitle: string
+}
+
+export interface JsonStringLeafReplacementResult {
+ handled: boolean
+ success: boolean
+ nextValue?: unknown
+ reason?: string
+}
+
+function parseJsonValue(value: string): unknown | null {
+ try {
+ return JSON.parse(value)
+ } catch {
+ return null
+ }
+}
+
+function getParsedValue(value: unknown): { parsed: unknown; stringify: boolean } | null {
+ if (typeof value === 'string') {
+ const parsed = parseJsonValue(value)
+ return parsed === null ? null : { parsed, stringify: true }
+ }
+
+ if (value && typeof value === 'object') {
+ return { parsed: value, stringify: false }
+ }
+
+ return null
+}
+
+function getObjectStringLeaves({
+ value,
+ path = [],
+ fieldTitle,
+}: {
+ value: unknown
+ path?: WorkflowSearchValuePath
+ fieldTitle: string
+}): SearchableJsonStringLeaf[] {
+ if (typeof value === 'string' && value.length > 0) {
+ return [{ path, value, originalValue: value, fieldTitle }]
+ }
+
+ if (Array.isArray(value)) {
+ return value.flatMap((item, index) =>
+ getObjectStringLeaves({ value: item, path: [...path, index], fieldTitle })
+ )
+ }
+
+ if (!value || typeof value !== 'object') return []
+
+ return Object.entries(value).flatMap(([fieldKey, fieldValue]) =>
+ getObjectStringLeaves({ value: fieldValue, path: [...path, fieldKey], fieldTitle })
+ )
+}
+
+export function isSearchableJsonValueSubBlock(
+ subBlockType: SubBlockType | undefined
+): subBlockType is
+ | 'condition-input'
+ | 'router-input'
+ | 'knowledge-tag-filters'
+ | 'document-tag-entry'
+ | 'variables-input'
+ | 'input-mapping'
+ | 'workflow-input-mapper'
+ | 'table' {
+ return Boolean(
+ subBlockType &&
+ (subBlockType === 'table' ||
+ SEARCHABLE_JSON_ARRAY_VALUE_FIELDS[subBlockType] ||
+ SEARCHABLE_JSON_OBJECT_VALUE_FIELDS[subBlockType])
+ )
+}
+
+export function shouldParseSerializedSubBlockValue(
+ subBlockType: SubBlockType | undefined
+): subBlockType is SubBlockType {
+ return Boolean(
+ subBlockType &&
+ (isSearchableJsonValueSubBlock(subBlockType) ||
+ SERIALIZED_SUBBLOCK_VALUE_TYPES.has(subBlockType))
+ )
+}
+
+export function getSearchableJsonStringLeaves(
+ value: unknown,
+ subBlockType: SubBlockType | undefined
+): SearchableJsonStringLeaf[] {
+ const parsedValue = getParsedValue(value)
+ if (!parsedValue) return []
+ const { parsed } = parsedValue
+
+ if (subBlockType === 'table') {
+ if (!Array.isArray(parsed)) return []
+ return parsed.flatMap((row, rowIndex) => {
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return []
+ const cells = (row as Record).cells
+ if (!cells || typeof cells !== 'object' || Array.isArray(cells)) return []
+ return Object.entries(cells).flatMap(([column, cellValue]) =>
+ typeof cellValue === 'string' && cellValue.length > 0
+ ? [
+ {
+ path: [rowIndex, 'cells', column],
+ value: cellValue,
+ originalValue: cellValue,
+ fieldTitle: column,
+ },
+ ]
+ : []
+ )
+ })
+ }
+
+ const arrayFieldTitles = subBlockType
+ ? SEARCHABLE_JSON_ARRAY_VALUE_FIELDS[subBlockType]
+ : undefined
+ if (arrayFieldTitles) {
+ if (!Array.isArray(parsed)) return []
+
+ return parsed.flatMap((row, index) => {
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return []
+ return Object.entries(arrayFieldTitles).flatMap(([fieldKey, fieldTitle]) => {
+ const fieldValue = (row as Record)[fieldKey]
+ return typeof fieldValue === 'string' && fieldValue.length > 0
+ ? [{ path: [index, fieldKey], value: fieldValue, originalValue: fieldValue, fieldTitle }]
+ : []
+ })
+ })
+ }
+
+ const objectFieldTitle = subBlockType
+ ? SEARCHABLE_JSON_OBJECT_VALUE_FIELDS[subBlockType]
+ : undefined
+ if (objectFieldTitle) {
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
+ return getObjectStringLeaves({ value: parsed, fieldTitle: objectFieldTitle })
+ }
+
+ return []
+}
+
+export function replaceJsonStringLeafRange({
+ value,
+ subBlockType,
+ path,
+ range,
+ rawValue,
+ replacement,
+}: {
+ value: unknown
+ subBlockType: SubBlockType | undefined
+ path: WorkflowSearchValuePath
+ range: WorkflowSearchRange
+ rawValue: string
+ replacement: string
+}): JsonStringLeafReplacementResult {
+ if (!isSearchableJsonValueSubBlock(subBlockType)) {
+ return { handled: false, success: false }
+ }
+
+ const parsedValue = getParsedValue(value)
+ if (!parsedValue) {
+ return { handled: true, success: false, reason: 'Target JSON is no longer valid' }
+ }
+ const { parsed, stringify } = parsedValue
+
+ const currentLeaf = getValueAtPath(parsed, path)
+ if (typeof currentLeaf !== 'string') {
+ for (let prefixLength = path.length - 1; prefixLength > 0; prefixLength -= 1) {
+ const valuePrefix = path.slice(0, prefixLength)
+ const nestedValue = getValueAtPath(parsed, valuePrefix)
+ if (typeof nestedValue !== 'string') continue
+
+ const nestedResult = replaceJsonStringLeafRange({
+ value: nestedValue,
+ subBlockType,
+ path: path.slice(prefixLength),
+ range,
+ rawValue,
+ replacement,
+ })
+ if (!nestedResult.handled || !nestedResult.success) return nestedResult
+
+ return {
+ handled: true,
+ success: true,
+ nextValue: stringify
+ ? JSON.stringify(setValueAtPath(parsed, valuePrefix, nestedResult.nextValue))
+ : setValueAtPath(parsed, valuePrefix, nestedResult.nextValue),
+ }
+ }
+ }
+
+ if (typeof currentLeaf !== 'string') {
+ return { handled: true, success: false, reason: 'Target value is no longer text' }
+ }
+
+ const currentRawValue = currentLeaf.slice(range.start, range.end)
+ if (currentRawValue !== rawValue) {
+ return { handled: true, success: false, reason: 'Target text changed since search' }
+ }
+
+ const nextLeaf = `${currentLeaf.slice(0, range.start)}${replacement}${currentLeaf.slice(range.end)}`
+ return {
+ handled: true,
+ success: true,
+ nextValue: stringify
+ ? JSON.stringify(setValueAtPath(parsed, path, nextLeaf))
+ : setValueAtPath(parsed, path, nextLeaf),
+ }
+}
diff --git a/apps/sim/lib/workflows/search-replace/replacements.test.ts b/apps/sim/lib/workflows/search-replace/replacements.test.ts
index 3c71d6308ce..8dd90198605 100644
--- a/apps/sim/lib/workflows/search-replace/replacements.test.ts
+++ b/apps/sim/lib/workflows/search-replace/replacements.test.ts
@@ -125,46 +125,1023 @@ describe('buildWorkflowSearchReplacePlan', () => {
expect(plan.updates[0].nextValue).toBe('kb-new,kb-old,kb-second')
})
+ it('replaces a selected duplicate structured resource when duplicates are separated', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second,kb-old'
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'kb-old',
+ mode: 'resource',
+ blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
+ }).filter((match) => match.kind === 'knowledge-base')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set([matches[1].id]),
+ defaultReplacement: 'kb-new',
+ resourceReplacementOptions: [
+ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
+ ],
+ })
+
+ expect(plan.conflicts).toEqual([])
+ expect(plan.updates).toHaveLength(1)
+ expect(plan.updates[0].nextValue).toBe('kb-old,kb-second,kb-new')
+ })
+
+ it('conflicts when a selected duplicate structured resource occurrence is removed', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second,kb-old'
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'kb-old',
+ mode: 'resource',
+ blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
+ }).filter((match) => match.kind === 'knowledge-base')
+
+ workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second'
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set([matches[1].id]),
+ defaultReplacement: 'kb-new',
+ resourceReplacementOptions: [
+ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
+ ],
+ })
+
+ expect(plan.updates).toEqual([])
+ expect(plan.conflicts).toEqual([
+ { matchId: matches[1].id, reason: 'Target resource changed since search' },
+ ])
+ })
+
+ it('replaces duplicate structured resources with blank comma segments consistently', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,,kb-second,kb-old'
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'kb-old',
+ mode: 'resource',
+ blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
+ }).filter((match) => match.kind === 'knowledge-base')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set([matches[1].id]),
+ defaultReplacement: 'kb-new',
+ resourceReplacementOptions: [
+ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
+ ],
+ })
+
+ expect(plan.conflicts).toEqual([])
+ expect(plan.updates).toHaveLength(1)
+ expect(plan.updates[0].nextValue).toBe('kb-old,,kb-second,kb-new')
+ })
+
it('replaces all compatible knowledge base references across blocks', () => {
const workflow = createSearchReplaceWorkflowFixture()
- workflow.blocks['knowledge-2'] = {
- ...workflow.blocks['knowledge-1'],
- id: 'knowledge-2',
- name: 'Knowledge 2',
+ workflow.blocks['knowledge-2'] = {
+ ...workflow.blocks['knowledge-1'],
+ id: 'knowledge-2',
+ name: 'Knowledge 2',
+ subBlocks: {
+ ...workflow.blocks['knowledge-1'].subBlocks,
+ knowledgeBaseIds: {
+ id: 'knowledgeBaseIds',
+ type: 'knowledge-base-selector',
+ value: 'kb-old',
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'kb-old',
+ mode: 'resource',
+ blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
+ }).filter((match) => match.kind === 'knowledge-base')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'kb-new',
+ resourceReplacementOptions: [
+ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
+ ],
+ })
+
+ expect(plan.conflicts).toEqual([])
+ expect(plan.updates).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ blockId: 'knowledge-1', nextValue: 'kb-new,kb-second' }),
+ expect.objectContaining({ blockId: 'knowledge-2', nextValue: 'kb-new' }),
+ ])
+ )
+ })
+
+ it('replaces selector-backed workflow and knowledge document resources with scoped options', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['workflow-tool-1'] = {
+ id: 'workflow-tool-1',
+ type: 'custom',
+ name: 'Workflow Tool',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ workflowId: {
+ id: 'workflowId',
+ type: 'workflow-selector',
+ value: 'workflow-old',
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ {
+ id: 'workflowId',
+ title: 'Workflow',
+ type: 'workflow-selector',
+ selectorKey: 'sim.workflows',
+ },
+ ],
+ },
+ }
+
+ const workflowMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'workflow-old',
+ mode: 'resource',
+ workspaceId: 'workspace-1',
+ workflowId: 'current-workflow',
+ blockConfigs,
+ }).filter((match) => match.kind === 'workflow')
+ const documentMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'doc-old',
+ mode: 'resource',
+ workspaceId: 'workspace-1',
+ workflowId: 'current-workflow',
+ blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
+ }).filter((match) => match.kind === 'knowledge-document')
+
+ const workflowPlan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches: workflowMatches,
+ selectedMatchIds: new Set(workflowMatches.map((match) => match.id)),
+ defaultReplacement: 'workflow-new',
+ resourceReplacementOptions: [
+ {
+ kind: 'workflow',
+ value: 'workflow-new',
+ label: 'New Workflow',
+ resourceGroupKey: workflowMatches[0].resource?.resourceGroupKey,
+ },
+ ],
+ })
+ const documentPlan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches: documentMatches,
+ selectedMatchIds: new Set(documentMatches.map((match) => match.id)),
+ defaultReplacement: 'doc-new',
+ resourceReplacementOptions: [
+ {
+ kind: 'knowledge-document',
+ value: 'doc-new',
+ label: 'New Document',
+ resourceGroupKey: documentMatches[0].resource?.resourceGroupKey,
+ },
+ ],
+ })
+
+ expect(workflowPlan.conflicts).toEqual([])
+ expect(workflowPlan.updates).toEqual([
+ expect.objectContaining({
+ blockId: 'workflow-tool-1',
+ subBlockId: 'workflowId',
+ nextValue: 'workflow-new',
+ }),
+ ])
+ expect(documentPlan.conflicts).toEqual([])
+ expect(documentPlan.updates).toEqual([
+ expect.objectContaining({
+ blockId: 'knowledge-1',
+ subBlockId: 'documentId',
+ nextValue: 'doc-new',
+ }),
+ ])
+ })
+
+ it('replaces structured file resources stored as serialized tool input params', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ credential: 'slack-credential',
+ text: 'message with file',
+ attachmentFiles: JSON.stringify({
+ name: 'contract.pdf',
+ key: 'file-key-old',
+ path: '/contract.pdf',
+ size: 12,
+ type: 'application/pdf',
+ }),
+ },
+ },
+ ],
+ },
+ },
+ }
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'contract',
+ mode: 'all',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.kind === 'file')
+
+ const replacementFile = {
+ name: 'contract-v2.pdf',
+ key: 'file-key-new',
+ path: '/contract-v2.pdf',
+ size: 24,
+ type: 'application/pdf',
+ }
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: JSON.stringify(replacementFile),
+ resourceReplacementOptions: [
+ { kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name },
+ ],
+ })
+
+ expect(plan.conflicts).toEqual([])
+ expect(plan.updates).toHaveLength(1)
+ expect(plan.updates[0].nextValue).toEqual([
+ expect.objectContaining({
+ params: expect.objectContaining({
+ attachmentFiles: JSON.stringify(replacementFile),
+ }),
+ }),
+ ])
+ })
+
+ it('replaces one duplicate file occurrence in a serialized tool input file array', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ const files = [
+ {
+ name: 'first.pdf',
+ key: 'file-key-old',
+ path: '/first.pdf',
+ size: 12,
+ type: 'application/pdf',
+ },
+ {
+ name: 'second.pdf',
+ key: 'file-key-other',
+ path: '/second.pdf',
+ size: 14,
+ type: 'application/pdf',
+ },
+ {
+ name: 'third.pdf',
+ key: 'file-key-old',
+ path: '/third.pdf',
+ size: 16,
+ type: 'application/pdf',
+ },
+ ]
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ credential: 'slack-credential',
+ text: 'message with files',
+ attachmentFiles: JSON.stringify(files),
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'file-key-old',
+ mode: 'resource',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.kind === 'file')
+ const replacementFile = {
+ name: 'replacement.pdf',
+ key: 'file-key-new',
+ path: '/replacement.pdf',
+ size: 24,
+ type: 'application/pdf',
+ }
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set([matches[1].id]),
+ defaultReplacement: JSON.stringify(replacementFile),
+ resourceReplacementOptions: [
+ { kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name },
+ ],
+ })
+ const nextTools = plan.updates[0].nextValue as Array<{ params: { attachmentFiles: string } }>
+
+ expect(plan.conflicts).toEqual([])
+ expect(JSON.parse(nextTools[0].params.attachmentFiles)).toEqual([
+ files[0],
+ files[1],
+ replacementFile,
+ ])
+ })
+
+ it('conflicts when a selected duplicate file occurrence is removed', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ const files = [
+ {
+ name: 'first.pdf',
+ key: 'file-key-old',
+ path: '/first.pdf',
+ size: 12,
+ type: 'application/pdf',
+ },
+ {
+ name: 'second.pdf',
+ key: 'file-key-other',
+ path: '/second.pdf',
+ size: 14,
+ type: 'application/pdf',
+ },
+ {
+ name: 'third.pdf',
+ key: 'file-key-old',
+ path: '/third.pdf',
+ size: 16,
+ type: 'application/pdf',
+ },
+ ]
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ credential: 'slack-credential',
+ text: 'message with files',
+ attachmentFiles: JSON.stringify(files),
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'file-key-old',
+ mode: 'resource',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.kind === 'file')
+
+ workflow.blocks['tool-input-1'].subBlocks.tools.value = [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ credential: 'slack-credential',
+ text: 'message with files',
+ attachmentFiles: JSON.stringify([files[0], files[1]]),
+ },
+ },
+ ]
+
+ const replacementFile = {
+ name: 'replacement.pdf',
+ key: 'file-key-new',
+ path: '/replacement.pdf',
+ size: 24,
+ type: 'application/pdf',
+ }
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set([matches[1].id]),
+ defaultReplacement: JSON.stringify(replacementFile),
+ resourceReplacementOptions: [
+ { kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name },
+ ],
+ })
+
+ expect(plan.updates).toEqual([])
+ expect(plan.conflicts).toEqual([
+ { matchId: matches[1].id, reason: 'Target resource changed since search' },
+ ])
+ })
+
+ it('conflicts when a selected duplicate file occurrence becomes a single file object', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ const firstFile = {
+ name: 'first.pdf',
+ key: 'file-key-old',
+ path: '/first.pdf',
+ size: 12,
+ type: 'application/pdf',
+ }
+ const secondFile = {
+ name: 'second.pdf',
+ key: 'file-key-old',
+ path: '/second.pdf',
+ size: 14,
+ type: 'application/pdf',
+ }
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ credential: 'slack-credential',
+ text: 'message with files',
+ attachmentFiles: [firstFile, secondFile],
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'file-key-old',
+ mode: 'resource',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.kind === 'file')
+
+ workflow.blocks['tool-input-1'].subBlocks.tools.value = [
+ {
+ type: 'slack',
+ toolId: 'slack_message',
+ operation: 'send',
+ title: 'Slack message',
+ params: {
+ authMethod: 'oauth',
+ credential: 'slack-credential',
+ text: 'message with files',
+ attachmentFiles: firstFile,
+ },
+ },
+ ]
+
+ const replacementFile = {
+ name: 'replacement.pdf',
+ key: 'file-key-new',
+ path: '/replacement.pdf',
+ size: 24,
+ type: 'application/pdf',
+ }
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set([matches[1].id]),
+ defaultReplacement: JSON.stringify(replacementFile),
+ resourceReplacementOptions: [
+ { kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name },
+ ],
+ })
+
+ expect(plan.updates).toEqual([])
+ expect(plan.conflicts).toEqual([
+ { matchId: matches[1].id, reason: 'Target resource changed since search' },
+ ])
+ })
+
+ it('clears nested tool-input dependents when replacing a parent resource', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'workflow_input',
+ toolId: 'workflow_executor',
+ title: 'Workflow',
+ params: {
+ workflowId: 'workflow-old',
+ inputMapping: JSON.stringify({ customerEmail: 'old email value' }),
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'workflow-old',
+ mode: 'resource',
+ workspaceId: 'workspace-1',
+ workflowId: 'current-workflow',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.kind === 'workflow')
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'workflow-new',
+ resourceReplacementOptions: [
+ {
+ kind: 'workflow',
+ value: 'workflow-new',
+ label: 'New Workflow',
+ resourceGroupKey: matches[0].resource?.resourceGroupKey,
+ },
+ ],
+ })
+
+ expect(plan.conflicts).toEqual([])
+ expect(plan.updates).toEqual([
+ expect.objectContaining({
+ subBlockId: 'tools',
+ nextValue: [
+ expect.objectContaining({
+ params: expect.objectContaining({
+ workflowId: 'workflow-new',
+ inputMapping: '',
+ }),
+ }),
+ ],
+ }),
+ ])
+ })
+
+ it('preserves selected nested dependent replacements when replacing a parent resource', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'workflow_input',
+ toolId: 'workflow_executor',
+ title: 'Workflow',
+ params: {
+ workflowId: 'workflow-old',
+ inputMapping: JSON.stringify({ customerEmail: 'old email value' }),
+ },
+ },
+ ],
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ }
+ const workflowMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'workflow-old',
+ mode: 'resource',
+ workspaceId: 'workspace-1',
+ workflowId: 'current-workflow',
+ blockConfigs,
+ }).filter((match) => match.kind === 'workflow')
+ const mappingMatches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old email',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.subBlockType === 'workflow-input-mapper')
+ const matches = [...workflowMatches, ...mappingMatches]
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ replacementByMatchId: {
+ [workflowMatches[0].id]: 'workflow-new',
+ [mappingMatches[0].id]: 'new email',
+ },
+ resourceReplacementOptions: [
+ {
+ kind: 'workflow',
+ value: 'workflow-new',
+ label: 'New Workflow',
+ resourceGroupKey: workflowMatches[0].resource?.resourceGroupKey,
+ },
+ ],
+ })
+ const nextTools = plan.updates[0].nextValue as Array<{
+ params: { inputMapping: string; workflowId: string }
+ }>
+
+ expect(plan.conflicts).toEqual([])
+ expect(nextTools[0].params.workflowId).toBe('workflow-new')
+ expect(JSON.parse(nextTools[0].params.inputMapping)).toEqual({
+ customerEmail: 'new email value',
+ })
+ })
+
+ it('replaces serialized workflow-input mapper values without changing keys', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'workflow_input',
+ toolId: 'workflow_executor',
+ title: 'Workflow',
+ params: {
+ workflowId: 'workflow-old',
+ inputMapping: JSON.stringify({ customerEmail: 'old email value' }),
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old email',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.subBlockType === 'workflow-input-mapper')
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'new email',
+ })
+ const nextTools = plan.updates[0].nextValue as Array<{ params: { inputMapping: string } }>
+
+ expect(plan.conflicts).toEqual([])
+ expect(JSON.parse(nextTools[0].params.inputMapping)).toEqual({
+ customerEmail: 'new email value',
+ })
+ })
+
+ it('replaces object-valued fallback tool params without changing metadata', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'mcp',
+ title: 'MCP tool',
+ params: {
+ payload: {
+ type: 'metadata-type',
+ filter: { status: 'old customer' },
+ },
+ },
+ },
+ ],
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'new',
+ })
+ const nextTools = plan.updates[0].nextValue as Array<{
+ params: { payload: { type: string; filter: { status: string } } }
+ }>
+
+ expect(plan.conflicts).toEqual([])
+ expect(nextTools[0].params.payload).toEqual({
+ type: 'metadata-type',
+ filter: { status: 'new customer' },
+ })
+ })
+
+ it('replaces serialized JSON fallback tool param values without changing keys', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tool-input-1'] = {
+ id: 'tool-input-1',
+ type: 'custom',
+ name: 'Tool Input Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
subBlocks: {
- ...workflow.blocks['knowledge-1'].subBlocks,
- knowledgeBaseIds: {
- id: 'knowledgeBaseIds',
- type: 'knowledge-base-selector',
- value: 'kb-old',
+ tools: {
+ id: 'tools',
+ type: 'tool-input',
+ value: [
+ {
+ type: 'mcp',
+ title: 'MCP tool',
+ params: {
+ payload: JSON.stringify({ customer: { name: 'old customer' } }),
+ },
+ },
+ ],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
- query: 'kb-old',
- mode: 'resource',
- blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
- }).filter((match) => match.kind === 'knowledge-base')
+ query: 'old',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'tool-input-1')
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'new',
+ })
+ const nextTools = plan.updates[0].nextValue as Array<{ params: { payload: string } }>
+
+ expect(plan.conflicts).toEqual([])
+ expect(JSON.parse(nextTools[0].params.payload)).toEqual({
+ customer: { name: 'new customer' },
+ })
+ })
+
+ it('replaces stringified variables-input values without changing metadata', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['variables-1'] = {
+ id: 'variables-1',
+ type: 'custom',
+ name: 'Variables',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ assignments: {
+ id: 'assignments',
+ type: 'variables-input',
+ value: JSON.stringify([
+ {
+ id: 'assignment-id',
+ variableId: 'variable-id',
+ variableName: 'customer',
+ type: 'string',
+ value: 'old customer',
+ isExisting: true,
+ },
+ ]),
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'assignments', title: 'Variables', type: 'variables-input' }],
+ },
+ },
+ }).filter((match) => match.blockId === 'variables-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
- defaultReplacement: 'kb-new',
- resourceReplacementOptions: [
- { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
- ],
+ defaultReplacement: 'new',
})
+ const nextAssignments = JSON.parse(plan.updates[0].nextValue as string)
expect(plan.conflicts).toEqual([])
- expect(plan.updates).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ blockId: 'knowledge-1', nextValue: 'kb-new,kb-second' }),
- expect.objectContaining({ blockId: 'knowledge-2', nextValue: 'kb-new' }),
- ])
- )
+ expect(nextAssignments).toEqual([
+ {
+ id: 'assignment-id',
+ variableId: 'variable-id',
+ variableName: 'customer',
+ type: 'string',
+ value: 'new customer',
+ isExisting: true,
+ },
+ ])
+ })
+
+ it('replaces stringified table cell values without changing row metadata', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['table-1'] = {
+ id: 'table-1',
+ type: 'custom',
+ name: 'Table',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ rows: {
+ id: 'rows',
+ type: 'table',
+ value: JSON.stringify([{ id: 'row-id', cells: { Name: 'old customer' } }]),
+ },
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old',
+ mode: 'text',
+ blockConfigs: {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'rows', title: 'Rows', type: 'table', columns: ['Name'] }],
+ },
+ },
+ }).filter((match) => match.blockId === 'table-1')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'new',
+ })
+ const nextRows = JSON.parse(plan.updates[0].nextValue as string)
+
+ expect(plan.conflicts).toEqual([])
+ expect(nextRows).toEqual([{ id: 'row-id', cells: { Name: 'new customer' } }])
+ })
+
+ it('allows replacing text matches with an empty string', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'email',
+ mode: 'text',
+ blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
+ }).filter((match) => match.blockId === 'agent-1')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set([matches[0].id]),
+ defaultReplacement: '',
+ })
+
+ expect(plan.conflicts).toEqual([])
+ expect(plan.updates).toEqual([
+ expect.objectContaining({
+ nextValue: ' {{OLD_SECRET}} and then email again. Use .',
+ }),
+ ])
})
it('replaces loop and parallel subflow editor values', () => {
@@ -244,6 +1221,321 @@ describe('buildWorkflowSearchReplacePlan', () => {
])
})
+ it('replaces JSON-backed tag value fields without touching tag metadata', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['tag-block-1'] = {
+ id: 'tag-block-1',
+ type: 'custom',
+ name: 'Tag Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ tagFilters: {
+ id: 'tagFilters',
+ type: 'knowledge-tag-filters',
+ value: JSON.stringify([
+ {
+ id: 'filter-open',
+ tagName: 'Status',
+ fieldType: 'text',
+ operator: 'eq',
+ tagValue: 'open ticket',
+ collapsed: false,
+ },
+ ]),
+ },
+ documentTags: {
+ id: 'documentTags',
+ type: 'document-tag-entry',
+ value: JSON.stringify([
+ {
+ id: 'tag-open',
+ tagName: 'Priority',
+ fieldType: 'text',
+ value: 'open escalation',
+ collapsed: false,
+ },
+ ]),
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [
+ { id: 'tagFilters', title: 'Tag Filters', type: 'knowledge-tag-filters' },
+ { id: 'documentTags', title: 'Document Tags', type: 'document-tag-entry' },
+ ],
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'open',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'tag-block-1')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'resolved',
+ })
+
+ const tagFilterUpdate = plan.updates.find((update) => update.subBlockId === 'tagFilters')
+ const documentTagUpdate = plan.updates.find((update) => update.subBlockId === 'documentTags')
+ const nextTagFilterValue = JSON.parse(String(tagFilterUpdate?.nextValue))
+ const nextDocumentTagValue = JSON.parse(String(documentTagUpdate?.nextValue))
+
+ expect(plan.conflicts).toEqual([])
+ expect(nextTagFilterValue).toEqual([
+ {
+ id: 'filter-open',
+ tagName: 'Status',
+ fieldType: 'text',
+ operator: 'eq',
+ tagValue: 'resolved ticket',
+ collapsed: false,
+ },
+ ])
+ expect(nextDocumentTagValue).toEqual([
+ {
+ id: 'tag-open',
+ tagName: 'Priority',
+ fieldType: 'text',
+ value: 'resolved escalation',
+ collapsed: false,
+ },
+ ])
+ })
+
+ it('replaces JSON-backed condition branch values without touching branch metadata', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['branch-1'] = {
+ id: 'branch-1',
+ type: 'custom',
+ name: 'Branch Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ conditions: {
+ id: 'conditions',
+ type: 'condition-input',
+ value: JSON.stringify([
+ {
+ id: 'branch-open',
+ title: 'if',
+ value: 'open ticket',
+ showTags: false,
+ showEnvVars: false,
+ },
+ ]),
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'conditions', title: 'Conditions', type: 'condition-input' }],
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'open',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'branch-1')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'resolved',
+ })
+
+ const branchUpdate = plan.updates.find((update) => update.subBlockId === 'conditions')
+ const nextValue = JSON.parse(String(branchUpdate?.nextValue))
+
+ expect(plan.conflicts).toEqual([])
+ expect(nextValue).toEqual([
+ {
+ id: 'branch-open',
+ title: 'if',
+ value: 'resolved ticket',
+ showTags: false,
+ showEnvVars: false,
+ },
+ ])
+ })
+
+ it('replaces object-backed input mapping values without changing mapping keys', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['mapping-1'] = {
+ id: 'mapping-1',
+ type: 'custom',
+ name: 'Mapping Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ inputMapping: {
+ id: 'inputMapping',
+ type: 'input-mapping',
+ value: { customerEmail: 'old email value' },
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'inputMapping', title: 'Input Mapping', type: 'input-mapping' }],
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old',
+ mode: 'text',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'mapping-1')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: 'new',
+ })
+
+ expect(plan.conflicts).toEqual([])
+ expect(plan.updates).toEqual([
+ expect.objectContaining({
+ subBlockId: 'inputMapping',
+ nextValue: { customerEmail: 'new email value' },
+ }),
+ ])
+ })
+
+ it('replaces workspace file upload resources with the selected file object', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['file-upload-1'] = {
+ id: 'file-upload-1',
+ type: 'custom',
+ name: 'File Upload Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ file: {
+ id: 'file',
+ type: 'file-upload',
+ value: {
+ name: 'old.csv',
+ path: '/workspace/ws-1/old-key',
+ key: 'old-key',
+ size: 42,
+ type: 'text/csv',
+ },
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'file', title: 'File', type: 'file-upload' }],
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old',
+ mode: 'resource',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'file-upload-1')
+ const replacementFile = {
+ name: 'new.csv',
+ path: '/workspace/ws-1/new-key',
+ key: 'new-key',
+ size: 84,
+ type: 'text/csv',
+ }
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: JSON.stringify(replacementFile),
+ resourceReplacementOptions: [
+ { kind: 'file', value: JSON.stringify(replacementFile), label: 'new.csv' },
+ ],
+ })
+
+ expect(plan.conflicts).toEqual([])
+ expect(plan.updates).toEqual([
+ expect.objectContaining({
+ subBlockId: 'file',
+ nextValue: replacementFile,
+ }),
+ ])
+ })
+
+ it('rejects invalid file upload replacement payloads', () => {
+ const workflow = createSearchReplaceWorkflowFixture()
+ workflow.blocks['file-upload-1'] = {
+ id: 'file-upload-1',
+ type: 'custom',
+ name: 'File Upload Block',
+ position: { x: 0, y: 0 },
+ enabled: true,
+ outputs: {},
+ subBlocks: {
+ file: {
+ id: 'file',
+ type: 'file-upload',
+ value: {
+ name: 'old.csv',
+ path: '/workspace/ws-1/old-key',
+ key: 'old-key',
+ },
+ },
+ },
+ }
+ const blockConfigs = {
+ ...SEARCH_REPLACE_BLOCK_CONFIGS,
+ custom: {
+ subBlocks: [{ id: 'file', title: 'File', type: 'file-upload' }],
+ },
+ }
+
+ const matches = indexWorkflowSearchMatches({
+ workflow,
+ query: 'old',
+ mode: 'resource',
+ blockConfigs,
+ }).filter((match) => match.blockId === 'file-upload-1')
+
+ const plan = buildWorkflowSearchReplacePlan({
+ blocks: workflow.blocks,
+ matches,
+ selectedMatchIds: new Set(matches.map((match) => match.id)),
+ defaultReplacement: '"not-a-file-object"',
+ resourceReplacementOptions: [
+ { kind: 'file', value: '"not-a-file-object"', label: 'Invalid file' },
+ ],
+ })
+
+ expect(plan.updates).toEqual([])
+ expect(plan.conflicts).toEqual([
+ {
+ matchId: matches[0].id,
+ reason: 'Replacement file is no longer valid',
+ },
+ ])
+ })
+
it('rejects invalid subflow iteration replacements', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['parallel-1'] = {
diff --git a/apps/sim/lib/workflows/search-replace/replacements.ts b/apps/sim/lib/workflows/search-replace/replacements.ts
index 9f1dc74c8c5..9692f52de85 100644
--- a/apps/sim/lib/workflows/search-replace/replacements.ts
+++ b/apps/sim/lib/workflows/search-replace/replacements.ts
@@ -1,4 +1,10 @@
-import { getWorkflowSearchReplacementIssue } from '@/lib/workflows/search-replace/replacement-validation'
+import { replaceJsonStringLeafRange } from '@/lib/workflows/search-replace/json-value-fields'
+import {
+ getWorkflowSearchReplacementIssue,
+ normalizeWorkflowSearchResourceReplacement,
+ replaceWorkflowSearchResourceValue,
+ workflowSearchResourceValueContains,
+} from '@/lib/workflows/search-replace/resources'
import {
getWorkflowSearchSubflowField,
parseWorkflowSearchSubflowReplacement,
@@ -27,67 +33,47 @@ interface BuildWorkflowSearchReplacePlanParams {
}
function normalizeReplacement(match: WorkflowSearchMatch, replacement: string): string {
- if (match.kind === 'environment') {
- const trimmed = replacement.trim()
- if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) return trimmed
- return `{{${trimmed}}}`
- }
- return replacement
+ return normalizeWorkflowSearchResourceReplacement(match, replacement)
}
function replaceRange(value: string, start: number, end: number, replacement: string): string {
return `${value.slice(0, start)}${replacement}${value.slice(end)}`
}
-function replaceStructuredValue(
- value: unknown,
- rawValue: string,
- replacement: string,
- targetOccurrenceIndex?: number
-): unknown {
- let occurrenceIndex = 0
-
- const shouldReplace = (item: string) => {
- if (item !== rawValue) return false
- const currentOccurrenceIndex = occurrenceIndex
- occurrenceIndex += 1
- return targetOccurrenceIndex === undefined || currentOccurrenceIndex === targetOccurrenceIndex
- }
-
- if (typeof value === 'string') {
- const parts = value.split(',').map((part) => part.trim())
- if (parts.length > 1) {
- return parts.map((part) => (shouldReplace(part) ? replacement : part)).join(',')
- }
- return shouldReplace(value) ? replacement : value
- }
+function clearDependentValues(value: unknown, paths: WorkflowSearchMatch['dependentValuePaths']) {
+ return (paths ?? []).reduce((currentValue, path) => setValueAtPath(currentValue, path, ''), value)
+}
- if (Array.isArray(value)) {
- const replaceItem = (item: unknown): unknown => {
- if (typeof item === 'string') {
- return shouldReplace(item) ? replacement : item
- }
- if (Array.isArray(item)) return item.map(replaceItem)
- return item
- }
+function pathStartsWith(
+ path: WorkflowSearchMatch['valuePath'],
+ prefix: WorkflowSearchMatch['valuePath']
+) {
+ return prefix.every((segment, index) => path[index] === segment)
+}
- return value.map(replaceItem)
+function getTouchedPathsByField(matches: WorkflowSearchMatch[]) {
+ const touchedPathsByField = new Map()
+ for (const match of matches) {
+ if (match.target.kind !== 'subblock') continue
+ const updateKey = `${match.blockId}:${match.subBlockId}`
+ const paths = touchedPathsByField.get(updateKey) ?? []
+ paths.push(match.valuePath)
+ touchedPathsByField.set(updateKey, paths)
}
-
- return value
+ return touchedPathsByField
}
-function structuredValueContains(value: unknown, rawValue: string): boolean {
- if (typeof value === 'string') {
- return value
- .split(',')
- .map((part) => part.trim())
- .includes(rawValue)
- }
- if (Array.isArray(value)) {
- return value.some((item) => structuredValueContains(item, rawValue))
- }
- return false
+function getDependentValuePathsToClear(
+ match: WorkflowSearchMatch,
+ touchedPathsByField: Map
+) {
+ if (!match.dependentValuePaths?.length) return undefined
+ const updateKey = `${match.blockId}:${match.subBlockId}`
+ const touchedPaths = touchedPathsByField.get(updateKey) ?? []
+ return match.dependentValuePaths.filter(
+ (dependentPath) =>
+ !touchedPaths.some((touchedPath) => pathStartsWith(touchedPath, dependentPath))
+ )
}
function getReplacement(
@@ -114,6 +100,7 @@ export function buildWorkflowSearchReplacePlan({
const subflowUpdatesByField = new Map()
const selectedMatches = matches.filter((match) => selectedMatchIds.has(match.id))
+ const touchedPathsByField = getTouchedPathsByField(selectedMatches)
const orderedMatches = [...selectedMatches].sort((a, b) => {
const blockCompare = a.blockId.localeCompare(b.blockId)
if (blockCompare !== 0) return blockCompare
@@ -228,8 +215,37 @@ export function buildWorkflowSearchReplacePlan({
const existingUpdate = updatesByField.get(updateKey)
const previousValue: unknown = existingUpdate?.previousValue ?? subBlock.value
let nextValue: unknown = existingUpdate?.nextValue ?? subBlock.value
+ const dependentValuePathsToClear = getDependentValuePathsToClear(match, touchedPathsByField)
if (match.range) {
+ const jsonReplacement = replaceJsonStringLeafRange({
+ value: nextValue,
+ subBlockType: match.subBlockType,
+ path: match.valuePath,
+ range: match.range,
+ rawValue: match.rawValue,
+ replacement,
+ })
+ if (jsonReplacement.handled) {
+ if (!jsonReplacement.success) {
+ conflicts.push({
+ matchId: match.id,
+ reason: jsonReplacement.reason ?? 'Target value is no longer text',
+ })
+ continue
+ }
+ nextValue = jsonReplacement.nextValue
+ nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
+ updatesByField.set(updateKey, {
+ blockId: match.blockId,
+ subBlockId: match.subBlockId,
+ previousValue,
+ nextValue,
+ matchIds: [...(existingUpdate?.matchIds ?? []), match.id],
+ })
+ continue
+ }
+
const currentLeaf = getValueAtPath(nextValue, match.valuePath)
if (typeof currentLeaf !== 'string') {
conflicts.push({ matchId: match.id, reason: 'Target value is no longer text' })
@@ -247,24 +263,33 @@ export function buildWorkflowSearchReplacePlan({
match.valuePath,
replaceRange(currentLeaf, match.range.start, match.range.end, replacement)
)
+ nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
} else {
const currentValue = getValueAtPath(nextValue, match.valuePath)
const valueForReplacement = match.valuePath.length === 0 ? nextValue : currentValue
- if (!structuredValueContains(valueForReplacement, match.rawValue)) {
+ if (!workflowSearchResourceValueContains(match, valueForReplacement)) {
conflicts.push({ matchId: match.id, reason: 'Target resource changed since search' })
continue
}
- const replacedValue = replaceStructuredValue(
+ const resourceReplacement = replaceWorkflowSearchResourceValue(
+ match,
valueForReplacement,
- match.rawValue,
- replacement,
- match.structuredOccurrenceIndex
+ replacement
)
+ if (!resourceReplacement.success) {
+ conflicts.push({
+ matchId: match.id,
+ reason: resourceReplacement.reason ?? 'Target resource is no longer replaceable',
+ })
+ continue
+ }
+ const replacedValue = resourceReplacement.nextValue
nextValue =
match.valuePath.length === 0
? replacedValue
: setValueAtPath(nextValue, match.valuePath, replacedValue)
+ nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
}
updatesByField.set(updateKey, {
diff --git a/apps/sim/lib/workflows/search-replace/resources/index.ts b/apps/sim/lib/workflows/search-replace/resources/index.ts
new file mode 100644
index 00000000000..31571a3a84c
--- /dev/null
+++ b/apps/sim/lib/workflows/search-replace/resources/index.ts
@@ -0,0 +1,4 @@
+export * from './references'
+export * from './registry'
+export * from './resolvers'
+export * from './validation'
diff --git a/apps/sim/lib/workflows/search-replace/reference-registry.ts b/apps/sim/lib/workflows/search-replace/resources/references.ts
similarity index 50%
rename from apps/sim/lib/workflows/search-replace/reference-registry.ts
rename to apps/sim/lib/workflows/search-replace/resources/references.ts
index a484107370b..ad2619f9e66 100644
--- a/apps/sim/lib/workflows/search-replace/reference-registry.ts
+++ b/apps/sim/lib/workflows/search-replace/resources/references.ts
@@ -1,7 +1,9 @@
-import type { SubBlockType } from '@sim/workflow-types/blocks'
-import { buildWorkflowSearchResourceGroupKey } from '@/lib/workflows/search-replace/resource-resolvers'
+import {
+ getWorkflowSearchSubBlockResourceKind,
+ parseWorkflowSearchSubBlockResources,
+ type StructuredResourceReference,
+} from '@/lib/workflows/search-replace/resources/registry'
import type {
- WorkflowSearchMatchKind,
WorkflowSearchRange,
WorkflowSearchResourceMeta,
} from '@/lib/workflows/search-replace/types'
@@ -17,40 +19,10 @@ export interface ParsedInlineReference {
resource: WorkflowSearchResourceMeta
}
-export interface StructuredResourceReference {
- kind: Exclude
- rawValue: string
- searchText: string
- resource: WorkflowSearchResourceMeta
-}
-
-const RESOURCE_KIND_BY_SUBBLOCK_TYPE: Partial<
- Record<
- SubBlockType,
- Exclude
- >
-> = {
- 'oauth-input': 'oauth-credential',
- 'knowledge-base-selector': 'knowledge-base',
- 'document-selector': 'knowledge-document',
- 'workflow-selector': 'workflow',
- 'mcp-server-selector': 'mcp-server',
- 'mcp-tool-selector': 'mcp-tool',
- 'table-selector': 'table',
- 'file-selector': 'file',
- 'channel-selector': 'selector-resource',
- 'user-selector': 'selector-resource',
- 'sheet-selector': 'selector-resource',
- 'folder-selector': 'selector-resource',
- 'project-selector': 'selector-resource',
- 'variables-input': 'selector-resource',
-}
-
export function getResourceKindForSubBlock(
subBlockConfig?: Pick
): StructuredResourceReference['kind'] | null {
- if (!subBlockConfig) return null
- return RESOURCE_KIND_BY_SUBBLOCK_TYPE[subBlockConfig.type] ?? null
+ return getWorkflowSearchSubBlockResourceKind(subBlockConfig)
}
export function parseInlineReferences(value: string): ParsedInlineReference[] {
@@ -95,49 +67,12 @@ export function parseInlineReferences(value: string): ParsedInlineReference[] {
return references.sort((a, b) => a.range.start - b.range.start)
}
-function splitStructuredValue(value: unknown): string[] {
- if (typeof value === 'string') {
- return value
- .split(',')
- .map((part) => part.trim())
- .filter(Boolean)
- }
-
- if (Array.isArray(value)) {
- return value.flatMap((item) => splitStructuredValue(item))
- }
-
- return []
-}
-
export function parseStructuredResourceReferences(
value: unknown,
- subBlockConfig?: SubBlockConfig,
+ subBlockConfig?: Pick,
selectorContext?: SelectorContext
): StructuredResourceReference[] {
- const kind = getResourceKindForSubBlock(subBlockConfig)
- if (!kind) return []
-
- const values = splitStructuredValue(value)
- return values.map((rawValue) => {
- const resource: WorkflowSearchResourceMeta = {
- kind,
- providerId: subBlockConfig?.serviceId,
- serviceId: subBlockConfig?.serviceId,
- selectorKey: subBlockConfig?.selectorKey,
- selectorContext: subBlockConfig?.selectorKey ? selectorContext : undefined,
- requiredScopes: subBlockConfig?.requiredScopes,
- key: rawValue,
- }
- resource.resourceGroupKey = buildWorkflowSearchResourceGroupKey(resource)
-
- return {
- kind,
- rawValue,
- searchText: rawValue,
- resource,
- }
- })
+ return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext)
}
export function matchesSearchText(
diff --git a/apps/sim/lib/workflows/search-replace/resources/registry.ts b/apps/sim/lib/workflows/search-replace/resources/registry.ts
new file mode 100644
index 00000000000..b38a134f162
--- /dev/null
+++ b/apps/sim/lib/workflows/search-replace/resources/registry.ts
@@ -0,0 +1,420 @@
+import type { SubBlockType } from '@sim/workflow-types/blocks'
+import { buildWorkflowSearchResourceGroupKey } from '@/lib/workflows/search-replace/resources/resolvers'
+import type {
+ WorkflowSearchMatch,
+ WorkflowSearchMatchKind,
+ WorkflowSearchResourceMeta,
+} from '@/lib/workflows/search-replace/types'
+import type { SubBlockConfig } from '@/blocks/types'
+import type { SelectorContext } from '@/hooks/selectors/types'
+
+export type StructuredWorkflowSearchResourceKind = Exclude<
+ WorkflowSearchMatchKind,
+ 'text' | 'environment' | 'workflow-reference'
+>
+
+interface ResourceCodecParseParams {
+ value: unknown
+ kind: StructuredWorkflowSearchResourceKind
+ subBlockConfig: Pick
+ selectorContext?: SelectorContext
+}
+
+export interface StructuredResourceReference {
+ kind: StructuredWorkflowSearchResourceKind
+ rawValue: string
+ searchText: string
+ resource: WorkflowSearchResourceMeta
+}
+
+interface ResourceCodecReplaceResult {
+ success: boolean
+ nextValue?: unknown
+ reason?: string
+}
+
+interface WorkflowSearchResourceCodec {
+ parse(params: ResourceCodecParseParams): StructuredResourceReference[]
+ contains(value: unknown, rawValue: string): boolean
+ replace(
+ value: unknown,
+ rawValue: string,
+ replacement: string,
+ targetOccurrenceIndex?: number
+ ): ResourceCodecReplaceResult
+}
+
+interface WorkflowSearchResourceKindDefinition {
+ label: string
+ constrained: boolean
+ normalizeReplacement?: (replacement: string) => string
+}
+
+interface WorkflowSearchSubBlockResourceDefinition {
+ kind: StructuredWorkflowSearchResourceKind
+ codec: WorkflowSearchResourceCodec
+}
+
+function createResourceMeta({
+ kind,
+ rawValue,
+ subBlockConfig,
+ selectorContext,
+}: {
+ kind: StructuredWorkflowSearchResourceKind
+ rawValue: string
+ subBlockConfig: Pick
+ selectorContext?: SelectorContext
+}): WorkflowSearchResourceMeta {
+ const resource: WorkflowSearchResourceMeta = {
+ kind,
+ providerId: subBlockConfig.serviceId,
+ serviceId: subBlockConfig.serviceId,
+ selectorKey: subBlockConfig.selectorKey,
+ selectorContext:
+ selectorContext && Object.keys(selectorContext).length > 0 ? selectorContext : undefined,
+ requiredScopes: subBlockConfig.requiredScopes,
+ key: rawValue,
+ }
+ resource.resourceGroupKey = buildWorkflowSearchResourceGroupKey(resource)
+ return resource
+}
+
+function splitCommaResourceValue(value: unknown): string[] {
+ if (typeof value === 'string') {
+ return value
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ }
+
+ if (Array.isArray(value)) {
+ return value.flatMap((item) => splitCommaResourceValue(item))
+ }
+
+ return []
+}
+
+function replaceCommaResourceValue(
+ value: unknown,
+ rawValue: string,
+ replacement: string,
+ targetOccurrenceIndex?: number
+): ResourceCodecReplaceResult {
+ let occurrenceIndex = 0
+ let replaced = false
+
+ const shouldReplace = (item: string) => {
+ if (!item) return false
+ const currentOccurrenceIndex = occurrenceIndex
+ occurrenceIndex += 1
+ if (item !== rawValue) return false
+ const matchesTarget =
+ targetOccurrenceIndex === undefined || currentOccurrenceIndex === targetOccurrenceIndex
+ if (matchesTarget) replaced = true
+ return matchesTarget
+ }
+
+ const replaceItem = (item: unknown): unknown => {
+ if (typeof item === 'string') return shouldReplace(item) ? replacement : item
+ if (Array.isArray(item)) return item.map(replaceItem)
+ return item
+ }
+
+ if (typeof value === 'string') {
+ const parts = value.split(',').map((part) => part.trim())
+ if (parts.length > 1) {
+ const nextValue = parts.map(replaceItem).join(',')
+ if (targetOccurrenceIndex !== undefined && !replaced) {
+ return { success: false, reason: 'Target resource changed since search' }
+ }
+ return { success: true, nextValue }
+ }
+ const nextValue = shouldReplace(value) ? replacement : value
+ if (targetOccurrenceIndex !== undefined && !replaced) {
+ return { success: false, reason: 'Target resource changed since search' }
+ }
+ return { success: true, nextValue }
+ }
+
+ if (Array.isArray(value)) {
+ const nextValue = value.map(replaceItem)
+ if (targetOccurrenceIndex !== undefined && !replaced) {
+ return { success: false, reason: 'Target resource changed since search' }
+ }
+ return { success: true, nextValue }
+ }
+
+ return { success: false, reason: 'Target resource is no longer replaceable' }
+}
+
+function getFileResourceKey(value: unknown): string | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
+ const record = value as Record
+ const key = record.key ?? record.path ?? record.name
+ return typeof key === 'string' && key.trim().length > 0 ? key : null
+}
+
+function parseSerializedResourceValue(value: unknown): { value: unknown; serialized: boolean } {
+ if (typeof value !== 'string') return { value, serialized: false }
+
+ const trimmed = value.trim()
+ if (
+ !(
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
+ )
+ ) {
+ return { value, serialized: false }
+ }
+
+ try {
+ return { value: JSON.parse(trimmed), serialized: true }
+ } catch {
+ return { value, serialized: false }
+ }
+}
+
+function parseFileReplacement(replacement: string): ResourceCodecReplaceResult {
+ try {
+ const parsed: unknown = JSON.parse(replacement)
+ if (!getFileResourceKey(parsed)) {
+ return { success: false, reason: 'Replacement file is no longer valid' }
+ }
+ return { success: true, nextValue: parsed }
+ } catch {
+ return { success: false, reason: 'Replacement file is no longer valid' }
+ }
+}
+
+const scalarResourceCodec: WorkflowSearchResourceCodec = {
+ parse({ value, kind, subBlockConfig, selectorContext }) {
+ return splitCommaResourceValue(value).map((rawValue) => ({
+ kind,
+ rawValue,
+ searchText: rawValue,
+ resource: createResourceMeta({ kind, rawValue, subBlockConfig, selectorContext }),
+ }))
+ },
+ contains(value, rawValue) {
+ return splitCommaResourceValue(value).includes(rawValue)
+ },
+ replace: replaceCommaResourceValue,
+}
+
+const fileUploadResourceCodec: WorkflowSearchResourceCodec = {
+ parse({ value, kind, subBlockConfig, selectorContext }) {
+ const parsed = parseSerializedResourceValue(value).value
+ const values = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []
+ return values.flatMap((item) => {
+ const rawValue = getFileResourceKey(item)
+ if (!rawValue) return []
+ const name = (item as Record).name
+ return [
+ {
+ kind,
+ rawValue,
+ searchText: typeof name === 'string' ? name : rawValue,
+ resource: createResourceMeta({ kind, rawValue, subBlockConfig, selectorContext }),
+ },
+ ]
+ })
+ },
+ contains(value, rawValue) {
+ const parsed = parseSerializedResourceValue(value).value
+ if (Array.isArray(parsed))
+ return parsed.some((item) => fileUploadResourceCodec.contains(item, rawValue))
+ return getFileResourceKey(parsed) === rawValue
+ },
+ replace(value, rawValue, replacement, targetOccurrenceIndex) {
+ const parsed = parseSerializedResourceValue(value)
+ let occurrenceIndex = 0
+ let replaced = false
+
+ const shouldReplace = (item: unknown) => {
+ const itemKey = getFileResourceKey(item)
+ if (!itemKey) return false
+ const currentOccurrenceIndex = occurrenceIndex
+ occurrenceIndex += 1
+ if (itemKey !== rawValue) return false
+ const matchesTarget =
+ targetOccurrenceIndex === undefined || currentOccurrenceIndex === targetOccurrenceIndex
+ if (matchesTarget) replaced = true
+ return matchesTarget
+ }
+
+ const replaceItem = (item: unknown): ResourceCodecReplaceResult => {
+ if (!shouldReplace(item)) return { success: true, nextValue: item }
+ return parseFileReplacement(replacement)
+ }
+
+ if (Array.isArray(parsed.value)) {
+ const nextValue: unknown[] = []
+ for (const item of parsed.value) {
+ const result = replaceItem(item)
+ if (!result.success) return result
+ nextValue.push(result.nextValue)
+ }
+ if (targetOccurrenceIndex !== undefined && !replaced) {
+ return { success: false, reason: 'Target resource changed since search' }
+ }
+ return { success: true, nextValue: parsed.serialized ? JSON.stringify(nextValue) : nextValue }
+ }
+
+ const result = replaceItem(parsed.value)
+ if (!result.success) return result
+ if (targetOccurrenceIndex !== undefined && !replaced) {
+ return { success: false, reason: 'Target resource changed since search' }
+ }
+ if (!parsed.serialized) return result
+ return { success: true, nextValue: JSON.stringify(result.nextValue) }
+ },
+}
+
+const WORKFLOW_SEARCH_RESOURCE_KINDS: Record<
+ Exclude,
+ WorkflowSearchResourceKindDefinition
+> = {
+ environment: {
+ label: 'environment variable',
+ constrained: true,
+ normalizeReplacement: (replacement) => {
+ const trimmed = replacement.trim()
+ if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) return trimmed
+ return `{{${trimmed}}}`
+ },
+ },
+ 'workflow-reference': {
+ label: 'workflow reference',
+ constrained: false,
+ },
+ 'oauth-credential': {
+ label: 'OAuth credential',
+ constrained: true,
+ },
+ 'knowledge-base': {
+ label: 'knowledge base',
+ constrained: true,
+ },
+ 'knowledge-document': {
+ label: 'knowledge document',
+ constrained: true,
+ },
+ workflow: {
+ label: 'workflow',
+ constrained: true,
+ },
+ 'mcp-server': {
+ label: 'MCP server',
+ constrained: true,
+ },
+ 'mcp-tool': {
+ label: 'MCP tool',
+ constrained: true,
+ },
+ table: {
+ label: 'table',
+ constrained: true,
+ },
+ file: {
+ label: 'file',
+ constrained: true,
+ },
+ 'selector-resource': {
+ label: 'selector resource',
+ constrained: true,
+ },
+}
+
+const WORKFLOW_SEARCH_SUBBLOCK_RESOURCES: Partial<
+ Record
+> = {
+ 'oauth-input': { kind: 'oauth-credential', codec: scalarResourceCodec },
+ 'knowledge-base-selector': { kind: 'knowledge-base', codec: scalarResourceCodec },
+ 'document-selector': { kind: 'knowledge-document', codec: scalarResourceCodec },
+ 'workflow-selector': { kind: 'workflow', codec: scalarResourceCodec },
+ 'mcp-server-selector': { kind: 'mcp-server', codec: scalarResourceCodec },
+ 'mcp-tool-selector': { kind: 'mcp-tool', codec: scalarResourceCodec },
+ 'table-selector': { kind: 'table', codec: scalarResourceCodec },
+ 'file-selector': { kind: 'file', codec: scalarResourceCodec },
+ 'file-upload': { kind: 'file', codec: fileUploadResourceCodec },
+ 'channel-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
+ 'user-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
+ 'sheet-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
+ 'folder-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
+ 'project-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
+}
+
+export function getWorkflowSearchResourceKindDefinition(
+ kind: WorkflowSearchMatchKind
+): WorkflowSearchResourceKindDefinition | null {
+ return kind === 'text' ? null : WORKFLOW_SEARCH_RESOURCE_KINDS[kind]
+}
+
+export function isConstrainedWorkflowSearchResourceKind(kind: WorkflowSearchMatchKind): boolean {
+ return getWorkflowSearchResourceKindDefinition(kind)?.constrained ?? false
+}
+
+export function getWorkflowSearchResourceKindLabel(kind: WorkflowSearchMatchKind): string {
+ return getWorkflowSearchResourceKindDefinition(kind)?.label ?? 'resource'
+}
+
+export function normalizeWorkflowSearchResourceReplacement(
+ match: WorkflowSearchMatch,
+ replacement: string
+): string {
+ return (
+ getWorkflowSearchResourceKindDefinition(match.kind)?.normalizeReplacement?.(replacement) ??
+ replacement
+ )
+}
+
+export function getWorkflowSearchSubBlockResourceDefinition(
+ subBlockConfig?: Pick
+): WorkflowSearchSubBlockResourceDefinition | null {
+ if (!subBlockConfig) return null
+ return WORKFLOW_SEARCH_SUBBLOCK_RESOURCES[subBlockConfig.type] ?? null
+}
+
+export function getWorkflowSearchSubBlockResourceKind(
+ subBlockConfig?: Pick
+): StructuredWorkflowSearchResourceKind | null {
+ return getWorkflowSearchSubBlockResourceDefinition(subBlockConfig)?.kind ?? null
+}
+
+export function parseWorkflowSearchSubBlockResources(
+ value: unknown,
+ subBlockConfig?: Pick,
+ selectorContext?: SelectorContext
+): StructuredResourceReference[] {
+ const definition = getWorkflowSearchSubBlockResourceDefinition(subBlockConfig)
+ if (!definition || !subBlockConfig) return []
+ return definition.codec.parse({
+ value,
+ kind: definition.kind,
+ subBlockConfig,
+ selectorContext,
+ })
+}
+
+export function workflowSearchResourceValueContains(
+ match: WorkflowSearchMatch,
+ value: unknown
+): boolean {
+ return (
+ getWorkflowSearchSubBlockResourceDefinition({ type: match.subBlockType })?.codec.contains(
+ value,
+ match.rawValue
+ ) ?? false
+ )
+}
+
+export function replaceWorkflowSearchResourceValue(
+ match: WorkflowSearchMatch,
+ value: unknown,
+ replacement: string
+): ResourceCodecReplaceResult {
+ const codec = getWorkflowSearchSubBlockResourceDefinition({ type: match.subBlockType })?.codec
+ if (!codec) return { success: false, reason: 'Target resource is no longer replaceable' }
+ return codec.replace(value, match.rawValue, replacement, match.structuredOccurrenceIndex)
+}
diff --git a/apps/sim/lib/workflows/search-replace/resource-resolvers.ts b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts
similarity index 100%
rename from apps/sim/lib/workflows/search-replace/resource-resolvers.ts
rename to apps/sim/lib/workflows/search-replace/resources/resolvers.ts
diff --git a/apps/sim/lib/workflows/search-replace/replacement-validation.ts b/apps/sim/lib/workflows/search-replace/resources/validation.ts
similarity index 61%
rename from apps/sim/lib/workflows/search-replace/replacement-validation.ts
rename to apps/sim/lib/workflows/search-replace/resources/validation.ts
index ddec51daf54..75be834a0fe 100644
--- a/apps/sim/lib/workflows/search-replace/replacement-validation.ts
+++ b/apps/sim/lib/workflows/search-replace/resources/validation.ts
@@ -1,46 +1,16 @@
-import { replacementOptionMatchesResourceMatch } from '@/lib/workflows/search-replace/resource-resolvers'
+import {
+ getWorkflowSearchResourceKindLabel,
+ isConstrainedWorkflowSearchResourceKind,
+ normalizeWorkflowSearchResourceReplacement,
+} from '@/lib/workflows/search-replace/resources/registry'
+import { replacementOptionMatchesResourceMatch } from '@/lib/workflows/search-replace/resources/resolvers'
import type {
WorkflowSearchMatch,
- WorkflowSearchMatchKind,
WorkflowSearchReplacementOption,
} from '@/lib/workflows/search-replace/types'
-const CONSTRAINED_RESOURCE_KINDS = new Set([
- 'environment',
- 'oauth-credential',
- 'knowledge-base',
- 'knowledge-document',
- 'workflow',
- 'mcp-server',
- 'mcp-tool',
- 'table',
- 'file',
- 'selector-resource',
-])
-
-const RESOURCE_KIND_LABELS: Partial> = {
- environment: 'environment variable',
- 'oauth-credential': 'OAuth credential',
- 'knowledge-base': 'knowledge base',
- 'knowledge-document': 'knowledge document',
- workflow: 'workflow',
- 'mcp-server': 'MCP server',
- 'mcp-tool': 'MCP tool',
- table: 'table',
- file: 'file',
- 'selector-resource': 'selector resource',
-}
-
export function isConstrainedResourceMatch(match: WorkflowSearchMatch): boolean {
- return CONSTRAINED_RESOURCE_KINDS.has(match.kind)
-}
-
-function normalizeResourceReplacement(match: WorkflowSearchMatch, replacement: string): string {
- if (match.kind !== 'environment') return replacement
-
- const trimmed = replacement.trim()
- if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) return trimmed
- return `{{${trimmed}}}`
+ return isConstrainedWorkflowSearchResourceKind(match.kind)
}
export function getCompatibleResourceReplacementOptions(
@@ -81,7 +51,7 @@ export function getWorkflowSearchReplacementIssue({
}
const [firstMatch] = constrainedMatches
- const normalizedReplacement = normalizeResourceReplacement(firstMatch, replacement)
+ const normalizedReplacement = normalizeWorkflowSearchResourceReplacement(firstMatch, replacement)
const compatibleOptions = getCompatibleResourceReplacementOptions(
constrainedMatches,
resourceOptions
@@ -92,6 +62,6 @@ export function getWorkflowSearchReplacementIssue({
if (hasResolvableReplacement) return null
- const label = RESOURCE_KIND_LABELS[firstMatch.kind] ?? 'resource'
+ const label = getWorkflowSearchResourceKindLabel(firstMatch.kind)
return `Choose a valid ${label} replacement.`
}
diff --git a/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts b/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts
index e76dcb3c096..39f24ea952f 100644
--- a/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts
+++ b/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts
@@ -45,6 +45,60 @@ export const SEARCH_REPLACE_BLOCK_CONFIGS: Record {
+ it('detects stale loop and parallel field values before replace apply', () => {
+ expect(
+ workflowSearchSubflowFieldMatchesExpected(
+ { type: 'loop', data: { loopType: 'for', count: 5 } },
+ WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
+ '5'
+ )
+ ).toBe(true)
+ expect(
+ workflowSearchSubflowFieldMatchesExpected(
+ { type: 'loop', data: { loopType: 'for', count: 10 } },
+ WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
+ '5'
+ )
+ ).toBe(false)
+ expect(
+ workflowSearchSubflowFieldMatchesExpected(
+ { type: 'parallel', data: { parallelType: 'collection', collection: '{{items}}' } },
+ WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items,
+ '{{items}}'
+ )
+ ).toBe(true)
+ })
+})
diff --git a/apps/sim/lib/workflows/search-replace/subflow-fields.ts b/apps/sim/lib/workflows/search-replace/subflow-fields.ts
index be31095981a..c87b982efb2 100644
--- a/apps/sim/lib/workflows/search-replace/subflow-fields.ts
+++ b/apps/sim/lib/workflows/search-replace/subflow-fields.ts
@@ -126,6 +126,15 @@ export function getWorkflowSearchSubflowField(
return getWorkflowSearchSubflowFields(block).find((field) => field.id === fieldId)
}
+export function workflowSearchSubflowFieldMatchesExpected(
+ block: WorkflowSearchSubflowBlock,
+ fieldId: WorkflowSearchSubflowFieldId,
+ expectedValue: unknown
+): boolean {
+ const field = getWorkflowSearchSubflowField(block, fieldId)
+ return Boolean(field && String(field.value) === String(expectedValue))
+}
+
export function parseWorkflowSearchSubflowReplacement({
blockType,
fieldId,
diff --git a/apps/sim/lib/workflows/search-replace/types.ts b/apps/sim/lib/workflows/search-replace/types.ts
index 8b1c509c78d..ea43c06843b 100644
--- a/apps/sim/lib/workflows/search-replace/types.ts
+++ b/apps/sim/lib/workflows/search-replace/types.ts
@@ -62,6 +62,7 @@ export interface WorkflowSearchMatch {
searchText: string
range?: WorkflowSearchRange
structuredOccurrenceIndex?: number
+ dependentValuePaths?: WorkflowSearchValuePath[]
resource?: WorkflowSearchResourceMeta
editable: boolean
navigable: boolean
@@ -92,22 +93,12 @@ export interface WorkflowSearchIndexerOptions {
readonlyReason?: string
workspaceId?: string
workflowId?: string
- blockConfigs?: Record
-}
-
-export interface IndexedSubBlockContext {
- block: WorkflowSearchBlockState
- blockConfig?: { subBlocks?: SubBlockConfig[] }
- subBlockConfig?: SubBlockConfig
- subBlockId: string
- canonicalSubBlockId: string
- protected: boolean
- isSnapshotView?: boolean
-}
-
-export interface WorkflowSearchReplacementTarget {
- matchId: string
- replacement: string
+ blockConfigs?: Record<
+ string,
+ | { subBlocks?: SubBlockConfig[]; triggers?: { enabled?: boolean }; category?: string }
+ | undefined
+ >
+ credentialTypeById?: Record
}
export interface WorkflowSearchReplacementOption {
diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts
index fd9eb85b74c..fb8de2121a8 100644
--- a/apps/sim/lib/workflows/subblocks/visibility.ts
+++ b/apps/sim/lib/workflows/subblocks/visibility.ts
@@ -32,6 +32,54 @@ export interface CanonicalValueSelection {
advancedSourceId?: string
}
+interface TriggerVisibilityBlockConfig {
+ category?: string
+ triggers?: {
+ enabled?: boolean
+ }
+}
+
+export function parseDependsOn(dependsOn: SubBlockConfig['dependsOn']): {
+ allFields: string[]
+ anyFields: string[]
+ allDependsOnFields: string[]
+} {
+ if (!dependsOn) {
+ return { allFields: [], anyFields: [], allDependsOnFields: [] }
+ }
+
+ if (Array.isArray(dependsOn)) {
+ return { allFields: dependsOn, anyFields: [], allDependsOnFields: dependsOn }
+ }
+
+ const allFields = dependsOn.all || []
+ const anyFields = dependsOn.any || []
+ return {
+ allFields,
+ anyFields,
+ allDependsOnFields: [...allFields, ...anyFields],
+ }
+}
+
+export function normalizeDependencyValue(rawValue: unknown): unknown {
+ if (rawValue === null || rawValue === undefined) return null
+
+ if (typeof rawValue === 'object') {
+ if (Array.isArray(rawValue)) {
+ if (rawValue.length === 0) return null
+ return rawValue.map((item) => normalizeDependencyValue(item))
+ }
+
+ const record = rawValue as Record
+ if ('value' in record) return normalizeDependencyValue(record.value)
+ if ('id' in record) return record.id
+
+ return record
+ }
+
+ return rawValue
+}
+
/**
* Build a flat map of subblock values keyed by subblock id.
*/
@@ -262,6 +310,38 @@ export function isSubBlockVisibleForMode(
return true
}
+export function isTriggerModeSubBlock(subBlock: Pick): boolean {
+ return subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced'
+}
+
+export function isTriggerConfigSubBlock(subBlock: Pick): boolean {
+ return String(subBlock.type) === 'trigger-config'
+}
+
+export function shouldUseSubBlockForTriggerModeCanonicalIndex(
+ subBlock: Pick
+): boolean {
+ return isTriggerModeSubBlock(subBlock) || isTriggerConfigSubBlock(subBlock)
+}
+
+export function isPureTriggerBlockConfig(blockConfig?: TriggerVisibilityBlockConfig): boolean {
+ return Boolean(blockConfig?.triggers?.enabled && blockConfig.category === 'triggers')
+}
+
+export function isSubBlockVisibleForTriggerMode(
+ subBlock: Pick,
+ displayTriggerMode: boolean,
+ blockConfig?: TriggerVisibilityBlockConfig
+): boolean {
+ if (isTriggerConfigSubBlock(subBlock)) {
+ return displayTriggerMode || isPureTriggerBlockConfig(blockConfig)
+ }
+
+ if (isTriggerModeSubBlock(subBlock)) return displayTriggerMode
+ if (displayTriggerMode) return false
+ return true
+}
+
/**
* Resolve the dependency value for a dependsOn key, honoring canonical swaps.
*/
diff --git a/apps/sim/lib/workflows/tool-input/synthetic-subblocks.ts b/apps/sim/lib/workflows/tool-input/synthetic-subblocks.ts
new file mode 100644
index 00000000000..35686d79b43
--- /dev/null
+++ b/apps/sim/lib/workflows/tool-input/synthetic-subblocks.ts
@@ -0,0 +1,11 @@
+const SYNTHETIC_TOOL_SUBBLOCK_RE = /-tool-\d+-/
+
+/**
+ * Returns true for ToolSubBlockRenderer mirror subblocks.
+ *
+ * These IDs follow `{subBlockId}-tool-{index}-{paramId}` and duplicate values
+ * already stored in the aggregate `tool-input` subblock.
+ */
+export function isSyntheticToolSubBlockId(subBlockId: string): boolean {
+ return SYNTHETIC_TOOL_SUBBLOCK_RE.test(subBlockId)
+}
diff --git a/apps/sim/lib/workflows/tool-input/types.ts b/apps/sim/lib/workflows/tool-input/types.ts
new file mode 100644
index 00000000000..1d6491a4437
--- /dev/null
+++ b/apps/sim/lib/workflows/tool-input/types.ts
@@ -0,0 +1,70 @@
+export interface StoredToolSchema {
+ description?: string
+ properties?: Record
+ required?: string[]
+ function?: {
+ name?: string
+ parameters?: {
+ properties?: Record
+ required?: string[]
+ }
+ }
+}
+
+/**
+ * Represents a tool selected and configured in a workflow tool-input field.
+ */
+export interface StoredTool {
+ type: string
+ title?: string
+ toolId?: string
+ params?: Record
+ isExpanded?: boolean
+ customToolId?: string
+ schema?: StoredToolSchema
+ code?: string
+ operation?: string
+ usageControl?: 'auto' | 'force' | 'none'
+}
+
+export interface ParsedStoredTool extends Omit {
+ params?: Record
+}
+
+export function parseStoredToolInputValue(value: unknown): ParsedStoredTool[] {
+ if (!Array.isArray(value)) return []
+
+ return value.flatMap((tool) => {
+ if (!tool || typeof tool !== 'object' || Array.isArray(tool)) return []
+ const record = tool as Record
+ if (typeof record.type !== 'string') return []
+
+ const params =
+ record.params && typeof record.params === 'object' && !Array.isArray(record.params)
+ ? (record.params as Record)
+ : undefined
+
+ return [
+ {
+ type: record.type,
+ title: typeof record.title === 'string' ? record.title : undefined,
+ toolId: typeof record.toolId === 'string' ? record.toolId : undefined,
+ operation: typeof record.operation === 'string' ? record.operation : undefined,
+ params,
+ customToolId: typeof record.customToolId === 'string' ? record.customToolId : undefined,
+ code: typeof record.code === 'string' ? record.code : undefined,
+ usageControl:
+ record.usageControl === 'auto' ||
+ record.usageControl === 'force' ||
+ record.usageControl === 'none'
+ ? record.usageControl
+ : undefined,
+ isExpanded: typeof record.isExpanded === 'boolean' ? record.isExpanded : undefined,
+ schema:
+ record.schema && typeof record.schema === 'object' && !Array.isArray(record.schema)
+ ? (record.schema as StoredToolSchema)
+ : undefined,
+ },
+ ]
+ })
+}
diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts
index 6336f4293a8..f1f97b999ca 100644
--- a/apps/sim/tools/params.ts
+++ b/apps/sim/tools/params.ts
@@ -6,10 +6,15 @@ import {
evaluateSubBlockCondition,
isCanonicalPair,
isSubBlockHidden,
+ isTriggerModeSubBlock,
resolveCanonicalMode,
type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
-import type { SubBlockConfig as BlockSubBlockConfig, GenerationType } from '@/blocks/types'
+import type {
+ BlockConfig as AppBlockConfig,
+ SubBlockConfig as BlockSubBlockConfig,
+ GenerationType,
+} from '@/blocks/types'
import { safeAssign } from '@/tools/safe-assign'
import { isEmptyTagValue } from '@/tools/shared/tags'
import type { OAuthConfig, ParameterVisibility, ToolConfig } from '@/tools/types'
@@ -49,6 +54,7 @@ export interface UIComponentConfig {
title?: string
value?: unknown
serviceId?: string
+ selectorKey?: BlockSubBlockConfig['selectorKey']
requiredScopes?: string[]
mimeType?: string
columns?: string[]
@@ -104,10 +110,7 @@ export interface SubBlockConfig {
dependsOn?: string[]
}
-export interface BlockConfig {
- type: string
- subBlocks?: SubBlockConfig[]
-}
+type ToolInputBlockConfig = Pick
export interface SchemaProperty {
type: string
@@ -157,15 +160,15 @@ export interface ToolWithParameters {
optionalParameters: ToolParameterConfig[] // Nice to have, shown to user
}
-let blockConfigCache: Record | null = null
+let blockConfigCache: Record | null = null
-function getBlockConfigurations(): Record {
+function getBlockConfigurations(): Record {
if (!blockConfigCache) {
try {
const { getAllBlocks } = require('@/blocks')
const allBlocks = getAllBlocks()
blockConfigCache = {}
- allBlocks.forEach((block: BlockConfig) => {
+ allBlocks.forEach((block: AppBlockConfig) => {
blockConfigCache![block.type] = block
})
} catch (error) {
@@ -176,13 +179,39 @@ function getBlockConfigurations(): Record {
return blockConfigCache
}
+/**
+ * Gets the correct tool ID for a block operation.
+ */
+export function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
+ const block = getBlockConfigurations()[blockType]
+ if (!block?.tools?.access) return undefined
+
+ if (block.tools.access.length === 1) {
+ return block.tools.access[0]
+ }
+
+ if (operation && block.tools.config?.tool) {
+ try {
+ return block.tools.config.tool({ operation })
+ } catch (error) {
+ logger.error('Error selecting tool for operation:', error)
+ }
+ }
+
+ if (operation && block.tools.access.includes(operation)) {
+ return operation
+ }
+
+ return block.tools.access[0]
+}
+
function resolveSubBlockForParam(
paramId: string,
- subBlocks: SubBlockConfig[],
+ subBlocks: BlockSubBlockConfig[],
valuesWithOperation: Record,
paramType: string
): BlockSubBlockConfig | undefined {
- const blockSubBlocks = subBlocks as BlockSubBlockConfig[]
+ const blockSubBlocks = subBlocks
// First pass: find subblock with matching condition
let fallbackMatch: BlockSubBlockConfig | undefined
@@ -252,6 +281,7 @@ export function getToolParametersConfig(
uiComponent: {
type: 'workflow-selector',
placeholder: 'Select workflow to execute',
+ selectorKey: 'sim.workflows',
},
},
{
@@ -268,6 +298,7 @@ export function getToolParametersConfig(
value: '',
not: true, // Show when workflowId is not empty
},
+ dependsOn: ['workflowId'],
},
},
]
@@ -286,7 +317,7 @@ export function getToolParametersConfig(
}
// Get block configuration for UI component information
- let blockConfig: BlockConfig | null = null
+ let blockConfig: ToolInputBlockConfig | null = null
if (blockType) {
const blockConfigs = getBlockConfigurations()
blockConfig = blockConfigs[blockType] || null
@@ -337,6 +368,7 @@ export function getToolParametersConfig(
title: subBlock.title,
value: subBlock.value,
serviceId: subBlock.serviceId,
+ selectorKey: subBlock.selectorKey,
requiredScopes: subBlock.requiredScopes,
mimeType: subBlock.mimeType,
columns: subBlock.columns,
@@ -955,7 +987,8 @@ export function getSubBlocksForToolInput(
toolId: string,
blockType: string,
currentValues?: Record,
- canonicalModeOverrides?: CanonicalModeOverrides
+ canonicalModeOverrides?: CanonicalModeOverrides,
+ blockConfigOverride?: Pick
): SubBlocksForToolInput | null {
try {
const toolConfig = getTool(toolId)
@@ -965,7 +998,7 @@ export function getSubBlocksForToolInput(
}
const blockConfigs = getBlockConfigurations()
- const blockConfig = blockConfigs[blockType]
+ const blockConfig = blockConfigOverride ?? blockConfigs[blockType]
if (!blockConfig?.subBlocks?.length) {
return null
}
@@ -999,7 +1032,7 @@ export function getSubBlocksForToolInput(
if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue
// Skip trigger-mode-only subblocks
- if (sb.mode === 'trigger' || sb.mode === 'trigger-advanced') continue
+ if (isTriggerModeSubBlock(sb)) continue
// Hide tool API key fields when running on hosted Sim or when env var is set
if (isSubBlockHidden(sb)) continue