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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,621 @@
import { createLogger } from '@sim/logger'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
extractBlockFieldsForComparison,
extractSubBlockRest,
filterSubBlockIds,
normalizedStringify,
normalizeEdge,
normalizeLoop,
normalizeParallel,
normalizeSubBlockValue,
normalizeTriggerConfigValues,
normalizeValue,
normalizeVariables,
sanitizeVariable,
} from './normalize'
import { formatValueForDisplay, resolveFieldLabel, resolveValueForDisplay } from './resolve-values'
const MAX_CHANGES_PER_BLOCK = 6
const MAX_EDGE_DETAILS = 3
const logger = createLogger('WorkflowComparison')
/**
* Compare the current workflow state with the deployed state to detect meaningful changes.
* Uses generateWorkflowDiffSummary internally to ensure consistent change detection.
*/
export function hasWorkflowChanged(
currentState: WorkflowState,
deployedState: WorkflowState | null
): boolean {
return generateWorkflowDiffSummary(currentState, deployedState).hasChanges
}
/**
* Represents a single field change with old and new values
*/
interface FieldChange {
field: string
oldValue: unknown
newValue: unknown
}
/**
* Result of workflow diff analysis between two workflow states
*/
export interface WorkflowDiffSummary {
addedBlocks: Array<{ id: string; type: string; name?: string }>
removedBlocks: Array<{ id: string; type: string; name?: string }>
modifiedBlocks: Array<{ id: string; type: string; name?: string; changes: FieldChange[] }>
edgeChanges: {
added: number
removed: number
addedDetails: Array<{ sourceName: string; targetName: string }>
removedDetails: Array<{ sourceName: string; targetName: string }>
}
loopChanges: { added: number; removed: number; modified: number }
parallelChanges: { added: number; removed: number; modified: number }
variableChanges: {
added: number
removed: number
modified: number
addedNames: string[]
removedNames: string[]
modifiedNames: string[]
}
hasChanges: boolean
}
/**
* Generate a detailed diff summary between two workflow states
*/
export function generateWorkflowDiffSummary(
currentState: WorkflowState,
previousState: WorkflowState | null
): WorkflowDiffSummary {
const result: WorkflowDiffSummary = {
addedBlocks: [],
removedBlocks: [],
modifiedBlocks: [],
edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] },
loopChanges: { added: 0, removed: 0, modified: 0 },
parallelChanges: { added: 0, removed: 0, modified: 0 },
variableChanges: {
added: 0,
removed: 0,
modified: 0,
addedNames: [],
removedNames: [],
modifiedNames: [],
},
hasChanges: false,
}
if (!previousState) {
const currentBlocks = currentState.blocks || {}
for (const [id, block] of Object.entries(currentBlocks)) {
result.addedBlocks.push({
id,
type: block.type,
name: block.name,
})
}
const edges = currentState.edges || []
result.edgeChanges.added = edges.length
for (const edge of edges) {
const sourceBlock = currentBlocks[edge.source]
const targetBlock = currentBlocks[edge.target]
result.edgeChanges.addedDetails.push({
sourceName: sourceBlock?.name || sourceBlock?.type || edge.source,
targetName: targetBlock?.name || targetBlock?.type || edge.target,
})
}
result.loopChanges.added = Object.keys(currentState.loops || {}).length
result.parallelChanges.added = Object.keys(currentState.parallels || {}).length
const variables = currentState.variables || {}
const varEntries = Object.entries(variables)
result.variableChanges.added = varEntries.length
for (const [id, variable] of varEntries) {
result.variableChanges.addedNames.push((variable as { name?: string }).name || id)
}
result.hasChanges = true
return result
}
const currentBlocks = currentState.blocks || {}
const previousBlocks = previousState.blocks || {}
const currentBlockIds = new Set(Object.keys(currentBlocks))
const previousBlockIds = new Set(Object.keys(previousBlocks))
for (const id of currentBlockIds) {
if (!previousBlockIds.has(id)) {
const block = currentBlocks[id]
result.addedBlocks.push({
id,
type: block.type,
name: block.name,
})
}
}
for (const id of previousBlockIds) {
if (!currentBlockIds.has(id)) {
const block = previousBlocks[id]
result.removedBlocks.push({
id,
type: block.type,
name: block.name,
})
}
}
for (const id of currentBlockIds) {
if (!previousBlockIds.has(id)) continue
const currentBlock = currentBlocks[id]
const previousBlock = previousBlocks[id]
const changes: FieldChange[] = []
const {
blockRest: currentRest,
normalizedData: currentDataRest,
subBlocks: currentSubBlocks,
} = extractBlockFieldsForComparison(currentBlock)
const {
blockRest: previousRest,
normalizedData: previousDataRest,
subBlocks: previousSubBlocks,
} = extractBlockFieldsForComparison(previousBlock)
const normalizedCurrentBlock = { ...currentRest, data: currentDataRest, subBlocks: undefined }
const normalizedPreviousBlock = {
...previousRest,
data: previousDataRest,
subBlocks: undefined,
}
if (
normalizedStringify(normalizedCurrentBlock) !== normalizedStringify(normalizedPreviousBlock)
) {
if (currentBlock.type !== previousBlock.type) {
changes.push({ field: 'type', oldValue: previousBlock.type, newValue: currentBlock.type })
}
if (currentBlock.name !== previousBlock.name) {
changes.push({ field: 'name', oldValue: previousBlock.name, newValue: currentBlock.name })
}
if (currentBlock.enabled !== previousBlock.enabled) {
changes.push({
field: 'enabled',
oldValue: previousBlock.enabled,
newValue: currentBlock.enabled,
})
}
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const
for (const field of blockFields) {
if (!!currentBlock[field] !== !!previousBlock[field]) {
changes.push({
field,
oldValue: previousBlock[field],
newValue: currentBlock[field],
})
}
}
if (normalizedStringify(currentDataRest) !== normalizedStringify(previousDataRest)) {
const allDataKeys = new Set([
...Object.keys(currentDataRest),
...Object.keys(previousDataRest),
])
for (const key of allDataKeys) {
if (
normalizedStringify(currentDataRest[key]) !== normalizedStringify(previousDataRest[key])
) {
changes.push({
field: `data.${key}`,
oldValue: previousDataRest[key] ?? null,
newValue: currentDataRest[key] ?? null,
})
}
}
}
}
const normalizedCurrentSubs = normalizeTriggerConfigValues(currentSubBlocks)
const normalizedPreviousSubs = normalizeTriggerConfigValues(previousSubBlocks)
const allSubBlockIds = filterSubBlockIds([
...new Set([...Object.keys(normalizedCurrentSubs), ...Object.keys(normalizedPreviousSubs)]),
])
for (const subId of allSubBlockIds) {
const currentSub = normalizedCurrentSubs[subId] as Record<string, unknown> | undefined
const previousSub = normalizedPreviousSubs[subId] as Record<string, unknown> | undefined
if (!currentSub || !previousSub) {
changes.push({
field: subId,
oldValue: (previousSub as Record<string, unknown> | undefined)?.value ?? null,
newValue: (currentSub as Record<string, unknown> | undefined)?.value ?? null,
})
continue
}
const currentValue = normalizeSubBlockValue(subId, currentSub.value)
const previousValue = normalizeSubBlockValue(subId, previousSub.value)
if (typeof currentValue === 'string' && typeof previousValue === 'string') {
if (currentValue !== previousValue) {
changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value })
}
} else {
const normalizedCurrent = normalizeValue(currentValue)
const normalizedPrevious = normalizeValue(previousValue)
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value })
}
}
const currentSubRest = extractSubBlockRest(currentSub)
const previousSubRest = extractSubBlockRest(previousSub)
if (normalizedStringify(currentSubRest) !== normalizedStringify(previousSubRest)) {
changes.push({
field: `${subId}.properties`,
oldValue: previousSubRest,
newValue: currentSubRest,
})
}
}
if (changes.length > 0) {
result.modifiedBlocks.push({
id,
type: currentBlock.type,
name: currentBlock.name,
changes,
})
}
}
const currentEdges = (currentState.edges || []).map(normalizeEdge)
const previousEdges = (previousState.edges || []).map(normalizeEdge)
const currentEdgeSet = new Set(currentEdges.map(normalizedStringify))
const previousEdgeSet = new Set(previousEdges.map(normalizedStringify))
const resolveBlockName = (blockId: string): string => {
const block = currentBlocks[blockId] || previousBlocks[blockId]
return block?.name || block?.type || blockId
}
for (const edgeStr of currentEdgeSet) {
if (!previousEdgeSet.has(edgeStr)) {
result.edgeChanges.added++
const edge = JSON.parse(edgeStr) as { source: string; target: string }
result.edgeChanges.addedDetails.push({
sourceName: resolveBlockName(edge.source),
targetName: resolveBlockName(edge.target),
})
}
}
for (const edgeStr of previousEdgeSet) {
if (!currentEdgeSet.has(edgeStr)) {
result.edgeChanges.removed++
const edge = JSON.parse(edgeStr) as { source: string; target: string }
result.edgeChanges.removedDetails.push({
sourceName: resolveBlockName(edge.source),
targetName: resolveBlockName(edge.target),
})
}
}
const currentLoops = currentState.loops || {}
const previousLoops = previousState.loops || {}
const currentLoopIds = Object.keys(currentLoops)
const previousLoopIds = Object.keys(previousLoops)
for (const id of currentLoopIds) {
if (!previousLoopIds.includes(id)) {
result.loopChanges.added++
} else {
const normalizedCurrent = normalizeValue(normalizeLoop(currentLoops[id]))
const normalizedPrevious = normalizeValue(normalizeLoop(previousLoops[id]))
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
result.loopChanges.modified++
}
}
}
for (const id of previousLoopIds) {
if (!currentLoopIds.includes(id)) {
result.loopChanges.removed++
}
}
const currentParallels = currentState.parallels || {}
const previousParallels = previousState.parallels || {}
const currentParallelIds = Object.keys(currentParallels)
const previousParallelIds = Object.keys(previousParallels)
for (const id of currentParallelIds) {
if (!previousParallelIds.includes(id)) {
result.parallelChanges.added++
} else {
const normalizedCurrent = normalizeValue(normalizeParallel(currentParallels[id]))
const normalizedPrevious = normalizeValue(normalizeParallel(previousParallels[id]))
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
result.parallelChanges.modified++
}
}
}
for (const id of previousParallelIds) {
if (!currentParallelIds.includes(id)) {
result.parallelChanges.removed++
}
}
const currentVars = normalizeVariables(currentState.variables)
const previousVars = normalizeVariables(previousState.variables)
const currentVarIds = Object.keys(currentVars)
const previousVarIds = Object.keys(previousVars)
for (const id of currentVarIds) {
if (!previousVarIds.includes(id)) {
result.variableChanges.added++
result.variableChanges.addedNames.push(currentVars[id].name || id)
}
}
for (const id of previousVarIds) {
if (!currentVarIds.includes(id)) {
result.variableChanges.removed++
result.variableChanges.removedNames.push(previousVars[id].name || id)
}
}
for (const id of currentVarIds) {
if (!previousVarIds.includes(id)) continue
const currentVar = normalizeValue(sanitizeVariable(currentVars[id]))
const previousVar = normalizeValue(sanitizeVariable(previousVars[id]))
if (normalizedStringify(currentVar) !== normalizedStringify(previousVar)) {
result.variableChanges.modified++
result.variableChanges.modifiedNames.push(currentVars[id].name || id)
}
}
result.hasChanges =
result.addedBlocks.length > 0 ||
result.removedBlocks.length > 0 ||
result.modifiedBlocks.length > 0 ||
result.edgeChanges.added > 0 ||
result.edgeChanges.removed > 0 ||
result.loopChanges.added > 0 ||
result.loopChanges.removed > 0 ||
result.loopChanges.modified > 0 ||
result.parallelChanges.added > 0 ||
result.parallelChanges.removed > 0 ||
result.parallelChanges.modified > 0 ||
result.variableChanges.added > 0 ||
result.variableChanges.removed > 0 ||
result.variableChanges.modified > 0
return result
}
/**
* Convert a WorkflowDiffSummary to a human-readable string for AI description generation
*/
export function formatDiffSummaryForDescription(summary: WorkflowDiffSummary): string {
if (!summary.hasChanges) {
return 'No structural changes detected (configuration may have changed)'
}
const changes: string[] = []
for (const block of summary.addedBlocks) {
const name = block.name || block.type
changes.push(`Added block: ${name} (${block.type})`)
}
for (const block of summary.removedBlocks) {
const name = block.name || block.type
changes.push(`Removed block: ${name} (${block.type})`)
}
for (const block of summary.modifiedBlocks) {
const name = block.name || block.type
const meaningfulChanges = block.changes.filter((c) => !c.field.endsWith('.properties'))
for (const change of meaningfulChanges.slice(0, MAX_CHANGES_PER_BLOCK)) {
const fieldLabel = resolveFieldLabel(block.type, change.field)
const oldStr = formatValueForDisplay(change.oldValue)
const newStr = formatValueForDisplay(change.newValue)
changes.push(`Modified ${name}: ${fieldLabel} changed from "${oldStr}" to "${newStr}"`)
}
if (meaningfulChanges.length > MAX_CHANGES_PER_BLOCK) {
changes.push(
` ...and ${meaningfulChanges.length - MAX_CHANGES_PER_BLOCK} more changes in ${name}`
)
}
}
formatEdgeChanges(summary, changes)
formatCountChanges(summary.loopChanges, 'loop', changes)
formatCountChanges(summary.parallelChanges, 'parallel group', changes)
formatVariableChanges(summary, changes)
return changes.join('\n')
}
/**
* Converts a WorkflowDiffSummary to a human-readable string with resolved display names.
* Resolves IDs (credentials, channels, workflows, etc.) to human-readable names using
* the selector registry infrastructure.
*
* @param summary - The diff summary to format
* @param currentState - The current workflow state for context extraction
* @param workflowId - The workflow ID for API calls
* @returns A formatted string describing the changes with resolved names
*/
export async function formatDiffSummaryForDescriptionAsync(
summary: WorkflowDiffSummary,
currentState: WorkflowState,
workflowId: string
): Promise<string> {
if (!summary.hasChanges) {
return 'No structural changes detected (configuration may have changed)'
}
const changes: string[] = []
for (const block of summary.addedBlocks) {
const name = block.name || block.type
changes.push(`Added block: ${name} (${block.type})`)
}
for (const block of summary.removedBlocks) {
const name = block.name || block.type
changes.push(`Removed block: ${name} (${block.type})`)
}
const modifiedBlockPromises = summary.modifiedBlocks.map(async (block) => {
const name = block.name || block.type
const blockChanges: string[] = []
const meaningfulChanges = block.changes.filter((c) => !c.field.endsWith('.properties'))
const changesToProcess = meaningfulChanges.slice(0, MAX_CHANGES_PER_BLOCK)
const resolvedChanges = await Promise.all(
changesToProcess.map(async (change) => {
const context = {
blockType: block.type,
subBlockId: change.field,
workflowId,
currentState,
blockId: block.id,
}
const [oldResolved, newResolved] = await Promise.all([
resolveValueForDisplay(change.oldValue, context),
resolveValueForDisplay(change.newValue, context),
])
return {
field: resolveFieldLabel(block.type, change.field),
oldLabel: oldResolved.displayLabel,
newLabel: newResolved.displayLabel,
}
})
)
for (const resolved of resolvedChanges) {
blockChanges.push(
`Modified ${name}: ${resolved.field} changed from "${resolved.oldLabel}" to "${resolved.newLabel}"`
)
}
if (meaningfulChanges.length > MAX_CHANGES_PER_BLOCK) {
blockChanges.push(
` ...and ${meaningfulChanges.length - MAX_CHANGES_PER_BLOCK} more changes in ${name}`
)
}
return blockChanges
})
const allModifiedBlockChanges = await Promise.all(modifiedBlockPromises)
for (const blockChanges of allModifiedBlockChanges) {
changes.push(...blockChanges)
}
formatEdgeChanges(summary, changes)
formatCountChanges(summary.loopChanges, 'loop', changes)
formatCountChanges(summary.parallelChanges, 'parallel group', changes)
formatVariableChanges(summary, changes)
logger.info('Generated async diff description', {
workflowId,
changeCount: changes.length,
modifiedBlocks: summary.modifiedBlocks.length,
})
return changes.join('\n')
}
function formatEdgeDetailList(
edges: Array<{ sourceName: string; targetName: string }>,
total: number,
verb: string,
changes: string[]
): void {
if (edges.length === 0) {
changes.push(`${verb} ${total} connection(s)`)
return
}
for (const edge of edges.slice(0, MAX_EDGE_DETAILS)) {
changes.push(`${verb} connection: ${edge.sourceName} -> ${edge.targetName}`)
}
if (total > MAX_EDGE_DETAILS) {
changes.push(` ...and ${total - MAX_EDGE_DETAILS} more ${verb.toLowerCase()} connection(s)`)
}
}
function formatEdgeChanges(summary: WorkflowDiffSummary, changes: string[]): void {
if (summary.edgeChanges.added > 0) {
formatEdgeDetailList(
summary.edgeChanges.addedDetails ?? [],
summary.edgeChanges.added,
'Added',
changes
)
}
if (summary.edgeChanges.removed > 0) {
formatEdgeDetailList(
summary.edgeChanges.removedDetails ?? [],
summary.edgeChanges.removed,
'Removed',
changes
)
}
}
function formatCountChanges(
counts: { added: number; removed: number; modified: number },
label: string,
changes: string[]
): void {
if (counts.added > 0) changes.push(`Added ${counts.added} ${label}(s)`)
if (counts.removed > 0) changes.push(`Removed ${counts.removed} ${label}(s)`)
if (counts.modified > 0) changes.push(`Modified ${counts.modified} ${label}(s)`)
}
function formatVariableChanges(summary: WorkflowDiffSummary, changes: string[]): void {
const categories = [
{
count: summary.variableChanges.added,
names: summary.variableChanges.addedNames ?? [],
verb: 'added',
},
{
count: summary.variableChanges.removed,
names: summary.variableChanges.removedNames ?? [],
verb: 'removed',
},
{
count: summary.variableChanges.modified,
names: summary.variableChanges.modifiedNames ?? [],
verb: 'modified',
},
] as const
const varParts: string[] = []
for (const { count, names, verb } of categories) {
if (count > 0) {
varParts.push(
names.length > 0 ? `${verb} ${names.map((n) => `"${n}"`).join(', ')}` : `${count} ${verb}`
)
}
}
if (varParts.length > 0) {
changes.push(`Variables: ${varParts.join(', ')}`)
}
}
@@ -0,0 +1,859 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetBlock } = vi.hoisted(() => ({
mockGetBlock: vi.fn(),
}))
vi.mock('@/lib/workflows/subblocks/visibility', () => ({
isNonEmptyValue: (v: unknown) => v !== null && v !== undefined && v !== '',
}))
vi.mock('@/triggers/constants', () => ({
SYSTEM_SUBBLOCK_IDS: [],
TRIGGER_RUNTIME_SUBBLOCK_IDS: [],
}))
vi.mock('@/blocks/types', () => ({
SELECTOR_TYPES_HYDRATION_REQUIRED: [],
}))
vi.mock('@/executor/constants', () => ({
isUuid: (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
}))
vi.mock('@/blocks/registry', () => ({
getBlock: mockGetBlock,
getAllBlocks: () => ({}),
getAllBlockTypes: () => [],
registry: {},
}))
vi.mock('@/lib/workflows/subblocks/context', () => ({
buildSelectorContextFromBlock: vi.fn(() => ({})),
}))
vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({
fetchOAuthCredentialDetail: vi.fn(() => []),
}))
vi.mock('@/hooks/selectors/registry', () => ({
getSelectorDefinition: vi.fn(() => ({ fetchList: vi.fn(() => []) })),
}))
vi.mock('@/hooks/selectors/resolution', () => ({
resolveSelectorForSubBlock: vi.fn(),
}))
import { WorkflowBuilder } from '@sim/testing'
import type { WorkflowDiffSummary } from '@/lib/workflows/comparison/compare'
import {
formatDiffSummaryForDescription,
formatDiffSummaryForDescriptionAsync,
generateWorkflowDiffSummary,
} from '@/lib/workflows/comparison/compare'
import { formatValueForDisplay, resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values'
function emptyDiffSummary(overrides: Partial<WorkflowDiffSummary> = {}): WorkflowDiffSummary {
return {
addedBlocks: [],
removedBlocks: [],
modifiedBlocks: [],
edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] },
loopChanges: { added: 0, removed: 0, modified: 0 },
parallelChanges: { added: 0, removed: 0, modified: 0 },
variableChanges: {
added: 0,
removed: 0,
modified: 0,
addedNames: [],
removedNames: [],
modifiedNames: [],
},
hasChanges: false,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('resolveFieldLabel', () => {
it('resolves subBlock id to its title', () => {
mockGetBlock.mockReturnValue({
subBlocks: [
{ id: 'systemPrompt', title: 'System Prompt' },
{ id: 'model', title: 'Model' },
],
})
expect(resolveFieldLabel('agent', 'systemPrompt')).toBe('System Prompt')
expect(resolveFieldLabel('agent', 'model')).toBe('Model')
})
it('falls back to raw id when block not found', () => {
mockGetBlock.mockReturnValue(null)
expect(resolveFieldLabel('unknown_type', 'someField')).toBe('someField')
})
it('falls back to raw id when subBlock not found', () => {
mockGetBlock.mockReturnValue({ subBlocks: [{ id: 'other', title: 'Other' }] })
expect(resolveFieldLabel('agent', 'missingField')).toBe('missingField')
})
it('converts data.* fields to Title Case', () => {
expect(resolveFieldLabel('agent', 'data.loopType')).toBe('Loop Type')
expect(resolveFieldLabel('agent', 'data.canonicalModes')).toBe('Canonical Modes')
expect(resolveFieldLabel('agent', 'data.isStarter')).toBe('Is Starter')
})
})
describe('formatValueForDisplay', () => {
it('handles null/undefined', () => {
expect(formatValueForDisplay(null)).toBe('(none)')
expect(formatValueForDisplay(undefined)).toBe('(none)')
})
it('handles booleans', () => {
expect(formatValueForDisplay(true)).toBe('enabled')
expect(formatValueForDisplay(false)).toBe('disabled')
})
it('truncates long strings', () => {
const longStr = 'a'.repeat(60)
expect(formatValueForDisplay(longStr)).toBe(`${'a'.repeat(50)}...`)
})
it('handles empty string', () => {
expect(formatValueForDisplay('')).toBe('(empty)')
})
})
describe('formatDiffSummaryForDescription', () => {
it('returns no-changes message for empty diff', () => {
const result = formatDiffSummaryForDescription(emptyDiffSummary())
expect(result).toBe('No structural changes detected (configuration may have changed)')
})
it('uses human-readable field labels for modified blocks', () => {
mockGetBlock.mockReturnValue({
subBlocks: [
{ id: 'systemPrompt', title: 'System Prompt' },
{ id: 'model', title: 'Model' },
],
})
const summary = emptyDiffSummary({
hasChanges: true,
modifiedBlocks: [
{
id: 'block-1',
type: 'agent',
name: 'My Agent',
changes: [
{ field: 'systemPrompt', oldValue: 'You are helpful', newValue: 'You are an expert' },
{ field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet-4-5' },
],
},
],
})
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain(
'Modified My Agent: System Prompt changed from "You are helpful" to "You are an expert"'
)
expect(result).toContain(
'Modified My Agent: Model changed from "gpt-4o" to "claude-sonnet-4-5"'
)
expect(result).not.toContain('systemPrompt')
expect(result).not.toContain('model changed')
})
it('filters out .properties changes', () => {
mockGetBlock.mockReturnValue({ subBlocks: [] })
const summary = emptyDiffSummary({
hasChanges: true,
modifiedBlocks: [
{
id: 'block-1',
type: 'agent',
name: 'Agent',
changes: [
{ field: 'systemPrompt', oldValue: 'old', newValue: 'new' },
{
field: 'systemPrompt.properties',
oldValue: { some: 'meta' },
newValue: { some: 'other' },
},
{ field: 'model.properties', oldValue: {}, newValue: { x: 1 } },
],
},
],
})
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('systemPrompt changed')
expect(result).not.toContain('.properties')
expect(result).not.toContain('model.properties')
})
it('respects MAX_CHANGES_PER_BLOCK limit of 6', () => {
mockGetBlock.mockReturnValue({ subBlocks: [] })
const changes = Array.from({ length: 8 }, (_, i) => ({
field: `field${i}`,
oldValue: `old${i}`,
newValue: `new${i}`,
}))
const summary = emptyDiffSummary({
hasChanges: true,
modifiedBlocks: [{ id: 'b1', type: 'agent', name: 'Agent', changes }],
})
const result = formatDiffSummaryForDescription(summary)
const lines = result.split('\n')
const modifiedLines = lines.filter((l) => l.startsWith('Modified'))
expect(modifiedLines).toHaveLength(6)
expect(result).toContain('...and 2 more changes in Agent')
})
it('shows edge changes with block names', () => {
const summary = emptyDiffSummary({
hasChanges: true,
edgeChanges: {
added: 2,
removed: 1,
addedDetails: [
{ sourceName: 'My Agent', targetName: 'Slack' },
{ sourceName: 'Router', targetName: 'Gmail' },
],
removedDetails: [{ sourceName: 'Function', targetName: 'Webhook' }],
},
})
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Added connection: My Agent -> Slack')
expect(result).toContain('Added connection: Router -> Gmail')
expect(result).toContain('Removed connection: Function -> Webhook')
})
it('truncates edge details beyond MAX_EDGE_DETAILS', () => {
const summary = emptyDiffSummary({
hasChanges: true,
edgeChanges: {
added: 5,
removed: 0,
addedDetails: [
{ sourceName: 'A', targetName: 'B' },
{ sourceName: 'C', targetName: 'D' },
{ sourceName: 'E', targetName: 'F' },
{ sourceName: 'G', targetName: 'H' },
{ sourceName: 'I', targetName: 'J' },
],
removedDetails: [],
},
})
const result = formatDiffSummaryForDescription(summary)
const connectionLines = result.split('\n').filter((l) => l.startsWith('Added connection'))
expect(connectionLines).toHaveLength(3)
expect(result).toContain('...and 2 more added connection(s)')
})
it('shows variable changes with names', () => {
const summary = emptyDiffSummary({
hasChanges: true,
variableChanges: {
added: 2,
removed: 1,
modified: 1,
addedNames: ['counter', 'apiKey'],
removedNames: ['oldVar'],
modifiedNames: ['threshold'],
},
})
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain(
'Variables: added "counter", "apiKey", removed "oldVar", modified "threshold"'
)
})
it('handles data.* fields with Title Case labels', () => {
mockGetBlock.mockReturnValue({ subBlocks: [] })
const summary = emptyDiffSummary({
hasChanges: true,
modifiedBlocks: [
{
id: 'b1',
type: 'agent',
name: 'Agent',
changes: [
{ field: 'data.loopType', oldValue: 'for', newValue: 'forEach' },
{ field: 'data.isStarter', oldValue: true, newValue: false },
],
},
],
})
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Modified Agent: Loop Type changed from "for" to "forEach"')
expect(result).toContain('Modified Agent: Is Starter changed from "enabled" to "disabled"')
})
it('formats a realistic multi-block workflow change', () => {
mockGetBlock.mockImplementation((type: string) => {
if (type === 'agent') {
return {
subBlocks: [
{ id: 'systemPrompt', title: 'System Prompt' },
{ id: 'model', title: 'Model' },
{ id: 'temperature', title: 'Temperature' },
],
}
}
if (type === 'slack') {
return {
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ id: 'slack_send_message', label: 'Send Message' },
{ id: 'slack_list_channels', label: 'List Channels' },
],
},
{ id: 'channel', title: 'Channel' },
{ id: 'credential', title: 'Slack Account' },
],
}
}
return null
})
const summary = emptyDiffSummary({
hasChanges: true,
addedBlocks: [{ id: 'b3', type: 'gmail', name: 'Gmail Notifications' }],
removedBlocks: [{ id: 'b4', type: 'function', name: 'Legacy Transform' }],
modifiedBlocks: [
{
id: 'b1',
type: 'agent',
name: 'AI Assistant',
changes: [
{ field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet-4-5' },
{ field: 'temperature', oldValue: '0.7', newValue: '0.3' },
],
},
{
id: 'b2',
type: 'slack',
name: 'Slack Alert',
changes: [{ field: 'channel', oldValue: '#general', newValue: '#alerts' }],
},
],
edgeChanges: {
added: 1,
removed: 0,
addedDetails: [{ sourceName: 'AI Assistant', targetName: 'Gmail Notifications' }],
removedDetails: [],
},
variableChanges: {
added: 1,
removed: 0,
modified: 0,
addedNames: ['errorCount'],
removedNames: [],
modifiedNames: [],
},
})
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Added block: Gmail Notifications (gmail)')
expect(result).toContain('Removed block: Legacy Transform (function)')
expect(result).toContain(
'Modified AI Assistant: Model changed from "gpt-4o" to "claude-sonnet-4-5"'
)
expect(result).toContain('Modified AI Assistant: Temperature changed from "0.7" to "0.3"')
expect(result).toContain('Modified Slack Alert: Channel changed from "#general" to "#alerts"')
expect(result).toContain('Added connection: AI Assistant -> Gmail Notifications')
expect(result).toContain('Variables: added "errorCount"')
})
})
describe('formatDiffSummaryForDescriptionAsync', () => {
it('resolves dropdown values to labels', async () => {
mockGetBlock.mockReturnValue({
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ id: 'calendly_get_current_user', label: 'Get Current User' },
{ id: 'calendly_list_event_types', label: 'List Event Types' },
],
},
],
})
const summary = emptyDiffSummary({
hasChanges: true,
modifiedBlocks: [
{
id: 'b1',
type: 'calendly',
name: 'Calendly',
changes: [
{
field: 'operation',
oldValue: 'calendly_get_current_user',
newValue: 'calendly_list_event_types',
},
],
},
],
})
const mockState = { blocks: {} } as any
const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1')
expect(result).toContain(
'Modified Calendly: Operation changed from "Get Current User" to "List Event Types"'
)
expect(result).not.toContain('calendly_get_current_user')
})
it('uses field titles in async path', async () => {
mockGetBlock.mockReturnValue({
subBlocks: [{ id: 'systemPrompt', title: 'System Prompt' }],
})
const summary = emptyDiffSummary({
hasChanges: true,
modifiedBlocks: [
{
id: 'b1',
type: 'agent',
name: 'Agent',
changes: [{ field: 'systemPrompt', oldValue: 'Be helpful', newValue: 'Be concise' }],
},
],
})
const mockState = { blocks: {} } as any
const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1')
expect(result).toContain('System Prompt')
expect(result).not.toContain('systemPrompt')
})
it('filters .properties changes in async path', async () => {
mockGetBlock.mockReturnValue({ subBlocks: [] })
const summary = emptyDiffSummary({
hasChanges: true,
modifiedBlocks: [
{
id: 'b1',
type: 'agent',
name: 'Agent',
changes: [
{ field: 'prompt', oldValue: 'old', newValue: 'new' },
{ field: 'prompt.properties', oldValue: {}, newValue: { x: 1 } },
],
},
],
})
const mockState = { blocks: {} } as any
const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1')
expect(result).not.toContain('.properties')
})
})
describe('end-to-end: generateWorkflowDiffSummary + formatDiffSummaryForDescription', () => {
beforeEach(() => {
mockGetBlock.mockReturnValue(null)
})
it('detects added and removed blocks between two workflow versions', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addAgent('agent-1', undefined, 'Summarizer')
.connect('start', 'agent-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addAgent('agent-1', undefined, 'Summarizer')
.addFunction('func-1', undefined, 'Formatter')
.connect('start', 'agent-1')
.connect('agent-1', 'func-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Added block: Formatter (function)')
expect(result).toContain('Added connection: Summarizer -> Formatter')
expect(result).not.toContain('Removed')
})
it('detects block removal and edge removal', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addAgent('agent-1', undefined, 'Classifier')
.addFunction('func-1', undefined, 'Logger')
.connect('start', 'agent-1')
.connect('agent-1', 'func-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addAgent('agent-1', undefined, 'Classifier')
.connect('start', 'agent-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Removed block: Logger (function)')
expect(result).toContain('Removed connection: Classifier -> Logger')
expect(result).not.toContain('Added block')
})
it('detects subBlock value changes on modified blocks', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addAgent('agent-1', undefined, 'Writer')
.connect('start', 'agent-1')
.build()
previous.blocks['agent-1'].subBlocks = {
systemPrompt: { id: 'systemPrompt', value: 'You are a helpful assistant' },
model: { id: 'model', value: 'gpt-4o' },
}
const current = new WorkflowBuilder()
.addStarter('start')
.addAgent('agent-1', undefined, 'Writer')
.connect('start', 'agent-1')
.build()
current.blocks['agent-1'].subBlocks = {
systemPrompt: { id: 'systemPrompt', value: 'You are a concise writer' },
model: { id: 'model', value: 'claude-sonnet-4-5' },
}
mockGetBlock.mockReturnValue({
subBlocks: [
{ id: 'systemPrompt', title: 'System Prompt' },
{ id: 'model', title: 'Model' },
],
})
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain(
'Modified Writer: System Prompt changed from "You are a helpful assistant" to "You are a concise writer"'
)
expect(result).toContain('Modified Writer: Model changed from "gpt-4o" to "claude-sonnet-4-5"')
})
it('detects loop addition with correct count', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addFunction('func-1', undefined, 'Process')
.connect('start', 'func-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addFunction('func-1', undefined, 'Process')
.addLoop('loop-1', undefined, { iterations: 5, loopType: 'for' })
.addLoopChild('loop-1', 'loop-body', 'function')
.connect('start', 'func-1')
.connect('func-1', 'loop-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Added block: Loop (loop)')
expect(result).toContain('Added block: loop-body (function)')
expect(result).toContain('Added 1 loop(s)')
expect(result).toContain('Added connection: Process -> Loop')
})
it('detects loop removal', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' })
.addLoopChild('loop-1', 'loop-body', 'agent')
.connect('start', 'loop-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addAgent('agent-1', undefined, 'Direct Agent')
.connect('start', 'agent-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Removed block: Loop (loop)')
expect(result).toContain('Removed 1 loop(s)')
expect(result).toContain('Added block: Direct Agent (agent)')
})
it('detects loop modification when iterations change', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' })
.addLoopChild('loop-1', 'loop-body', 'function')
.connect('start', 'loop-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addLoop('loop-1', undefined, { iterations: 10, loopType: 'for' })
.addLoopChild('loop-1', 'loop-body', 'function')
.connect('start', 'loop-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Modified 1 loop(s)')
})
it('detects parallel addition', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addFunction('func-1', undefined, 'Sequencer')
.connect('start', 'func-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addParallel('par-1', undefined, { count: 3, parallelType: 'count' })
.addParallelChild('par-1', 'par-task-1', 'agent')
.addParallelChild('par-1', 'par-task-2', 'function')
.connect('start', 'par-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Added block: Parallel (parallel)')
expect(result).toContain('Added 1 parallel group(s)')
expect(result).toContain('Removed block: Sequencer (function)')
})
it('detects parallel removal', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addParallel('par-1', undefined, { count: 2 })
.addParallelChild('par-1', 'par-task', 'function')
.connect('start', 'par-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addFunction('func-1', undefined, 'Simple Step')
.connect('start', 'func-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Removed block: Parallel (parallel)')
expect(result).toContain('Removed 1 parallel group(s)')
expect(result).toContain('Added block: Simple Step (function)')
})
it('detects parallel modification when count changes', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addParallel('par-1', undefined, { count: 2, parallelType: 'count' })
.addParallelChild('par-1', 'par-task', 'function')
.connect('start', 'par-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addParallel('par-1', undefined, { count: 5, parallelType: 'count' })
.addParallelChild('par-1', 'par-task', 'function')
.connect('start', 'par-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Modified 1 parallel group(s)')
})
it('detects variable additions and removals with names', () => {
const previous = new WorkflowBuilder().addStarter('start').build()
previous.variables = {
v1: { id: 'v1', name: 'retryCount', type: 'number', value: 3 },
v2: { id: 'v2', name: 'apiEndpoint', type: 'string', value: 'https://api.example.com' },
}
const current = new WorkflowBuilder().addStarter('start').build()
current.variables = {
v1: { id: 'v1', name: 'retryCount', type: 'number', value: 5 },
v3: { id: 'v3', name: 'timeout', type: 'number', value: 30 },
}
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Variables:')
expect(result).toContain('added "timeout"')
expect(result).toContain('removed "apiEndpoint"')
expect(result).toContain('modified "retryCount"')
})
it('produces no-change message for identical workflows', () => {
const workflow = new WorkflowBuilder()
.addStarter('start')
.addAgent('agent-1', undefined, 'Agent')
.connect('start', 'agent-1')
.build()
const summary = generateWorkflowDiffSummary(workflow, workflow)
const result = formatDiffSummaryForDescription(summary)
expect(result).toBe('No structural changes detected (configuration may have changed)')
})
it('handles complex scenario: loop replaced with parallel + new connections + variables', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addLoop('loop-1', undefined, { iterations: 5 })
.addLoopChild('loop-1', 'loop-task', 'agent')
.addFunction('sink', undefined, 'Output')
.connect('start', 'loop-1')
.connect('loop-1', 'sink')
.build()
previous.variables = {
v1: { id: 'v1', name: 'batchSize', type: 'number', value: 10 },
}
const current = new WorkflowBuilder()
.addStarter('start')
.addParallel('par-1', undefined, { count: 3 })
.addParallelChild('par-1', 'par-task', 'agent')
.addFunction('sink', undefined, 'Output')
.addAgent('agg', undefined, 'Aggregator')
.connect('start', 'par-1')
.connect('par-1', 'agg')
.connect('agg', 'sink')
.build()
current.variables = {
v1: { id: 'v1', name: 'batchSize', type: 'number', value: 25 },
v2: { id: 'v2', name: 'concurrency', type: 'number', value: 3 },
}
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Added block: Parallel (parallel)')
expect(result).toContain('Added block: Aggregator (agent)')
expect(result).toContain('Removed block: Loop (loop)')
expect(result).toContain('Added 1 parallel group(s)')
expect(result).toContain('Removed 1 loop(s)')
expect(result).toContain('added "concurrency"')
expect(result).toContain('modified "batchSize"')
const lines = result.split('\n')
expect(lines.length).toBeGreaterThanOrEqual(7)
})
it('detects edge rewiring without block changes', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addAgent('a', undefined, 'Agent A')
.addAgent('b', undefined, 'Agent B')
.addFunction('sink', undefined, 'Output')
.connect('start', 'a')
.connect('a', 'sink')
.connect('start', 'b')
.connect('b', 'sink')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addAgent('a', undefined, 'Agent A')
.addAgent('b', undefined, 'Agent B')
.addFunction('sink', undefined, 'Output')
.connect('start', 'a')
.connect('a', 'b')
.connect('b', 'sink')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(summary.addedBlocks).toHaveLength(0)
expect(summary.removedBlocks).toHaveLength(0)
expect(result).toContain('Added connection: Agent A -> Agent B')
expect(result).toContain('Removed connection:')
expect(result).not.toContain('Added block')
expect(result).not.toContain('Removed block')
})
it('detects data field changes with human-readable labels', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addBlock('custom-1', 'function', undefined, 'Processor')
.connect('start', 'custom-1')
.build()
previous.blocks['custom-1'].data = { isStarter: true, retryPolicy: 'linear' }
const current = new WorkflowBuilder()
.addStarter('start')
.addBlock('custom-1', 'function', undefined, 'Processor')
.connect('start', 'custom-1')
.build()
current.blocks['custom-1'].data = { isStarter: false, retryPolicy: 'exponential' }
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Is Starter')
expect(result).toContain('Retry Policy')
expect(result).toContain('enabled')
expect(result).toContain('disabled')
expect(result).toContain('linear')
expect(result).toContain('exponential')
})
it('detects loop type change via loop config modification', () => {
const previous = new WorkflowBuilder()
.addStarter('start')
.addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' })
.addLoopChild('loop-1', 'loop-body', 'function')
.connect('start', 'loop-1')
.build()
const current = new WorkflowBuilder()
.addStarter('start')
.addLoop('loop-1', undefined, { iterations: 3, loopType: 'forEach' })
.addLoopChild('loop-1', 'loop-body', 'function')
.connect('start', 'loop-1')
.build()
const summary = generateWorkflowDiffSummary(current, previous)
const result = formatDiffSummaryForDescription(summary)
expect(result).toContain('Modified 1 loop(s)')
})
})
@@ -0,0 +1,9 @@
export {
generateWorkflowDiffSummary,
hasWorkflowChanged,
type WorkflowDiffSummary,
} from './compare'
export {
normalizedStringify,
normalizeWorkflowState,
} from './normalize'
@@ -0,0 +1,874 @@
/**
* Tests for workflow normalization utilities
*/
import { describe, expect, it } from 'vitest'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import {
extractBlockFieldsForComparison,
filterSubBlockIds,
normalizedStringify,
normalizeEdge,
normalizeLoop,
normalizeParallel,
normalizeTriggerConfigValues,
normalizeValue,
sanitizeInputFormat,
sanitizeTools,
sanitizeVariable,
sortEdges,
} from './normalize'
describe('Workflow Normalization Utilities', () => {
describe('normalizeValue', () => {
it.concurrent('should return primitives unchanged', () => {
expect(normalizeValue(42)).toBe(42)
expect(normalizeValue('hello')).toBe('hello')
expect(normalizeValue(true)).toBe(true)
expect(normalizeValue(false)).toBe(false)
})
it.concurrent('should normalize null and undefined to undefined', () => {
// null and undefined are semantically equivalent in our system
expect(normalizeValue(null)).toBe(undefined)
expect(normalizeValue(undefined)).toBe(undefined)
})
it.concurrent('should handle arrays by normalizing each element', () => {
const input = [
{ b: 2, a: 1 },
{ d: 4, c: 3 },
]
const result = normalizeValue(input)
expect(result).toEqual([
{ a: 1, b: 2 },
{ c: 3, d: 4 },
])
})
it.concurrent('should sort object keys alphabetically', () => {
const input = { zebra: 1, apple: 2, mango: 3 }
const result = normalizeValue(input) as Record<string, unknown>
expect(Object.keys(result)).toEqual(['apple', 'mango', 'zebra'])
})
it.concurrent('should recursively normalize nested objects', () => {
const input = {
outer: {
z: 1,
a: {
y: 2,
b: 3,
},
},
first: 'value',
}
const result = normalizeValue(input) as {
first: string
outer: { z: number; a: { y: number; b: number } }
}
expect(Object.keys(result)).toEqual(['first', 'outer'])
expect(Object.keys(result.outer)).toEqual(['a', 'z'])
expect(Object.keys(result.outer.a)).toEqual(['b', 'y'])
})
it.concurrent('should handle empty objects', () => {
expect(normalizeValue({})).toEqual({})
})
it.concurrent('should handle empty arrays', () => {
expect(normalizeValue([])).toEqual([])
})
it.concurrent('should handle arrays with mixed types', () => {
const input = [1, 'string', { b: 2, a: 1 }, null, [3, 2, 1]]
const result = normalizeValue(input) as unknown[]
expect(result[0]).toBe(1)
expect(result[1]).toBe('string')
expect(Object.keys(result[2] as Record<string, unknown>)).toEqual(['a', 'b'])
expect(result[3]).toBe(undefined) // null normalized to undefined
expect(result[4]).toEqual([3, 2, 1]) // Array order preserved
})
it.concurrent('should handle deeply nested structures', () => {
const input = {
level1: {
level2: {
level3: {
level4: {
z: 'deep',
a: 'value',
},
},
},
},
}
const result = normalizeValue(input) as {
level1: { level2: { level3: { level4: { z: string; a: string } } } }
}
expect(Object.keys(result.level1.level2.level3.level4)).toEqual(['a', 'z'])
})
})
describe('normalizedStringify', () => {
it.concurrent('should produce identical strings for objects with different key orders', () => {
const obj1 = { b: 2, a: 1, c: 3 }
const obj2 = { a: 1, c: 3, b: 2 }
const obj3 = { c: 3, b: 2, a: 1 }
const str1 = normalizedStringify(obj1)
const str2 = normalizedStringify(obj2)
const str3 = normalizedStringify(obj3)
expect(str1).toBe(str2)
expect(str2).toBe(str3)
})
it.concurrent('should produce valid JSON', () => {
const obj = { nested: { value: [1, 2, 3] }, name: 'test' }
const str = normalizedStringify(obj)
expect(() => JSON.parse(str)).not.toThrow()
})
it.concurrent('should handle primitive values', () => {
expect(normalizedStringify(42)).toBe('42')
expect(normalizedStringify('hello')).toBe('"hello"')
expect(normalizedStringify(true)).toBe('true')
})
it.concurrent('should treat null and undefined equivalently', () => {
// Both null and undefined normalize to undefined, which JSON.stringify returns as undefined
expect(normalizedStringify(null)).toBe(normalizedStringify(undefined))
})
it.concurrent('should produce different strings for different values', () => {
const obj1 = { a: 1, b: 2 }
const obj2 = { a: 1, b: 3 }
expect(normalizedStringify(obj1)).not.toBe(normalizedStringify(obj2))
})
})
describe('sanitizeVariable', () => {
it.concurrent('removes UI-only fields without changing persisted values', () => {
expect(
sanitizeVariable({
id: 'variable-a',
workflowId: 'workflow-a',
name: 'Optional payload',
type: 'object',
value: null,
validationError: 'invalid',
})
).toEqual({
id: 'variable-a',
name: 'Optional payload',
type: 'object',
value: null,
})
})
})
describe('normalizeLoop', () => {
it.concurrent('should normalize null/undefined to undefined', () => {
// null and undefined are semantically equivalent
expect(normalizeLoop(null)).toBe(undefined)
expect(normalizeLoop(undefined)).toBe(undefined)
})
it.concurrent('should normalize "for" loop type', () => {
const loop: Loop & { extraField?: string } = {
id: 'loop1',
nodes: ['block1', 'block2'],
loopType: 'for',
iterations: 10,
forEachItems: 'should-be-excluded',
whileCondition: 'should-be-excluded',
doWhileCondition: 'should-be-excluded',
extraField: 'should-be-excluded',
}
const result = normalizeLoop(loop)
expect(result).toEqual({
id: 'loop1',
nodes: ['block1', 'block2'],
loopType: 'for',
iterations: 10,
})
})
it.concurrent('should normalize "forEach" loop type', () => {
const loop: Loop = {
id: 'loop2',
nodes: ['block1'],
loopType: 'forEach',
iterations: 5,
forEachItems: '<block.items>',
whileCondition: 'should-be-excluded',
}
const result = normalizeLoop(loop)
expect(result).toEqual({
id: 'loop2',
nodes: ['block1'],
loopType: 'forEach',
forEachItems: '<block.items>',
})
})
it.concurrent('should normalize "while" loop type', () => {
const loop: Loop = {
id: 'loop3',
nodes: ['block1', 'block2', 'block3'],
loopType: 'while',
iterations: 0,
whileCondition: '<block.condition> === true',
doWhileCondition: 'should-be-excluded',
}
const result = normalizeLoop(loop)
expect(result).toEqual({
id: 'loop3',
nodes: ['block1', 'block2', 'block3'],
loopType: 'while',
whileCondition: '<block.condition> === true',
})
})
it.concurrent('should normalize "doWhile" loop type', () => {
const loop: Loop = {
id: 'loop4',
nodes: ['block1'],
loopType: 'doWhile',
iterations: 0,
doWhileCondition: '<counter.value> < 100',
whileCondition: 'should-be-excluded',
}
const result = normalizeLoop(loop)
expect(result).toEqual({
id: 'loop4',
nodes: ['block1'],
loopType: 'doWhile',
doWhileCondition: '<counter.value> < 100',
})
})
it.concurrent('should extract only relevant fields for for loop type', () => {
const loop: Loop = {
id: 'loop5',
nodes: ['block1'],
loopType: 'for',
iterations: 5,
forEachItems: 'items',
}
const result = normalizeLoop(loop)
expect(result).toEqual({
id: 'loop5',
nodes: ['block1'],
loopType: 'for',
iterations: 5,
})
})
})
describe('normalizeParallel', () => {
it.concurrent('should normalize null/undefined to undefined', () => {
// null and undefined are semantically equivalent
expect(normalizeParallel(null)).toBe(undefined)
expect(normalizeParallel(undefined)).toBe(undefined)
})
it.concurrent('should normalize "count" parallel type', () => {
const parallel: Parallel & { extraField?: string } = {
id: 'parallel1',
nodes: ['block1', 'block2'],
parallelType: 'count',
count: 5,
distribution: 'should-be-excluded',
extraField: 'should-be-excluded',
}
const result = normalizeParallel(parallel)
expect(result).toEqual({
id: 'parallel1',
nodes: ['block1', 'block2'],
parallelType: 'count',
count: 5,
})
})
it.concurrent('should normalize "collection" parallel type', () => {
const parallel: Parallel = {
id: 'parallel2',
nodes: ['block1'],
parallelType: 'collection',
count: 10,
distribution: '<block.items>',
}
const result = normalizeParallel(parallel)
expect(result).toEqual({
id: 'parallel2',
nodes: ['block1'],
parallelType: 'collection',
distribution: '<block.items>',
})
})
it.concurrent('should include base fields for undefined parallel type', () => {
const parallel: Parallel = {
id: 'parallel3',
nodes: ['block1'],
parallelType: undefined,
count: 5,
distribution: 'items',
}
const result = normalizeParallel(parallel)
expect(result).toEqual({
id: 'parallel3',
nodes: ['block1'],
parallelType: undefined,
})
})
})
describe('sanitizeTools', () => {
it.concurrent('should return empty array for undefined', () => {
expect(sanitizeTools(undefined)).toEqual([])
})
it.concurrent('should return empty array for non-array input', () => {
expect(sanitizeTools(null as any)).toEqual([])
expect(sanitizeTools('not-an-array' as any)).toEqual([])
expect(sanitizeTools({} as any)).toEqual([])
})
it.concurrent('should remove isExpanded field from tools', () => {
const tools = [
{ id: 'tool1', name: 'Search', isExpanded: true },
{ id: 'tool2', name: 'Calculator', isExpanded: false },
{ id: 'tool3', name: 'Weather' },
]
const result = sanitizeTools(tools)
expect(result).toEqual([
{ id: 'tool1', name: 'Search' },
{ id: 'tool2', name: 'Calculator' },
{ id: 'tool3', name: 'Weather' },
])
})
it.concurrent('should preserve all other fields', () => {
const tools = [
{
id: 'tool1',
name: 'Complex Tool',
isExpanded: true,
schema: { type: 'function', name: 'search' },
params: { query: 'test' },
nested: { deep: { value: 123 } },
},
]
const result = sanitizeTools(tools)
expect(result[0]).toEqual({
id: 'tool1',
name: 'Complex Tool',
schema: { type: 'function', name: 'search' },
params: { query: 'test' },
nested: { deep: { value: 123 } },
})
})
it.concurrent('should handle empty array', () => {
expect(sanitizeTools([])).toEqual([])
})
})
describe('sanitizeInputFormat', () => {
it.concurrent('should return empty array for undefined', () => {
expect(sanitizeInputFormat(undefined)).toEqual([])
})
it.concurrent('should return empty array for non-array input', () => {
expect(sanitizeInputFormat(null as any)).toEqual([])
expect(sanitizeInputFormat('not-an-array' as any)).toEqual([])
expect(sanitizeInputFormat({} as any)).toEqual([])
})
it.concurrent('should remove collapsed field but keep value', () => {
const inputFormat = [
{ id: 'input1', name: 'Name', value: 'John', collapsed: true },
{ id: 'input2', name: 'Age', value: 25, collapsed: false },
{ id: 'input3', name: 'Email' },
]
const result = sanitizeInputFormat(inputFormat)
expect(result).toEqual([
{ id: 'input1', name: 'Name', value: 'John' },
{ id: 'input2', name: 'Age', value: 25 },
{ id: 'input3', name: 'Email' },
])
})
it.concurrent('should preserve all other fields including value', () => {
const inputFormat = [
{
id: 'input1',
name: 'Complex Input',
value: 'test-value',
collapsed: true,
type: 'string',
required: true,
validation: { min: 0, max: 100 },
},
]
const result = sanitizeInputFormat(inputFormat)
expect(result[0]).toEqual({
id: 'input1',
name: 'Complex Input',
value: 'test-value',
type: 'string',
required: true,
validation: { min: 0, max: 100 },
})
})
it.concurrent('should handle empty array', () => {
expect(sanitizeInputFormat([])).toEqual([])
})
})
describe('normalizeEdge', () => {
it.concurrent('should extract only connection-relevant fields', () => {
const edge = {
id: 'edge1',
source: 'block1',
sourceHandle: 'output',
target: 'block2',
targetHandle: 'input',
type: 'smoothstep',
animated: true,
style: { stroke: 'red' },
data: { label: 'connection' },
}
const result = normalizeEdge(edge)
expect(result).toEqual({
source: 'block1',
sourceHandle: 'output',
target: 'block2',
targetHandle: 'input',
})
})
it.concurrent('should handle edges without handles', () => {
const edge = {
id: 'edge1',
source: 'block1',
target: 'block2',
}
const result = normalizeEdge(edge)
expect(result).toEqual({
source: 'block1',
sourceHandle: undefined,
target: 'block2',
targetHandle: undefined,
})
})
it.concurrent('should handle edges with only source handle', () => {
const edge = {
id: 'edge1',
source: 'block1',
sourceHandle: 'output',
target: 'block2',
}
const result = normalizeEdge(edge)
expect(result).toEqual({
source: 'block1',
sourceHandle: 'output',
target: 'block2',
targetHandle: undefined,
})
})
})
describe('sortEdges', () => {
it.concurrent('should sort edges consistently', () => {
const edges = [
{ source: 'c', target: 'd' },
{ source: 'a', target: 'b' },
{ source: 'b', target: 'c' },
]
const result = sortEdges(edges)
expect(result[0].source).toBe('a')
expect(result[1].source).toBe('b')
expect(result[2].source).toBe('c')
})
it.concurrent(
'should sort by source, then sourceHandle, then target, then targetHandle',
() => {
const edges = [
{ source: 'a', sourceHandle: 'out2', target: 'b', targetHandle: 'in1' },
{ source: 'a', sourceHandle: 'out1', target: 'b', targetHandle: 'in1' },
{ source: 'a', sourceHandle: 'out1', target: 'b', targetHandle: 'in2' },
{ source: 'a', sourceHandle: 'out1', target: 'c', targetHandle: 'in1' },
]
const result = sortEdges(edges)
expect(result[0]).toEqual({
source: 'a',
sourceHandle: 'out1',
target: 'b',
targetHandle: 'in1',
})
expect(result[1]).toEqual({
source: 'a',
sourceHandle: 'out1',
target: 'b',
targetHandle: 'in2',
})
expect(result[2]).toEqual({
source: 'a',
sourceHandle: 'out1',
target: 'c',
targetHandle: 'in1',
})
expect(result[3]).toEqual({
source: 'a',
sourceHandle: 'out2',
target: 'b',
targetHandle: 'in1',
})
}
)
it.concurrent('should not mutate the original array', () => {
const edges = [
{ source: 'c', target: 'd' },
{ source: 'a', target: 'b' },
]
const originalFirst = edges[0]
sortEdges(edges)
expect(edges[0]).toBe(originalFirst)
})
it.concurrent('should handle empty array', () => {
expect(sortEdges([])).toEqual([])
})
it.concurrent('should handle edges with undefined handles', () => {
const edges = [
{ source: 'b', target: 'c' },
{ source: 'a', target: 'b', sourceHandle: 'out' },
]
const result = sortEdges(edges)
expect(result[0].source).toBe('a')
expect(result[1].source).toBe('b')
})
it.concurrent('should produce identical results regardless of input order', () => {
const edges1 = [
{ source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' },
{ source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' },
{ source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' },
]
const edges2 = [
{ source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' },
{ source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' },
{ source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' },
]
const edges3 = [
{ source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' },
{ source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' },
{ source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' },
]
const result1 = normalizedStringify(sortEdges(edges1))
const result2 = normalizedStringify(sortEdges(edges2))
const result3 = normalizedStringify(sortEdges(edges3))
expect(result1).toBe(result2)
expect(result2).toBe(result3)
})
})
describe('filterSubBlockIds', () => {
it.concurrent('should exclude exact SYSTEM_SUBBLOCK_IDS', () => {
const ids = ['signingSecret', 'samplePayload', 'triggerInstructions', 'botToken']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['botToken', 'signingSecret'])
})
it.concurrent('should exclude namespaced SYSTEM_SUBBLOCK_IDS (prefix matching)', () => {
const ids = [
'signingSecret',
'samplePayload_slack_webhook',
'triggerInstructions_slack_webhook',
'webhookUrlDisplay_slack_webhook',
'botToken',
]
const result = filterSubBlockIds(ids)
expect(result).toEqual(['botToken', 'signingSecret'])
})
it.concurrent('should exclude exact TRIGGER_RUNTIME_SUBBLOCK_IDS', () => {
const ids = ['webhookId', 'triggerPath', 'triggerConfig', 'triggerId', 'signingSecret']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['signingSecret'])
})
it.concurrent('should not exclude IDs that merely contain a system ID substring', () => {
const ids = ['mySamplePayload', 'notSamplePayload']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['mySamplePayload', 'notSamplePayload'])
})
it.concurrent('should return sorted results', () => {
const ids = ['zebra', 'alpha', 'middle']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['alpha', 'middle', 'zebra'])
})
it.concurrent('should handle empty array', () => {
expect(filterSubBlockIds([])).toEqual([])
})
it.concurrent('should handle all IDs being excluded', () => {
const ids = ['webhookId', 'triggerPath', 'samplePayload', 'triggerConfig']
const result = filterSubBlockIds(ids)
expect(result).toEqual([])
})
it.concurrent('should exclude setupScript and scheduleInfo namespaced variants', () => {
const ids = ['setupScript_google_sheets_row', 'scheduleInfo_cron_trigger', 'realField']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['realField'])
})
it.concurrent('should exclude triggerCredentials namespaced variants', () => {
const ids = ['triggerCredentials_slack_webhook', 'signingSecret']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['signingSecret'])
})
it.concurrent('should exclude synthetic tool-input subBlock IDs', () => {
const ids = [
'toolConfig',
'toolConfig-tool-0-query',
'toolConfig-tool-0-url',
'toolConfig-tool-1-status',
'systemPrompt',
]
const result = filterSubBlockIds(ids)
expect(result).toEqual(['systemPrompt', 'toolConfig'])
})
})
describe('normalizeTriggerConfigValues', () => {
it.concurrent('should return subBlocks unchanged when no triggerConfig exists', () => {
const subBlocks = {
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' },
botToken: { id: 'botToken', type: 'short-input', value: 'token456' },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect(result).toEqual(subBlocks)
})
it.concurrent('should return subBlocks unchanged when triggerConfig value is null', () => {
const subBlocks = {
triggerConfig: { id: 'triggerConfig', type: 'short-input', value: null },
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect(result).toEqual(subBlocks)
})
it.concurrent(
'should return subBlocks unchanged when triggerConfig value is not an object',
() => {
const subBlocks = {
triggerConfig: { id: 'triggerConfig', type: 'short-input', value: 'string-value' },
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect(result).toEqual(subBlocks)
}
)
it.concurrent('should populate null individual fields from triggerConfig', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123', botToken: 'token456' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
botToken: { id: 'botToken', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
expect((result.botToken as Record<string, unknown>).value).toBe('token456')
})
it.concurrent('should populate undefined individual fields from triggerConfig', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: undefined },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
})
it.concurrent('should populate empty string individual fields from triggerConfig', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: '' },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
})
it.concurrent('should NOT overwrite existing non-empty individual field values', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'old-secret' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'user-edited-secret' },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe('user-edited-secret')
})
it.concurrent('should skip triggerConfig fields that are null/undefined', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: null, botToken: undefined },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
botToken: { id: 'botToken', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe(null)
expect((result.botToken as Record<string, unknown>).value).toBe(null)
})
it.concurrent('should skip fields from triggerConfig that have no matching subBlock', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { nonExistentField: 'value123' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect(result.nonExistentField).toBeUndefined()
expect((result.signingSecret as Record<string, unknown>).value).toBe(null)
})
it.concurrent('should not mutate the original subBlocks object', () => {
const original = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
}
normalizeTriggerConfigValues(original)
expect((original.signingSecret as Record<string, unknown>).value).toBe(null)
})
it.concurrent('should preserve other subBlock properties when populating value', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
signingSecret: {
id: 'signingSecret',
type: 'short-input',
value: null,
placeholder: 'Enter signing secret',
},
}
const result = normalizeTriggerConfigValues(subBlocks)
const normalized = result.signingSecret as Record<string, unknown>
expect(normalized.value).toBe('secret123')
expect(normalized.id).toBe('signingSecret')
expect(normalized.type).toBe('short-input')
expect(normalized.placeholder).toBe('Enter signing secret')
})
})
describe('extractBlockFieldsForComparison', () => {
function createBlock(overrides: Partial<BlockState> = {}): BlockState {
return {
id: 'block-1',
type: 'agent',
name: 'Test',
enabled: true,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
...overrides,
} as BlockState
}
it.concurrent('should strip the locked field from blockRest', () => {
const { blockRest } = extractBlockFieldsForComparison(createBlock({ locked: true }))
expect((blockRest as Record<string, unknown>).locked).toBeUndefined()
})
it.concurrent(
'should yield identical blockRest when only locked differs between two blocks',
() => {
const lockedBlock = createBlock({ locked: true })
const unlockedBlock = createBlock({ locked: false })
const { blockRest: lockedRest } = extractBlockFieldsForComparison(lockedBlock)
const { blockRest: unlockedRest } = extractBlockFieldsForComparison(unlockedBlock)
expect(normalizedStringify(lockedRest)).toBe(normalizedStringify(unlockedRest))
}
)
it.concurrent('should keep functional fields like name and enabled', () => {
const { blockRest } = extractBlockFieldsForComparison(
createBlock({ name: 'A', enabled: false, locked: true })
)
const rest = blockRest as Record<string, unknown>
expect(rest.name).toBe('A')
expect(rest.enabled).toBe(false)
expect(rest.locked).toBeUndefined()
})
})
})
@@ -0,0 +1,574 @@
/**
* Shared normalization utilities for workflow change detection.
* Used by both client-side signature computation and server-side comparison.
*/
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,
Parallel,
Variable,
WorkflowState,
} from '@/stores/workflows/workflow/types'
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
/**
* Block data fields to exclude from comparison/hashing.
* These are either:
* - Visual/runtime-derived fields
* - React Flow internal fields
* - Duplicated in loops/parallels state (source of truth is there, not block.data)
* - Duplicated in subBlocks (user config comes from subBlocks, block.data is just a copy)
*/
export const EXCLUDED_BLOCK_DATA_FIELDS: readonly string[] = [
// Visual/layout fields
'width', // Container dimensions from autolayout
'height', // Container dimensions from autolayout
// React Flow internal fields
'id', // Duplicated from block.id
'type', // React Flow node type (e.g., "subflowNode")
'parentId', // Parent-child relationship for React Flow
'extent', // React Flow extent setting
// Loop fields - duplicated in loops state and/or subBlocks
'nodes', // Subflow node membership (derived at runtime)
'loopType', // Duplicated in loops state
'count', // Iteration count (duplicated in loops state)
'collection', // Items to iterate (duplicated in subBlocks)
'whileCondition', // While condition (duplicated in subBlocks)
'doWhileCondition', // Do-While condition (duplicated in subBlocks)
'forEachItems', // ForEach items (duplicated in loops state)
'iterations', // Loop iterations (duplicated in loops state)
// Parallel fields - duplicated in parallels state and/or subBlocks
'parallelType', // Duplicated in parallels state
'distribution', // Parallel distribution (derived during execution)
] as const
/**
* Normalizes a value for consistent comparison by:
* - Sorting object keys recursively
* - Filtering out null/undefined values from objects (treats them as equivalent to missing)
* - Recursively normalizing array elements
*
* @param value - The value to normalize
* @returns A normalized version of the value with sorted keys and no null/undefined fields
*/
export function normalizeValue(value: unknown): unknown {
// Treat null and undefined as equivalent - both become undefined (omitted from objects)
if (value === null || value === undefined) {
return undefined
}
if (typeof value !== 'object') {
return value
}
if (Array.isArray(value)) {
return value.map(normalizeValue)
}
const sorted: Record<string, unknown> = {}
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
const normalized = normalizeValue((value as Record<string, unknown>)[key])
// Only include non-null/undefined values
if (normalized !== undefined) {
sorted[key] = normalized
}
}
return sorted
}
/**
* Generates a normalized JSON string for comparison
* @param value - The value to normalize and stringify
* @returns A normalized JSON string
*/
export function normalizedStringify(value: unknown): string {
return JSON.stringify(normalizeValue(value))
}
/** Normalized loop result type with only essential fields */
interface NormalizedLoop {
id: string
nodes: string[]
loopType: Loop['loopType']
iterations?: number
forEachItems?: Loop['forEachItems']
whileCondition?: string
doWhileCondition?: string
}
/**
* Normalizes a loop configuration by extracting only the relevant fields for the loop type.
* Sorts the nodes array for consistent comparison (order doesn't affect execution - edges determine flow).
* Only includes optional fields if they have non-null/undefined values.
*
* @param loop - The loop configuration object
* @returns Normalized loop with only relevant fields
*/
export function normalizeLoop(loop: Loop | null | undefined): NormalizedLoop | null | undefined {
if (!loop) return undefined // Normalize null to undefined
const { id, nodes, loopType, iterations, forEachItems, whileCondition, doWhileCondition } = loop
// Sort nodes for consistent comparison (execution order is determined by edges, not array order)
const sortedNodes = [...nodes].sort()
const base: NormalizedLoop = { id, nodes: sortedNodes, loopType }
// Only add optional fields if they have non-null/undefined values
switch (loopType) {
case 'for':
if (iterations != null) base.iterations = iterations
break
case 'forEach':
if (forEachItems != null) base.forEachItems = forEachItems
break
case 'while':
if (whileCondition != null) base.whileCondition = whileCondition
break
case 'doWhile':
if (doWhileCondition != null) base.doWhileCondition = doWhileCondition
break
}
return base
}
/** Normalized parallel result type with only essential fields */
interface NormalizedParallel {
id: string
nodes: string[]
parallelType: Parallel['parallelType']
count?: number
distribution?: Parallel['distribution']
}
/**
* Normalizes a parallel configuration by extracting only the relevant fields for the parallel type.
* Sorts the nodes array for consistent comparison (parallel execution doesn't depend on array order).
* Only includes optional fields if they have non-null/undefined values.
*
* @param parallel - The parallel configuration object
* @returns Normalized parallel with only relevant fields
*/
export function normalizeParallel(
parallel: Parallel | null | undefined
): NormalizedParallel | null | undefined {
if (!parallel) return undefined // Normalize null to undefined
const { id, nodes, parallelType, count, distribution } = parallel
// Sort nodes for consistent comparison (parallel execution doesn't depend on array order)
const sortedNodes = [...nodes].sort()
const base: NormalizedParallel = {
id,
nodes: sortedNodes,
parallelType,
}
// Only add optional fields if they have non-null/undefined values
switch (parallelType) {
case 'count':
if (count != null) base.count = count
break
case 'collection':
if (distribution != null) base.distribution = distribution
break
}
return base
}
/** Tool configuration with optional UI-only isExpanded field */
type ToolWithExpanded = Record<string, unknown> & { isExpanded?: boolean }
/**
* Sanitizes tools array by removing UI-only fields like isExpanded
* @param tools - Array of tool configurations
* @returns Sanitized tools array
*/
export function sanitizeTools(tools: unknown[] | undefined): Record<string, unknown>[] {
if (!Array.isArray(tools)) return []
return tools.map((tool) => {
if (tool && typeof tool === 'object' && !Array.isArray(tool)) {
const { isExpanded, ...rest } = tool as ToolWithExpanded
return rest
}
return tool as Record<string, unknown>
})
}
/** Variable with optional UI-only fields */
type VariableWithUiFields = Variable & { validationError?: string; workflowId?: string }
/**
* Sanitizes a variable by removing UI-only fields.
* @param variable - The variable object
* @returns Sanitized variable object
*/
export function sanitizeVariable(
variable: VariableWithUiFields | null | undefined
): Omit<VariableWithUiFields, 'validationError' | 'workflowId'> | null | undefined {
if (!variable || typeof variable !== 'object') return variable
const { validationError: _validationError, workflowId: _workflowId, ...rest } = variable
return rest
}
/**
* Normalizes the variables structure to always be an object.
* Handles legacy data where variables might be stored as an empty array.
* @param variables - The variables to normalize
* @returns A normalized variables object
*/
export function normalizeVariables(variables: unknown): Record<string, Variable> {
if (!variables) return {}
if (Array.isArray(variables)) return {}
if (typeof variables !== 'object') return {}
return variables as Record<string, Variable>
}
/** Input format item with optional UI-only fields */
type InputFormatItem = Record<string, unknown> & { collapsed?: boolean }
/**
* Sanitizes inputFormat array by removing UI-only fields like collapsed
* @param inputFormat - Array of input format configurations
* @returns Sanitized input format array
*/
export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record<string, unknown>[] {
if (!Array.isArray(inputFormat)) return []
return inputFormat.map((item) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const { collapsed, ...rest } = item as InputFormatItem
return rest
}
return item as Record<string, unknown>
})
}
/** Normalized edge with only connection-relevant fields */
interface NormalizedEdge {
source: string
sourceHandle?: string | null
target: string
targetHandle?: string | null
}
/**
* Normalizes an edge by extracting only the connection-relevant fields.
* Treats null and undefined as equivalent (omits the field if null/undefined).
* @param edge - The edge object
* @returns Normalized edge with only connection fields
*/
export function normalizeEdge(edge: Edge): NormalizedEdge {
const normalized: NormalizedEdge = {
source: edge.source,
target: edge.target,
}
// Only include handles if they have a non-null value
// This treats null and undefined as equivalent (both omitted)
if (edge.sourceHandle != null) {
normalized.sourceHandle = edge.sourceHandle
}
if (edge.targetHandle != null) {
normalized.targetHandle = edge.targetHandle
}
return normalized
}
/**
* Sorts edges for consistent comparison
* @param edges - Array of edges to sort
* @returns Sorted array of normalized edges
*/
export function sortEdges(
edges: Array<{
source: string
sourceHandle?: string | null
target: string
targetHandle?: string | null
}>
): Array<{
source: string
sourceHandle?: string | null
target: string
targetHandle?: string | null
}> {
return [...edges].sort((a, b) =>
`${a.source}-${a.sourceHandle}-${a.target}-${a.targetHandle}`.localeCompare(
`${b.source}-${b.sourceHandle}-${b.target}-${b.targetHandle}`
)
)
}
/** Block with optional diff markers added by copilot */
export type BlockWithDiffMarkers = BlockState & {
is_diff?: string
field_diffs?: Record<string, unknown>
}
/** SubBlock with optional diff marker */
export type SubBlockWithDiffMarker = {
id: string
type: string
value: unknown
is_diff?: string
}
/** Normalized block structure for comparison */
interface NormalizedBlock {
[key: string]: unknown
data: Record<string, unknown>
subBlocks: Record<string, NormalizedSubBlock>
}
/** Normalized subBlock structure */
interface NormalizedSubBlock {
[key: string]: unknown
value: unknown
}
/** Normalized workflow state structure */
interface NormalizedWorkflowState {
blocks: Record<string, NormalizedBlock>
edges: Array<{
source: string
sourceHandle?: string | null
target: string
targetHandle?: string | null
}>
loops: Record<string, unknown>
parallels: Record<string, unknown>
variables: unknown
}
/** Result of extracting block fields for comparison */
export interface ExtractedBlockFields {
/** Block fields excluding visual-only fields (position, layout, height, outputs, diff markers) */
blockRest: Record<string, unknown>
/** Normalized data object excluding width/height/nodes/distribution */
normalizedData: Record<string, unknown>
/** SubBlocks map */
subBlocks: Record<string, unknown>
}
/**
* Normalizes block data by excluding visual/runtime/duplicated fields.
* See EXCLUDED_BLOCK_DATA_FIELDS for the list of excluded fields.
*
* Also normalizes empty strings to undefined (removes them) because:
* - Legacy deployed states may have empty string fields that current states don't have
* - Empty string and undefined/missing are semantically equivalent for config fields
*
* @param data - The block data object
* @returns Normalized data object
*/
export function normalizeBlockData(
data: Record<string, unknown> | undefined
): Record<string, unknown> {
const normalized: Record<string, unknown> = {}
for (const [key, value] of Object.entries(data || {})) {
// Skip excluded fields
if (EXCLUDED_BLOCK_DATA_FIELDS.includes(key)) continue
// Skip empty/null/undefined values (treat as equivalent to missing)
if (!isNonEmptyValue(value)) continue
normalized[key] = value
}
return normalized
}
/**
* Extracts block fields for comparison, excluding visual-only and runtime fields.
* Excludes: position, layout, height, outputs, is_diff, field_diffs, locked
*
* @param block - The block state
* @returns Extracted fields suitable for comparison
*/
export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlockFields {
const blockWithDiff = block as BlockWithDiffMarkers
const {
position: _position,
subBlocks = {},
layout: _layout,
height: _height,
outputs: _outputs,
is_diff: _isDiff,
field_diffs: _fieldDiffs,
locked: _locked,
...blockRest
} = blockWithDiff
return {
blockRest,
normalizedData: normalizeBlockData(blockRest.data as Record<string, unknown> | undefined),
subBlocks,
}
}
/**
* Filters subBlock IDs to exclude system, trigger runtime, and synthetic tool subBlocks.
*
* @param subBlockIds - Array of subBlock IDs to filter
* @returns Filtered and sorted array of subBlock IDs
*/
export function filterSubBlockIds(subBlockIds: string[]): string[] {
return subBlockIds
.filter((id) => {
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false
if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`)))
return false
if (isSyntheticToolSubBlockId(id)) return false
return true
})
.sort()
}
/**
* Normalizes trigger block subBlocks by populating null/empty individual fields
* from the triggerConfig aggregate subBlock. This compensates for the runtime
* population done by populateTriggerFieldsFromConfig, ensuring consistent
* comparison between client state (with populated values) and deployed state
* (with null values from DB).
*/
export function normalizeTriggerConfigValues(
subBlocks: Record<string, unknown>
): Record<string, unknown> {
const triggerConfigSub = subBlocks.triggerConfig as Record<string, unknown> | undefined
const triggerConfigValue = triggerConfigSub?.value
if (!triggerConfigValue || typeof triggerConfigValue !== 'object') {
return subBlocks
}
const result = { ...subBlocks }
for (const [fieldId, configValue] of Object.entries(
triggerConfigValue as Record<string, unknown>
)) {
if (configValue === null || configValue === undefined) continue
const existingSub = result[fieldId] as Record<string, unknown> | undefined
if (
existingSub &&
(existingSub.value === null || existingSub.value === undefined || existingSub.value === '')
) {
result[fieldId] = { ...existingSub, value: configValue }
}
}
return result
}
/**
* Normalizes a subBlock value with sanitization for specific subBlock types.
* Sanitizes: tools (removes isExpanded), inputFormat (removes collapsed)
*
* @param subBlockId - The subBlock ID
* @param value - The subBlock value
* @returns Normalized value
*/
export function normalizeSubBlockValue(subBlockId: string, value: unknown): unknown {
let normalizedValue = value ?? null
if (subBlockId === 'tools' && Array.isArray(normalizedValue)) {
normalizedValue = sanitizeTools(normalizedValue)
}
if (subBlockId === 'inputFormat' && Array.isArray(normalizedValue)) {
normalizedValue = sanitizeInputFormat(normalizedValue)
}
return normalizedValue
}
/**
* Extracts subBlock fields for comparison, excluding diff markers.
*
* @param subBlock - The subBlock object
* @returns SubBlock fields excluding value and is_diff
*/
export function extractSubBlockRest(subBlock: Record<string, unknown>): Record<string, unknown> {
const {
value: _v,
is_diff: _sd,
type: _type,
...rest
} = subBlock as SubBlockWithDiffMarker & {
type?: unknown
}
return rest
}
/**
* Normalizes a workflow state for comparison or hashing.
* Excludes non-functional fields (position, layout, height, outputs, diff markers, locked)
* and system/trigger runtime subBlocks.
*
* @param state - The workflow state to normalize
* @returns A normalized workflow state suitable for comparison or hashing
*/
export function normalizeWorkflowState(state: WorkflowState): NormalizedWorkflowState {
// 1. Normalize and sort edges (connection-relevant fields only)
const normalizedEdges = sortEdges((state.edges || []).map(normalizeEdge))
// 2. Normalize blocks
const normalizedBlocks: Record<string, NormalizedBlock> = {}
for (const [blockId, block] of Object.entries(state.blocks || {})) {
const {
blockRest,
normalizedData,
subBlocks: blockSubBlocks,
} = extractBlockFieldsForComparison(block)
// Filter and normalize subBlocks (exclude system/trigger runtime subBlocks)
const normalizedSubBlocks: Record<string, NormalizedSubBlock> = {}
const subBlockIds = filterSubBlockIds(Object.keys(blockSubBlocks))
for (const subBlockId of subBlockIds) {
const subBlock = blockSubBlocks[subBlockId] as SubBlockWithDiffMarker
const value = normalizeSubBlockValue(subBlockId, subBlock.value)
const subBlockRest = extractSubBlockRest(subBlock as Record<string, unknown>)
normalizedSubBlocks[subBlockId] = {
...subBlockRest,
value: normalizeValue(value),
}
}
normalizedBlocks[blockId] = {
...blockRest,
data: normalizedData,
subBlocks: normalizedSubBlocks,
}
}
// 3. Normalize loops using specialized normalizeLoop (extracts only type-relevant fields)
const normalizedLoops: Record<string, unknown> = {}
for (const [loopId, loop] of Object.entries(state.loops || {})) {
normalizedLoops[loopId] = normalizeValue(normalizeLoop(loop))
}
// 4. Normalize parallels using specialized normalizeParallel
const normalizedParallels: Record<string, unknown> = {}
for (const [parallelId, parallel] of Object.entries(state.parallels || {})) {
normalizedParallels[parallelId] = normalizeValue(normalizeParallel(parallel))
}
// 5. Normalize variables (remove UI-only validationError field)
const variables = normalizeVariables(state.variables)
const normalizedVariablesObj = normalizeValue(
Object.fromEntries(Object.entries(variables).map(([id, v]) => [id, sanitizeVariable(v)]))
)
return {
blocks: normalizedBlocks,
edges: normalizedEdges,
loops: normalizedLoops,
parallels: normalizedParallels,
variables: normalizedVariablesObj,
}
}
@@ -0,0 +1,285 @@
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context'
import { getBlock } from '@/blocks/registry'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { isUuid } from '@/executor/constants'
import { fetchOAuthCredentialDetail } from '@/hooks/queries/oauth/oauth-credentials'
import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { formatParameterLabel } from '@/tools/params'
const logger = createLogger('ResolveValues')
/**
* Result of resolving a value for display
*/
interface ResolvedValue {
/** The original value before resolution */
original: unknown
/** Human-readable label for display */
displayLabel: string
/** Whether the value was successfully resolved to a name */
resolved: boolean
}
/**
* Context needed to resolve values for display
*/
interface ResolutionContext {
/** The block type (e.g., 'slack', 'gmail') */
blockType: string
/** The subBlock field ID (e.g., 'channel', 'credential') */
subBlockId: string
/** The workflow ID for API calls */
workflowId: string
/** The workspace scope for selector-based lookups */
workspaceId?: string
/** The current workflow state for extracting additional context */
currentState: WorkflowState
/** The block ID being resolved */
blockId?: string
}
function getSemanticFallback(subBlockConfig: SubBlockConfig): string {
return (subBlockConfig.title ?? subBlockConfig.id).toLowerCase()
}
async function resolveCredential(credentialId: string, workflowId: string): Promise<string | null> {
try {
const credentials = await fetchOAuthCredentialDetail(credentialId, workflowId)
if (credentials.length > 0) {
return credentials[0].name ?? null
}
return null
} catch (error) {
logger.warn('Failed to resolve credential', { credentialId, error })
return null
}
}
async function resolveWorkflow(workflowId: string, workspaceId?: string): Promise<string | null> {
if (!workspaceId) return null
try {
const definition = getSelectorDefinition('sim.workflows')
if (definition.fetchById) {
const result = await definition.fetchById({
key: 'sim.workflows',
context: { workspaceId },
detailId: workflowId,
})
return result?.label ?? null
}
return null
} catch (error) {
logger.warn('Failed to resolve workflow', { workflowId, error })
return null
}
}
async function resolveSelectorValue(
value: string,
selectorKey: SelectorKey,
selectorContext: SelectorContext
): Promise<string | null> {
try {
const definition = getSelectorDefinition(selectorKey)
if (definition.fetchById) {
const result = await definition.fetchById({
key: selectorKey,
context: selectorContext,
detailId: value,
})
if (result?.label) {
return result.label
}
}
const options = await loadAllSelectorOptions(definition, {
key: selectorKey,
context: selectorContext,
})
const match = options.find((opt) => opt.id === value)
return match?.label ?? null
} catch (error) {
logger.warn('Failed to resolve selector value', { value, selectorKey, error })
return null
}
}
function extractMcpToolName(toolId: string): string {
const withoutPrefix = toolId.startsWith('mcp-') ? toolId.slice(4) : toolId
const parts = withoutPrefix.split('_')
if (parts.length >= 2) {
return parts[parts.length - 1]
}
return withoutPrefix
}
/**
* Resolves a subBlock field ID to its human-readable title.
* Falls back to the raw ID if the block or subBlock is not found.
*/
export function resolveFieldLabel(blockType: string, subBlockId: string): string {
if (subBlockId.startsWith('data.')) {
return formatParameterLabel(subBlockId.slice(5))
}
const blockConfig = getBlock(blockType)
if (!blockConfig) return subBlockId
const subBlockConfig = blockConfig.subBlocks.find((sb) => sb.id === subBlockId)
return subBlockConfig?.title ?? subBlockId
}
/**
* Resolves a dropdown option ID to its human-readable label.
* Returns null if the subBlock is not a dropdown or the value is not found.
*/
function resolveDropdownLabel(subBlockConfig: SubBlockConfig, value: string): string | null {
if (subBlockConfig.type !== 'dropdown') return null
if (!subBlockConfig.options) return null
const options =
typeof subBlockConfig.options === 'function' ? subBlockConfig.options() : subBlockConfig.options
const match = options.find((opt) => opt.id === value)
return match?.label ?? null
}
/**
* Formats a value for display in diff descriptions.
*/
export function formatValueForDisplay(value: unknown): string {
if (value === null || value === undefined) return '(none)'
if (typeof value === 'string') {
if (value.length > 50) return truncate(value, 50)
return value || '(empty)'
}
if (typeof value === 'boolean') return value ? 'enabled' : 'disabled'
if (typeof value === 'number') return String(value)
if (Array.isArray(value)) return `[${value.length} items]`
if (typeof value === 'object') {
const json = JSON.stringify(value)
return truncate(json, 50)
}
return String(value)
}
function extractSelectorContext(
blockId: string,
currentState: WorkflowState,
workflowId: string,
workspaceId?: string
): SelectorContext {
const block = currentState.blocks?.[blockId]
if (!block?.subBlocks) return { workflowId, workspaceId }
return buildSelectorContextFromBlock(block.type, block.subBlocks, {
workflowId,
workspaceId,
canonicalModes: block.data?.canonicalModes,
})
}
/**
* Resolves a value to a human-readable display label.
* Uses the selector registry infrastructure to resolve IDs to names.
*
* @param value - The value to resolve (credential ID, channel ID, UUID, etc.)
* @param context - Context needed for resolution (block type, subBlock ID, workflow state)
* @returns ResolvedValue with the display label and resolution status
*/
export async function resolveValueForDisplay(
value: unknown,
context: ResolutionContext
): Promise<ResolvedValue> {
if (typeof value !== 'string' || !value) {
return {
original: value,
displayLabel: formatValueForDisplay(value),
resolved: false,
}
}
const blockConfig = getBlock(context.blockType)
const subBlockConfig = blockConfig?.subBlocks.find((sb) => sb.id === context.subBlockId)
if (!subBlockConfig) {
return { original: value, displayLabel: formatValueForDisplay(value), resolved: false }
}
const semanticFallback = getSemanticFallback(subBlockConfig)
const selectorCtx = context.blockId
? extractSelectorContext(
context.blockId,
context.currentState,
context.workflowId,
context.workspaceId
)
: { workflowId: context.workflowId, workspaceId: context.workspaceId }
const isCredentialField =
subBlockConfig.type === 'oauth-input' || context.subBlockId === 'credential'
if (isCredentialField && isUuid(value)) {
const label = await resolveCredential(value, context.workflowId)
if (label) {
return { original: value, displayLabel: label, resolved: true }
}
return { original: value, displayLabel: semanticFallback, resolved: true }
}
if (subBlockConfig.type === 'workflow-selector' && isUuid(value)) {
const label = await resolveWorkflow(value, selectorCtx.workspaceId)
if (label) {
return { original: value, displayLabel: label, resolved: true }
}
return { original: value, displayLabel: semanticFallback, resolved: true }
}
if (subBlockConfig.type === 'mcp-tool-selector') {
const toolName = extractMcpToolName(value)
return { original: value, displayLabel: toolName, resolved: true }
}
if (subBlockConfig.type === 'dropdown') {
try {
const label = resolveDropdownLabel(subBlockConfig, value)
if (label) {
return { original: value, displayLabel: label, resolved: true }
}
} catch (error) {
logger.warn('Failed to resolve dropdown label', {
value,
subBlockId: context.subBlockId,
error,
})
}
}
if (SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) {
const resolution = resolveSelectorForSubBlock(subBlockConfig, selectorCtx)
if (resolution?.key) {
const label = await resolveSelectorValue(value, resolution.key, selectorCtx)
if (label) {
return { original: value, displayLabel: label, resolved: true }
}
}
return { original: value, displayLabel: semanticFallback, resolved: true }
}
if (isUuid(value)) {
return { original: value, displayLabel: semanticFallback, resolved: true }
}
if (/^C[A-Z0-9]{8,}$/.test(value) || /^[UW][A-Z0-9]{8,}$/.test(value)) {
return { original: value, displayLabel: semanticFallback, resolved: true }
}
return {
original: value,
displayLabel: formatValueForDisplay(value),
resolved: false,
}
}