diff --git a/apps/sim/lib/workflows/editing/operations.test.ts b/apps/sim/lib/workflows/editing/operations.test.ts index a4f4825a1df..6ced9063121 100644 --- a/apps/sim/lib/workflows/editing/operations.test.ts +++ b/apps/sim/lib/workflows/editing/operations.test.ts @@ -120,6 +120,17 @@ const MOCK_REGISTRY_BLOCKS: Record = { }, ], }, + table: { + type: 'table', + name: 'Table (Legacy)', + sunset: { status: 'legacy', replacedBy: 'table_v2' }, + subBlocks: [{ id: 'tableId', type: 'short-input' }], + }, + table_v2: { + type: 'table_v2', + name: 'Table', + subBlocks: [{ id: 'tableId', type: 'short-input' }], + }, } const mockGetAllBlocks = getAllBlocks as Mock const mockGetBlock = getBlock as Mock @@ -875,6 +886,128 @@ describe('minted block ids', () => { }) }) +describe('sunset block types', () => { + const legacyId = '44444444-4444-4444-8444-444444444444' + const newId = '55555555-5555-4555-8555-555555555555' + const loopId = '66666666-6666-4666-8666-666666666666' + const insertedId = '77777777-7777-4777-8777-777777777777' + + function tableBlock(id: string, type: string, name: string, parentId?: string) { + return { + id, + type, + name, + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: { tableId: { id: 'tableId', type: 'short-input', value: 'tbl_old' } }, + outputs: {}, + data: parentId ? { parentId } : {}, + } + } + + it('updates a placed legacy child but refuses a new one when a container edit merges children', () => { + const workflow = { + blocks: { + [loopId]: { ...tableBlock(loopId, 'loop', 'Each'), subBlocks: {} }, + [legacyId]: tableBlock(legacyId, 'table', 'Rows', loopId), + }, + edges: [], + loops: {}, + parallels: {}, + } + + const { state, skippedItems } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: loopId, + params: { + nestedNodes: { + [legacyId]: { type: 'table', name: 'Rows', inputs: { tableId: 'tbl_new' } }, + [newId]: { type: 'table', name: 'Fresh rows' }, + }, + }, + }, + ]) + + expect(state.blocks[legacyId].subBlocks.tableId.value).toBe('tbl_new') + expect(state.blocks[newId]).toBeUndefined() + expect(skippedItems).toEqual([ + expect.objectContaining({ + type: 'block_not_allowed', + operationType: 'add_nested_node', + blockId: newId, + }), + ]) + }) + + it('refuses a new legacy block, top-level or nested, and names its successor', () => { + const { state, skippedItems } = applyOperationsToWorkflowState( + { blocks: {}, edges: [], loops: {}, parallels: {} }, + [ + { operation_type: 'add', block_id: newId, params: { type: 'table', name: 'Rows' } }, + { + operation_type: 'add', + block_id: loopId, + params: { + type: 'loop', + name: 'Each', + nestedNodes: { [legacyId]: { type: 'table', name: 'Nested rows' } }, + }, + }, + { + operation_type: 'insert_into_subflow', + block_id: insertedId, + params: { type: 'table', name: 'Inserted rows', subflowId: loopId }, + }, + ] + ) + + expect(state.blocks[newId]).toBeUndefined() + expect(state.blocks[legacyId]).toBeUndefined() + expect(state.blocks[insertedId]).toBeUndefined() + expect(state.blocks[loopId]).toBeDefined() + expect(skippedItems).toHaveLength(3) + expect(skippedItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'block_not_allowed', + operationType: 'add', + reason: expect.stringContaining('use "table_v2" instead'), + details: { requestedType: 'table', replacedBy: 'table_v2' }, + }), + expect.objectContaining({ type: 'block_not_allowed', operationType: 'add_nested_node' }), + expect.objectContaining({ + type: 'block_not_allowed', + operationType: 'insert_into_subflow', + }), + ]) + ) + }) + + it('keeps an existing legacy block editable but refuses switching another block to it', () => { + const workflow = { + blocks: { + [legacyId]: tableBlock(legacyId, 'table', 'Rows'), + [newId]: tableBlock(newId, 'table_v2', 'V2'), + }, + edges: [], + loops: {}, + parallels: {}, + } + + const { state, skippedItems } = applyOperationsToWorkflowState(workflow, [ + { operation_type: 'edit', block_id: legacyId, params: { inputs: { tableId: 'tbl_new' } } }, + { operation_type: 'edit', block_id: newId, params: { type: 'table' } }, + ]) + + expect(state.blocks[legacyId].subBlocks.tableId.value).toBe('tbl_new') + expect(state.blocks[newId].type).toBe('table_v2') + expect(skippedItems).toEqual([ + expect.objectContaining({ type: 'block_not_allowed', operationType: 'edit', blockId: newId }), + ]) + }) +}) + describe('permission-group tool access', () => { const denyCanvas = { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedTools: ['slack_canvas'] } diff --git a/apps/sim/lib/workflows/editing/operations.ts b/apps/sim/lib/workflows/editing/operations.ts index da03e6e4d21..9af72759aa3 100644 --- a/apps/sim/lib/workflows/editing/operations.ts +++ b/apps/sim/lib/workflows/editing/operations.ts @@ -21,7 +21,7 @@ import { updateCanonicalModesForInputs, } from './builders' import { VALID_LOOP_TYPES, VALID_PARALLEL_TYPES } from './container-types' -import type { EditWorkflowOperation, OperationContext } from './types' +import type { EditWorkflowOperation, OperationContext, SkippedItem } from './types' import { logSkippedItem } from './types' import { findBlockWithDuplicateNormalizedName, @@ -31,6 +31,36 @@ import { const logger = createLogger('EditWorkflowServerTool') +/** + * Refuses a new block of a sunset (`legacy` / `deprecated`) type and names its + * successor. Placed instances keep executing and stay editable. + * + * @returns whether the placement was refused + */ +function refuseSunsetPlacement( + skippedItems: SkippedItem[], + placement: { + blockType: string | undefined + operationType: string + blockId: string + outcome: string + details?: Record + } +): boolean { + const { blockType, operationType, blockId, outcome, details } = placement + const sunset = blockType ? getBlock(blockType)?.sunset : undefined + if (!sunset) return false + const successor = sunset.replacedBy ? ` - use "${sunset.replacedBy}" instead` : '' + logSkippedItem(skippedItems, { + type: 'block_not_allowed', + operationType, + blockId, + reason: `Block type "${blockType}" is ${sunset.status}${successor} - ${outcome}`, + details: { requestedType: blockType, replacedBy: sunset.replacedBy, ...details }, + }) + return true +} + /** * Applies loop/parallel container config from `inputs` onto a block state (data.loopType, etc.). */ @@ -104,6 +134,18 @@ function processNestedNodesForParent( return } + if ( + refuseSunsetPlacement(skippedItems, { + blockType: childBlock.type, + operationType: 'add_nested_node', + blockId: childId, + outcome: 'child block skipped', + details: { parentBlockId }, + }) + ) { + return + } + const childBlockState = createBlockFromParams( childId, childBlock, @@ -283,6 +325,18 @@ function mergeNestedNodesForParent( return } + if ( + refuseSunsetPlacement(skippedItems, { + blockType: childBlock.type, + operationType: 'add_nested_node', + blockId: childId, + outcome: 'child block skipped', + details: { parentBlockId }, + }) + ) { + return + } + const childBlockState = createBlockFromParams( childId, childBlock, @@ -607,7 +661,15 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon reason: `Block type "${params.type}" is unavailable in this deployment or blocked by access control - type change skipped`, details: { requestedType: params.type }, }) - } else { + } else if ( + params.type === block.type || + !refuseSunsetPlacement(skippedItems, { + blockType: params.type, + operationType: 'edit', + blockId: block_id, + outcome: 'type change skipped', + }) + ) { block.type = params.type } } @@ -791,6 +853,17 @@ export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationCont return } + if ( + refuseSunsetPlacement(skippedItems, { + blockType: params.type, + operationType: 'add', + blockId: block_id, + outcome: 'block not added', + }) + ) { + return + } + const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type) if (triggerIssue) { logSkippedItem(skippedItems, { @@ -1022,6 +1095,18 @@ export function handleInsertIntoSubflowOperation( return } + if ( + refuseSunsetPlacement(skippedItems, { + blockType: params.type, + operationType: 'insert_into_subflow', + blockId: block_id, + outcome: 'block not inserted into subflow', + details: { subflowId }, + }) + ) { + return + } + const newBlock = createBlockFromParams( block_id, params,