Skip to content

Commit 5ea80a8

Browse files
v0.6.70: legacy workflow sanitization
2 parents 8d934f3 + 0942555 commit 5ea80a8

8 files changed

Lines changed: 423 additions & 76 deletions

File tree

apps/sim/lib/workflows/migrations/subblock-migrations.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,69 @@ describe('migrateSubblockIds', () => {
133133
expect(blocks.b1.subBlocks.code.value).toBe('console.log("hi")')
134134
})
135135

136+
it('should repair malformed subBlocks for every block type without deleting values', () => {
137+
const input: Record<string, BlockState> = {
138+
b1: makeBlock({
139+
type: 'function',
140+
subBlocks: {
141+
code: { id: 'code', type: 'unknown', value: 'console.log("hi")' },
142+
language: { value: 'javascript' },
143+
undefined: { type: 'unknown', value: null },
144+
noId: { type: 'short-input', value: 'stale' },
145+
noType: { id: 'noType', value: 'stale' },
146+
unknownType: { id: 'unknownType', type: 'unknown', value: 'preserved' },
147+
notRecord: 'stale',
148+
arrayValue: ['a', 'b'],
149+
} as unknown as BlockState['subBlocks'],
150+
}),
151+
}
152+
153+
const { blocks, migrated } = migrateSubblockIds(input)
154+
155+
expect(migrated).toBe(true)
156+
expect(blocks.b1.subBlocks.code).toEqual({
157+
id: 'code',
158+
type: 'code',
159+
value: 'console.log("hi")',
160+
})
161+
expect(blocks.b1.subBlocks.language).toEqual({
162+
id: 'language',
163+
type: 'dropdown',
164+
value: 'javascript',
165+
})
166+
expect(blocks.b1.subBlocks.undefined).toBeUndefined()
167+
expect(blocks.b1.subBlocks.noId).toBeUndefined()
168+
expect(blocks.b1.subBlocks.noType).toBeUndefined()
169+
expect(blocks.b1.subBlocks.unknownType).toBeUndefined()
170+
expect(blocks.b1.subBlocks.notRecord).toBeUndefined()
171+
expect(blocks.b1.subBlocks.arrayValue).toBeUndefined()
172+
})
173+
174+
it('should preserve malformed legacy subBlocks before renaming them', () => {
175+
const input: Record<string, BlockState> = {
176+
b1: makeBlock({
177+
type: 'knowledge',
178+
subBlocks: {
179+
knowledgeBaseId: {
180+
id: 'knowledgeBaseId',
181+
type: 'unknown',
182+
value: 'kb-uuid-123',
183+
},
184+
},
185+
}),
186+
}
187+
188+
const { blocks, migrated } = migrateSubblockIds(input)
189+
190+
expect(migrated).toBe(true)
191+
expect(blocks.b1.subBlocks.knowledgeBaseId).toBeUndefined()
192+
expect(blocks.b1.subBlocks.knowledgeBaseSelector).toEqual({
193+
id: 'knowledgeBaseSelector',
194+
type: 'knowledge-base-selector',
195+
value: 'kb-uuid-123',
196+
})
197+
})
198+
136199
it('should migrate multiple blocks in one pass', () => {
137200
const input: Record<string, BlockState> = {
138201
b1: makeBlock({

apps/sim/lib/workflows/migrations/subblock-migrations.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { createLogger } from '@sim/logger'
2+
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
3+
import { isPlainRecord } from '@/lib/core/utils/records'
4+
import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks'
25
import {
36
buildCanonicalIndex,
47
buildSubBlockValues,
@@ -68,6 +71,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
6871
* Returns a new subBlocks record if anything changed, or the original if not.
6972
*/
7073
function migrateBlockSubblockIds(
74+
blockType: string,
7175
subBlocks: Record<string, BlockState['subBlocks'][string]>,
7276
renames: Record<string, string>
7377
): { subBlocks: Record<string, BlockState['subBlocks'][string]>; migrated: boolean } {
@@ -83,6 +87,7 @@ function migrateBlockSubblockIds(
8387
if (!migrated) return { subBlocks, migrated: false }
8488

8589
const result = { ...subBlocks }
90+
const blockConfig = getBlock(blockType)
8691

8792
for (const [oldId, newId] of Object.entries(renames)) {
8893
if (!(oldId in result)) continue
@@ -93,7 +98,24 @@ function migrateBlockSubblockIds(
9398
}
9499

95100
const oldEntry = result[oldId]
96-
result[newId] = { ...oldEntry, id: newId }
101+
const configuredType = blockConfig?.subBlocks?.find((config) => config.id === newId)?.type
102+
result[newId] = isPlainRecord(oldEntry)
103+
? {
104+
...oldEntry,
105+
id: newId,
106+
type:
107+
configuredType ||
108+
(typeof oldEntry.type === 'string' && oldEntry.type.length > 0
109+
? oldEntry.type === 'unknown'
110+
? DEFAULT_SUBBLOCK_TYPE
111+
: oldEntry.type
112+
: DEFAULT_SUBBLOCK_TYPE),
113+
}
114+
: ({
115+
id: newId,
116+
type: configuredType || DEFAULT_SUBBLOCK_TYPE,
117+
value: oldEntry,
118+
} as BlockState['subBlocks'][string])
97119
delete result[oldId]
98120
}
99121

@@ -112,20 +134,28 @@ export function migrateSubblockIds(blocks: Record<string, BlockState>): {
112134
const result: Record<string, BlockState> = {}
113135

114136
for (const [blockId, block] of Object.entries(blocks)) {
115-
const renames = SUBBLOCK_ID_MIGRATIONS[block.type]
116-
if (!renames || !block.subBlocks) {
137+
if (!block.subBlocks) {
117138
result[blockId] = block
118139
continue
119140
}
120141

121-
const { subBlocks, migrated } = migrateBlockSubblockIds(block.subBlocks, renames)
122-
if (migrated) {
123-
logger.info('Migrated legacy subblock IDs', {
124-
blockId: block.id,
125-
blockType: block.type,
126-
})
142+
const renames = SUBBLOCK_ID_MIGRATIONS[block.type]
143+
const renamed = renames
144+
? migrateBlockSubblockIds(block.type, block.subBlocks, renames)
145+
: { subBlocks: block.subBlocks, migrated: false }
146+
const renamedBlock = renamed.migrated ? { ...block, subBlocks: renamed.subBlocks } : block
147+
const sanitized = sanitizeMalformedSubBlocks(renamedBlock)
148+
const blockMigrated = renamed.migrated || sanitized.changed
149+
150+
if (blockMigrated) {
151+
if (renamed.migrated) {
152+
logger.info('Migrated legacy subblock IDs', {
153+
blockId: block.id,
154+
blockType: block.type,
155+
})
156+
}
127157
anyMigrated = true
128-
result[blockId] = { ...block, subBlocks }
158+
result[blockId] = { ...renamedBlock, subBlocks: sanitized.subBlocks }
129159
} else {
130160
result[blockId] = block
131161
}

apps/sim/lib/workflows/operations/import-export.test.ts

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,139 @@
1-
/**
2-
* @vitest-environment node
3-
*/
4-
import { describe, expect, it } from 'vitest'
5-
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
vi.unmock('@/blocks/registry')
4+
5+
import {
6+
extractWorkflowName,
7+
parseWorkflowJson,
8+
sanitizePathSegment,
9+
} from '@/lib/workflows/operations/import-export'
10+
11+
function createLegacyState() {
12+
return {
13+
blocks: {
14+
'start-1': {
15+
id: 'start-1',
16+
type: 'start_trigger',
17+
name: 'Start',
18+
position: { x: 0, y: 0 },
19+
enabled: true,
20+
subBlocks: {
21+
inputFormat: {
22+
id: 'inputFormat',
23+
type: 'input-format',
24+
value: [],
25+
},
26+
undefined: {
27+
type: 'unknown',
28+
value: 'stale duplicate',
29+
},
30+
},
31+
outputs: {},
32+
data: {},
33+
},
34+
},
35+
edges: [],
36+
loops: {},
37+
parallels: {},
38+
variables: {},
39+
metadata: {
40+
name: 'Wrapped Workflow',
41+
color: '#FFBF00',
42+
},
43+
}
44+
}
45+
46+
describe('workflow import/export parsing', () => {
47+
it('parses workflow exports wrapped in an API data envelope', () => {
48+
const content = JSON.stringify({
49+
data: {
50+
version: '1.0',
51+
exportedAt: '2026-05-07T06:45:06.892Z',
52+
workflow: {
53+
name: 'Wrapped Workflow',
54+
},
55+
state: createLegacyState(),
56+
},
57+
})
58+
59+
const result = parseWorkflowJson(content, false)
60+
61+
expect(result.errors).toEqual([])
62+
expect(result.data?.blocks['start-1']).toBeDefined()
63+
expect(result.data?.blocks['start-1'].subBlocks.inputFormat).toEqual({
64+
id: 'inputFormat',
65+
type: 'input-format',
66+
value: [],
67+
})
68+
expect(result.data?.blocks['start-1'].subBlocks.undefined).toBeUndefined()
69+
})
70+
71+
it('extracts workflow names from wrapped exports', () => {
72+
const content = JSON.stringify({
73+
data: {
74+
workflow: {
75+
name: 'Wrapped Workflow',
76+
},
77+
state: createLegacyState(),
78+
},
79+
})
80+
81+
expect(extractWorkflowName(content, 'wf.json')).toBe('Wrapped Workflow')
82+
})
83+
84+
it('parses API envelopes that contain state without an export version', () => {
85+
const content = JSON.stringify({
86+
data: {
87+
workflow: {
88+
name: 'API Workflow',
89+
},
90+
state: createLegacyState(),
91+
},
92+
})
93+
94+
const result = parseWorkflowJson(content, false)
95+
96+
expect(result.errors).toEqual([])
97+
expect(result.data?.blocks['start-1']).toBeDefined()
98+
expect(result.data?.blocks['start-1'].subBlocks.undefined).toBeUndefined()
99+
})
100+
101+
it('preserves malformed legacy renamed subBlocks during import parsing', () => {
102+
const state = {
103+
...createLegacyState(),
104+
blocks: {
105+
knowledge: {
106+
id: 'knowledge',
107+
type: 'knowledge',
108+
name: 'Knowledge',
109+
position: { x: 0, y: 0 },
110+
enabled: true,
111+
subBlocks: {
112+
operation: { id: 'operation', type: 'dropdown', value: 'search' },
113+
knowledgeBaseId: {
114+
id: 'knowledgeBaseId',
115+
type: 'unknown',
116+
value: 'kb-uuid-123',
117+
},
118+
},
119+
outputs: {},
120+
data: {},
121+
},
122+
},
123+
}
124+
const content = JSON.stringify({ data: { workflow: { name: 'Knowledge Workflow' }, state } })
125+
126+
const result = parseWorkflowJson(content, false)
127+
128+
expect(result.errors).toEqual([])
129+
expect(result.data?.blocks.knowledge.subBlocks.knowledgeBaseId).toBeUndefined()
130+
expect(result.data?.blocks.knowledge.subBlocks.knowledgeBaseSelector).toEqual({
131+
id: 'knowledgeBaseSelector',
132+
type: 'knowledge-base-selector',
133+
value: 'kb-uuid-123',
134+
})
135+
})
136+
})
6137

7138
describe('sanitizePathSegment', () => {
8139
it('should preserve ASCII alphanumeric characters', () => {

0 commit comments

Comments
 (0)