Skip to content

Commit d444d58

Browse files
authored
fix(workflows): refuse new placements of sunset blocks in workflow edits (#9456)
* fix(workflows): refuse new placements of sunset blocks in workflow edits * test(workflows): cover sunset refusal when a container edit merges children
1 parent 9c571fb commit d444d58

2 files changed

Lines changed: 220 additions & 2 deletions

File tree

‎apps/sim/lib/workflows/editing/operations.test.ts‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ const MOCK_REGISTRY_BLOCKS: Record<string, any> = {
120120
},
121121
],
122122
},
123+
table: {
124+
type: 'table',
125+
name: 'Table (Legacy)',
126+
sunset: { status: 'legacy', replacedBy: 'table_v2' },
127+
subBlocks: [{ id: 'tableId', type: 'short-input' }],
128+
},
129+
table_v2: {
130+
type: 'table_v2',
131+
name: 'Table',
132+
subBlocks: [{ id: 'tableId', type: 'short-input' }],
133+
},
123134
}
124135
const mockGetAllBlocks = getAllBlocks as Mock
125136
const mockGetBlock = getBlock as Mock
@@ -875,6 +886,128 @@ describe('minted block ids', () => {
875886
})
876887
})
877888

889+
describe('sunset block types', () => {
890+
const legacyId = '44444444-4444-4444-8444-444444444444'
891+
const newId = '55555555-5555-4555-8555-555555555555'
892+
const loopId = '66666666-6666-4666-8666-666666666666'
893+
const insertedId = '77777777-7777-4777-8777-777777777777'
894+
895+
function tableBlock(id: string, type: string, name: string, parentId?: string) {
896+
return {
897+
id,
898+
type,
899+
name,
900+
position: { x: 0, y: 0 },
901+
enabled: true,
902+
subBlocks: { tableId: { id: 'tableId', type: 'short-input', value: 'tbl_old' } },
903+
outputs: {},
904+
data: parentId ? { parentId } : {},
905+
}
906+
}
907+
908+
it('updates a placed legacy child but refuses a new one when a container edit merges children', () => {
909+
const workflow = {
910+
blocks: {
911+
[loopId]: { ...tableBlock(loopId, 'loop', 'Each'), subBlocks: {} },
912+
[legacyId]: tableBlock(legacyId, 'table', 'Rows', loopId),
913+
},
914+
edges: [],
915+
loops: {},
916+
parallels: {},
917+
}
918+
919+
const { state, skippedItems } = applyOperationsToWorkflowState(workflow, [
920+
{
921+
operation_type: 'edit',
922+
block_id: loopId,
923+
params: {
924+
nestedNodes: {
925+
[legacyId]: { type: 'table', name: 'Rows', inputs: { tableId: 'tbl_new' } },
926+
[newId]: { type: 'table', name: 'Fresh rows' },
927+
},
928+
},
929+
},
930+
])
931+
932+
expect(state.blocks[legacyId].subBlocks.tableId.value).toBe('tbl_new')
933+
expect(state.blocks[newId]).toBeUndefined()
934+
expect(skippedItems).toEqual([
935+
expect.objectContaining({
936+
type: 'block_not_allowed',
937+
operationType: 'add_nested_node',
938+
blockId: newId,
939+
}),
940+
])
941+
})
942+
943+
it('refuses a new legacy block, top-level or nested, and names its successor', () => {
944+
const { state, skippedItems } = applyOperationsToWorkflowState(
945+
{ blocks: {}, edges: [], loops: {}, parallels: {} },
946+
[
947+
{ operation_type: 'add', block_id: newId, params: { type: 'table', name: 'Rows' } },
948+
{
949+
operation_type: 'add',
950+
block_id: loopId,
951+
params: {
952+
type: 'loop',
953+
name: 'Each',
954+
nestedNodes: { [legacyId]: { type: 'table', name: 'Nested rows' } },
955+
},
956+
},
957+
{
958+
operation_type: 'insert_into_subflow',
959+
block_id: insertedId,
960+
params: { type: 'table', name: 'Inserted rows', subflowId: loopId },
961+
},
962+
]
963+
)
964+
965+
expect(state.blocks[newId]).toBeUndefined()
966+
expect(state.blocks[legacyId]).toBeUndefined()
967+
expect(state.blocks[insertedId]).toBeUndefined()
968+
expect(state.blocks[loopId]).toBeDefined()
969+
expect(skippedItems).toHaveLength(3)
970+
expect(skippedItems).toEqual(
971+
expect.arrayContaining([
972+
expect.objectContaining({
973+
type: 'block_not_allowed',
974+
operationType: 'add',
975+
reason: expect.stringContaining('use "table_v2" instead'),
976+
details: { requestedType: 'table', replacedBy: 'table_v2' },
977+
}),
978+
expect.objectContaining({ type: 'block_not_allowed', operationType: 'add_nested_node' }),
979+
expect.objectContaining({
980+
type: 'block_not_allowed',
981+
operationType: 'insert_into_subflow',
982+
}),
983+
])
984+
)
985+
})
986+
987+
it('keeps an existing legacy block editable but refuses switching another block to it', () => {
988+
const workflow = {
989+
blocks: {
990+
[legacyId]: tableBlock(legacyId, 'table', 'Rows'),
991+
[newId]: tableBlock(newId, 'table_v2', 'V2'),
992+
},
993+
edges: [],
994+
loops: {},
995+
parallels: {},
996+
}
997+
998+
const { state, skippedItems } = applyOperationsToWorkflowState(workflow, [
999+
{ operation_type: 'edit', block_id: legacyId, params: { inputs: { tableId: 'tbl_new' } } },
1000+
{ operation_type: 'edit', block_id: newId, params: { type: 'table' } },
1001+
])
1002+
1003+
expect(state.blocks[legacyId].subBlocks.tableId.value).toBe('tbl_new')
1004+
expect(state.blocks[newId].type).toBe('table_v2')
1005+
expect(skippedItems).toEqual([
1006+
expect.objectContaining({ type: 'block_not_allowed', operationType: 'edit', blockId: newId }),
1007+
])
1008+
})
1009+
})
1010+
8781011
describe('permission-group tool access', () => {
8791012
const denyCanvas = { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedTools: ['slack_canvas'] }
8801013

‎apps/sim/lib/workflows/editing/operations.ts‎

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
updateCanonicalModesForInputs,
2222
} from './builders'
2323
import { VALID_LOOP_TYPES, VALID_PARALLEL_TYPES } from './container-types'
24-
import type { EditWorkflowOperation, OperationContext } from './types'
24+
import type { EditWorkflowOperation, OperationContext, SkippedItem } from './types'
2525
import { logSkippedItem } from './types'
2626
import {
2727
findBlockWithDuplicateNormalizedName,
@@ -31,6 +31,36 @@ import {
3131

3232
const logger = createLogger('EditWorkflowServerTool')
3333

34+
/**
35+
* Refuses a new block of a sunset (`legacy` / `deprecated`) type and names its
36+
* successor. Placed instances keep executing and stay editable.
37+
*
38+
* @returns whether the placement was refused
39+
*/
40+
function refuseSunsetPlacement(
41+
skippedItems: SkippedItem[],
42+
placement: {
43+
blockType: string | undefined
44+
operationType: string
45+
blockId: string
46+
outcome: string
47+
details?: Record<string, unknown>
48+
}
49+
): boolean {
50+
const { blockType, operationType, blockId, outcome, details } = placement
51+
const sunset = blockType ? getBlock(blockType)?.sunset : undefined
52+
if (!sunset) return false
53+
const successor = sunset.replacedBy ? ` - use "${sunset.replacedBy}" instead` : ''
54+
logSkippedItem(skippedItems, {
55+
type: 'block_not_allowed',
56+
operationType,
57+
blockId,
58+
reason: `Block type "${blockType}" is ${sunset.status}${successor} - ${outcome}`,
59+
details: { requestedType: blockType, replacedBy: sunset.replacedBy, ...details },
60+
})
61+
return true
62+
}
63+
3464
/**
3565
* Applies loop/parallel container config from `inputs` onto a block state (data.loopType, etc.).
3666
*/
@@ -104,6 +134,18 @@ function processNestedNodesForParent(
104134
return
105135
}
106136

137+
if (
138+
refuseSunsetPlacement(skippedItems, {
139+
blockType: childBlock.type,
140+
operationType: 'add_nested_node',
141+
blockId: childId,
142+
outcome: 'child block skipped',
143+
details: { parentBlockId },
144+
})
145+
) {
146+
return
147+
}
148+
107149
const childBlockState = createBlockFromParams(
108150
childId,
109151
childBlock,
@@ -283,6 +325,18 @@ function mergeNestedNodesForParent(
283325
return
284326
}
285327

328+
if (
329+
refuseSunsetPlacement(skippedItems, {
330+
blockType: childBlock.type,
331+
operationType: 'add_nested_node',
332+
blockId: childId,
333+
outcome: 'child block skipped',
334+
details: { parentBlockId },
335+
})
336+
) {
337+
return
338+
}
339+
286340
const childBlockState = createBlockFromParams(
287341
childId,
288342
childBlock,
@@ -607,7 +661,15 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon
607661
reason: `Block type "${params.type}" is unavailable in this deployment or blocked by access control - type change skipped`,
608662
details: { requestedType: params.type },
609663
})
610-
} else {
664+
} else if (
665+
params.type === block.type ||
666+
!refuseSunsetPlacement(skippedItems, {
667+
blockType: params.type,
668+
operationType: 'edit',
669+
blockId: block_id,
670+
outcome: 'type change skipped',
671+
})
672+
) {
611673
block.type = params.type
612674
}
613675
}
@@ -791,6 +853,17 @@ export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationCont
791853
return
792854
}
793855

856+
if (
857+
refuseSunsetPlacement(skippedItems, {
858+
blockType: params.type,
859+
operationType: 'add',
860+
blockId: block_id,
861+
outcome: 'block not added',
862+
})
863+
) {
864+
return
865+
}
866+
794867
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type)
795868
if (triggerIssue) {
796869
logSkippedItem(skippedItems, {
@@ -1022,6 +1095,18 @@ export function handleInsertIntoSubflowOperation(
10221095
return
10231096
}
10241097

1098+
if (
1099+
refuseSunsetPlacement(skippedItems, {
1100+
blockType: params.type,
1101+
operationType: 'insert_into_subflow',
1102+
blockId: block_id,
1103+
outcome: 'block not inserted into subflow',
1104+
details: { subflowId },
1105+
})
1106+
) {
1107+
return
1108+
}
1109+
10251110
const newBlock = createBlockFromParams(
10261111
block_id,
10271112
params,

0 commit comments

Comments
 (0)