Skip to content

Commit 8e116d6

Browse files
committed
fix(kbs): legacy subblock id migration + CI check
1 parent 0a6a2ee commit 8e116d6

File tree

5 files changed

+440
-2
lines changed

5 files changed

+440
-2
lines changed

.github/workflows/test-build.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ jobs:
9090
9191
echo "✅ All feature flags are properly configured"
9292
93+
- name: Check subblock ID stability
94+
run: |
95+
if [ "${{ github.event_name }}" = "pull_request" ]; then
96+
BASE_REF="origin/${{ github.base_ref }}"
97+
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
98+
else
99+
BASE_REF="HEAD~1"
100+
fi
101+
bun run apps/sim/scripts/check-subblock-id-stability.ts "$BASE_REF"
102+
93103
- name: Lint code
94104
run: bun run lint:check
95105

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { BlockState } from '@/stores/workflows/workflow/types'
6+
import { migrateSubblockIds } from './subblock-migrations'
7+
8+
function makeBlock(overrides: Partial<BlockState> & { type: string }): BlockState {
9+
return {
10+
id: 'block-1',
11+
name: 'Test',
12+
position: { x: 0, y: 0 },
13+
subBlocks: {},
14+
outputs: {},
15+
enabled: true,
16+
...overrides,
17+
} as BlockState
18+
}
19+
20+
describe('migrateSubblockIds', () => {
21+
describe('knowledge block', () => {
22+
it('should rename knowledgeBaseId to knowledgeBaseSelector', () => {
23+
const blocks: Record<string, BlockState> = {
24+
b1: makeBlock({
25+
type: 'knowledge',
26+
subBlocks: {
27+
operation: { id: 'operation', type: 'dropdown', value: 'search' },
28+
knowledgeBaseId: {
29+
id: 'knowledgeBaseId',
30+
type: 'knowledge-base-selector',
31+
value: 'kb-uuid-123',
32+
},
33+
},
34+
}),
35+
}
36+
37+
const migrated = migrateSubblockIds(blocks)
38+
39+
expect(migrated).toBe(true)
40+
expect(blocks['b1'].subBlocks['knowledgeBaseSelector']).toEqual({
41+
id: 'knowledgeBaseSelector',
42+
type: 'knowledge-base-selector',
43+
value: 'kb-uuid-123',
44+
})
45+
expect(blocks['b1'].subBlocks['knowledgeBaseId']).toBeUndefined()
46+
expect(blocks['b1'].subBlocks['operation'].value).toBe('search')
47+
})
48+
49+
it('should prefer new key when both old and new exist', () => {
50+
const blocks: Record<string, BlockState> = {
51+
b1: makeBlock({
52+
type: 'knowledge',
53+
subBlocks: {
54+
knowledgeBaseId: {
55+
id: 'knowledgeBaseId',
56+
type: 'knowledge-base-selector',
57+
value: 'stale-kb',
58+
},
59+
knowledgeBaseSelector: {
60+
id: 'knowledgeBaseSelector',
61+
type: 'knowledge-base-selector',
62+
value: 'fresh-kb',
63+
},
64+
},
65+
}),
66+
}
67+
68+
const migrated = migrateSubblockIds(blocks)
69+
70+
expect(migrated).toBe(true)
71+
expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('fresh-kb')
72+
expect(blocks['b1'].subBlocks['knowledgeBaseId']).toBeUndefined()
73+
})
74+
75+
it('should not touch blocks that already use the new key', () => {
76+
const blocks: Record<string, BlockState> = {
77+
b1: makeBlock({
78+
type: 'knowledge',
79+
subBlocks: {
80+
knowledgeBaseSelector: {
81+
id: 'knowledgeBaseSelector',
82+
type: 'knowledge-base-selector',
83+
value: 'kb-uuid',
84+
},
85+
},
86+
}),
87+
}
88+
89+
const migrated = migrateSubblockIds(blocks)
90+
91+
expect(migrated).toBe(false)
92+
expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-uuid')
93+
})
94+
})
95+
96+
it('should skip blocks with no registered migrations', () => {
97+
const blocks: Record<string, BlockState> = {
98+
b1: makeBlock({
99+
type: 'function',
100+
subBlocks: {
101+
code: { id: 'code', type: 'code', value: 'console.log("hi")' },
102+
},
103+
}),
104+
}
105+
106+
const migrated = migrateSubblockIds(blocks)
107+
108+
expect(migrated).toBe(false)
109+
expect(blocks['b1'].subBlocks['code'].value).toBe('console.log("hi")')
110+
})
111+
112+
it('should migrate multiple blocks in one pass', () => {
113+
const blocks: Record<string, BlockState> = {
114+
b1: makeBlock({
115+
id: 'b1',
116+
type: 'knowledge',
117+
subBlocks: {
118+
knowledgeBaseId: {
119+
id: 'knowledgeBaseId',
120+
type: 'knowledge-base-selector',
121+
value: 'kb-1',
122+
},
123+
},
124+
}),
125+
b2: makeBlock({
126+
id: 'b2',
127+
type: 'knowledge',
128+
subBlocks: {
129+
knowledgeBaseId: {
130+
id: 'knowledgeBaseId',
131+
type: 'knowledge-base-selector',
132+
value: 'kb-2',
133+
},
134+
},
135+
}),
136+
b3: makeBlock({
137+
id: 'b3',
138+
type: 'function',
139+
subBlocks: {
140+
code: { id: 'code', type: 'code', value: '' },
141+
},
142+
}),
143+
}
144+
145+
const migrated = migrateSubblockIds(blocks)
146+
147+
expect(migrated).toBe(true)
148+
expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-1')
149+
expect(blocks['b2'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-2')
150+
expect(blocks['b3'].subBlocks['code']).toBeDefined()
151+
})
152+
153+
it('should handle blocks with empty subBlocks', () => {
154+
const blocks: Record<string, BlockState> = {
155+
b1: makeBlock({ type: 'knowledge', subBlocks: {} }),
156+
}
157+
158+
const migrated = migrateSubblockIds(blocks)
159+
160+
expect(migrated).toBe(false)
161+
})
162+
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { BlockState } from '@/stores/workflows/workflow/types'
3+
4+
const logger = createLogger('SubblockMigrations')
5+
6+
/**
7+
* Maps old subblock IDs to their current equivalents per block type.
8+
*
9+
* When a subblock is renamed in a block definition, old deployed/saved states
10+
* still carry the value under the previous key. Without this mapping the
11+
* serializer silently drops the value, breaking execution.
12+
*
13+
* Format: { blockType: { oldSubblockId: newSubblockId } }
14+
*/
15+
export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
16+
knowledge: {
17+
knowledgeBaseId: 'knowledgeBaseSelector',
18+
},
19+
}
20+
21+
/**
22+
* Migrates legacy subblock IDs inside a single block's subBlocks map.
23+
* If an old key is found and the new key does not already exist, the entry
24+
* is moved to the new key. When both exist the new key wins (user already
25+
* re-saved) and the old entry is removed to avoid orphans.
26+
*
27+
* Returns true if any migration was applied.
28+
*/
29+
function migrateBlockSubblockIds(block: BlockState, renames: Record<string, string>): boolean {
30+
const subBlocks = block.subBlocks
31+
if (!subBlocks) return false
32+
33+
let migrated = false
34+
35+
for (const [oldId, newId] of Object.entries(renames)) {
36+
if (!(oldId in subBlocks)) continue
37+
38+
if (newId in subBlocks) {
39+
delete subBlocks[oldId]
40+
migrated = true
41+
continue
42+
}
43+
44+
const oldEntry = subBlocks[oldId]
45+
subBlocks[newId] = { ...oldEntry, id: newId }
46+
delete subBlocks[oldId]
47+
migrated = true
48+
}
49+
50+
return migrated
51+
}
52+
53+
/**
54+
* Applies subblock-ID migrations to every block in a workflow.
55+
* Safe to call on any state – blocks whose type has no registered
56+
* migrations are left untouched.
57+
*
58+
* Mutates `blocks` in place and returns whether anything changed.
59+
*/
60+
export function migrateSubblockIds(blocks: Record<string, BlockState>): boolean {
61+
let anyMigrated = false
62+
63+
for (const block of Object.values(blocks)) {
64+
const renames = SUBBLOCK_ID_MIGRATIONS[block.type]
65+
if (!renames) continue
66+
67+
if (migrateBlockSubblockIds(block, renames)) {
68+
logger.info('Migrated legacy subblock IDs', {
69+
blockId: block.id,
70+
blockType: block.type,
71+
})
72+
anyMigrated = true
73+
}
74+
}
75+
76+
return anyMigrated
77+
}

apps/sim/lib/workflows/persistence/utils.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
1414
import type { Edge } from 'reactflow'
1515
import { v4 as uuidv4 } from 'uuid'
1616
import type { DbOrTx } from '@/lib/db/types'
17+
import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations'
1718
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
1819
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
1920
import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types'
@@ -114,12 +115,14 @@ export async function loadDeployedWorkflowState(
114115
}
115116

116117
const resolvedBlocks = state.blocks || {}
117-
const { blocks: migratedBlocks } = resolvedWorkspaceId
118+
const { blocks: credMigratedBlocks } = resolvedWorkspaceId
118119
? await migrateCredentialIds(resolvedBlocks, resolvedWorkspaceId)
119120
: { blocks: resolvedBlocks }
120121

122+
migrateSubblockIds(credMigratedBlocks)
123+
121124
return {
122-
blocks: migratedBlocks,
125+
blocks: credMigratedBlocks,
123126
edges: state.edges || [],
124127
loops: state.loops || {},
125128
parallels: state.parallels || {},
@@ -386,6 +389,22 @@ export async function loadWorkflowFromNormalizedTables(
386389
})
387390
}
388391

392+
const subblockMigrated = migrateSubblockIds(credMigratedBlocks)
393+
if (subblockMigrated) {
394+
Promise.resolve().then(async () => {
395+
try {
396+
for (const [blockId, block] of Object.entries(credMigratedBlocks)) {
397+
await db
398+
.update(workflowBlocks)
399+
.set({ subBlocks: block.subBlocks, updatedAt: new Date() })
400+
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
401+
}
402+
} catch (err) {
403+
logger.warn('Failed to persist subblock ID migration', { workflowId, error: err })
404+
}
405+
})
406+
}
407+
389408
// Convert edges to the expected format
390409
const edgesArray: Edge[] = edges.map((edge) => ({
391410
id: edge.id,

0 commit comments

Comments
 (0)