Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/sim/lib/workflows/migrations/subblock-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,69 @@ describe('migrateSubblockIds', () => {
expect(blocks.b1.subBlocks.code.value).toBe('console.log("hi")')
})

it('should repair malformed subBlocks for every block type without deleting values', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'function',
subBlocks: {
code: { id: 'code', type: 'unknown', value: 'console.log("hi")' },
language: { value: 'javascript' },
undefined: { type: 'unknown', value: null },
noId: { type: 'short-input', value: 'stale' },
noType: { id: 'noType', value: 'stale' },
unknownType: { id: 'unknownType', type: 'unknown', value: 'preserved' },
notRecord: 'stale',
arrayValue: ['a', 'b'],
} as unknown as BlockState['subBlocks'],
}),
}

const { blocks, migrated } = migrateSubblockIds(input)

expect(migrated).toBe(true)
expect(blocks.b1.subBlocks.code).toEqual({
id: 'code',
type: 'code',
value: 'console.log("hi")',
})
expect(blocks.b1.subBlocks.language).toEqual({
id: 'language',
type: 'dropdown',
value: 'javascript',
})
expect(blocks.b1.subBlocks.undefined).toBeUndefined()
expect(blocks.b1.subBlocks.noId).toBeUndefined()
expect(blocks.b1.subBlocks.noType).toBeUndefined()
expect(blocks.b1.subBlocks.unknownType).toBeUndefined()
expect(blocks.b1.subBlocks.notRecord).toBeUndefined()
expect(blocks.b1.subBlocks.arrayValue).toBeUndefined()
})

it('should preserve malformed legacy subBlocks before renaming them', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
subBlocks: {
knowledgeBaseId: {
id: 'knowledgeBaseId',
type: 'unknown',
value: 'kb-uuid-123',
},
},
}),
}

const { blocks, migrated } = migrateSubblockIds(input)

expect(migrated).toBe(true)
expect(blocks.b1.subBlocks.knowledgeBaseId).toBeUndefined()
expect(blocks.b1.subBlocks.knowledgeBaseSelector).toEqual({
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid-123',
})
})

it('should migrate multiple blocks in one pass', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
Expand Down
50 changes: 40 additions & 10 deletions apps/sim/lib/workflows/migrations/subblock-migrations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createLogger } from '@sim/logger'
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
import { isPlainRecord } from '@/lib/core/utils/records'
import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks'
import {
buildCanonicalIndex,
buildSubBlockValues,
Expand Down Expand Up @@ -68,6 +71,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
* Returns a new subBlocks record if anything changed, or the original if not.
*/
function migrateBlockSubblockIds(
blockType: string,
subBlocks: Record<string, BlockState['subBlocks'][string]>,
renames: Record<string, string>
): { subBlocks: Record<string, BlockState['subBlocks'][string]>; migrated: boolean } {
Expand All @@ -83,6 +87,7 @@ function migrateBlockSubblockIds(
if (!migrated) return { subBlocks, migrated: false }

const result = { ...subBlocks }
const blockConfig = getBlock(blockType)

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

const oldEntry = result[oldId]
result[newId] = { ...oldEntry, id: newId }
const configuredType = blockConfig?.subBlocks?.find((config) => config.id === newId)?.type
result[newId] = isPlainRecord(oldEntry)
? {
...oldEntry,
id: newId,
type:
configuredType ||
(typeof oldEntry.type === 'string' && oldEntry.type.length > 0
? oldEntry.type === 'unknown'
? DEFAULT_SUBBLOCK_TYPE
: oldEntry.type
: DEFAULT_SUBBLOCK_TYPE),
}
: ({
id: newId,
type: configuredType || DEFAULT_SUBBLOCK_TYPE,
value: oldEntry,
} as BlockState['subBlocks'][string])
delete result[oldId]
}

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

for (const [blockId, block] of Object.entries(blocks)) {
const renames = SUBBLOCK_ID_MIGRATIONS[block.type]
if (!renames || !block.subBlocks) {
if (!block.subBlocks) {
result[blockId] = block
continue
}

const { subBlocks, migrated } = migrateBlockSubblockIds(block.subBlocks, renames)
if (migrated) {
logger.info('Migrated legacy subblock IDs', {
blockId: block.id,
blockType: block.type,
})
const renames = SUBBLOCK_ID_MIGRATIONS[block.type]
const renamed = renames
? migrateBlockSubblockIds(block.type, block.subBlocks, renames)
: { subBlocks: block.subBlocks, migrated: false }
const renamedBlock = renamed.migrated ? { ...block, subBlocks: renamed.subBlocks } : block
const sanitized = sanitizeMalformedSubBlocks(renamedBlock)
const blockMigrated = renamed.migrated || sanitized.changed

if (blockMigrated) {
if (renamed.migrated) {
logger.info('Migrated legacy subblock IDs', {
blockId: block.id,
blockType: block.type,
})
}
anyMigrated = true
result[blockId] = { ...block, subBlocks }
result[blockId] = { ...renamedBlock, subBlocks: sanitized.subBlocks }
} else {
result[blockId] = block
}
Comment thread
icecrasher321 marked this conversation as resolved.
Expand Down
141 changes: 136 additions & 5 deletions apps/sim/lib/workflows/operations/import-export.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,139 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { describe, expect, it, vi } from 'vitest'

vi.unmock('@/blocks/registry')

import {
extractWorkflowName,
parseWorkflowJson,
sanitizePathSegment,
} from '@/lib/workflows/operations/import-export'

function createLegacyState() {
return {
blocks: {
'start-1': {
id: 'start-1',
type: 'start_trigger',
name: 'Start',
position: { x: 0, y: 0 },
enabled: true,
subBlocks: {
inputFormat: {
id: 'inputFormat',
type: 'input-format',
value: [],
},
undefined: {
type: 'unknown',
value: 'stale duplicate',
},
},
outputs: {},
data: {},
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
metadata: {
name: 'Wrapped Workflow',
color: '#FFBF00',
},
}
}

describe('workflow import/export parsing', () => {
it('parses workflow exports wrapped in an API data envelope', () => {
const content = JSON.stringify({
data: {
version: '1.0',
exportedAt: '2026-05-07T06:45:06.892Z',
workflow: {
name: 'Wrapped Workflow',
},
state: createLegacyState(),
},
})

const result = parseWorkflowJson(content, false)

expect(result.errors).toEqual([])
expect(result.data?.blocks['start-1']).toBeDefined()
expect(result.data?.blocks['start-1'].subBlocks.inputFormat).toEqual({
id: 'inputFormat',
type: 'input-format',
value: [],
})
expect(result.data?.blocks['start-1'].subBlocks.undefined).toBeUndefined()
})

it('extracts workflow names from wrapped exports', () => {
const content = JSON.stringify({
data: {
workflow: {
name: 'Wrapped Workflow',
},
state: createLegacyState(),
},
})

expect(extractWorkflowName(content, 'wf.json')).toBe('Wrapped Workflow')
})

it('parses API envelopes that contain state without an export version', () => {
const content = JSON.stringify({
data: {
workflow: {
name: 'API Workflow',
},
state: createLegacyState(),
},
})

const result = parseWorkflowJson(content, false)

expect(result.errors).toEqual([])
expect(result.data?.blocks['start-1']).toBeDefined()
expect(result.data?.blocks['start-1'].subBlocks.undefined).toBeUndefined()
})

it('preserves malformed legacy renamed subBlocks during import parsing', () => {
const state = {
...createLegacyState(),
blocks: {
knowledge: {
id: 'knowledge',
type: 'knowledge',
name: 'Knowledge',
position: { x: 0, y: 0 },
enabled: true,
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseId: {
id: 'knowledgeBaseId',
type: 'unknown',
value: 'kb-uuid-123',
},
},
outputs: {},
data: {},
},
},
}
const content = JSON.stringify({ data: { workflow: { name: 'Knowledge Workflow' }, state } })

const result = parseWorkflowJson(content, false)

expect(result.errors).toEqual([])
expect(result.data?.blocks.knowledge.subBlocks.knowledgeBaseId).toBeUndefined()
expect(result.data?.blocks.knowledge.subBlocks.knowledgeBaseSelector).toEqual({
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid-123',
})
})
})

describe('sanitizePathSegment', () => {
it('should preserve ASCII alphanumeric characters', () => {
Expand Down
Loading
Loading