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/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
new file mode 100644
index 00000000000..1ac4b301728
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts
@@ -0,0 +1,60 @@
+/**
+ * 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'
+
+/**
+ * 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/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 1752bcae5b4..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
@@ -6,6 +6,7 @@ import {
ChevronDown,
Chip,
ChipCombobox,
+ ChipModalField,
ChipSwitch,
CollapsibleCard,
cn,
@@ -32,6 +33,10 @@ import {
forkBlockerResolution,
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
+import {
+ 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'
import {
applyDependentRepick,
@@ -205,12 +210,47 @@ function DependentSelector({
reconfig,
setReconfig,
}: DependentSelectorProps) {
+ // `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) =>
- copying
+ copying && !isCustomBlockInput
? 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 value = effectiveValue(field)
+ const shared = { title: field.title, required: field.required }
+ switch (customBlockInputControl(field.fieldType)) {
+ case 'switch':
+ return (
+
+
+
+ )
+ case 'textarea':
+ return
+ default:
+ return
+ }
+ }
+
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 +268,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 abbe788a5b4..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
@@ -489,3 +489,229 @@ 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),
+ dependentOverrides: new Map([
+ ['tgt-blk-cb', new Map([[`${PROD}::string::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),
+ dependentOverrides: new Map([
+ [
+ 'tgt-blk-cb',
+ new Map([
+ [`${PROD}::string::workflowId`, 'crafted'],
+ [`${PROD}::string::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,
+ dependentOverrides: new Map([
+ ['tgt-blk-cb', new Map([[`${PROD}::string::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()
+ })
+
+ 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')
+ })
+
+ 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/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts
index 520a9f8613b..6d89d41168f 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 {
@@ -551,12 +552,23 @@ 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 — 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, nextBlockType)
+ }
+
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/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..9e335097c70
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts
@@ -0,0 +1,148 @@
+/**
+ * @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)
+ // 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
+ // 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..60ba062e578
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts
@@ -0,0 +1,131 @@
+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 {
+ customBlockInputStorageKey,
+ 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) {
+ // 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,
+ 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(fieldId),
+ consumesContextKeys: [],
+ context: {},
+ })
+ }
+ }
+ return out
+}
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..a8a7df7f3f4 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,98 @@ 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 (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.
+ *
+ * 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,
+ targetType: string
+): SubBlockRecord {
+ const next: SubBlockRecord = {}
+ for (const [key, subBlock] of Object.entries(subBlocks)) {
+ if (RESERVED_PARAMS.has(key)) next[key] = subBlock
+ }
+ for (const [key, value] of values ?? []) {
+ const parsed = parseCustomBlockInputStorageKey(key)
+ if (!parsed || parsed.targetType !== targetType) continue
+ if (RESERVED_PARAMS.has(parsed.fieldId)) continue
+ 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 }
+ }
+ return next
+}
+
/**
* Repoint a placed custom block at the fork's own published block.
*
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(),
/**