From 68229674a0491693423fce345f520ebeb8979b3b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 16:16:40 -0700 Subject: [PATCH 1/5] fix(workspace-forking): stop a remapped custom block losing every input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repointing a placed custom block at another environment's block left its inputs behind. They are keyed by the SOURCE block's Start field ids, so against the new config they are fields that do not exist, and the serializer drops a stored value with no matching config as a deleted input. The block synced with its name intact and every field blank — and because both environments' blocks share a name, that read as "the sync did nothing and corrupted the block". The type rewrite itself was landing; a test now pins that rather than leaving it to the eye, since a successful rewrite is visually identical. On a type change the inputs are now replaced outright with the ones configured for the TARGET block, and reserved wiring is preserved. There is deliberately no attempt to migrate values across the swap: two custom blocks are independent workflows, so a field id that happened to collide would carry a value meaning something else. When the type does not change — no mapping, or an explicit identity mapping — nothing is touched and values carry as they always did. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/copy/copy-workflows.test.ts | 147 ++++++++++++++++++ .../lib/copy/copy-workflows.ts | 24 ++- .../lib/remap/remap-references.ts | 36 ++++- 3 files changed, 203 insertions(+), 4 deletions(-) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index abbe788a5b4..3dcbbbfa500 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -489,3 +489,150 @@ describe('copyWorkflowStateIntoTarget webhook path pinning', () => { expect(writtenSubBlocks().triggerPath).toBeUndefined() }) }) + +describe('copyWorkflowStateIntoTarget custom-block remap', () => { + const PROD = 'custom_block_prod01' + const UAT = 'custom_block_uat0001' + + /** A placed custom block whose inputs are keyed by the SOURCE block's Start field ids. */ + const customBlockState = { + blocks: { + 'blk-cb': { + id: 'blk-cb', + type: UAT, + name: 'Invoice Parser', + position: { x: 0, y: 0 }, + subBlocks: { + workflowId: { id: 'workflowId', type: 'short-input', value: 'wf-uat' }, + 'field-uat-a': { id: 'field-uat-a', type: 'short-input', value: 'uat value A' }, + 'field-uat-b': { id: 'field-uat-b', type: 'short-input', value: 'uat value B' }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as never + + const baseParams = { + targetWorkflowId: 'wf-tgt', + targetWorkspaceId: 'ws-parent', + userId: 'u1', + mode: 'replace' as const, + now: new Date('2026-07-01'), + sourceState: customBlockState, + sourceMeta: { name: 'Orchestrator', description: null, folderId: null, sortOrder: 0 }, + workflowIdMap: new Map(), + folderIdMap: new Map(), + nameRegistry: buildWorkflowNameRegistry([]), + resolveBlockId: (_t: string, sourceBlockId: string) => `tgt-${sourceBlockId}`, + } + + const stubTx = () => + ({ update: () => ({ set: () => ({ where: () => Promise.resolve() }) }) }) as unknown as DbOrTx + + function writtenBlock() { + const state = mockSaveWorkflowToNormalizedTables.mock.calls.at(-1)?.[1] as { + blocks: Record }> + } + return state.blocks['tgt-blk-cb'] + } + + it('repoints the placed block at the mapped target type', async () => { + // The push symptom was read as "it still has the old custom block" — but both + // environments' blocks share a NAME, so a successful rewrite looks identical on the + // canvas. Pin the type itself rather than trusting the visual. + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + + await copyWorkflowStateIntoTarget({ + ...baseParams, + tx: stubTx(), + transformBlockType: (type) => (type === UAT ? PROD : type), + }) + + expect(writtenBlock().type).toBe(PROD) + }) + + it('drops the source-keyed inputs when the type changes, instead of leaving them to rot', async () => { + // Left in place they survive the copy and are then dropped SILENTLY by the serializer + // (a stored value with no matching config is a deleted input), which is what made a + // synced block render with its name and no fields. + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + + await copyWorkflowStateIntoTarget({ + ...baseParams, + tx: stubTx(), + transformBlockType: (type) => (type === UAT ? PROD : type), + }) + + const subBlocks = writtenBlock().subBlocks ?? {} + expect(subBlocks['field-uat-a']).toBeUndefined() + expect(subBlocks['field-uat-b']).toBeUndefined() + }) + + it('writes the inputs configured for the target block', async () => { + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + + await copyWorkflowStateIntoTarget({ + ...baseParams, + tx: stubTx(), + transformBlockType: (type) => (type === UAT ? PROD : type), + customBlockInputsByBlockId: new Map([ + ['tgt-blk-cb', new Map([['field-prod-x', 'prod value X']])], + ]), + }) + + const subBlocks = writtenBlock().subBlocks ?? {} + expect(subBlocks['field-prod-x']?.value).toBe('prod value X') + // No value migrated across the swap — two custom blocks are independent workflows. + expect(subBlocks['field-uat-a']).toBeUndefined() + }) + + it('preserves reserved wiring across the swap', async () => { + // `workflowId`/`inputMapping` are computed value-fns the serializer recomputes; dropping + // them here would be harmless but replacing them with a stale literal would not be. + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + + await copyWorkflowStateIntoTarget({ + ...baseParams, + tx: stubTx(), + transformBlockType: (type) => (type === UAT ? PROD : type), + customBlockInputsByBlockId: new Map([ + [ + 'tgt-blk-cb', + new Map([ + ['workflowId', 'crafted'], + ['field-prod-x', 'ok'], + ]), + ], + ]), + }) + + const subBlocks = writtenBlock().subBlocks ?? {} + expect(subBlocks.workflowId?.value).toBe('wf-uat') + expect(subBlocks['field-prod-x']?.value).toBe('ok') + }) + + it('leaves inputs untouched when the type does NOT change', async () => { + // Identity mapping, or no mapping at all: the field ids still describe this same block, + // so the values carry exactly like a regular block's. + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + + await copyWorkflowStateIntoTarget({ + ...baseParams, + tx: stubTx(), + transformBlockType: (type) => type, + customBlockInputsByBlockId: new Map([ + ['tgt-blk-cb', new Map([['field-prod-x', 'must not apply']])], + ]), + }) + + const subBlocks = writtenBlock().subBlocks ?? {} + expect(writtenBlock().type).toBe(UAT) + expect(subBlocks['field-uat-a']?.value).toBe('uat value A') + expect(subBlocks['field-prod-x']).toBeUndefined() + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 520a9f8613b..b7d52ebe94d 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -24,6 +24,7 @@ import { applyDependentOverrides, collectClearedDependents, type NeedsConfigurationField, + replaceCustomBlockInputs, type SubBlockTransform, } from '@/ee/workspace-forking/lib/remap/remap-references' import type { @@ -371,6 +372,14 @@ export interface CopyWorkflowStateParams { * the source block rather than deleting the node — see {@link remapForkBlockType}. */ transformBlockType?: (blockType: string, block: { id: string; name: string }) => string + /** + * Per TARGET block id, the inputs the user configured for a custom block whose type this + * sync repoints. Applied ONLY when the type actually changes: the source's inputs are keyed + * by the source block's field ids and mean nothing against the new config, so they are + * dropped and these written in their place. Already allowlisted to the target block's + * declared field ids by the planner, which is the only side that can resolve that config. + */ + customBlockInputsByBlockId?: ReadonlyMap> /** * The target workflow's current draft subBlocks (block id -> subBlocks), for * `replace` mode only. When present, required dependents that the sync left empty @@ -432,6 +441,7 @@ export async function copyWorkflowStateIntoTarget( folderIdMap, transformSubBlocks, transformBlockType, + customBlockInputsByBlockId, targetCurrentBlocks, dependentOverrides, nameRegistry, @@ -551,12 +561,20 @@ export async function copyWorkflowStateIntoTarget( ) } + const nextBlockType = transformBlockType + ? transformBlockType(block.type, { id: oldBlockId, name: block.name }) + : block.type + if (nextBlockType !== block.type) { + // Only a custom block can change type, and once it does its stored inputs describe the + // OLD block's fields. Replace them with what the user configured for the target rather + // than leaving the serializer to drop them silently. + subBlocks = replaceCustomBlockInputs(subBlocks, customBlockInputsByBlockId?.get(newBlockId)) + } + newBlocks[newBlockId] = { ...block, id: newBlockId, - type: transformBlockType - ? transformBlockType(block.type, { id: oldBlockId, name: block.name }) - : block.type, + type: nextBlockType, // double-cast-allowed: remap helpers return SubBlockRecord; the entries retain the SubBlockState shape this block requires subBlocks: subBlocks as unknown as Record, data: updatedData, diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index 36280033699..1614bb9611e 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -38,7 +38,7 @@ import { resolveToolParamRequired, } from '@/lib/workflows/tool-input/param-visibility' import type { ParsedStoredTool } from '@/lib/workflows/tool-input/types' -import { isCustomBlockType } from '@/blocks/custom/build-config' +import { isCustomBlockType, RESERVED_PARAMS } from '@/blocks/custom/build-config' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { @@ -238,6 +238,40 @@ export interface RemapForkBlockTypeResult { resolved: boolean } +/** + * Replace a retyped custom block's inputs with the values configured for the TARGET block. + * + * A custom block's input sub-blocks are keyed by the SOURCE Start field's stable id, so once + * the block's `type` is repointed they describe fields the new config does not declare. The + * serializer would drop them silently ({@link file://apps/sim/serializer/index.ts} — a stored + * value with no matching config is a deleted input), which is what made a synced block look + * corrupted: same name, no fields. + * + * There is deliberately NO attempt to match or migrate values across the swap. Two custom + * blocks are independent workflows; a field id that happens to collide would carry a value + * that means something else. Instead the inputs the user configured for the target at sync + * time are written verbatim, and everything else is dropped explicitly. + * + * `values` is already allowlisted to the target block's declared field ids by the planner, + * which is the only side that can resolve the target config. Reserved wiring + * (`workflowId`/`inputMapping`) is preserved untouched — those are computed value-fns the + * serializer recomputes and never carries forward. + */ +export function replaceCustomBlockInputs( + subBlocks: SubBlockRecord, + values: ReadonlyMap | undefined +): SubBlockRecord { + const next: SubBlockRecord = {} + for (const [key, subBlock] of Object.entries(subBlocks)) { + if (RESERVED_PARAMS.has(key)) next[key] = subBlock + } + for (const [fieldId, value] of values ?? []) { + if (RESERVED_PARAMS.has(fieldId)) continue + next[fieldId] = { value } + } + return next +} + /** * Repoint a placed custom block at the fork's own published block. * From 5a92fcd6151050da9092a7f5314bbc65be05de7b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 16:25:48 -0700 Subject: [PATCH 2/5] feat(workspace-forking): configure a repointed custom block's inputs at sync time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repointing a custom block leaves it with no usable inputs — its sub-blocks are keyed by the SOURCE block's Start field ids, which describe nothing on the new block. Until now the user had to open the synced workflow and re-enter them by hand, with no indication anything was missing. A credential or table swap already makes its `dependsOn` fields reconfigurable in the sync modal. Repointing a custom block is the same idea at its limit: not a subset of fields is invalidated but ALL of them, so all of them are offered. They travel the existing dependent-value channel end to end — collected into the diff, stored per (target workflow, block, sub-block), pre-filled from the store, gating Sync when required and empty, and applied to the written state — so nothing about storage, pre-fill, or the Sync gate is new. `parentKind`/`parentSourceId` are the block itself, which is already the key a reconfig is joined to its mapping row on, so the fields render under their own row with no extra wiring. `selectorKey`/`parentContextKey` become optional: a custom block's inputs are typed values, not selectors, and the modal renders a plain field (a textarea for the JSON-valued types) instead of an option list. Deliberately no seeding from the source value: it belongs to a different block's field of the same position, so pre-filling it would carry a value meaning something else. A block whose type does not change is skipped entirely — its ids still describe it, so its values carry as they always did. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/workspaces/[id]/fork/diff/route.ts | 19 +++ .../components/fork-sync/fork-sync-view.tsx | 38 ++++- .../lib/copy/copy-workflows.test.ts | 10 +- .../lib/copy/copy-workflows.ts | 18 +-- .../mapping/custom-block-reconfigs.test.ts | 143 ++++++++++++++++++ .../lib/mapping/custom-block-reconfigs.ts | 125 +++++++++++++++ apps/sim/lib/api/contracts/workspace-fork.ts | 25 ++- 7 files changed, 350 insertions(+), 28 deletions(-) create mode 100644 apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts create mode 100644 apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts index 4bb41317799..87816c36b97 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -14,6 +14,7 @@ import { } from '@/ee/workspace-forking/lib/copy/deploy-bridge' import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz' import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' +import { collectForkCustomBlockReconfigs } from '@/ee/workspace-forking/lib/mapping/custom-block-reconfigs' import { collectForkDependentReconfigs, collectForkResourceUsages, @@ -127,7 +128,25 @@ export const GET = withRouteHandler( // that's exactly what the first sync copies verbatim, so the pre-fill is honest and // configuring it ahead of the first sync is possible (the deterministic target ids // already exist). + // Custom-block inputs join the same list: repointing a block makes every one of its + // inputs reconfigurable (see `collectForkCustomBlockReconfigs`), and they store, pre-fill, + // gate Sync, and apply through this identical channel. + const customBlockReconfigs = await collectForkCustomBlockReconfigs({ + items: plan.items, + sourceStates, + resolveTargetBlockId: resolveBlockId, + resolve: plan.resolver, + targetWorkspaceId: plan.targetWorkspaceId, + }) + const dependentReconfigs = [ + ...customBlockReconfigs.map((field) => ({ + ...field, + currentValue: + storedByKey.get( + forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) + ) ?? field.currentValue, + })), ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({ ...field, currentValue: diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index 1752bcae5b4..65c2393f5a0 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -6,6 +6,7 @@ import { ChevronDown, Chip, ChipCombobox, + ChipModalField, ChipSwitch, CollapsibleCard, cn, @@ -90,6 +91,9 @@ const NEW_TRIGGER_URL_VALUE = '__new_trigger_url__' */ const MAPPING_TARGET_TRIGGER_CLASS = 'w-[380px] flex-shrink-0' +/** Custom-block input types whose value is JSON, so the field needs a textarea, not a line. */ +const CUSTOM_BLOCK_JSON_FIELD_TYPES = new Set(['object', 'array']) + interface DependentBlock { targetBlockId: string blockName: string @@ -205,12 +209,38 @@ function DependentSelector({ reconfig, setReconfig, }: DependentSelectorProps) { + // A custom block's inputs exist BECAUSE its type was repointed, so `parentChanged` is + // always true for them — but unlike a re-picked selector their stored value is the user's + // own configuration for the target and must pre-fill, not blank out. + const isCustomBlockInput = field.parentKind === 'custom-block' const effectiveValueIn = (f: ForkDependentReconfig, state: DependentReconfigState) => - copying - ? effectiveCopyDependentValue(f, state) - : effectiveDependentValue(f, state, parentChanged) + isCustomBlockInput + ? effectiveDependentValue(f, state, false) + : copying + ? effectiveCopyDependentValue(f, state) + : effectiveDependentValue(f, state, parentChanged) const baselineValueFor = (f: ForkDependentReconfig) => effectiveValueIn(f, {}) const effectiveValue = (f: ForkDependentReconfig) => effectiveValueIn(f, reconfig) + if (isCustomBlockInput) { + // Not a selector: there is no parent resource to browse and no options to fetch, just the + // target block's own declared input. Rendered as a plain field so the user types the value + // the repointed block should run with. Structured types get a textarea because their value + // is JSON, matching how `subBlockTypeForField` renders them on the canvas. + const setValue = (value: string) => + setReconfig((current) => ({ ...current, [dependentKey(field)]: value })) + const shared = { + title: field.title, + required: field.required, + value: effectiveValue(field), + onChange: setValue, + } + return CUSTOM_BLOCK_JSON_FIELD_TYPES.has(field.fieldType ?? '') ? ( + + ) : ( + + ) + } + const { providedValues, providedContextKeys } = blockChainState(block, field, effectiveValue) // Disabled until every in-block parent it depends on has a value, so a child never queries // a stale upstream value. @@ -228,7 +258,7 @@ function DependentSelector({ ...providedValues, // Owning workspace, for workspace-scoped selectors like table.columns. workspaceId: copying ? sourceWorkspaceId : workspaceId, - [field.parentContextKey]: parentValue, + ...(field.parentContextKey ? { [field.parentContextKey]: parentValue } : {}), }} enabled={parentValue !== '' && ready} value={effectiveValue(field)} diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index 3dcbbbfa500..93c061005a7 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -580,9 +580,7 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { ...baseParams, tx: stubTx(), transformBlockType: (type) => (type === UAT ? PROD : type), - customBlockInputsByBlockId: new Map([ - ['tgt-blk-cb', new Map([['field-prod-x', 'prod value X']])], - ]), + dependentOverrides: new Map([['tgt-blk-cb', new Map([['field-prod-x', 'prod value X']])]]), }) const subBlocks = writtenBlock().subBlocks ?? {} @@ -600,7 +598,7 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { ...baseParams, tx: stubTx(), transformBlockType: (type) => (type === UAT ? PROD : type), - customBlockInputsByBlockId: new Map([ + dependentOverrides: new Map([ [ 'tgt-blk-cb', new Map([ @@ -625,9 +623,7 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { ...baseParams, tx: stubTx(), transformBlockType: (type) => type, - customBlockInputsByBlockId: new Map([ - ['tgt-blk-cb', new Map([['field-prod-x', 'must not apply']])], - ]), + dependentOverrides: new Map([['tgt-blk-cb', new Map([['field-prod-x', 'must not apply']])]]), }) const subBlocks = writtenBlock().subBlocks ?? {} diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index b7d52ebe94d..2fdcca910cd 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -372,14 +372,6 @@ export interface CopyWorkflowStateParams { * the source block rather than deleting the node — see {@link remapForkBlockType}. */ transformBlockType?: (blockType: string, block: { id: string; name: string }) => string - /** - * Per TARGET block id, the inputs the user configured for a custom block whose type this - * sync repoints. Applied ONLY when the type actually changes: the source's inputs are keyed - * by the source block's field ids and mean nothing against the new config, so they are - * dropped and these written in their place. Already allowlisted to the target block's - * declared field ids by the planner, which is the only side that can resolve that config. - */ - customBlockInputsByBlockId?: ReadonlyMap> /** * The target workflow's current draft subBlocks (block id -> subBlocks), for * `replace` mode only. When present, required dependents that the sync left empty @@ -441,7 +433,6 @@ export async function copyWorkflowStateIntoTarget( folderIdMap, transformSubBlocks, transformBlockType, - customBlockInputsByBlockId, targetCurrentBlocks, dependentOverrides, nameRegistry, @@ -566,9 +557,12 @@ export async function copyWorkflowStateIntoTarget( : block.type if (nextBlockType !== block.type) { // Only a custom block can change type, and once it does its stored inputs describe the - // OLD block's fields. Replace them with what the user configured for the target rather - // than leaving the serializer to drop them silently. - subBlocks = replaceCustomBlockInputs(subBlocks, customBlockInputsByBlockId?.get(newBlockId)) + // OLD block's fields. Replace them with what the user configured for the target — the + // same stored dependent values every other reconfigurable field uses, just applied + // wholesale because ALL of a custom block's inputs are reconfigurable, not a `dependsOn` + // subset. `applyDependentOverrides` above is a no-op for them: it allowlists on + // `dependsOn` + `selectorKey`, which no custom-block input has. + subBlocks = replaceCustomBlockInputs(subBlocks, blockOverrides) } newBlocks[newBlockId] = { diff --git a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts new file mode 100644 index 00000000000..fd21a26027b --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const { mockResolveBinding } = vi.hoisted(() => ({ mockResolveBinding: vi.fn() })) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + resolveCustomBlockToolBinding: mockResolveBinding, +})) + +import { collectForkCustomBlockReconfigs } from '@/ee/workspace-forking/lib/mapping/custom-block-reconfigs' + +const PROD = 'custom_block_prod01' +const UAT = 'custom_block_uat0001' + +function stateWith(blocks: Record): WorkflowState { + return { + blocks: Object.fromEntries( + Object.entries(blocks).map(([id, b]) => [ + id, + { id, type: b.type, name: b.name, position: { x: 0, y: 0 }, subBlocks: {}, outputs: {} }, + ]) + ), + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState +} + +const baseParams = { + items: [{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt' }], + sourceStates: new Map([ + ['wf-src', stateWith({ 'blk-cb': { type: UAT, name: 'Invoice Parser' } })], + ]), + resolveTargetBlockId: (_t: string, sourceBlockId: string) => `tgt-${sourceBlockId}`, + targetWorkspaceId: 'ws-parent', +} + +/** Maps the placed UAT block onto the PROD block, i.e. a real repoint. */ +const swapResolver: ForkReferenceResolver = (kind, sourceId) => + kind === 'custom-block' && sourceId === UAT ? PROD : null + +describe('collectForkCustomBlockReconfigs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveBinding.mockResolvedValue({ + workflowId: 'wf-prod-impl', + inputFields: [ + { id: 'field-a', name: 'Invoice URL', type: 'string' }, + { id: 'field-b', name: 'Options', type: 'object' }, + ], + requiredInputIds: ['field-a'], + }) + }) + + it('offers every input of the target block, keyed by the field id the canvas reads', async () => { + const out = await collectForkCustomBlockReconfigs({ ...baseParams, resolve: swapResolver }) + + expect(out).toHaveLength(2) + expect(out.map((f) => f.subBlockKey)).toEqual(['field-a', 'field-b']) + expect(out[0]).toMatchObject({ + parentKind: 'custom-block', + // Joined to its own mapping row on (parentKind, parentSourceId) — the SOURCE type is + // exactly what the mapping entry's sourceId is. + parentSourceId: UAT, + targetWorkflowId: 'wf-tgt', + targetBlockId: 'tgt-blk-cb', + title: 'Invoice URL', + fieldType: 'string', + required: true, + }) + expect(out[1].required).toBe(false) + }) + + it('never seeds the source value — it belongs to a different block', async () => { + const out = await collectForkCustomBlockReconfigs({ ...baseParams, resolve: swapResolver }) + + expect(out.every((f) => f.currentValue === '' && f.sourceValue === '')).toBe(true) + }) + + it('offers nothing when the type does not change', async () => { + // No mapping, or an explicit identity mapping: the field ids still describe this block, so + // its values carry across and there is nothing to re-pick. + expect( + await collectForkCustomBlockReconfigs({ ...baseParams, resolve: () => null }) + ).toHaveLength(0) + expect( + await collectForkCustomBlockReconfigs({ ...baseParams, resolve: (_k, id) => id }) + ).toHaveLength(0) + expect(mockResolveBinding).not.toHaveBeenCalled() + }) + + it('ignores non-custom blocks entirely', async () => { + const out = await collectForkCustomBlockReconfigs({ + ...baseParams, + sourceStates: new Map([['wf-src', stateWith({ a: { type: 'agent', name: 'Agent 1' } })]]), + resolve: swapResolver, + }) + + expect(out).toHaveLength(0) + }) + + it('resolves each distinct target type once, not once per placement', async () => { + const out = await collectForkCustomBlockReconfigs({ + ...baseParams, + sourceStates: new Map([ + [ + 'wf-src', + stateWith({ + 'blk-1': { type: UAT, name: 'Parse A' }, + 'blk-2': { type: UAT, name: 'Parse B' }, + }), + ], + ]), + resolve: swapResolver, + }) + + // Two placements, each independently configurable, but one schema lookup. + expect(mockResolveBinding).toHaveBeenCalledTimes(1) + expect(new Set(out.map((f) => f.targetBlockId))).toEqual(new Set(['tgt-blk-1', 'tgt-blk-2'])) + }) + + it('drops the fields rather than failing the whole diff when a target will not resolve', async () => { + // An unresolvable target is already a sync blocker via the mapping's own existence check; + // throwing here would hide every other finding in the diff. + mockResolveBinding.mockResolvedValue(null) + + expect( + await collectForkCustomBlockReconfigs({ ...baseParams, resolve: swapResolver }) + ).toHaveLength(0) + }) + + it('survives a binding lookup that throws', async () => { + mockResolveBinding.mockRejectedValue(new Error('deployment read failed')) + + expect( + await collectForkCustomBlockReconfigs({ ...baseParams, resolve: swapResolver }) + ).toHaveLength(0) + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts new file mode 100644 index 00000000000..169e9d5e8ba --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork' +import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' +import { isCustomBlockType } from '@/blocks/custom/build-config' +import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' +import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('ForkCustomBlockReconfigs') + +interface CustomBlockReconfigItem { + sourceWorkflowId: string + targetWorkflowId: string +} + +export interface CollectForkCustomBlockReconfigsParams { + items: CustomBlockReconfigItem[] + sourceStates: Map + resolveTargetBlockId: ForkBlockIdResolver + /** The promote resolver, to find each placed custom block's mapped target type. */ + resolve: ForkReferenceResolver + /** The TARGET workspace, which scopes the org the target block is resolved in. */ + targetWorkspaceId: string +} + +/** + * The reconfigurable inputs of every custom block this sync REPOINTS at a different block. + * + * A credential/KB/table swap invalidates the `dependsOn` fields scoped to it. Repointing a + * custom block is the same idea taken to its limit: the block's inputs are keyed by the SOURCE + * block's Start field ids, so after the swap NONE of them describe a field the new block has — + * every input is reconfigurable, not a subset. They are emitted through the same + * {@link ForkDependentReconfig} channel so they store, pre-fill, gate Sync, and apply exactly + * like a re-picked Gmail label does. + * + * `parentKind`/`parentSourceId` are the block itself (`custom-block` + the SOURCE type), which + * is precisely the key the mapping entry is joined on — so the fields render under their own + * mapping row with no extra wiring. + * + * A block whose type does NOT change is skipped: its field ids still describe it, so its values + * carry across untouched and there is nothing to re-pick. + */ +export async function collectForkCustomBlockReconfigs( + params: CollectForkCustomBlockReconfigsParams +): Promise { + const { items, sourceStates, resolveTargetBlockId, resolve, targetWorkspaceId } = params + + /** Source type -> target type, for every placed custom block this sync repoints. */ + const swaps: Array<{ + targetWorkflowId: string + targetBlockId: string + blockName: string + sourceType: string + targetType: string + }> = [] + + for (const item of items) { + const state = sourceStates.get(item.sourceWorkflowId) + if (!state) continue + for (const [sourceBlockId, block] of Object.entries(state.blocks)) { + if (!isCustomBlockType(block.type)) continue + const targetType = resolve('custom-block', block.type) + if (!targetType || targetType === block.type) continue + swaps.push({ + targetWorkflowId: item.targetWorkflowId, + targetBlockId: resolveTargetBlockId(item.targetWorkflowId, sourceBlockId), + blockName: block.name, + sourceType: block.type, + targetType, + }) + } + } + if (swaps.length === 0) return [] + + // One binding lookup per distinct TARGET type, not per placement: the same block placed in + // five workflows resolves the same input schema every time. + const bindingByType = new Map>>() + for (const targetType of new Set(swaps.map((swap) => swap.targetType))) { + try { + bindingByType.set( + targetType, + await resolveCustomBlockToolBinding(targetType, targetWorkspaceId) + ) + } catch (error) { + // A target that will not resolve is already a sync blocker via the mapping's own + // existence check; failing the whole diff over it would hide every other finding. + logger.warn('Could not resolve a mapped custom block; its inputs are not offered', { + error: getErrorMessage(error), + }) + bindingByType.set(targetType, null) + } + } + + const out: ForkDependentReconfig[] = [] + for (const swap of swaps) { + const binding = bindingByType.get(swap.targetType) + if (!binding) continue + for (const field of binding.inputFields) { + // Sub-blocks are keyed by the field's stable id, falling back to its name for legacy + // fields with none — the same rule `buildCustomBlockConfig` keys its sub-blocks on, so + // a stored value lands on the sub-block the canvas will read it from. + const subBlockKey = field.id ?? field.name + out.push({ + parentKind: 'custom-block', + parentSourceId: swap.sourceType, + targetWorkflowId: swap.targetWorkflowId, + targetBlockId: swap.targetBlockId, + blockName: swap.blockName, + subBlockKey, + title: field.name, + fieldType: field.type, + // The source's value is never a sensible seed here: it belongs to a different block's + // field of the same position, so pre-filling it would silently carry a value meaning + // something else. The diff overlays the stored value on top of this. + currentValue: '', + sourceValue: '', + required: binding.requiredInputIds.includes(subBlockKey), + consumesContextKeys: [], + context: {}, + }) + } + } + return out +} diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index 3319966220a..49c7bdd25ca 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -339,17 +339,32 @@ export const forkWorkflowChangeSchema = z.object({ * so blocks aren't padded with every operation variant. */ export const forkDependentReconfigSchema = z.object({ - /** The remappable parent resource kind whose target swap clears this field. */ - parentKind: z.enum(['credential', 'knowledge-base', 'table']), + /** + * The remappable parent whose target swap makes this field reconfigurable. For + * `custom-block` the "parent" IS the block itself: repointing it at another environment's + * block makes EVERY one of its inputs reconfigurable, not the `dependsOn` subset a + * credential/KB/table swap invalidates. + */ + parentKind: z.enum(['credential', 'knowledge-base', 'table', 'custom-block']), /** Source id of that parent (matches a mapping entry's `sourceId`). */ parentSourceId: z.string(), - /** SelectorContext key the new parent value is supplied under (`oauthCredential` | `knowledgeBaseId` | `tableId`). */ - parentContextKey: z.string(), + /** + * SelectorContext key the new parent value is supplied under (`oauthCredential` | + * `knowledgeBaseId` | `tableId`). Absent for `custom-block`: its inputs are plain typed + * fields, not selectors, so there is no parent value to feed them. + */ + parentContextKey: z.string().optional(), targetWorkflowId: z.string(), targetBlockId: z.string(), blockName: z.string(), subBlockKey: z.string(), - selectorKey: z.string(), + /** Absent for `custom-block` fields, which are typed inputs rather than selectors. */ + selectorKey: z.string().optional(), + /** + * A `custom-block` input's declared field type (`string` | `number` | `boolean` | `object` | + * `array` | ...), so the modal renders the matching control instead of a selector. + */ + fieldType: z.string().optional(), /** Plain field title (e.g. `Label`), never a `Tool: Field` composite. */ title: z.string(), /** From 7b2fa42bba058251503cc1483172536102b32685 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 16:42:41 -0700 Subject: [PATCH 3/5] fix(workspace-forking): namespace, type, and single-source a custom block's configured inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, all real, three of them the same root cause: the dependent store holds a plain string keyed only by (target workflow, block, sub-block), and that key carried none of what applying the value correctly needs. The key now carries the TARGET TYPE and the field's declared TYPE. Target type, because remapping a block to A, configuring it, then remapping to B would otherwise pre-fill and submit A's value into any field id the two happened to share — a different workflow's field of the same name. Namespacing makes that structurally impossible instead of a rule to remember. Field type, because the canvas stores a boolean input as a real boolean (its sub-block is a `switch`), so a stored `'false'` written as text is truthy to the child workflow. The apply side reads the type off the key and restores it, and the modal offers a switch rather than a text field. `object`/`array` stay strings: they are authored as JSON and parsed by the executor. Separately, the carve-out that keeps a custom block's stored value alive through its always-true `parentChanged` was applied at the render site, so the modal showed the stored value while the Sync gate and the submitted payload still saw blank — required fields looked filled but kept Sync disabled, and optional ones submitted empty and wiped the stored mapping. It now lives in `effectiveDependentValue`, the one place all three read through. Field-type-to-control selection moves out of the component into its own module, where it sits beside the boolean round-trip constants it has to agree with. Reported by Greptile and Cursor Bugbot on #6871. Co-Authored-By: Claude Opus 5 (1M context) --- .../fork-sync/custom-block-input-control.ts | 38 +++++++++ .../fork-sync/dependent-value.test.ts | 21 +++++ .../components/fork-sync/dependent-value.ts | 8 +- .../components/fork-sync/fork-sync-view.tsx | 54 ++++++++----- .../lib/copy/copy-workflows.test.ts | 64 ++++++++++++++- .../lib/copy/copy-workflows.ts | 2 +- .../mapping/custom-block-reconfigs.test.ts | 7 +- .../lib/mapping/custom-block-reconfigs.ts | 18 +++-- .../lib/remap/remap-references.ts | 77 +++++++++++++++---- 9 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts new file mode 100644 index 00000000000..2635b6c8749 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts @@ -0,0 +1,38 @@ +/** + * Which control the sync modal renders for a repointed custom block's input, derived from the + * field type its Start block declares. + * + * Mirrors `subBlockTypeForField` in `@/blocks/custom/build-config`, which decides the same thing + * for the canvas — a field the user configures here must read and behave the way it will once + * the block is open in the editor. + */ +export type CustomBlockInputControl = 'switch' | 'textarea' | 'input' + +export function customBlockInputControl(fieldType: string | undefined): CustomBlockInputControl { + switch (fieldType) { + // Stored as a real boolean on the canvas (its sub-block is a `switch`), so it must be + // toggled here rather than typed — a text field would persist the string `'true'`. + case 'boolean': + return 'switch' + // Authored as JSON and parsed by the executor before the child receives it. + case 'object': + case 'array': + return 'textarea' + default: + return 'input' + } +} + +/** + * The two string values a boolean input round-trips through the string-valued dependent store. + * `replaceCustomBlockInputs` turns them back into a real boolean on apply, because the canvas + * stores a `switch` sub-block as one. + */ +export const CUSTOM_BLOCK_BOOLEAN_TRUE = 'true' +export const CUSTOM_BLOCK_BOOLEAN_FALSE = 'false' + +/** Segments for a boolean input, in the order a switch reads them. */ +export const CUSTOM_BLOCK_BOOLEAN_OPTIONS = [ + { value: CUSTOM_BLOCK_BOOLEAN_TRUE, label: 'True' }, + { value: CUSTOM_BLOCK_BOOLEAN_FALSE, label: 'False' }, +] as const diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts index 077175f1bd6..ee83e40c56b 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts @@ -55,6 +55,27 @@ describe('effectiveDependentValue', () => { expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, false)).toBe('INBOX') }) + it('keeps a custom-block input\'s stored value even though its parent always "changed"', () => { + // These fields exist BECAUSE the block's type was repointed, so `parentChanged` is always + // true — but the stored value is the user's configuration for that exact target (the + // storage key namespaces it by target type), not a stale pick against an old parent. + // Blanking it here desyncs the rendered value from the Sync gate and the submitted + // payload: required fields look filled but keep Sync disabled, and optional ones submit + // empty and wipe the stored mapping. + const customBlockField = field({ + parentKind: 'custom-block', + parentSourceId: 'custom_block_uat0001', + currentValue: 'configured for the target', + }) + + expect(effectiveDependentValue(customBlockField, {}, true)).toBe('configured for the target') + }) + + it('still blanks a custom-block input the user explicitly cleared', () => { + const f = field({ parentKind: 'custom-block', currentValue: 'stored' }) + expect(effectiveDependentValue(f, { [dependentKey(f)]: null }, true)).toBe('') + }) + it('returns blank when the parent changed (the stored value no longer resolves)', () => { expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, true)).toBe('') }) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts index f23111dc4e3..ebb0ab3c89e 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts @@ -150,7 +150,13 @@ export function effectiveDependentValue( const repicked = reconfig[dependentKey(field)] if (repicked === null) return '' if (repicked !== undefined) return repicked - return parentChanged ? '' : field.currentValue + // A custom block's inputs exist BECAUSE its type was repointed, so `parentChanged` is always + // true for them — but their stored value IS the user's configuration for that exact target + // (the storage key namespaces it by target type), not a stale pick against an old parent, so + // it must survive. The rule lives here rather than at the render site so the displayed value, + // the Sync gate, and the submitted payload can never disagree about what a field holds. + if (parentChanged && field.parentKind !== 'custom-block') return '' + return field.currentValue } /** diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index 65c2393f5a0..de56ea9676b 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -33,6 +33,12 @@ import { forkBlockerResolution, } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list' import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation' +import { + CUSTOM_BLOCK_BOOLEAN_FALSE, + CUSTOM_BLOCK_BOOLEAN_OPTIONS, + CUSTOM_BLOCK_BOOLEAN_TRUE, + customBlockInputControl, +} from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector' import { applyDependentRepick, @@ -91,9 +97,6 @@ const NEW_TRIGGER_URL_VALUE = '__new_trigger_url__' */ const MAPPING_TARGET_TRIGGER_CLASS = 'w-[380px] flex-shrink-0' -/** Custom-block input types whose value is JSON, so the field needs a textarea, not a line. */ -const CUSTOM_BLOCK_JSON_FIELD_TYPES = new Set(['object', 'array']) - interface DependentBlock { targetBlockId: string blockName: string @@ -209,16 +212,13 @@ function DependentSelector({ reconfig, setReconfig, }: DependentSelectorProps) { - // A custom block's inputs exist BECAUSE its type was repointed, so `parentChanged` is - // always true for them — but unlike a re-picked selector their stored value is the user's - // own configuration for the target and must pre-fill, not blank out. + // `effectiveDependentValue` owns the custom-block carve-out, so the value shown here is the + // same one the Sync gate and the submitted payload see. const isCustomBlockInput = field.parentKind === 'custom-block' const effectiveValueIn = (f: ForkDependentReconfig, state: DependentReconfigState) => - isCustomBlockInput - ? effectiveDependentValue(f, state, false) - : copying - ? effectiveCopyDependentValue(f, state) - : effectiveDependentValue(f, state, parentChanged) + copying && !isCustomBlockInput + ? effectiveCopyDependentValue(f, state) + : effectiveDependentValue(f, state, parentChanged) const baselineValueFor = (f: ForkDependentReconfig) => effectiveValueIn(f, {}) const effectiveValue = (f: ForkDependentReconfig) => effectiveValueIn(f, reconfig) if (isCustomBlockInput) { @@ -228,17 +228,29 @@ function DependentSelector({ // is JSON, matching how `subBlockTypeForField` renders them on the canvas. const setValue = (value: string) => setReconfig((current) => ({ ...current, [dependentKey(field)]: value })) - const shared = { - title: field.title, - required: field.required, - value: effectiveValue(field), - onChange: setValue, + const value = effectiveValue(field) + const shared = { title: field.title, required: field.required } + switch (customBlockInputControl(field.fieldType)) { + case 'switch': + return ( + + + + ) + case 'textarea': + return + default: + return } - return CUSTOM_BLOCK_JSON_FIELD_TYPES.has(field.fieldType ?? '') ? ( - - ) : ( - - ) } const { providedValues, providedContextKeys } = blockChainState(block, field, effectiveValue) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index 93c061005a7..b7ce8316b8c 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -580,7 +580,9 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { ...baseParams, tx: stubTx(), transformBlockType: (type) => (type === UAT ? PROD : type), - dependentOverrides: new Map([['tgt-blk-cb', new Map([['field-prod-x', 'prod value X']])]]), + dependentOverrides: new Map([ + ['tgt-blk-cb', new Map([[`${PROD}::string::field-prod-x`, 'prod value X']])], + ]), }) const subBlocks = writtenBlock().subBlocks ?? {} @@ -602,8 +604,8 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { [ 'tgt-blk-cb', new Map([ - ['workflowId', 'crafted'], - ['field-prod-x', 'ok'], + [`${PROD}::string::workflowId`, 'crafted'], + [`${PROD}::string::field-prod-x`, 'ok'], ]), ], ]), @@ -623,7 +625,9 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { ...baseParams, tx: stubTx(), transformBlockType: (type) => type, - dependentOverrides: new Map([['tgt-blk-cb', new Map([['field-prod-x', 'must not apply']])]]), + dependentOverrides: new Map([ + ['tgt-blk-cb', new Map([[`${PROD}::string::field-prod-x`, 'must not apply']])], + ]), }) const subBlocks = writtenBlock().subBlocks ?? {} @@ -631,4 +635,56 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { expect(subBlocks['field-uat-a']?.value).toBe('uat value A') expect(subBlocks['field-prod-x']).toBeUndefined() }) + + it('ignores values stored for a DIFFERENT target, so a second remap starts clean', async () => { + // Map to A, configure it, then remap to B. A field id present on both would otherwise + // carry A's value into B — a different workflow's field that happens to share a name. + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + const OTHER = 'custom_block_other99' + + await copyWorkflowStateIntoTarget({ + ...baseParams, + tx: stubTx(), + transformBlockType: (type) => (type === UAT ? PROD : type), + dependentOverrides: new Map([ + [ + 'tgt-blk-cb', + new Map([ + [`${OTHER}::string::shared-field`, 'value from the previous target'], + [`${PROD}::string::shared-field`, 'value for this target'], + ]), + ], + ]), + }) + + expect(writtenBlock().subBlocks?.['shared-field']?.value).toBe('value for this target') + }) + + it('restores a boolean input as a real boolean, not the string it was stored as', async () => { + // The dependent store holds strings, but a boolean field's sub-block is a `switch` and the + // canvas stores it as a boolean — `'false'` left as text is truthy to the child workflow. + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + + await copyWorkflowStateIntoTarget({ + ...baseParams, + tx: stubTx(), + transformBlockType: (type) => (type === UAT ? PROD : type), + dependentOverrides: new Map([ + [ + 'tgt-blk-cb', + new Map([ + [`${PROD}::boolean::flag-on`, 'true'], + [`${PROD}::boolean::flag-off`, 'false'], + [`${PROD}::string::text`, 'true'], + ]), + ], + ]), + }) + + const subBlocks = writtenBlock().subBlocks ?? {} + expect(subBlocks['flag-on']?.value).toBe(true) + expect(subBlocks['flag-off']?.value).toBe(false) + // A string field whose value happens to read "true" stays a string. + expect(subBlocks.text?.value).toBe('true') + }) }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 2fdcca910cd..6d89d41168f 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -562,7 +562,7 @@ export async function copyWorkflowStateIntoTarget( // wholesale because ALL of a custom block's inputs are reconfigurable, not a `dependsOn` // subset. `applyDependentOverrides` above is a no-op for them: it allowlists on // `dependsOn` + `selectorKey`, which no custom-block input has. - subBlocks = replaceCustomBlockInputs(subBlocks, blockOverrides) + subBlocks = replaceCustomBlockInputs(subBlocks, blockOverrides, nextBlockType) } newBlocks[newBlockId] = { diff --git a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts index fd21a26027b..9e335097c70 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts @@ -60,7 +60,12 @@ describe('collectForkCustomBlockReconfigs', () => { const out = await collectForkCustomBlockReconfigs({ ...baseParams, resolve: swapResolver }) expect(out).toHaveLength(2) - expect(out.map((f) => f.subBlockKey)).toEqual(['field-a', 'field-b']) + // Namespaced by TARGET type and FIELD type: re-pointing the block again cannot reuse these + // values, and the apply side can restore a boolean's real type without a second lookup. + expect(out.map((f) => f.subBlockKey)).toEqual([ + `${PROD}::string::field-a`, + `${PROD}::object::field-b`, + ]) expect(out[0]).toMatchObject({ parentKind: 'custom-block', // Joined to its own mapping row on (parentKind, parentSourceId) — the SOURCE type is diff --git a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts index 169e9d5e8ba..60ba062e578 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts @@ -4,7 +4,10 @@ import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork' import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' import { isCustomBlockType } from '@/blocks/custom/build-config' import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' -import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references' +import { + customBlockInputStorageKey, + type ForkReferenceResolver, +} from '@/ee/workspace-forking/lib/remap/remap-references' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('ForkCustomBlockReconfigs') @@ -97,10 +100,13 @@ export async function collectForkCustomBlockReconfigs( const binding = bindingByType.get(swap.targetType) if (!binding) continue for (const field of binding.inputFields) { - // Sub-blocks are keyed by the field's stable id, falling back to its name for legacy - // fields with none — the same rule `buildCustomBlockConfig` keys its sub-blocks on, so - // a stored value lands on the sub-block the canvas will read it from. - const subBlockKey = field.id ?? field.name + // The canvas keys a custom block's sub-blocks by the field's stable id, falling back to + // its name for legacy fields with none. The STORAGE key namespaces that by the target + // type and field type, so re-pointing the block again cannot reuse this target's values + // and the apply side can restore a boolean's real type — see + // `customBlockInputStorageKey`. + const fieldId = field.id ?? field.name + const subBlockKey = customBlockInputStorageKey(swap.targetType, field.type, fieldId) out.push({ parentKind: 'custom-block', parentSourceId: swap.sourceType, @@ -115,7 +121,7 @@ export async function collectForkCustomBlockReconfigs( // something else. The diff overlays the stored value on top of this. currentValue: '', sourceValue: '', - required: binding.requiredInputIds.includes(subBlockKey), + required: binding.requiredInputIds.includes(fieldId), consumesContextKeys: [], context: {}, }) diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index 1614bb9611e..94191450c58 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -238,36 +238,87 @@ export interface RemapForkBlockTypeResult { resolved: boolean } +/** + * Separator for a configured custom-block input's storage key. `::` cannot occur in a + * `custom_block_` type, and a field id that contained it would simply fail to parse and + * be skipped rather than land on the wrong field. + */ +const CUSTOM_BLOCK_INPUT_KEY_SEPARATOR = '::' + +/** + * Storage key for one configured input of a repointed custom block. + * + * The stored value's own key carries the TARGET TYPE and the field's declared TYPE, because the + * dependent-value store is keyed only by `(target workflow, block, sub-block)` and holds a plain + * string: + * - **target type** — remap a block to A, configure its fields, then remap it to B. Without the + * type in the key, a field id that happens to exist on both would pre-fill and submit A's + * value into B, which is a different workflow's field of the same name. Namespacing makes + * that structurally impossible rather than a rule someone has to remember. + * - **field type** — the canvas stores a `boolean` input as a real boolean (its sub-block is a + * `switch`), so a stored `'true'` has to become `true` on the way in. Reading the type from + * the key means the apply side needs no second lookup of the target's schema. + */ +export function customBlockInputStorageKey( + targetType: string, + fieldType: string, + fieldId: string +): string { + const sep = CUSTOM_BLOCK_INPUT_KEY_SEPARATOR + return `${targetType}${sep}${fieldType}${sep}${fieldId}` +} + +interface ParsedCustomBlockInputKey { + targetType: string + fieldType: string + fieldId: string +} + +/** Inverse of {@link customBlockInputStorageKey}; null when the key is not one of ours. */ +export function parseCustomBlockInputStorageKey(key: string): ParsedCustomBlockInputKey | null { + const parts = key.split(CUSTOM_BLOCK_INPUT_KEY_SEPARATOR) + if (parts.length !== 3) return null + const [targetType, fieldType, fieldId] = parts + if (!targetType || !fieldType || !fieldId) return null + return { targetType, fieldType, fieldId } +} + /** * Replace a retyped custom block's inputs with the values configured for the TARGET block. * * A custom block's input sub-blocks are keyed by the SOURCE Start field's stable id, so once * the block's `type` is repointed they describe fields the new config does not declare. The - * serializer would drop them silently ({@link file://apps/sim/serializer/index.ts} — a stored - * value with no matching config is a deleted input), which is what made a synced block look - * corrupted: same name, no fields. + * serializer would drop them silently (a stored value with no matching config is a deleted + * input), which is what made a synced block look corrupted: same name, no fields. * * There is deliberately NO attempt to match or migrate values across the swap. Two custom * blocks are independent workflows; a field id that happens to collide would carry a value - * that means something else. Instead the inputs the user configured for the target at sync - * time are written verbatim, and everything else is dropped explicitly. + * that means something else. * - * `values` is already allowlisted to the target block's declared field ids by the planner, - * which is the only side that can resolve the target config. Reserved wiring - * (`workflowId`/`inputMapping`) is preserved untouched — those are computed value-fns the - * serializer recomputes and never carries forward. + * Only values stored for THIS target type are applied — a key naming a previous target is + * skipped, so re-pointing a block twice never carries the first target's values into the + * second. Reserved wiring (`workflowId`/`inputMapping`) is preserved untouched: those are + * computed value-fns the serializer recomputes and never carries forward. */ export function replaceCustomBlockInputs( subBlocks: SubBlockRecord, - values: ReadonlyMap | undefined + values: ReadonlyMap | undefined, + targetType: string ): SubBlockRecord { const next: SubBlockRecord = {} for (const [key, subBlock] of Object.entries(subBlocks)) { if (RESERVED_PARAMS.has(key)) next[key] = subBlock } - for (const [fieldId, value] of values ?? []) { - if (RESERVED_PARAMS.has(fieldId)) continue - next[fieldId] = { value } + for (const [key, value] of values ?? []) { + const parsed = parseCustomBlockInputStorageKey(key) + if (!parsed || parsed.targetType !== targetType) continue + if (RESERVED_PARAMS.has(parsed.fieldId)) continue + // A `boolean` field's sub-block is a `switch`, which the canvas stores as a real boolean. + // Everything else is stored as text: `object`/`array` are authored as JSON and parsed by + // the executor, and a number rides a `short-input` like it does on the canvas. + next[parsed.fieldId] = { + value: parsed.fieldType === 'boolean' ? value === 'true' : value, + } } return next } From 8973f611b28573442fc7333a13f7e6a193dd7969 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 16:49:42 -0700 Subject: [PATCH 4/5] fix(workspace-forking): keep an unset custom-block boolean unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boolean handling collapsed a tri-state. `''` is a flag the user never touched, and it is not `false`. On apply, any non-`'true'` string became `false` — so an untouched optional flag was written as one. `assembleCustomBlockInputMapping` skips `''` but keeps `false`, so that value reached the child's `inputMapping` and overrode whatever default the Start field declares. Only an explicit `'true'`/`'false'` is applied now; anything else leaves the field unset, and the child's own default stands. In the modal the switch mapped `''` to the False segment, so a required flag rendered as configured while the Sync gate still read it as empty — the same display-versus-gate split the previous commit moved into `effectiveDependentValue` to close, reintroduced one layer up. The value is passed through unmapped instead: `''` matches neither segment, so the switch renders with nothing selected, which is what it is. Reported by Greptile and Cursor Bugbot on #6871. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/fork-sync/fork-sync-view.tsx | 12 ++++----- .../lib/copy/copy-workflows.test.ts | 27 +++++++++++++++++++ .../lib/remap/remap-references.ts | 15 ++++++++--- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index de56ea9676b..2e003ee11e6 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -34,9 +34,7 @@ import { } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list' import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation' import { - CUSTOM_BLOCK_BOOLEAN_FALSE, CUSTOM_BLOCK_BOOLEAN_OPTIONS, - CUSTOM_BLOCK_BOOLEAN_TRUE, customBlockInputControl, } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector' @@ -236,11 +234,11 @@ function DependentSelector({ diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index b7ce8316b8c..f0cf677964d 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -687,4 +687,31 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { // A string field whose value happens to read "true" stays a string. expect(subBlocks.text?.value).toBe('true') }) + + it('leaves an unset boolean unset rather than writing false', async () => { + // The modal submits '' for an untouched optional flag. Coercing that to `false` writes a + // value the user never chose: `assembleCustomBlockInputMapping` skips '' but keeps + // `false`, so it would reach the child's inputMapping and override the Start field's own + // default. Only an explicit 'false' means false. + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + + await copyWorkflowStateIntoTarget({ + ...baseParams, + tx: stubTx(), + transformBlockType: (type) => (type === UAT ? PROD : type), + dependentOverrides: new Map([ + [ + 'tgt-blk-cb', + new Map([ + [`${PROD}::boolean::untouched`, ''], + [`${PROD}::boolean::explicit-false`, 'false'], + ]), + ], + ]), + }) + + const subBlocks = writtenBlock().subBlocks ?? {} + expect(subBlocks).not.toHaveProperty('untouched') + expect(subBlocks['explicit-false']?.value).toBe(false) + }) }) diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index 94191450c58..a8a7df7f3f4 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -313,12 +313,19 @@ export function replaceCustomBlockInputs( const parsed = parseCustomBlockInputStorageKey(key) if (!parsed || parsed.targetType !== targetType) continue if (RESERVED_PARAMS.has(parsed.fieldId)) continue - // A `boolean` field's sub-block is a `switch`, which the canvas stores as a real boolean. + if (parsed.fieldType === 'boolean') { + // A `boolean` field's sub-block is a `switch`, which the canvas stores as a real boolean + // — but only `'true'`/`'false'` mean anything. An untouched optional flag submits `''`, + // and coercing that to `false` would write a value the user never chose: + // `assembleCustomBlockInputMapping` skips `''` and keeps `false`, so it would reach the + // child's `inputMapping` and override the Start field's own default. Leave it unset. + if (value !== 'true' && value !== 'false') continue + next[parsed.fieldId] = { value: value === 'true' } + continue + } // Everything else is stored as text: `object`/`array` are authored as JSON and parsed by // the executor, and a number rides a `short-input` like it does on the canvas. - next[parsed.fieldId] = { - value: parsed.fieldType === 'boolean' ? value === 'true' : value, - } + next[parsed.fieldId] = { value } } return next } From ac8473767282718b2ab3ff6f9106698f7a5b6fc5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 16:55:17 -0700 Subject: [PATCH 5/5] fix(workspace-forking): let an optional custom-block boolean return to its default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A two-segment switch has no transition back to "nothing selected", so once a user picked True or False there was no way to stop overriding the target workflow's declared default — a single click pinned the flag for every later sync. An optional boolean now carries a third `Default` segment, trailing the two real values because choosing one is the common action and reverting is the escape hatch. A required boolean keeps two: the Sync gate demands a value, so unset is not a state it can end in and offering it would present an unsubmittable choice. Reported by Greptile on #6871. Co-Authored-By: Claude Opus 5 (1M context) --- .../custom-block-input-control.test.ts | 54 +++++++++++++++++++ .../fork-sync/custom-block-input-control.ts | 26 ++++++++- .../components/fork-sync/fork-sync-view.tsx | 4 +- 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts new file mode 100644 index 00000000000..ef3c141c8ed --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + CUSTOM_BLOCK_BOOLEAN_FALSE, + CUSTOM_BLOCK_BOOLEAN_TRUE, + CUSTOM_BLOCK_BOOLEAN_UNSET, + customBlockBooleanOptions, + customBlockInputControl, +} from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' + +describe('customBlockInputControl', () => { + it('matches how the canvas renders each field type', () => { + // Mirrors `subBlockTypeForField`: a field configured here must behave the way it will + // once the block is open in the editor. + expect(customBlockInputControl('boolean')).toBe('switch') + expect(customBlockInputControl('object')).toBe('textarea') + expect(customBlockInputControl('array')).toBe('textarea') + expect(customBlockInputControl('string')).toBe('input') + expect(customBlockInputControl('number')).toBe('input') + }) + + it('falls back to a plain input for an unknown or absent type', () => { + expect(customBlockInputControl('something-new')).toBe('input') + expect(customBlockInputControl(undefined)).toBe('input') + }) +}) + +describe('customBlockBooleanOptions', () => { + it('lets an OPTIONAL flag return to the workflow default', () => { + // Without this a single click permanently pins the flag: a two-segment switch has no + // transition back to "nothing selected", so every later sync would keep overriding the + // child's declared default. + const options = customBlockBooleanOptions(false) + + expect(options.map((o) => o.value)).toEqual([ + CUSTOM_BLOCK_BOOLEAN_TRUE, + CUSTOM_BLOCK_BOOLEAN_FALSE, + CUSTOM_BLOCK_BOOLEAN_UNSET, + ]) + }) + + it('offers a REQUIRED flag only real values', () => { + // The Sync gate demands a value, so "unset" is not a state it can end in — offering it + // would present a choice that cannot be submitted. + const options = customBlockBooleanOptions(true) + + expect(options.map((o) => o.value)).toEqual([ + CUSTOM_BLOCK_BOOLEAN_TRUE, + CUSTOM_BLOCK_BOOLEAN_FALSE, + ]) + }) +}) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts index 2635b6c8749..1ac4b301728 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts @@ -31,8 +31,30 @@ export function customBlockInputControl(fieldType: string | undefined): CustomBl export const CUSTOM_BLOCK_BOOLEAN_TRUE = 'true' export const CUSTOM_BLOCK_BOOLEAN_FALSE = 'false' -/** Segments for a boolean input, in the order a switch reads them. */ -export const CUSTOM_BLOCK_BOOLEAN_OPTIONS = [ +/** + * The unset value. Distinct from `false`: it means the sync writes no value at all, so the + * target workflow's Start field keeps whatever default it declares. + */ +export const CUSTOM_BLOCK_BOOLEAN_UNSET = '' + +const BOOLEAN_VALUE_OPTIONS = [ { value: CUSTOM_BLOCK_BOOLEAN_TRUE, label: 'True' }, { value: CUSTOM_BLOCK_BOOLEAN_FALSE, label: 'False' }, ] as const + +const BOOLEAN_OPTIONAL_OPTIONS = [ + ...BOOLEAN_VALUE_OPTIONS, + // Trails the two real values: choosing one is the common action, returning to the default + // is the escape hatch. Without it a single click would permanently pin an optional flag, + // since a two-segment switch has no transition back to "nothing selected". + { value: CUSTOM_BLOCK_BOOLEAN_UNSET, label: 'Default' }, +] as const + +/** + * Segments for a boolean input. An OPTIONAL field gets a third `Default` segment so the user + * can stop overriding the child workflow's declared default; a REQUIRED one does not, because + * the Sync gate demands a value and "unset" is not a state it can end in. + */ +export function customBlockBooleanOptions(required: boolean) { + return required ? BOOLEAN_VALUE_OPTIONS : BOOLEAN_OPTIONAL_OPTIONS +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index 2e003ee11e6..ec914822681 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -34,7 +34,7 @@ import { } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list' import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation' import { - CUSTOM_BLOCK_BOOLEAN_OPTIONS, + customBlockBooleanOptions, customBlockInputControl, } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector' @@ -233,7 +233,7 @@ function DependentSelector({ return (