Files
sim/apps/sim/lib/workflows/comparison/compare.ts
Waleed 4db6e556b7 feat(canvas): added the ability to lock blocks (#3102)
* feat(canvas): added the ability to lock blocks

* unlock duplicates of locked blocks

* fix(duplicate): place duplicate outside locked container

When duplicating a block that's inside a locked loop/parallel,
the duplicate is now placed outside the container since nothing
should be added to a locked container.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(duplicate): unlock all blocks when duplicating workflow

- Server-side workflow duplication now sets locked: false for all blocks
- regenerateWorkflowStateIds also unlocks blocks for templates
- Client-side regenerateBlockIds already handled this (for paste/import)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix code block disabled state, allow unlock from editor

* fix(lock): address code review feedback

- Fix toggle enabled using first toggleable block, not first block
- Delete button now checks isParentLocked
- Lock button now has disabled state
- Editor lock icon distinguishes block vs parent lock state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lock): prevent unlocking blocks inside locked containers

- Editor: can't unlock block if parent container is locked
- Action bar: can't unlock block if parent container is locked
- Shows "Parent container is locked" tooltip in both cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lock): ensure consistent behavior across all UIs

Block Menu, Editor, Action Bar now all have identical behavior:
- Enable/Disable: disabled when locked OR parent locked
- Flip Handles: disabled when locked OR parent locked
- Delete: disabled when locked OR parent locked
- Remove from Subflow: disabled when locked OR parent locked
- Lock: always available for admins
- Unlock: disabled when parent is locked (unlock parent first)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(enable): consistent behavior - can't enable if parent disabled

Same pattern as lock: must enable parent container first before
enabling children inside it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs(quick-reference): add lock block action

Added documentation for the lock/unlock block feature (admin only).
Note: Image placeholder added, pending actual screenshot.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* remove prefix square brackets in error notif

* add lock block image

* fix(block-menu): paste should not be disabled for locked selection

Paste creates new blocks, doesn't modify selected ones. Changed from
disableEdit (includes lock state) to !userCanEdit (permission only),
matching the Duplicate action behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(workflow): extract block deletion protection into shared utility

Extract duplicated block protection logic from workflow.tsx into
a reusable filterProtectedBlocks helper in utils/block-protection-utils.ts.
This ensures consistent behavior between context menu delete and
keyboard delete operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(workflow): extend block protection utilities for edge protection

Add isEdgeProtected, filterUnprotectedEdges, and hasProtectedBlocks
utilities. Refactor workflow.tsx to use these helpers for:
- onEdgesChange edge removal filtering
- onConnect connection prevention
- onNodeDragStart drag prevention
- Keyboard edge deletion
- Block menu disableEdit calculation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lock): address review comments for lock feature

1. Store batchToggleEnabled now uses continue to skip locked blocks
   entirely, matching database operation behavior

2. Copilot add operation now checks if parent container is locked
   before adding nested nodes (defensive check for consistency)

3. Remove unused filterUnprotectedEdges function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(copilot): add lock checks for insert and extract operations

- insert_into_subflow: Check if existing block being moved is locked
- extract_from_subflow: Check if block or parent subflow is locked

These operations now match the UI behavior where locked blocks
cannot be moved into/out of containers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lock): prevent duplicates inside locked containers via regenerateBlockIds

1. regenerateBlockIds now checks if existing parent is locked before
   keeping the block inside it. If parent is locked, the duplicate
   is placed outside (parentId cleared) instead of creating an
   inconsistent state.

2. Remove unnecessary effectivePermissions.canAdmin and potentialParentId
   from onNodeDragStart dependency array.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lock): fix toggle locked target state and draggable check

1. BATCH_TOGGLE_LOCKED now uses first block from blocksToToggle set
   instead of blockIds[0], matching BATCH_TOGGLE_ENABLED pattern.
   Also added early exit if blocksToToggle is empty.

2. Blocks inside locked containers are now properly non-draggable.
   Changed draggable check from !block.locked to use isBlockProtected()
   which checks both block lock and parent container lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(copilot): check parent lock in edit and delete operations

Both edit and delete operations now check if the block's parent
container is locked, not just if the block itself is locked. This
ensures consistent behavior with the UI which uses isBlockProtected
utility that checks both direct lock and parent lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(socket): add server-side lock validation and admin-only permissions

1. BATCH_TOGGLE_LOCKED now requires admin role - non-admin users with
   write role can no longer bypass UI restriction via direct socket
   messages

2. BATCH_REMOVE_BLOCKS now validates lock status server-side - filters
   out protected blocks (locked or inside locked parent) before deletion

3. Remove duplicate/outdated comment in regenerateBlockIds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(socket): update permission test for admin-only lock toggle

batch-toggle-locked is now admin-only, so write role should be denied.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(undo-redo): use consistent target state for toggle redo

The redo logic for BATCH_TOGGLE_ENABLED and BATCH_TOGGLE_LOCKED was
incorrectly computing each block's new state as !previousStates[blockId].
However, the store's batchToggleEnabled/batchToggleLocked set ALL blocks
to the SAME target state based on the first block's previous state.

Now redo computes targetState = !previousStates[firstBlockId] and applies
it to all blocks, matching the store's behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(socket): add comprehensive lock validation across operations

Based on audit findings, adds lock validation to multiple operations:

1. BATCH_TOGGLE_HANDLES - now skips locked/protected blocks at:
   - Store layer (batchToggleHandles)
   - Collaborative hook (collaborativeBatchToggleBlockHandles)
   - Server socket handler

2. BATCH_ADD_BLOCKS - server now filters blocks being added to
   locked parent containers

3. BATCH_UPDATE_PARENT - server now:
   - Skips protected blocks (locked or inside locked container)
   - Prevents moving blocks into locked containers

All validations use consistent isProtected() helper that checks both
direct lock and parent container lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(workflow): use pre-computed lock state from contextMenuBlocks

contextMenuBlocks already has locked and isParentLocked properties
computed in use-canvas-context-menu.ts, so there's no need to look
up blocks again via hasProtectedBlocks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lock): add lock validation to block rename operations

Defense-in-depth: although the UI disables rename for locked blocks,
the collaborative layer and server now also validate locks.

- collaborativeUpdateBlockName: checks if block is locked or inside
  locked container before attempting rename
- UPDATE_NAME server handler: checks lock status and parent lock
  before performing database update

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* added defense in depth for renaming locked blocks

* fix(socket): add server-side lock validation for edges and subblocks

Defense-in-depth: adds lock checks to server-side handlers that were
previously relying only on client-side validation.

Edge operations (ADD, REMOVE, BATCH_ADD, BATCH_REMOVE):
- Check if source or target blocks are protected before modifying edges

Subblock updates:
- Check if parent block is protected before updating subblock values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lock): fetch parent blocks for edge protection checks and consistent tooltip

- Fixed edge operations to fetch parent blocks before checking lock status
  - Previously, isBlockProtected checked if parent was locked, but the parent
    wasn't in blocksById because only source/target blocks were fetched
  - Now fetches parent blocks for all four edge operations: ADD, REMOVE,
    BATCH_ADD_EDGES, BATCH_REMOVE_EDGES
- Fixed tooltip inconsistency: changed "Run previous blocks first" to
  "Run upstream blocks first" in action-bar to match workflow.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* updated tooltip text for run from block

* fix(lock): add lock check to duplicate button and clean up drag handler

- Added lock check to duplicate button in action bar to prevent
  duplicating locked blocks (consistent with other edit operations)
- Removed ineffective early return in onNodeDragStart since the
  `draggable` property on nodes already prevents dragging protected
  blocks - the early return was misleading as it couldn't actually
  stop a drag operation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lock): use disableEdit for duplicate in block menu

Changed duplicate menu item to use disableEdit (which includes lock
check) instead of !userCanEdit for consistency with action bar and
other edit operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:15:04 -08:00

527 lines
18 KiB
TypeScript

import { createLogger } from '@sim/logger'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
extractBlockFieldsForComparison,
extractSubBlockRest,
filterSubBlockIds,
normalizedStringify,
normalizeEdge,
normalizeLoop,
normalizeParallel,
normalizeSubBlockValue,
normalizeValue,
normalizeVariables,
sanitizeVariable,
} from './normalize'
import { formatValueForDisplay, resolveValueForDisplay } from './resolve-values'
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
*/
export 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 }
loopChanges: { added: number; removed: number; modified: number }
parallelChanges: { added: number; removed: number; modified: number }
variableChanges: { added: number; removed: number; modified: number }
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 },
loopChanges: { added: 0, removed: 0, modified: 0 },
parallelChanges: { added: 0, removed: 0, modified: 0 },
variableChanges: { added: 0, removed: 0, modified: 0 },
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,
})
}
result.edgeChanges.added = (currentState.edges || []).length
result.loopChanges.added = Object.keys(currentState.loops || {}).length
result.parallelChanges.added = Object.keys(currentState.parallels || {}).length
result.variableChanges.added = Object.keys(currentState.variables || {}).length
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[] = []
// Use shared helpers for block field extraction (single source of truth)
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,
})
}
// Check other block properties (boolean fields)
// Use !! to normalize: null/undefined/false are all equivalent (falsy)
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] 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)) {
changes.push({ field: 'data', oldValue: previousDataRest, newValue: currentDataRest })
}
}
// Compare subBlocks using shared helper for filtering (single source of truth)
const allSubBlockIds = filterSubBlockIds([
...new Set([...Object.keys(currentSubBlocks), ...Object.keys(previousSubBlocks)]),
])
for (const subId of allSubBlockIds) {
const currentSub = currentSubBlocks[subId] as Record<string, unknown> | undefined
const previousSub = previousSubBlocks[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
}
// Use shared helper for subBlock value normalization (single source of truth)
const currentValue = normalizeSubBlockValue(subId, currentSub.value)
const previousValue = normalizeSubBlockValue(subId, previousSub.value)
// For string values, compare directly to catch even small text changes
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 })
}
}
// Use shared helper for subBlock REST extraction (single source of truth)
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))
for (const edge of currentEdgeSet) {
if (!previousEdgeSet.has(edge)) result.edgeChanges.added++
}
for (const edge of previousEdgeSet) {
if (!currentEdgeSet.has(edge)) result.edgeChanges.removed++
}
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)
result.variableChanges.added = currentVarIds.filter((id) => !previousVarIds.includes(id)).length
result.variableChanges.removed = previousVarIds.filter((id) => !currentVarIds.includes(id)).length
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.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
for (const change of block.changes.slice(0, 3)) {
const oldStr = formatValueForDisplay(change.oldValue)
const newStr = formatValueForDisplay(change.newValue)
changes.push(`Modified ${name}: ${change.field} changed from "${oldStr}" to "${newStr}"`)
}
if (block.changes.length > 3) {
changes.push(` ...and ${block.changes.length - 3} more changes in ${name}`)
}
}
if (summary.edgeChanges.added > 0) {
changes.push(`Added ${summary.edgeChanges.added} connection(s)`)
}
if (summary.edgeChanges.removed > 0) {
changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`)
}
if (summary.loopChanges.added > 0) {
changes.push(`Added ${summary.loopChanges.added} loop(s)`)
}
if (summary.loopChanges.removed > 0) {
changes.push(`Removed ${summary.loopChanges.removed} loop(s)`)
}
if (summary.loopChanges.modified > 0) {
changes.push(`Modified ${summary.loopChanges.modified} loop(s)`)
}
if (summary.parallelChanges.added > 0) {
changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`)
}
if (summary.parallelChanges.removed > 0) {
changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`)
}
if (summary.parallelChanges.modified > 0) {
changes.push(`Modified ${summary.parallelChanges.modified} parallel group(s)`)
}
const varChanges: string[] = []
if (summary.variableChanges.added > 0) {
varChanges.push(`${summary.variableChanges.added} added`)
}
if (summary.variableChanges.removed > 0) {
varChanges.push(`${summary.variableChanges.removed} removed`)
}
if (summary.variableChanges.modified > 0) {
varChanges.push(`${summary.variableChanges.modified} modified`)
}
if (varChanges.length > 0) {
changes.push(`Variables: ${varChanges.join(', ')}`)
}
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 changesToProcess = block.changes.slice(0, 3)
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: 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 (block.changes.length > 3) {
blockChanges.push(` ...and ${block.changes.length - 3} more changes in ${name}`)
}
return blockChanges
})
const allModifiedBlockChanges = await Promise.all(modifiedBlockPromises)
for (const blockChanges of allModifiedBlockChanges) {
changes.push(...blockChanges)
}
if (summary.edgeChanges.added > 0) {
changes.push(`Added ${summary.edgeChanges.added} connection(s)`)
}
if (summary.edgeChanges.removed > 0) {
changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`)
}
if (summary.loopChanges.added > 0) {
changes.push(`Added ${summary.loopChanges.added} loop(s)`)
}
if (summary.loopChanges.removed > 0) {
changes.push(`Removed ${summary.loopChanges.removed} loop(s)`)
}
if (summary.loopChanges.modified > 0) {
changes.push(`Modified ${summary.loopChanges.modified} loop(s)`)
}
if (summary.parallelChanges.added > 0) {
changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`)
}
if (summary.parallelChanges.removed > 0) {
changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`)
}
if (summary.parallelChanges.modified > 0) {
changes.push(`Modified ${summary.parallelChanges.modified} parallel group(s)`)
}
const varChanges: string[] = []
if (summary.variableChanges.added > 0) {
varChanges.push(`${summary.variableChanges.added} added`)
}
if (summary.variableChanges.removed > 0) {
varChanges.push(`${summary.variableChanges.removed} removed`)
}
if (summary.variableChanges.modified > 0) {
varChanges.push(`${summary.variableChanges.modified} modified`)
}
if (varChanges.length > 0) {
changes.push(`Variables: ${varChanges.join(', ')}`)
}
logger.info('Generated async diff description', {
workflowId,
changeCount: changes.length,
modifiedBlocks: summary.modifiedBlocks.length,
})
return changes.join('\n')
}