chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,4 @@
export * from './references'
export * from './registry'
export * from './resolvers'
export * from './validation'
@@ -0,0 +1,87 @@
import {
getWorkflowSearchSubBlockResourceKind,
parseWorkflowSearchSubBlockResources,
type StructuredResourceReference,
} from '@/lib/workflows/search-replace/resources/registry'
import type {
WorkflowSearchRange,
WorkflowSearchResourceMeta,
} from '@/lib/workflows/search-replace/types'
import type { SubBlockConfig } from '@/blocks/types'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import type { SelectorContext } from '@/hooks/selectors/types'
export interface ParsedInlineReference {
kind: 'environment' | 'workflow-reference'
rawValue: string
searchText: string
range: WorkflowSearchRange
resource: WorkflowSearchResourceMeta
}
export function getResourceKindForSubBlock(
subBlockConfig?: Pick<SubBlockConfig, 'type'>
): StructuredResourceReference['kind'] | null {
return getWorkflowSearchSubBlockResourceKind(subBlockConfig)
}
export function parseInlineReferences(value: string): ParsedInlineReference[] {
const references: ParsedInlineReference[] = []
const envPattern = createEnvVarPattern()
for (const match of value.matchAll(envPattern)) {
const rawValue = match[0]
const key = String(match[1] ?? '').trim()
const start = match.index ?? 0
references.push({
kind: 'environment',
rawValue,
searchText: key,
range: { start, end: start + rawValue.length },
resource: {
kind: 'environment',
token: rawValue,
key,
},
})
}
const referencePattern = createReferencePattern()
for (const match of value.matchAll(referencePattern)) {
const rawValue = match[0]
const reference = String(match[1] ?? '').trim()
const start = match.index ?? 0
references.push({
kind: 'workflow-reference',
rawValue,
searchText: reference,
range: { start, end: start + rawValue.length },
resource: {
kind: 'workflow-reference',
token: rawValue,
key: reference,
},
})
}
return references.sort((a, b) => a.range.start - b.range.start)
}
export function parseStructuredResourceReferences(
value: unknown,
subBlockConfig?: Pick<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>,
selectorContext?: SelectorContext
): StructuredResourceReference[] {
return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext)
}
export function matchesSearchText(
candidate: string,
query: string | undefined,
caseSensitive = false
): boolean {
if (!query) return true
const source = caseSensitive ? candidate : candidate.toLowerCase()
const target = caseSensitive ? query : query.toLowerCase()
return source.includes(target)
}
@@ -0,0 +1,431 @@
import type { SubBlockType } from '@sim/workflow-types/blocks'
import { buildWorkflowSearchResourceGroupKey } from '@/lib/workflows/search-replace/resources/resolvers'
import type {
WorkflowSearchMatch,
WorkflowSearchMatchKind,
WorkflowSearchResourceMeta,
WorkflowSearchValuePath,
} 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<
SubBlockConfig,
'type' | 'serviceId' | 'selectorKey' | 'requiredScopes' | 'multiSelect' | 'multiple'
>
selectorContext?: SelectorContext
}
export interface StructuredResourceReference {
kind: StructuredWorkflowSearchResourceKind
rawValue: string
searchText: string
valuePath?: WorkflowSearchValuePath
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<SubBlockConfig, 'serviceId' | 'selectorKey' | 'requiredScopes'>
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<string, unknown>
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 }) {
const values = splitCommaResourceValue(value)
return values.map((rawValue, index) => ({
kind,
rawValue,
searchText: rawValue,
valuePath: subBlockConfig.multiSelect || values.length > 1 ? [index] : [],
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, index) => {
const rawValue = getFileResourceKey(item)
if (!rawValue) return []
const name = (item as Record<string, unknown>).name
return [
{
kind,
rawValue,
searchText: typeof name === 'string' ? name : rawValue,
valuePath: subBlockConfig.multiple || values.length > 1 ? [index, 'name'] : [],
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<WorkflowSearchMatchKind, 'text'>,
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<SubBlockType, WorkflowSearchSubBlockResourceDefinition>
> = {
'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<SubBlockConfig, 'type'>
): WorkflowSearchSubBlockResourceDefinition | null {
if (!subBlockConfig) return null
return WORKFLOW_SEARCH_SUBBLOCK_RESOURCES[subBlockConfig.type] ?? null
}
export function getWorkflowSearchSubBlockResourceKind(
subBlockConfig?: Pick<SubBlockConfig, 'type'>
): StructuredWorkflowSearchResourceKind | null {
return getWorkflowSearchSubBlockResourceDefinition(subBlockConfig)?.kind ?? null
}
export function parseWorkflowSearchSubBlockResources(
value: unknown,
subBlockConfig?: Pick<
SubBlockConfig,
'type' | 'serviceId' | 'selectorKey' | 'requiredScopes' | 'multiSelect' | 'multiple'
>,
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)
}
@@ -0,0 +1,142 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
dedupeOverlappingWorkflowSearchMatches,
workflowSearchMatchMatchesQuery,
} from '@/lib/workflows/search-replace/resources/resolvers'
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
function createMatch(overrides: Partial<WorkflowSearchMatch>): WorkflowSearchMatch {
return {
id: 'match',
blockId: 'block-1',
blockName: 'Block',
blockType: 'function',
subBlockId: 'code',
canonicalSubBlockId: 'code',
subBlockType: 'code',
valuePath: [],
target: { kind: 'subblock' },
kind: 'text',
rawValue: '',
searchText: '',
editable: true,
navigable: true,
protected: false,
...overrides,
}
}
describe('dedupeOverlappingWorkflowSearchMatches', () => {
it('keeps the narrower text hit when a partial literal query overlaps an inline reference', () => {
const textMatch = createMatch({
id: 'text-partial',
kind: 'text',
rawValue: '<start.h',
searchText: "return '<start.hello>'",
range: { start: 8, end: 16 },
})
const referenceMatch = createMatch({
id: 'workflow-reference',
kind: 'workflow-reference',
rawValue: '<start.hello>',
searchText: 'start.hello',
range: { start: 8, end: 21 },
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
})
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([textMatch])
})
it('keeps the inline reference when it covers the same span as a text hit', () => {
const textMatch = createMatch({
id: 'text-full',
kind: 'text',
rawValue: '<start.hello>',
searchText: "return '<start.hello>'",
range: { start: 8, end: 21 },
})
const referenceMatch = createMatch({
id: 'workflow-reference',
kind: 'workflow-reference',
rawValue: '<start.hello>',
searchText: 'start.hello',
range: { start: 8, end: 21 },
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
})
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([
referenceMatch,
])
})
it('uses kind priority rather than iteration order for equal-span non-text matches', () => {
const workflowReferenceMatch = createMatch({
id: 'workflow-reference',
kind: 'workflow-reference',
rawValue: '{{API_KEY}}',
searchText: 'API_KEY',
range: { start: 0, end: 11 },
resource: { kind: 'workflow-reference', token: '{{API_KEY}}', key: 'API_KEY' },
})
const environmentMatch = createMatch({
id: 'environment',
kind: 'environment',
rawValue: '{{API_KEY}}',
searchText: 'API_KEY',
range: { start: 0, end: 11 },
resource: { kind: 'environment', token: '{{API_KEY}}', key: 'API_KEY' },
})
expect(
dedupeOverlappingWorkflowSearchMatches([workflowReferenceMatch, environmentMatch])
).toEqual([workflowReferenceMatch])
expect(
dedupeOverlappingWorkflowSearchMatches([environmentMatch, workflowReferenceMatch])
).toEqual([workflowReferenceMatch])
})
it('does not collapse matches from different fields', () => {
const firstMatch = createMatch({
id: 'first',
range: { start: 0, end: 4 },
valuePath: ['first'],
})
const secondMatch = createMatch({
id: 'second',
range: { start: 0, end: 4 },
valuePath: ['second'],
})
expect(dedupeOverlappingWorkflowSearchMatches([firstMatch, secondMatch])).toEqual([
firstMatch,
secondMatch,
])
})
})
describe('workflowSearchMatchMatchesQuery', () => {
it('does not keep structured resource matches alive from only block or field label text', () => {
const selectorMatch = createMatch({
id: 'selector-resource',
blockName: 'Testy',
fieldTitle: 'Select Presentation',
subBlockId: 'presentationId',
subBlockType: 'file-selector',
kind: 'file',
rawValue: 'opaque-presentation-id',
searchText: 'opaque-presentation-id',
range: undefined,
resource: { kind: 'file', key: 'opaque-presentation-id' },
})
expect(
workflowSearchMatchMatchesQuery({ ...selectorMatch, displayLabel: 'Gucci Case' }, 'Test')
).toBe(false)
expect(
workflowSearchMatchMatchesQuery({ ...selectorMatch, displayLabel: 'Gucci Case' }, 'Gucci')
).toBe(true)
})
})
@@ -0,0 +1,192 @@
import type {
WorkflowSearchMatch,
WorkflowSearchMatchKind,
WorkflowSearchReplacementOption,
WorkflowSearchResourceMeta,
WorkflowSearchValuePath,
} from '@/lib/workflows/search-replace/types'
import type { SelectorContext } from '@/hooks/selectors/types'
const OVERLAPPING_MATCH_KIND_PRIORITY: Record<WorkflowSearchMatchKind, number> = {
text: 0,
environment: 1,
'workflow-reference': 2,
'oauth-credential': 3,
'knowledge-base': 3,
'knowledge-document': 3,
workflow: 3,
'mcp-server': 3,
'mcp-tool': 3,
table: 3,
file: 3,
'selector-resource': 3,
}
export function stableStringifyWorkflowSearchValue(value: unknown): string {
if (!value || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringifyWorkflowSearchValue(item)).join(',')}]`
}
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyWorkflowSearchValue(item)}`)
.join(',')}}`
}
export function buildWorkflowSearchResourceGroupKey(
resource: Pick<
WorkflowSearchResourceMeta,
'kind' | 'providerId' | 'serviceId' | 'selectorKey' | 'selectorContext'
>
): string {
const provider = resource.providerId ?? resource.serviceId ?? ''
const selectorKey = resource.selectorKey ?? ''
const selectorContext = resource.selectorContext
? stableStringifyWorkflowSearchValue(resource.selectorContext)
: ''
return [resource.kind, provider, selectorKey, selectorContext].join(':')
}
export function getWorkflowSearchMatchResourceGroupKey(match: WorkflowSearchMatch): string {
return (
match.resource?.resourceGroupKey ??
buildWorkflowSearchResourceGroupKey({
kind: match.kind as WorkflowSearchResourceMeta['kind'],
providerId: match.resource?.providerId,
serviceId: match.resource?.serviceId,
selectorKey: match.resource?.selectorKey,
selectorContext: match.resource?.selectorContext,
})
)
}
export function selectorContextMatches(
left: SelectorContext | undefined,
right: SelectorContext | undefined
): boolean {
return (
stableStringifyWorkflowSearchValue(left ?? {}) ===
stableStringifyWorkflowSearchValue(right ?? {})
)
}
export function replacementOptionMatchesResourceMatch(
option: WorkflowSearchReplacementOption,
match: WorkflowSearchMatch
): boolean {
if (option.kind !== match.kind) return false
const optionGroupKey =
option.resourceGroupKey ??
buildWorkflowSearchResourceGroupKey({
kind: option.kind as WorkflowSearchResourceMeta['kind'],
providerId: option.providerId,
serviceId: option.serviceId,
selectorKey: option.selectorKey,
selectorContext: option.selectorContext,
})
return optionGroupKey === getWorkflowSearchMatchResourceGroupKey(match)
}
export function getWorkflowSearchCompatibleResourceMatches(
activeMatch: WorkflowSearchMatch | null,
matches: WorkflowSearchMatch[]
): WorkflowSearchMatch[] {
if (!activeMatch?.resource) return []
const activeGroupKey = getWorkflowSearchMatchResourceGroupKey(activeMatch)
return matches.filter(
(match) =>
match.editable &&
Boolean(match.resource) &&
getWorkflowSearchMatchResourceGroupKey(match) === activeGroupKey
)
}
function searchValuePathKey(path: WorkflowSearchValuePath): string {
return path.map((segment) => `${typeof segment}:${String(segment)}`).join('/')
}
function getRangeMatchScopeKey(match: WorkflowSearchMatch): string | null {
if (!match.range) return null
if (match.target.kind !== 'subblock') return null
return [match.blockId, match.subBlockId, searchValuePathKey(match.valuePath)].join(':')
}
function rangesOverlap(
left: NonNullable<WorkflowSearchMatch['range']>,
right: NonNullable<WorkflowSearchMatch['range']>
): boolean {
return left.start < right.end && right.start < left.end
}
function getRangeLength(match: WorkflowSearchMatch): number {
return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY
}
function shouldPreferOverlappingMatch(
candidate: WorkflowSearchMatch,
current: WorkflowSearchMatch
): boolean {
const candidateLength = getRangeLength(candidate)
const currentLength = getRangeLength(current)
if (candidateLength !== currentLength) return candidateLength < currentLength
const candidatePriority = OVERLAPPING_MATCH_KIND_PRIORITY[candidate.kind]
const currentPriority = OVERLAPPING_MATCH_KIND_PRIORITY[current.kind]
if (candidatePriority !== currentPriority) return candidatePriority > currentPriority
return false
}
export function dedupeOverlappingWorkflowSearchMatches<T extends WorkflowSearchMatch>(
matches: T[]
): T[] {
const deduped: T[] = []
for (const match of matches) {
const scopeKey = getRangeMatchScopeKey(match)
const matchRange = match.range
const existingIndex =
scopeKey && matchRange
? deduped.findIndex(
(candidate) =>
getRangeMatchScopeKey(candidate) === scopeKey &&
candidate.range &&
rangesOverlap(candidate.range, matchRange)
)
: -1
if (existingIndex === -1) {
deduped.push(match)
continue
}
if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) {
deduped[existingIndex] = match
}
}
return deduped
}
export function workflowSearchMatchMatchesQuery(
match: WorkflowSearchMatch & { displayLabel?: string },
query: string,
caseSensitive = false
): boolean {
const trimmedQuery = query.trim()
if (!trimmedQuery) return false
if (match.kind === 'text') return true
const normalize = (value: string) => (caseSensitive ? value : value.toLowerCase())
const searchable =
match.resource?.kind === 'workflow-reference' || match.resource?.kind === 'environment'
? [match.displayLabel, match.rawValue, match.searchText]
: [match.displayLabel]
const searchableText = searchable.filter(Boolean).join(' ')
return normalize(searchableText).includes(normalize(trimmedQuery))
}
@@ -0,0 +1,67 @@
import {
getWorkflowSearchResourceKindLabel,
isConstrainedWorkflowSearchResourceKind,
normalizeWorkflowSearchResourceReplacement,
} from '@/lib/workflows/search-replace/resources/registry'
import { replacementOptionMatchesResourceMatch } from '@/lib/workflows/search-replace/resources/resolvers'
import type {
WorkflowSearchMatch,
WorkflowSearchReplacementOption,
} from '@/lib/workflows/search-replace/types'
export function isConstrainedResourceMatch(match: WorkflowSearchMatch): boolean {
return isConstrainedWorkflowSearchResourceKind(match.kind)
}
export function getCompatibleResourceReplacementOptions(
matches: WorkflowSearchMatch[],
resourceOptions: WorkflowSearchReplacementOption[]
): WorkflowSearchReplacementOption[] {
const constrainedMatches = matches.filter(isConstrainedResourceMatch)
if (constrainedMatches.length === 0) return []
const kinds = new Set(constrainedMatches.map((match) => match.kind))
if (kinds.size !== 1) return []
return resourceOptions.filter((option) =>
constrainedMatches.every((match) => replacementOptionMatchesResourceMatch(option, match))
)
}
export function getWorkflowSearchReplacementIssue({
matches,
replacement,
resourceOptions = [],
}: {
matches: WorkflowSearchMatch[]
replacement: string
resourceOptions?: WorkflowSearchReplacementOption[]
}): string | null {
const editableMatches = matches.filter((match) => match.editable)
const constrainedMatches = editableMatches.filter(isConstrainedResourceMatch)
if (constrainedMatches.length === 0) return null
if (editableMatches.length !== constrainedMatches.length) {
return 'Replace references separately from text matches.'
}
const kinds = new Set(constrainedMatches.map((match) => match.kind))
if (kinds.size !== 1) {
return 'Replace one reference type at a time.'
}
const [firstMatch] = constrainedMatches
const normalizedReplacement = normalizeWorkflowSearchResourceReplacement(firstMatch, replacement)
const compatibleOptions = getCompatibleResourceReplacementOptions(
constrainedMatches,
resourceOptions
)
const hasResolvableReplacement = compatibleOptions.some(
(option) => option.value === normalizedReplacement
)
if (hasResolvableReplacement) return null
const label = getWorkflowSearchResourceKindLabel(firstMatch.kind)
return `Choose a valid ${label} replacement.`
}