Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/realtime/src/database/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,39 @@ async function handleBlockOperationTx(
break
}

case BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES: {
if (!payload.id || !payload.data?.canonicalModes) {
throw new Error('Missing required fields for replace canonical modes operation')
}

const existingBlock = await tx
.select({ data: workflowBlocks.data })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)

const currentData = (existingBlock?.[0]?.data as Record<string, unknown>) || {}

const updateResult = await tx
.update(workflowBlocks)
.set({
data: {
...currentData,
canonicalModes: payload.data.canonicalModes,
},
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
.returning({ id: workflowBlocks.id })

if (updateResult.length === 0) {
throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`)
}

logger.debug(`Replaced block canonical modes: ${payload.id}`)
break
}

case BLOCK_OPERATIONS.TOGGLE_HANDLES: {
if (!payload.id || payload.horizontalHandles === undefined) {
throw new Error('Missing required fields for toggle handles operation')
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/middleware/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const WRITE_OPERATIONS: string[] = [
BLOCK_OPERATIONS.UPDATE_PARENT,
BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE,
BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE,
BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES,
BLOCK_OPERATIONS.TOGGLE_HANDLES,
// Batch block operations
BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isCanonicalPair,
reindexToolCanonicalModes,
resolveCanonicalMode,
resolveDependencyValue,
type SubBlockCondition,
Expand Down Expand Up @@ -488,7 +489,15 @@ export const ToolInput = memo(function ToolInput({
[blockId]
)
)
const { collaborativeSetBlockCanonicalMode } = useCollaborativeWorkflow()
const { collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalModes } =
useCollaborativeWorkflow()
const reindexCanonicalModesOnMutate = useCallback(
(oldTools: StoredTool[], newTools: StoredTool[]) => {
const next = reindexToolCanonicalModes(oldTools, newTools, canonicalModeOverrides)
if (next) collaborativeSetBlockCanonicalModes(blockId, next)
},
[canonicalModeOverrides, collaborativeSetBlockCanonicalModes, blockId]
)

const value = isPreview ? previewValue : storeValue

Expand All @@ -514,11 +523,15 @@ export const ToolInput = memo(function ToolInput({
// Uses canonical resolution so the active field (basic vs advanced) is respected.
const toolCredentialId = useMemo(() => {
const allBlocks = getAllBlocks()
for (const tool of selectedTools) {
for (const [toolIndex, tool] of selectedTools.entries()) {
const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type)
if (!blockConfig?.subBlocks) continue
const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks)
const scopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type)
const scopedOverrides = scopeCanonicalModesForTool(
canonicalModeOverrides,
toolIndex,
tool.type
)
const reactiveSubBlock = blockConfig.subBlocks.find(
(sb: { reactiveCondition?: unknown }) => sb.reactiveCondition
)
Expand Down Expand Up @@ -909,19 +922,23 @@ export const ToolInput = memo(function ToolInput({
const handleRemoveTool = useCallback(
(toolIndex: number) => {
if (isPreview || disabled) return
setStoreValue(selectedTools.filter((_, index) => index !== toolIndex))
const updatedTools = selectedTools.filter((_, index) => index !== toolIndex)
reindexCanonicalModesOnMutate(selectedTools, updatedTools)
setStoreValue(updatedTools)
},
[isPreview, disabled, selectedTools, setStoreValue]
[isPreview, disabled, selectedTools, reindexCanonicalModesOnMutate, setStoreValue]
)

const handleRemoveAllFromServer = useCallback(
(serverId: string | undefined) => {
if (isPreview || disabled || !serverId) return
setStoreValue(
selectedTools.filter((t) => !(t.type === 'mcp' && t.params?.serverId === serverId))
const updatedTools = selectedTools.filter(
(t) => !(t.type === 'mcp' && t.params?.serverId === serverId)
)
reindexCanonicalModesOnMutate(selectedTools, updatedTools)
setStoreValue(updatedTools)
},
[isPreview, disabled, selectedTools, setStoreValue]
[isPreview, disabled, selectedTools, reindexCanonicalModesOnMutate, setStoreValue]
)

const handleDeleteTool = useCallback(
Expand Down Expand Up @@ -949,10 +966,11 @@ export const ToolInput = memo(function ToolInput({
})

if (updatedTools.length !== selectedTools.length) {
reindexCanonicalModesOnMutate(selectedTools, updatedTools)
setStoreValue(updatedTools)
}
},
[selectedTools, customTools, setStoreValue]
[selectedTools, customTools, reindexCanonicalModesOnMutate, setStoreValue]
)

const handleParamChange = useCallback(
Expand Down Expand Up @@ -1121,6 +1139,7 @@ export const ToolInput = memo(function ToolInput({
newTools.splice(adjustedDropIndex, 0, draggedTool)
}

reindexCanonicalModesOnMutate(selectedTools, newTools)
setStoreValue(newTools)
setDraggedIndex(null)
setDragOverIndex(null)
Expand Down Expand Up @@ -1420,6 +1439,10 @@ export const ToolInput = memo(function ToolInput({
description: mcpTool.description,
},
}))
// Diff against `filteredTools` (pre-spread, same refs as `selectedTools`) - the
// spread copy below preserves the same relative order, so this correctly reflects
// each surviving tool's new position.
reindexCanonicalModesOnMutate(selectedTools, filteredTools)
setStoreValue([...filteredTools.map((t) => ({ ...t, isExpanded: false })), ...newTools])
setMcpServerDrilldown(null)
setOpen(false)
Expand Down Expand Up @@ -1650,6 +1673,7 @@ export const ToolInput = memo(function ToolInput({
customUnsupported,
availableWorkflows,
isToolAlreadySelected,
reindexCanonicalModesOnMutate,
])

return (
Expand Down Expand Up @@ -1692,7 +1716,11 @@ export const ToolInput = memo(function ToolInput({
})
: null

const toolScopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type)
const toolScopedOverrides = scopeCanonicalModesForTool(
canonicalModeOverrides,
toolIndex,
tool.type
)

const subBlocksResult: SubBlocksForToolInput | null =
!isCustomTool && !isMcpTool && currentToolId
Expand Down Expand Up @@ -2086,7 +2114,7 @@ export const ToolInput = memo(function ToolInput({
const nextMode = canonicalMode === 'advanced' ? 'basic' : 'advanced'
collaborativeSetBlockCanonicalMode(
blockId,
Comment thread
waleedlatif1 marked this conversation as resolved.
`${tool.type}:${canonicalId}`,
`${toolIndex}:${canonicalId}`,
nextMode
)
},
Expand Down
61 changes: 61 additions & 0 deletions apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,64 @@ describe('copyWorkflowStateIntoTarget folder fallback', () => {
expect(result.name).toBe('Orphaned placement')
})
})

describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => {
it(
"persists a transform's reindexed canonicalModes on the copied block, and uses that " +
"SAME reindexed value (not the source's stale one) for every subsequent remap step",
async () => {
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
const seenCanonicalModes: Array<Record<string, 'basic' | 'advanced'> | undefined> = []
const tx = {
insert: () => ({ values: () => Promise.resolve() }),
} as unknown as DbOrTx

await copyWorkflowStateIntoTarget({
tx,
targetWorkflowId: 'wf-child',
targetWorkspaceId: 'ws-target',
userId: 'target-user',
mode: 'create',
now: new Date('2026-07-01'),
sourceState: {
blocks: {
block1: {
id: 'block1',
type: 'agent',
name: 'Agent',
subBlocks: {},
// The source's ORIGINAL (pre-drop) canonicalModes - every step after the
// transform must see the REINDEXED value below instead, not this one.
data: { canonicalModes: { '1:credential': 'advanced' } },
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
},
sourceMeta: { name: 'Reindex test', description: null, folderId: null, sortOrder: 0 },
workflowIdMap: new Map(),
folderIdMap: new Map(),
nameRegistry: buildWorkflowNameRegistry([]),
// Simulates a `tool-input` drop shifting tool 1 -> 0: returns subBlocks unchanged but
// reports the reindexed canonicalModes via the callback, exactly like
// `createForkBootstrapTransform`/`createForkSubBlockTransform` do.
transformSubBlocks: (subBlocks, _blockType, canonicalModes, onCanonicalModesChanged) => {
seenCanonicalModes.push(canonicalModes)
onCanonicalModesChanged?.({ '0:credential': 'advanced' })
return subBlocks
},
})

const [, remappedState] = mockSaveWorkflowToNormalizedTables.mock.calls.at(-1)!
const persistedBlock = Object.values(remappedState.blocks)[0] as {
data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> }
}
// The transform received the source's original value...
expect(seenCanonicalModes).toEqual([{ '1:credential': 'advanced' }])
// ...and the PERSISTED block carries the reindexed one, not the stale source value.
expect(persistedBlock.data?.canonicalModes).toEqual({ '0:credential': 'advanced' })
}
)
})
32 changes: 15 additions & 17 deletions apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
sanitizeSubBlocksForDuplicate,
} from '@/lib/workflows/persistence/remap-internal-ids'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
deriveForkBlockId,
type ForkBlockIdResolver,
Expand All @@ -20,6 +21,7 @@ import {
applyDependentOverrides,
collectClearedDependents,
type NeedsConfigurationField,
type SubBlockTransform,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import type {
BlockData,
Expand All @@ -33,12 +35,6 @@ import type {

const logger = createLogger('WorkspaceForkCopyWorkflows')

type SubBlockTransform = (
subBlocks: SubBlockRecord,
blockType: string,
canonicalModes?: Record<string, 'basic' | 'advanced'>
) => SubBlockRecord

interface ResolveForkFolderMappingParams {
tx: DbOrTx
sourceWorkspaceId: string
Expand Down Expand Up @@ -433,13 +429,18 @@ export async function copyWorkflowStateIntoTarget(
const sourceSubBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
const sanitizedSource = sanitizeSubBlocksForDuplicate(sourceSubBlocks)
let subBlocks: SubBlockRecord = sanitizedSource
// Tracks the block's live `canonicalModes` through this pass, so a `tool-input` reindex
// (a dropped custom-tool/MCP entry shifts later tools' array positions) is visible to every
// later step below that resolves a nested tool's basic/advanced mode - not just the final
// persisted `updatedData`. Starts as the source value; `transformSubBlocks` may replace it.
let activeCanonicalModes: CanonicalModeOverrides | undefined = (
block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined
)?.canonicalModes
if (transformSubBlocks) {
subBlocks = transformSubBlocks(
subBlocks,
block.type,
(block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined)
?.canonicalModes
)
subBlocks = transformSubBlocks(subBlocks, block.type, activeCanonicalModes, (next) => {
activeCanonicalModes = next
updatedData = { ...updatedData, canonicalModes: next } as BlockData
})
}
if (varIdMapping.size > 0) {
subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping)
Expand All @@ -448,9 +449,7 @@ export async function copyWorkflowStateIntoTarget(
// rather than leave them pointing at the source workspace.
subBlocks = remapWorkflowReferencesInSubBlocks(subBlocks, workflowIdMap, {
clearUnmapped: true,
canonicalModes: (
block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined
)?.canonicalModes,
canonicalModes: activeCanonicalModes,
})
subBlocks = remapConditionIdsInSubBlocks(subBlocks, oldBlockId, newBlockId) as SubBlockRecord

Expand All @@ -477,8 +476,7 @@ export async function copyWorkflowStateIntoTarget(
block.name,
targetCurrent,
subBlocks,
(block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined)
?.canonicalModes
activeCanonicalModes
)
)
}
Expand Down
Loading
Loading