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(), /**