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
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:
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user