diff --git a/apps/sim/lib/workflows/condition-ids.ts b/apps/sim/lib/workflows/condition-ids.ts new file mode 100644 index 00000000000..054975d880d --- /dev/null +++ b/apps/sim/lib/workflows/condition-ids.ts @@ -0,0 +1,57 @@ +import { EDGE } from '@/executor/constants' + +/** + * Remaps condition/router block IDs in a parsed conditions array. + * Condition IDs use the format `{blockId}-{suffix}` and must be updated + * when a block is duplicated to reference the new block ID. + * + * @param conditions - Parsed array of condition block objects with `id` fields + * @param oldBlockId - The original block ID prefix to replace + * @param newBlockId - The new block ID prefix + * @returns Whether any IDs were changed (mutates in place) + */ +export function remapConditionBlockIds( + conditions: Array<{ id: string; [key: string]: unknown }>, + oldBlockId: string, + newBlockId: string +): boolean { + let changed = false + const prefix = `${oldBlockId}-` + for (const condition of conditions) { + if (typeof condition.id === 'string' && condition.id.startsWith(prefix)) { + const suffix = condition.id.slice(oldBlockId.length) + condition.id = `${newBlockId}${suffix}` + changed = true + } + } + return changed +} + +/** Handle prefixes that embed block-scoped condition/route IDs */ +const HANDLE_PREFIXES = [EDGE.CONDITION_PREFIX, EDGE.ROUTER_PREFIX] as const + +/** + * Remaps a condition or router edge sourceHandle from the old block ID to the new one. + * Handle formats: + * - Condition: `condition-{blockId}-{suffix}` + * - Router V2: `router-{blockId}-{suffix}` + * + * @returns The remapped handle string, or the original if no remapping needed + */ +export function remapConditionEdgeHandle( + sourceHandle: string, + oldBlockId: string, + newBlockId: string +): string { + for (const handlePrefix of HANDLE_PREFIXES) { + if (!sourceHandle.startsWith(handlePrefix)) continue + + const innerId = sourceHandle.slice(handlePrefix.length) + if (!innerId.startsWith(`${oldBlockId}-`)) continue + + const suffix = innerId.slice(oldBlockId.length) + return `${handlePrefix}${newBlockId}${suffix}` + } + + return sourceHandle +} diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index cee5f467a35..b1a2be23316 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -8,6 +8,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, min } from 'drizzle-orm' +import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import type { Variable } from '@/stores/panel/variables/types' @@ -77,6 +78,40 @@ function remapVariableIdsInSubBlocks( return updated } +/** + * Remaps condition/router block IDs within subBlocks when a block is duplicated. + * Returns a new object without mutating the input. + */ +function remapConditionIdsInSubBlocks( + subBlocks: Record, + oldBlockId: string, + newBlockId: string +): Record { + const updated: Record = {} + + for (const [key, subBlock] of Object.entries(subBlocks)) { + if ( + subBlock && + typeof subBlock === 'object' && + (subBlock.type === 'condition-input' || subBlock.type === 'router-input') && + typeof subBlock.value === 'string' + ) { + try { + const parsed = JSON.parse(subBlock.value) + if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) { + updated[key] = { ...subBlock, value: JSON.stringify(parsed) } + continue + } + } catch { + // Not valid JSON, skip + } + } + updated[key] = subBlock + } + + return updated +} + /** * Duplicate a workflow with all its blocks, edges, and subflows * This is a shared helper used by both the workflow duplicate API and folder duplicate API @@ -259,6 +294,15 @@ export async function duplicateWorkflow( ) } + // Remap condition/router IDs to use the new block ID + if (updatedSubBlocks && typeof updatedSubBlocks === 'object') { + updatedSubBlocks = remapConditionIdsInSubBlocks( + updatedSubBlocks as Record, + block.id, + newBlockId + ) + } + return { ...block, id: newBlockId, @@ -286,15 +330,24 @@ export async function duplicateWorkflow( .where(eq(workflowEdges.workflowId, sourceWorkflowId)) if (sourceEdges.length > 0) { - const newEdges = sourceEdges.map((edge) => ({ - ...edge, - id: crypto.randomUUID(), // Generate new edge ID - workflowId: newWorkflowId, - sourceBlockId: blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId, - targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId, - createdAt: now, - updatedAt: now, - })) + const newEdges = sourceEdges.map((edge) => { + const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId + const newSourceHandle = + edge.sourceHandle && blockIdMapping.has(edge.sourceBlockId) + ? remapConditionEdgeHandle(edge.sourceHandle, edge.sourceBlockId, newSourceBlockId) + : edge.sourceHandle + + return { + ...edge, + id: crypto.randomUUID(), + workflowId: newWorkflowId, + sourceBlockId: newSourceBlockId, + targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId, + sourceHandle: newSourceHandle, + createdAt: now, + updatedAt: now, + } + }) await tx.insert(workflowEdges).values(newEdges) logger.info(`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 89b7b7f6029..3bfa9af38ac 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -14,6 +14,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { v4 as uuidv4 } from 'uuid' import type { DbOrTx } from '@/lib/db/types' +import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { backfillCanonicalModes, migrateSubblockIds, @@ -833,7 +834,12 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener Object.entries(state.blocks || {}).forEach(([oldId, block]) => { const newId = blockIdMapping.get(oldId)! // Duplicated blocks are always unlocked so users can edit them - const newBlock: BlockState = { ...block, id: newId, locked: false } + const newBlock: BlockState = { + ...block, + id: newId, + subBlocks: JSON.parse(JSON.stringify(block.subBlocks)), + locked: false, + } // Update parentId reference if it exists if (newBlock.data?.parentId) { @@ -857,6 +863,21 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value } + // Remap condition/router IDs embedded in condition-input/router-input subBlocks + if ( + (updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') && + typeof updatedSubBlock.value === 'string' + ) { + try { + const parsed = JSON.parse(updatedSubBlock.value) + if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldId, newId)) { + updatedSubBlock.value = JSON.stringify(parsed) + } + } catch { + // Not valid JSON, skip + } + } + updatedSubBlocks[subId] = updatedSubBlock }) newBlock.subBlocks = updatedSubBlocks @@ -871,12 +892,17 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener const newId = edgeIdMapping.get(edge.id)! const newSource = blockIdMapping.get(edge.source) || edge.source const newTarget = blockIdMapping.get(edge.target) || edge.target + const newSourceHandle = + edge.sourceHandle && blockIdMapping.has(edge.source) + ? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource) + : edge.sourceHandle newEdges.push({ ...edge, id: newId, source: newSource, target: newTarget, + sourceHandle: newSourceHandle, }) }) diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 1352db5d10e..e82bfcd7310 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -2,6 +2,7 @@ import type { Edge } from 'reactflow' import { v4 as uuidv4 } from 'uuid' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' @@ -363,13 +364,15 @@ export function regenerateWorkflowIds( const nameMap = new Map() const newBlocks: Record = {} - // First pass: generate new IDs + // First pass: generate new IDs and remap condition/router IDs in subBlocks Object.entries(workflowState.blocks).forEach(([oldId, block]) => { const newId = uuidv4() blockIdMap.set(oldId, newId) const oldNormalizedName = normalizeName(block.name) nameMap.set(oldNormalizedName, oldNormalizedName) - newBlocks[newId] = { ...block, id: newId } + const newBlock = { ...block, id: newId, subBlocks: JSON.parse(JSON.stringify(block.subBlocks)) } + remapConditionIds(newBlock.subBlocks, {}, oldId, newId) + newBlocks[newId] = newBlock }) // Second pass: update parentId references @@ -385,12 +388,21 @@ export function regenerateWorkflowIds( } }) - const newEdges = workflowState.edges.map((edge) => ({ - ...edge, - id: uuidv4(), - source: blockIdMap.get(edge.source) || edge.source, - target: blockIdMap.get(edge.target) || edge.target, - })) + const newEdges = workflowState.edges.map((edge) => { + const newSource = blockIdMap.get(edge.source) || edge.source + const newSourceHandle = + edge.sourceHandle && blockIdMap.has(edge.source) + ? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource) + : edge.sourceHandle + + return { + ...edge, + id: uuidv4(), + source: newSource, + target: blockIdMap.get(edge.target) || edge.target, + sourceHandle: newSourceHandle, + } + }) const newLoops: Record = {} if (workflowState.loops) { @@ -429,6 +441,37 @@ export function regenerateWorkflowIds( } } +/** + * Remaps condition/router block IDs within subBlock values when a block is duplicated. + * Mutates both `subBlocks` and `subBlockValues` in place (callers must pass cloned data). + */ +export function remapConditionIds( + subBlocks: Record, + subBlockValues: Record, + oldBlockId: string, + newBlockId: string +): void { + for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { + if (subBlock.type !== 'condition-input' && subBlock.type !== 'router-input') continue + + const value = subBlockValues[subBlockId] ?? subBlock.value + if (typeof value !== 'string') continue + + try { + const parsed = JSON.parse(value) + if (!Array.isArray(parsed)) continue + + if (remapConditionBlockIds(parsed, oldBlockId, newBlockId)) { + const newValue = JSON.stringify(parsed) + subBlock.value = newValue + subBlockValues[subBlockId] = newValue + } + } catch { + // Not valid JSON, skip + } + } +} + export function regenerateBlockIds( blocks: Record, edges: Edge[], @@ -497,6 +540,7 @@ export function regenerateBlockIds( id: newId, name: newName, position: newPosition, + subBlocks: JSON.parse(JSON.stringify(block.subBlocks)), // Temporarily keep data as-is, we'll fix parentId in second pass data: block.data ? { ...block.data } : block.data, // Duplicated blocks are always unlocked so users can edit them @@ -510,6 +554,9 @@ export function regenerateBlockIds( if (subBlockValues[oldId]) { newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId])) } + + // Remap condition/router IDs in the duplicated block + remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId) }) // Second pass: update parentId references for nested blocks @@ -542,12 +589,21 @@ export function regenerateBlockIds( } }) - const newEdges = edges.map((edge) => ({ - ...edge, - id: uuidv4(), - source: blockIdMap.get(edge.source) || edge.source, - target: blockIdMap.get(edge.target) || edge.target, - })) + const newEdges = edges.map((edge) => { + const newSource = blockIdMap.get(edge.source) || edge.source + const newSourceHandle = + edge.sourceHandle && blockIdMap.has(edge.source) + ? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource) + : edge.sourceHandle + + return { + ...edge, + id: uuidv4(), + source: newSource, + target: blockIdMap.get(edge.target) || edge.target, + sourceHandle: newSourceHandle, + } + }) const newLoops: Record = {} Object.entries(loops).forEach(([oldLoopId, loop]) => { diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index ba9c9bd35fb..bc97773d5cf 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -12,6 +12,7 @@ import { filterValidEdges, getUniqueBlockName, mergeSubblockState, + remapConditionIds, } from '@/stores/workflows/utils' import type { Position, @@ -611,6 +612,21 @@ export const useWorkflowStore = create()( {} ) + // Remap condition/router IDs in the duplicated subBlocks + const clonedSubBlockValues = activeWorkflowId + ? JSON.parse( + JSON.stringify( + useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {} + ) + ) + : {} + remapConditionIds( + newSubBlocks as Record, + clonedSubBlockValues, + id, + newId + ) + const newState = { blocks: { ...get().blocks, @@ -630,14 +646,12 @@ export const useWorkflowStore = create()( } if (activeWorkflowId) { - const subBlockValues = - useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {} useSubBlockStore.setState((state) => ({ workflowValues: { ...state.workflowValues, [activeWorkflowId]: { ...state.workflowValues[activeWorkflowId], - [newId]: JSON.parse(JSON.stringify(subBlockValues)), + [newId]: clonedSubBlockValues, }, }, }))