Skip to content

Commit 7175e0d

Browse files
committed
fix(sanitize): repair malformed subblock states
1 parent 76d602f commit 7175e0d

6 files changed

Lines changed: 273 additions & 62 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,50 @@ 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+
undefined: { type: 'unknown', value: null },
143+
noId: { type: 'short-input', value: 'stale' },
144+
noType: { id: 'noType', value: 'stale' },
145+
unknownType: { id: 'unknownType', type: 'unknown', value: 'preserved' },
146+
notRecord: 'stale',
147+
arrayValue: ['a', 'b'],
148+
} as unknown as BlockState['subBlocks'],
149+
}),
150+
}
151+
152+
const { blocks, migrated } = migrateSubblockIds(input)
153+
154+
expect(migrated).toBe(true)
155+
expect(blocks.b1.subBlocks.code).toEqual({
156+
id: 'code',
157+
type: 'code',
158+
value: 'console.log("hi")',
159+
})
160+
expect(blocks.b1.subBlocks.undefined).toBeUndefined()
161+
expect(blocks.b1.subBlocks.noId).toEqual({ id: 'noId', type: 'short-input', value: 'stale' })
162+
expect(blocks.b1.subBlocks.noType).toEqual({
163+
id: 'noType',
164+
type: 'short-input',
165+
value: 'stale',
166+
})
167+
expect(blocks.b1.subBlocks.unknownType).toBeUndefined()
168+
expect(blocks.b1.subBlocks.notRecord).toEqual({
169+
id: 'notRecord',
170+
type: 'short-input',
171+
value: 'stale',
172+
})
173+
expect(blocks.b1.subBlocks.arrayValue).toEqual({
174+
id: 'arrayValue',
175+
type: 'short-input',
176+
value: ['a', 'b'],
177+
})
178+
})
179+
136180
it('should migrate multiple blocks in one pass', () => {
137181
const input: Record<string, BlockState> = {
138182
b1: makeBlock({

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks'
23
import {
34
buildCanonicalIndex,
45
buildSubBlockValues,
@@ -112,18 +113,28 @@ export function migrateSubblockIds(blocks: Record<string, BlockState>): {
112113
const result: Record<string, BlockState> = {}
113114

114115
for (const [blockId, block] of Object.entries(blocks)) {
115-
const renames = SUBBLOCK_ID_MIGRATIONS[block.type]
116-
if (!renames || !block.subBlocks) {
116+
if (!block.subBlocks) {
117117
result[blockId] = block
118118
continue
119119
}
120120

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-
})
121+
const sanitized = sanitizeMalformedSubBlocks(block)
122+
const renames = SUBBLOCK_ID_MIGRATIONS[block.type]
123+
if (!renames) {
124+
result[blockId] = sanitized.changed ? { ...block, subBlocks: sanitized.subBlocks } : block
125+
anyMigrated = anyMigrated || sanitized.changed
126+
continue
127+
}
128+
129+
const { subBlocks, migrated } = migrateBlockSubblockIds(sanitized.subBlocks, renames)
130+
const blockMigrated = sanitized.changed || migrated
131+
if (blockMigrated) {
132+
if (migrated) {
133+
logger.info('Migrated legacy subblock IDs', {
134+
blockId: block.id,
135+
blockType: block.type,
136+
})
137+
}
127138
anyMigrated = true
128139
result[blockId] = { ...block, subBlocks }
129140
} else {

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

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,83 @@
1-
/**
2-
* @vitest-environment node
3-
*/
41
import { describe, expect, it } from 'vitest'
5-
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
2+
import {
3+
extractWorkflowName,
4+
parseWorkflowJson,
5+
sanitizePathSegment,
6+
} from '@/lib/workflows/operations/import-export'
7+
8+
function createLegacyState() {
9+
return {
10+
blocks: {
11+
'start-1': {
12+
id: 'start-1',
13+
type: 'start_trigger',
14+
name: 'Start',
15+
position: { x: 0, y: 0 },
16+
enabled: true,
17+
subBlocks: {
18+
inputFormat: {
19+
id: 'inputFormat',
20+
type: 'input-format',
21+
value: [],
22+
},
23+
undefined: {
24+
type: 'unknown',
25+
value: 'stale duplicate',
26+
},
27+
},
28+
outputs: {},
29+
data: {},
30+
},
31+
},
32+
edges: [],
33+
loops: {},
34+
parallels: {},
35+
variables: {},
36+
metadata: {
37+
name: 'Wrapped Workflow',
38+
color: '#FFBF00',
39+
},
40+
}
41+
}
42+
43+
describe('workflow import/export parsing', () => {
44+
it('parses workflow exports wrapped in an API data envelope', () => {
45+
const content = JSON.stringify({
46+
data: {
47+
version: '1.0',
48+
exportedAt: '2026-05-07T06:45:06.892Z',
49+
workflow: {
50+
name: 'Wrapped Workflow',
51+
},
52+
state: createLegacyState(),
53+
},
54+
})
55+
56+
const result = parseWorkflowJson(content, false)
57+
58+
expect(result.errors).toEqual([])
59+
expect(result.data?.blocks['start-1']).toBeDefined()
60+
expect(result.data?.blocks['start-1'].subBlocks.inputFormat).toEqual({
61+
id: 'inputFormat',
62+
type: 'input-format',
63+
value: [],
64+
})
65+
expect(result.data?.blocks['start-1'].subBlocks.undefined).toBeUndefined()
66+
})
67+
68+
it('extracts workflow names from wrapped exports', () => {
69+
const content = JSON.stringify({
70+
data: {
71+
workflow: {
72+
name: 'Wrapped Workflow',
73+
},
74+
state: createLegacyState(),
75+
},
76+
})
77+
78+
expect(extractWorkflowName(content, 'wf.json')).toBe('Wrapped Workflow')
79+
})
80+
})
681

782
describe('sanitizePathSegment', () => {
883
it('should preserve ASCII alphanumeric characters', () => {

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

Lines changed: 40 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,32 @@ import {
1313
type ExportWorkflowState,
1414
sanitizeForExport,
1515
} from '@/lib/workflows/sanitization/json-sanitizer'
16+
import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks'
1617
import { regenerateWorkflowIds } from '@/stores/workflows/utils'
1718
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
1819

1920
const logger = createLogger('WorkflowImportExport')
2021

22+
function isRecord(value: unknown): value is Record<string, unknown> {
23+
return typeof value === 'object' && value !== null && !Array.isArray(value)
24+
}
25+
26+
function unwrapWorkflowExportEnvelope(data: unknown): unknown {
27+
if (!isRecord(data)) {
28+
return data
29+
}
30+
31+
const envelopeData = data.data
32+
if (
33+
isRecord(envelopeData) &&
34+
(envelopeData.state || envelopeData.version || envelopeData.workflow)
35+
) {
36+
return envelopeData
37+
}
38+
39+
return data
40+
}
41+
2142
async function getJSZip() {
2243
const { default: JSZip } = await import('jszip')
2344
return JSZip
@@ -338,7 +359,7 @@ export interface WorkspaceImportMetadata {
338359

339360
function extractSortOrder(content: string): number | undefined {
340361
try {
341-
const parsed = JSON.parse(content)
362+
const parsed = unwrapWorkflowExportEnvelope(JSON.parse(content)) as Record<string, any>
342363
return parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder
343364
} catch {
344365
return undefined
@@ -418,11 +439,15 @@ export async function extractWorkflowsFromFiles(files: File[]): Promise<Imported
418439

419440
export function extractWorkflowName(content: string, filename: string): string {
420441
try {
421-
const parsed = JSON.parse(content)
442+
const parsed = unwrapWorkflowExportEnvelope(JSON.parse(content)) as Record<string, any>
422443

423444
if (parsed.state?.metadata?.name && typeof parsed.state.metadata.name === 'string') {
424445
return parsed.state.metadata.name.trim()
425446
}
447+
448+
if (parsed.workflow?.name && typeof parsed.workflow.name === 'string') {
449+
return parsed.workflow.name.trim()
450+
}
426451
} catch {
427452
// JSON parse failed, fall through to filename
428453
}
@@ -441,14 +466,12 @@ export function extractWorkflowName(content: string, filename: string): string {
441466
}
442467

443468
/**
444-
* Normalize subblock values by converting empty strings to null and filtering out invalid subblocks.
469+
* Normalize subblock values by converting empty strings to null and repairing invalid subblocks.
445470
* This provides backwards compatibility for workflows exported before the null sanitization fix,
446471
* preventing Zod validation errors like "Expected array, received string".
447472
*
448-
* Also filters out malformed subBlocks that may have been created by bugs in previous exports:
449-
* - SubBlocks with key "undefined" (caused by assigning to undefined key)
450-
* - SubBlocks missing required fields like `id`
451-
* - SubBlocks with `type: "unknown"` (indicates malformed data)
473+
* Also filters out subBlocks with the literal key "undefined", which cannot be associated
474+
* with a stable block field.
452475
*/
453476
function normalizeSubblockValues(blocks: Record<string, any>): Record<string, any> {
454477
const normalizedBlocks: Record<string, any> = {}
@@ -457,47 +480,14 @@ function normalizeSubblockValues(blocks: Record<string, any>): Record<string, an
457480
const normalizedBlock = { ...block }
458481

459482
if (block.subBlocks) {
460-
const normalizedSubBlocks: Record<string, any> = {}
461-
462-
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
463-
// Skip subBlocks with invalid keys (literal "undefined" string)
464-
if (subBlockId === 'undefined') {
465-
logger.warn(`Skipping malformed subBlock with key "undefined" in block ${blockId}`)
466-
return
467-
}
468-
469-
// Skip subBlocks that are null or not objects
470-
if (!subBlock || typeof subBlock !== 'object') {
471-
logger.warn(`Skipping invalid subBlock ${subBlockId} in block ${blockId}: not an object`)
472-
return
473-
}
474-
475-
// Skip subBlocks with type "unknown" (malformed data)
476-
if (subBlock.type === 'unknown') {
477-
logger.warn(
478-
`Skipping malformed subBlock ${subBlockId} in block ${blockId}: type is "unknown"`
479-
)
480-
return
481-
}
482-
483-
// Skip subBlocks missing required id field
484-
if (!subBlock.id) {
485-
logger.warn(
486-
`Skipping malformed subBlock ${subBlockId} in block ${blockId}: missing id field`
487-
)
488-
return
489-
}
490-
491-
const normalizedSubBlock = { ...subBlock }
492-
493-
// Convert empty strings to null for consistency
494-
if (normalizedSubBlock.value === '') {
495-
normalizedSubBlock.value = null
496-
}
497-
498-
normalizedSubBlocks[subBlockId] = normalizedSubBlock
499-
})
500-
483+
const { subBlocks: normalizedSubBlocks } = sanitizeMalformedSubBlocks(
484+
{
485+
id: typeof block.id === 'string' ? block.id : blockId,
486+
type: typeof block.type === 'string' ? block.type : '',
487+
subBlocks: block.subBlocks,
488+
},
489+
{ convertEmptyStringToNull: true }
490+
)
501491
normalizedBlock.subBlocks = normalizedSubBlocks
502492
}
503493

@@ -538,6 +528,8 @@ export function parseWorkflowJson(
538528
return { data: null, errors }
539529
}
540530

531+
data = unwrapWorkflowExportEnvelope(data)
532+
541533
// Handle new export format (version/exportedAt/state) or old format (blocks/edges at root)
542534
let workflowData: any
543535
if (data.version && data.state) {

0 commit comments

Comments
 (0)